diff --git a/.cargo/config.toml b/.cargo/config.toml index d3364a54b2a..7434b905168 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,6 +2,8 @@ # like e.g. out-of-tree builds ( https://github.com/rust-lang/cargo/issues/2930 ). # For this reason this file should be avoided as much as possible when there are alternatives. +[target.i586-unknown-redox] +linker = "i586-unknown-redox-gcc" [target.x86_64-unknown-redox] linker = "x86_64-unknown-redox-gcc" [target.aarch64-unknown-linux-gnu] @@ -12,5 +14,5 @@ rustflags = ["-C", "target-feature=+crt-static"] rustflags = ["-C", "target-feature=+crt-static"] [env] -# See feat_external_libstdbuf in src/uu/stdbuf/Cargo.toml -LIBSTDBUF_DIR = "/usr/local/libexec/coreutils" +# aws-lc is used only for checksum. Reduce binary size +AWS_LC_SYS_NO_JITTER_ENTROPY = "1" diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 5bc579f324e..db0d73da84c 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -32,5 +32,5 @@ RUN apt-get update \ python3-pip \ && rm -rf /var/lib/apt/lists/* -# pre-commit -RUN pip3 install --break-system-packages pre-commit +# prek +RUN pip3 install --break-system-packages prek diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index dab74a3082a..03a11dc2522 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,7 @@ } }, "onCreateCommand": { - "install pre-commit hooks": "pre-commit install", + "install prek hooks": "prek install", "update permissions for gnu coreutils volume": "sudo chown vscode:vscode ${containerWorkspaceFolder}/../gnu" }, "mounts": [ diff --git a/.github/skills/code-review/SKILL.md b/.github/skills/code-review/SKILL.md new file mode 100644 index 00000000000..7aa3acff197 --- /dev/null +++ b/.github/skills/code-review/SKILL.md @@ -0,0 +1,123 @@ +--- +name: code-review +description: Review a pull request or diff in uutils/coreutils. Checks GPL/GNU provenance, GNU behavior compatibility, tests, error handling, style, performance and commit hygiene, and produces short, actionable, line-level comments. +--- + +# Code review for uutils/coreutils + +uutils coreutils is a cross-platform reimplementation of the GNU coreutils in Rust. +Reviews are about keeping it *original*, *GNU-compatible*, *tested*, and *small*. + +## How to write the review + +- One-line, actionable comments anchored to the specific line. Say what to change, + not why it matters in three paragraphs. +- Push back on long-winded code, duplication, needless complexity, and changes + without tests. +- Don't restate the diff back to the author, don't praise, don't pad. +- Keep the tone that of a human reviewer: plain, direct, no generated-sounding prose. +- If nothing is wrong, say so in one line rather than inventing findings. + +## The one rule that cannot be bent + +uutils is **original** code. It cannot contain code derived from GNU or any other +GPL/LGPL implementation, and a PR must not even link to the GNU source. + +Flag as blocking: + +- Code, structure, comments, variable names or file layout that mirror GNU's. +- Tests ported verbatim from the GNU test suite — including the *fixtures*: input + strings, byte sequences, delimiters, field ranges and expected outputs. "It's only + a few chars" is not an exception; GNU tests are GPLv3+. +- Links or references to GNU source/test paths in code comments, commit messages or + the PR description. + +Reading the GNU *manual* and man pages is fine, as are permissively-licensed +implementations (Apple file_cmds, OpenBSD). Be extra careful with AI-assisted +patches: assistants can reproduce GPL sources verbatim. + +## Correctness and GNU compatibility + +- Behavior, options, output and exit codes should match GNU, checked + with `LC_ALL=C` (GNU messages are localized; the GNU tests run under `C`). + Error messages can be different when better. +- A "GNU does X" claim in a PR should name the version it was checked + against. And it should be a recent version. +- GNU compatibility must not regress (`util/run-gnu-test.sh`, + `util/remaining-gnu-error.py`). A compatibility fix should mention the test it makes + pass. +- Watch security-sensitive output that scripts parse: character classes + (`[:graph:]`, `[:print:]`, `[:alnum:]`) in `tr`/`sort`/`cut`, real vs effective + uid/gid in `id`/`groups`/`stat`/`whoami`, `--preserve-root` canonicalization, + and flags that must work independently of each other (e.g. `ln --no-dereference` + without `--force`). + +## Safety + +- **TOCTOU**: reject check-then-act on paths (stat/exists/permission check followed by + a separate open/unlink/chmod). Prefer `openat()` / `O_NOFOLLOW` / fd-relative + operations, especially in `cp`, `mv`, `rm`, `chown`, `chmod`, `chgrp`, `install`. +- Recursive traversal must not follow symlinks out of the intended tree. +- Untrusted input: no unbounded allocations, no integer overflow, no path-traversal + foot-guns. + +## Rust style + +- No `panic!`, `.unwrap()`, `expect()` on fallible paths, or stray `println!`. + `unreachable!` needs a comment justifying why the branch can't happen. +- Avoid `std::process::exit` in reusable utility logic; return `Result` and use `uucore::error`. Allow process-level entry points and platform/tooling paths that must terminate explicitly. +- `OsStr`/`Path` for paths, not `String`/`str`. +- Keep `unsafe` minimal; prefer FFI, allow rare documented non-FFI exceptions, and require a `// SAFETY:` comment. +- `thiserror`, not `quick-error`; `rustix` preferred over `nix`. +- Strip `(os error N)` from messages (`strip_errno`). +- Descriptive names, no cryptic abbreviations. Avoid single-call wrapper functions + and gratuitous macros. +- Do not use `#[allow(...)]` to hide warnings without a documented, narrowly scoped reason; prefer `#[expect(..., reason = "...")]` for justified exceptions. +- Clippy runs with `all` + `cargo` + `pedantic` + `use_self`; CI fails on any warning. + Common ones worth catching in review: `redundant_closure_for_method_calls`, + `map_unwrap_or`, `needless_for_each`, `items_after_statements`, `unnecessary_wraps`, + `assigning_clones`, `needless_continue`, `unreadable_literal`. +- Avoid one-line functions +- If the code has a lot of platform specific modifications (#cfg), suggest to create a new platform specific file. + +## Tests + +- New or change of behavior comes with a test. Integration tests live in + `tests/by-util/test_.rs`; unit tests next to the code. +- Tests must be original (see the provenance rule above) and pick values that are + clearly distinct from GNU's fixtures. +- Use `.no_output()` rather than `.no_stdout().no_stderr()`. +- Anything that can't run under WASI needs `#[cfg_attr(wasi_runner, ignore)]`. +- If a GNU test was failing while the Rust suite passed, the PR should add a Rust + test so the gap can't reopen. + +## Docs, help and i18n + +- New options or user-visible behavior should update the relevant `--help`/docs sources and generated man page as applicable, plus the appropriate `en-US.ftl` (utility, `uucore`, or shared crate). +- User-facing strings go through `translate!` with a namespaced key, not hardcoded. + +## Performance and size + +- Compare runtime changes with representative benchmarks, and apply the repository's established binary-size threshold rather than a blanket 3% limit. +- Extra memory is acceptable when it buys real speed or correctness. +- Watch for per-item work added to hot loops: new allocations, an extra pass over the + data, newly non-inlined cross-crate calls, and — very common — error context + (`format!`, `translate!`, `to_string()`) built eagerly on the *success* path. + Error messages must be materialized only inside the `Err` arm. + +## Scope, dependencies and commits + +- Avoid code duplication at all costs. Look in uucore if the feature/function isn't there. +- Small and self-contained. Unrelated refactors, formatting-only churn, and + dependency/lockfile bumps belong in their own PR. Pure code moves get their own commit. +- When a PR mixes several logical changes, ask for a split and point the author at the + `gh stack` extension to keep the pieces reviewable in order. +- New dependencies must be discussed and justified: build time, binary size, audit + surface, license compatibility. +- Descriptive title prefixed with the utility: `ls: fix version sort order`, not + `Fix #1234`. Same for commits: `: `. + +## Before approving + +CI green (fmt, clippy `-D warnings`, tests) on Linux, macOS and Windows, with +`#[cfg(...)]` used for platform-specific code rather than breaking other targets. diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 4223ea78be3..503e45c89e3 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -2,9 +2,9 @@ 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 nodocs -# spell-checker:ignore (jargon) SHAs deps dequote softprops subshell toolchain fuzzers dedupe devel profdata +# spell-checker:ignore (jargon) SHAs deps dequote softprops subshell toolchain fuzzers dedupe devel profdata profraw Cprofile # spell-checker:ignore (people) Peltoche rivy Anson dawidd -# spell-checker:ignore (shell/tools) binutils choco clippy dmake esac fakeroot fdesc fdescfs gmake grcov halium lcov libclang libcrypto libfuse libssl limactl nextest nocross pacman popd printf pushd redoxer rsync rustc rustfmt rustup shopt sccache utmpdump xargs zstd +# spell-checker:ignore (shell/tools) binutils choco clippy dmake esac fakeroot fdesc fdescfs gmake grcov halium lcov libclang libcrypto libfuse libssl limactl nextest nocross pacman popd printf pushd redoxer rsync rustc rustfmt rustup shopt sccache setfacl 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 tempfile testsuite toybox uutils libsystemd codspeed wasip libexecinfo env: @@ -37,7 +37,7 @@ jobs: name: Style/cargo-deny runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: EmbarkStudios/cargo-deny-action@v2 @@ -56,7 +56,7 @@ jobs: - { os: macos-latest , features: "feat_Tier1,feat_require_unix,feat_require_unix_utmpx" } - { os: windows-latest , features: feat_os_windows } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: taiki-e/install-action@cargo-udeps @@ -105,17 +105,13 @@ jobs: # - { os: macos-latest , features: feat_os_unix } # - { os: windows-latest , features: feat_os_windows } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - components: clippy - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -125,7 +121,7 @@ jobs: - name: Install/setup prerequisites shell: bash run: | - sudo apt-get -y update ; sudo apt-get -y install gcc-aarch64-linux-gnu libselinux1-dev + sudo apt-get -y update ; sudo apt-get -y install libselinux1-dev - name: Initialize workflow variables id: vars shell: bash @@ -167,8 +163,10 @@ jobs: matrix: job: - { os: ubuntu-latest , features: feat_os_unix } + # rustix defaults to raw Linux syscalls; make sure the libc backend works too + - { os: ubuntu-latest , features: "feat_os_unix,rustix/use-libc" } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@master @@ -179,7 +177,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -255,7 +253,7 @@ jobs: job: - { os: ubuntu-latest , features: feat_os_unix } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: "`cargo update` testing" @@ -264,11 +262,13 @@ jobs: ## `cargo update` testing # * convert any errors/warnings to GHA UI annotations; ref: for dir in "." "fuzz"; do - ( 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 ; } + ( cd "$dir" && cargo fetch --locked --quiet --target 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_rust_stable: - name: Build/stable + # Keep the name independent of `features`: these checks are required by + # branch protection, so a renamed job would block every PR. + name: Build/stable (${{ matrix.job.os }}, ${{ matrix.job.name }}) needs: [ min_version, deps ] runs-on: ${{ matrix.job.os }} timeout-minutes: 90 @@ -276,11 +276,13 @@ jobs: fail-fast: false matrix: job: - - { os: ubuntu-latest , features: feat_os_unix } - - { os: macos-latest , features: feat_os_unix } - - { os: windows-latest , features: feat_os_windows } + - { os: ubuntu-latest , name: feat_os_unix , features: feat_os_unix } + # Don't fail to build if SELinux specific utils are included by some reason. + # But we are not interested in existence of stubs + - { os: macos-latest , name: feat_os_unix , features: "feat_os_unix,chcon,runcon" } + - { os: windows-latest , name: feat_os_windows , features: "feat_os_windows,chcon,runcon" } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@stable @@ -288,7 +290,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -323,7 +325,7 @@ jobs: - { os: macos-latest , features: feat_os_unix } - { os: windows-latest , features: feat_os_windows } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@nightly @@ -331,7 +333,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -368,30 +370,41 @@ jobs: matrix: job: # - { os , target , default-features, features , use-cross , toolchain, skip-tests, workspace-tests, skip-package, skip-publish } + # `pgo: PGO` marks a published target whose instrumented binary the + # runner can execute, which is what training a profile needs. It is + # therefore off for the `cross` targets (no emulator) and the wasm + # ones (no runtime). The value is spelled `PGO` because GitHub appends + # the matrix values to the job title, so those jobs read as + # "Build (..., x86_64-unknown-linux-gnu, ..., PGO)". + # enable openssl for performance if min_version: already tests pore-Rust checksum crates - { 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-24.04-arm , target: aarch64-unknown-linux-gnu , features: feat_os_unix_gnueabihf, pgo: PGO } - { 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 } - { 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-gnu , features: "feat_os_unix,openssl,uudoc" , use-cross: no, workspace-tests: true, pgo: PGO } - { os: ubuntu-latest , target: x86_64-unknown-linux-musl , features: feat_os_unix_musl , use-cross: use-cross } - { os: ubuntu-latest , target: x86_64-unknown-netbsd, features: "feat_os_unix", use-cross: use-cross , skip-tests: true , check-only: true } - - { os: ubuntu-latest , target: x86_64-unknown-redox , features: feat_os_unix_redox , use-cross: redoxer , skip-tests: true , check-only: true } + # - { os: ubuntu-latest , target: x86_64-unknown-redox , features: feat_os_unix_redox , use-cross: redoxer , skip-tests: true , check-only: true } - { os: ubuntu-latest , target: wasm32-wasip1, default-features: false, features: feat_wasm, skip-tests: true } - - { os: macos-latest , target: aarch64-apple-darwin , features: feat_os_unix, workspace-tests: true } # M1 CPU - # PR #7964: chcon should not break build without the feature. cargo check is enough to detect it. - - { os: macos-latest , target: aarch64-apple-darwin , workspace-tests: true, check-only: true } # M1 CPU - - { os: macos-latest , target: x86_64-apple-darwin , features: feat_os_unix, workspace-tests: true } + - { os: ubuntu-latest , target: wasm32-wasip2, default-features: false, features: feat_wasm, skip-tests: true } + - { os: macos-latest , target: aarch64-apple-darwin , features: "feat_os_unix,openssl", workspace-tests: true, pgo: PGO } # M1 CPU + - { os: macos-latest , target: x86_64-apple-darwin , features: feat_os_unix, workspace-tests: true, pgo: PGO } - { os: windows-latest , target: i686-pc-windows-msvc , features: feat_os_windows } # msvc and gnu works on the same target. So publishing msvc is enough. - { os: windows-latest , target: x86_64-pc-windows-gnu , features: feat_os_windows, skip-publish: true } - - { os: windows-latest , target: x86_64-pc-windows-msvc , features: feat_os_windows } - - { os: windows-latest , target: aarch64-pc-windows-msvc , features: feat_os_windows, use-cross: use-cross , skip-tests: true } + - { os: windows-latest , target: x86_64-pc-windows-msvc , features: "feat_os_windows,openssl", pgo: PGO } + # No `pgo` here: on aarch64-pc-windows-msvc the instrumented binary + # writes .profraw files whose name table llvm-profdata rejects with + # "symbol name is empty", so every profile is unusable. Upstream bugs: + # https://github.com/rust-lang/rust/issues/150123 (same message with + # `-C instrument-coverage`) and https://github.com/rust-lang/rust/issues/156675. + - { os: windows-11-arm , target: aarch64-pc-windows-msvc , features: feat_os_windows } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Avoid no space left on device @@ -406,7 +419,7 @@ jobs: key: "${{ matrix.job.os }}_${{ matrix.job.target }}" - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -543,9 +556,6 @@ jobs: sudo apt-get -y install fuse3 libfuse-dev ;; esac - case '${{ matrix.job.os }}' in - macos-latest) brew install coreutils ;; # needed for testing - esac case '${{ matrix.job.os }}' in ubuntu-*) # selinux and systemd headers needed to build tests @@ -611,8 +621,6 @@ jobs: shell: bash if: matrix.job.skip-publish != true && matrix.job.check-only == true run: | - # expr breaks redox - if [[ "${{ matrix.job.target }}" == *"redox"* ]]; then sed -i.b '/"expr",/d' Cargo.toml; fi ${{ steps.vars.outputs.CARGO_CMD }} ${{ steps.vars.outputs.CARGO_CMD_OPTIONS }} check \ --target=${{ matrix.job.target }} ${{ steps.vars.outputs.CARGO_FEATURES_OPTION }} ${{ steps.vars.outputs.CARGO_DEFAULT_FEATURES_OPTION }} - name: Test @@ -625,6 +633,35 @@ jobs: ${{ steps.dep_vars.outputs.CARGO_UTILITY_LIST_OPTIONS }} -p coreutils env: RUST_BACKTRACE: "1" + - name: Install llvm-tools (PGO) + if: matrix.job.pgo + shell: bash + run: rustup component add llvm-tools + - name: Train PGO profiles + if: matrix.job.pgo + shell: bash + run: | + ## The target dir is relative: on Windows `github.workspace` is a + ## backslash path that git-bash would not resolve. The script writes the + ## profile's absolute path (natively spelled) to profdata-path.txt. + ./util/build-pgo.sh \ + --target "${{ matrix.job.target }}" \ + --target-dir target/coreutils-pgo \ + --features "${{ matrix.job.features }}" \ + --train-only + echo "RUSTFLAGS=${RUSTFLAGS:+${RUSTFLAGS} }-Cprofile-use=$(cat target/coreutils-pgo/profdata-path.txt)" >> "$GITHUB_ENV" + - name: Verify PGO is applied to the published build + if: matrix.job.pgo + shell: bash + run: | + ## The release artifact must be the PGO build: if RUSTFLAGS did not + ## survive GITHUB_ENV we would silently publish an unoptimized binary. + echo "RUSTFLAGS=${RUSTFLAGS}" + case "${RUSTFLAGS}" in + *-Cprofile-use=*) ;; + *) echo "::error::-Cprofile-use missing from RUSTFLAGS" ; exit 1 ;; + esac + test -s "$(cat target/coreutils-pgo/profdata-path.txt)" - name: Build coreutils shell: bash if: matrix.job.skip-publish != true && matrix.job.check-only != true && matrix.job.target != 'x86_64-pc-windows-msvc' @@ -680,6 +717,7 @@ jobs: if: steps.vars.outputs.DEPLOY && matrix.job.skip-publish != true && matrix.job.check-only != true with: draft: true + overwrite_files: true files: | ${{ steps.vars.outputs.STAGING }}/${{ steps.vars.outputs.PKG_NAME }} docs.tar.zst @@ -716,7 +754,7 @@ jobs: # FIXME: Re-enable Code Coverage on windows, which currently fails due to "profiler_builtins". See #6686. # - { os: windows-latest , features: windows, toolchain: stable-x86_64-pc-windows-gnu } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: taiki-e/install-action@v2 @@ -725,7 +763,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -765,15 +803,12 @@ jobs: shell: bash run: | ## Install/setup prerequisites - case '${{ matrix.job.os }}' in - macos-latest) brew install coreutils ;; # needed for testing - esac - case '${{ matrix.job.os }}' in ubuntu-latest) # selinux and systemd headers needed to build tests + # acl: setfacl for coverage profile-trace dir default ACL (umask 0777 tests) sudo apt-get -y update - sudo apt-get -y install libselinux1-dev libsystemd-dev + sudo apt-get -y install libselinux1-dev libsystemd-dev acl # pinky is a tool to show logged-in users from utmp, and gecos fields from /etc/passwd. # In GitHub Action *nix VMs, no accounts log in, even the "runner" account that runs the commands, and "system boot" entry is missing. # The account also has empty gecos fields. @@ -830,8 +865,8 @@ jobs: flags: coverage,${{ matrix.job.os }} fail_ci_if_error: false - test_openssl: - name: Build/OpenSSL + test_openssl_dynamic: + name: Build/OpenSSL (dynamic) needs: [ min_version, deps ] runs-on: ${{ matrix.job.os }} timeout-minutes: 60 @@ -839,73 +874,85 @@ jobs: fail-fast: false matrix: job: - # Linux: exercises both vendored (static) and OPENSSL_NO_VENDOR=1 - # (dynamic against system libcrypto). ubuntu-latest ships libssl-dev. - - { os: ubuntu-latest, features: feat_os_unix, dynamic: true } + # Linux: exercises OPENSSL_NO_VENDOR=1 (dynamic against system libcrypto). ubuntu-latest ships libssl-dev + # binaries with OPENSSL_NO_VENDOR=0 are tested and published at build job + - { os: ubuntu-latest, features: "md5sum,sha1sum,sha224sum,sha256sum,sha384sum,sha512sum,cksum,openssl" } # macOS: vendored only — system libcrypto needs OPENSSL_DIR # pointing at Homebrew, which isn't worth wiring up for a smoke test. - - { os: macos-latest, features: feat_os_unix, dynamic: false } - # Windows is omitted: the `openssl` dep is `cfg(unix)`, so the - # feature is a no-op there and there's nothing to exercise. + # MinGW(UCRT): done at make.yml as actual consumer is MSYS2 only steps: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@stable - uses: taiki-e/install-action@nextest - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' run: | echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV - # Default mode: `openssl` crate's `vendored` feature builds libcrypto - # from source and statically links it (mirrors how `expr` links - # oniguruma). Runs the md5sum/sha*sum/cksum integration tests end-to-end - # so we'd catch a wrong-digest regression, not just a type error. - - name: Test checksum utilities with OpenSSL (vendored / static) + - name: Test checksum utilities with OpenSSL (system / dynamic) shell: bash env: + OPENSSL_NO_VENDOR: "1" RUST_BACKTRACE: "1" run: | cargo nextest run --hide-progress-bar --profile ci \ - --features "${{ matrix.job.features }},openssl" \ - -E 'test(/^test_(md5sum|sha1sum|sha224sum|sha256sum|sha384sum|sha512sum|cksum)::/)' - # Confirm the vendored build is actually statically linked. If this - # regresses, the `vendored` feature has stopped doing its job. - - name: Verify static linkage (vendored) - if: matrix.job.os == 'ubuntu-latest' - shell: bash + --no-default-features --features "${{ matrix.job.features }}" + + test_no_diagnostics: + name: Build/No-diagnostics + needs: [ min_version, deps ] + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: taiki-e/install-action@nextest + - uses: Swatinem/rust-cache@v2 + - name: Run sccache-cache + id: sccache-setup + uses: mozilla-actions/sccache-action@v0.0.11 + continue-on-error: true + - name: Export sccache + if: steps.sccache-setup.outcome == 'success' run: | - cargo build --release --features "${{ matrix.job.features }},openssl" --bin coreutils - if ldd target/release/coreutils 2>&1 | grep -iE 'libssl|libcrypto'; then - echo "ERROR: coreutils dynamically links libssl/libcrypto despite vendored feature" - exit 1 - fi - echo "OK: no dynamic libssl/libcrypto linkage" - # Second linking mode: OPENSSL_NO_VENDOR=1 links dynamically against - # the system libcrypto/libssl. - - name: Test checksum utilities with OpenSSL (system / dynamic) - if: matrix.job.dynamic + echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV + echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV + # The caret diagnostics (`feat_diagnostics`) are on by default; packagers + # who want the smaller dependency tree build with them off, so that build + # must stay green. The caret snapshot tests are gated on the feature and + # compile out; everything else runs as usual against the plain messages. + - name: Test without the diagnostics feature shell: bash env: - OPENSSL_NO_VENDOR: "1" RUST_BACKTRACE: "1" run: | cargo nextest run --hide-progress-bar --profile ci \ - --features "${{ matrix.job.features }},openssl" \ - -E 'test(/^test_(md5sum|sha1sum|sha224sum|sha256sum|sha384sum|sha512sum|cksum)::/)' + --no-default-features --features feat_os_unix + # Confirm the feature actually compiles the renderer out. If ariadne is + # still in the tree, some crate re-grew a hard dependency on it. + # (`cargo tree -i` exits non-zero when the crate is absent from the graph.) + - name: Verify ariadne is compiled out + shell: bash + run: | + if cargo tree -p coreutils --no-default-features --features feat_os_unix -e normal -i ariadne 2>/dev/null; then + echo "ERROR: ariadne is still in the dependency tree without feat_diagnostics" + exit 1 + fi + echo "OK: ariadne is not in the dependency tree" test_selinux: name: Build/SELinux needs: [ min_version, deps ] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Setup Lima @@ -921,8 +968,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 --nodocs gcc g++ git rustup libselinux-devel clang-devel attr -y - lima rustup-init -y --default-toolchain stable --profile minimal -c clippy + lima sudo dnf install --nodocs cargo clippy gcc g++ git rust libselinux-devel clang-devel attr -y - name: Verify SELinux Status run: | lima getenforce @@ -934,44 +980,15 @@ jobs: - name: Lint with SELinux 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) - needs: [ min_version, deps ] - runs-on: ${{ matrix.job.os }} - strategy: - fail-fast: false - matrix: - job: - - { os: macos-latest , features: feat_os_unix } - - { os: windows-latest , features: feat_os_windows } - - steps: - - uses: actions/checkout@v7.0.0 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Build SELinux utilities as stubs - run: cargo build -p uu_chcon -p uu_runcon - - name: Verify stub binaries exist - shell: bash - run: | - test -f target/debug/chcon || test -f target/debug/chcon.exe - test -f target/debug/runcon || test -f target/debug/runcon.exe - # check is enough to detect workspace breakage by chcon - - name: Verify workspace builds with stubs - run: cargo check --features ${{ matrix.job.features }} - test_safe_traversal: name: Safe Traversal Security Check runs-on: ubuntu-latest needs: [ min_version, deps ] steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Install strace and libselinux headers (needed to build chcon) run: sudo apt-get update && sudo apt-get install -y strace libselinux1-dev @@ -980,16 +997,49 @@ jobs: - name: Run safe traversal verification run: ./util/check-safe-traversal.sh + test_libc_interposition: + name: libc Interposition Check + runs-on: ubuntu-latest + needs: [ min_version, deps ] + + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@v2 + - name: Install fakeroot + run: sudo apt-get update && sudo apt-get install -y fakeroot + - name: Build utilities that must stay interposable + run: cargo build --profile=release-small -p uu_chmod -p uu_chown + - name: Run libc interposition verification + run: ./util/check-libc-interposition.sh + + test_open_once: + name: Redundant Open Check + runs-on: ubuntu-latest + needs: [ min_version, deps ] + + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@v2 + - name: Install strace + run: sudo apt-get update && sudo apt-get install -y strace + - name: Build utilities for redundant open checks + run: cargo build --profile=release-small -p uu_cp -p uu_mv + - name: Run redundant open verification + run: ./util/check-open-once.sh + test_toctou: name: TOCTOU Security Check runs-on: ubuntu-latest needs: [ min_version, deps ] steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Install strace run: sudo apt-get update && sudo apt-get install -y strace diff --git a/.github/workflows/CheckScripts.yml b/.github/workflows/CheckScripts.yml index 51ce03e15a6..61d9cbf6bc1 100644 --- a/.github/workflows/CheckScripts.yml +++ b/.github/workflows/CheckScripts.yml @@ -29,7 +29,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Run ShellCheck @@ -47,7 +47,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Setup shfmt diff --git a/.github/workflows/FixPR.yml b/.github/workflows/FixPR.yml index aaca44294e1..a38a76a4432 100644 --- a/.github/workflows/FixPR.yml +++ b/.github/workflows/FixPR.yml @@ -29,7 +29,7 @@ jobs: job: - { os: ubuntu-latest , features: feat_os_unix } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Initialize job variables @@ -72,7 +72,7 @@ jobs: ## * using the 'stable' toolchain is necessary to avoid "unexpected '--filter-platform'" errors 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@v10 + uses: EndBug/add-and-commit@v11 with: new_branch: ${{ env.BRANCH_TARGET }} default_author: github_actions diff --git a/.github/workflows/GnuComment.yml b/.github/workflows/GnuComment.yml index 8217888f46b..ed5deb0505c 100644 --- a/.github/workflows/GnuComment.yml +++ b/.github/workflows/GnuComment.yml @@ -17,7 +17,9 @@ jobs: runs-on: ubuntu-latest if: > - github.event.workflow_run.event == 'pull_request' + github.event.workflow_run.event == 'pull_request' && + (github.event.workflow_run.conclusion == 'success' || + github.event.workflow_run.conclusion == 'failure') steps: - name: 'Download artifact' uses: actions/github-script@v9 @@ -34,6 +36,10 @@ jobs: var matchArtifact = artifacts.data.artifacts.filter((artifact) => { return artifact.name == "comment" })[0]; + if (!matchArtifact) { + core.info("No comment artifact found; skipping."); + return; + } var download = await github.rest.actions.downloadArtifact({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index bfde4a0913a..76ab2fe4b36 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -4,7 +4,6 @@ name: GnuTests # spell-checker:ignore (jargon) submodules devel # spell-checker:ignore (libs/utils) chksum dpkg getenforce 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 # spell-checker:ignore (vars) FILESET SUBDIRS XPASS # spell-checker:ignore userns nodocs @@ -15,6 +14,8 @@ on: push: branches: - '*' + ## the reference branch carries no code, only published test results + - '!gnu-reference' permissions: contents: read @@ -26,6 +27,9 @@ concurrency: env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + ## Machine-maintained branch holding the latest GNU test results of the + ## default branch; every PR run is compared against what it carries. + REFERENCE_BRANCH: 'gnu-reference' TEST_FULL_SUMMARY_FILE: 'gnu-full-result.json' TEST_ROOT_FULL_SUMMARY_FILE: 'gnu-root-full-result.json' TEST_STTY_FULL_SUMMARY_FILE: 'gnu-stty-full-result.json' @@ -36,11 +40,11 @@ env: jobs: native: name: Run GNU tests (native) - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 # some strace? test needs newer glibc steps: #### Get the code - name: Checkout code (uutils) - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: path: 'uutils' persist-credentials: false @@ -57,8 +61,6 @@ jobs: 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 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 shell: bash run: | @@ -110,21 +112,6 @@ jobs: path_UUTILS='uutils' python uutils/util/gnu-json-result.py gnu/tests > ${{ env.TEST_FULL_SUMMARY_FILE }} - ### Run tests as root - - name: Run GNU root tests - shell: bash - run: | - ## Run GNU root tests - path_GNU='gnu' - path_UUTILS='uutils' - bash "uutils/util/run-gnu-test.sh" run-root - - - name: Extract testing info from individual logs (run as root) into JSON - shell: bash - run : | - path_UUTILS='uutils' - python uutils/util/gnu-json-result.py gnu/tests > ${{ env.TEST_ROOT_FULL_SUMMARY_FILE }} - ### This shell has been changed from "bash" to this command ### "script" will start a pty and the -q command removes the "script" initiation log ### the -e flag makes it propagate the error code and -c runs the command in a pty @@ -146,6 +133,21 @@ jobs: path_UUTILS='uutils' python uutils/util/gnu-json-result.py gnu/tests > ${{ env.TEST_STTY_FULL_SUMMARY_FILE }} + ### Run tests as root + - name: Run GNU root tests + shell: bash + run: | + ## Run GNU root tests + path_GNU='gnu' + path_UUTILS='uutils' + bash "uutils/util/run-gnu-test.sh" run-root + + - name: Extract testing info from individual logs (run as root) into JSON + shell: bash + run : | + path_UUTILS='uutils' + python uutils/util/gnu-json-result.py gnu/tests > ${{ env.TEST_ROOT_FULL_SUMMARY_FILE }} + ### Upload artifacts - name: Upload full json results uses: actions/upload-artifact@v7 @@ -178,11 +180,11 @@ jobs: selinux: name: Run GNU tests (SELinux) - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest # using VM's glibc steps: #### Get the code - name: Checkout code (uutils) - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: path: 'uutils' persist-credentials: false @@ -208,8 +210,7 @@ jobs: - name: Install dependencies in VM run: | - 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 + lima sudo dnf -y install --nodocs autoconf bison cargo clippy gperf gcc gdb jq libacl-devel libattr-devel libcap-devel libselinux-devel attr rust clang-devel automake patch quilt - name: Copy the sources to VM run: | rsync -a -e ssh . lima-default:~/work/ @@ -290,10 +291,10 @@ jobs: qemu: name: Run GNU tests (SMACK/ROOTFS) - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest # using Arch glibc steps: - name: Checkout code (uutils) - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: path: 'uutils' persist-credentials: false @@ -331,11 +332,10 @@ jobs: aggregate: 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 - pull-requests: read # for dawidd6/action-download-artifact to query commit hash + actions: read # to download this run's own result artifacts + contents: read # for actions/checkout to fetch code, and to read the reference branch name: Aggregate GNU test results - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - name: Initialize workflow variables id: vars @@ -349,20 +349,86 @@ jobs: outputs TEST_SUMMARY_FILE AGGREGATED_SUMMARY_FILE - name: Checkout code (uutils) - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: path: 'uutils' persist-credentials: false - - name: Retrieve reference artifacts - uses: dawidd6/action-download-artifact@v21 - # ref: - continue-on-error: true ## don't break the build for missing reference artifacts (may be expired or just not generated yet) - with: - workflow: GnuTests.yml - branch: "${{ env.DEFAULT_BRANCH }}" - # workflow_conclusion: success ## (default); * but, if commit with failed GnuTests is merged into the default branch, future commits will all show regression errors in GnuTests CI until o/w fixed - workflow_conclusion: completed ## continually recalibrates to last commit of default branch with a successful GnuTests (ie, "self-heals" from GnuTest regressions, but needs more supervision for/of regressions) - path: "reference" + - name: Fetch reference results (default branch) + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + ## Fetch the reference results published by the last GnuTests run on the + ## default branch. + ## They live on a dedicated, machine-maintained branch rather than in a + ## workflow artifact: an artifact is addressable only by run id, so + ## finding one meant listing workflow runs, and the server-side 'branch' + ## filter of that listing is served by a search index that is only + ## eventually consistent (observed returning nothing newer than a month + ## old while the unfiltered listing was current). A run resolved from a + ## stale listing made every test fixed on the default branch since then + ## look like a fix contributed by the PR, and every test broken since + ## then like a regression introduced by it. A branch has no such index, + ## and no 90-day expiry either. + ## A missing reference is not fatal: the branch does not exist until the + ## first run on the default branch has published to it. + mkdir -p reference/aggregated-result reference/test-summary + ## Resolve the branch tip once and read every file at that commit. The + ## branch is force-pushed by 'publish-reference', so reading each file + ## by branch name could straddle a publish and pair one file with + ## another reference's, comparing against a baseline that never existed. + ## Only a 404 means "no reference yet"; any other failure must not be + ## mistaken for one, or a transient API error would silently skip both + ## comparisons and let a regression through unnoticed. + REFERENCE_SHA='' + ref_err="${RUNNER_TEMP:-/tmp}/reference-ref.err" + for attempt in 1 2 3; do + if REFERENCE_SHA=$(gh api "repos/${{ github.repository }}/git/refs/heads/${REFERENCE_BRANCH}" \ + --jq '.object.sha' 2>"${ref_err}"); then + break + fi + REFERENCE_SHA='' + if grep -q 'HTTP 404' "${ref_err}"; then + ## The branch does not exist until the first run on the default + ## branch has published to it. + break + fi + if [[ "${attempt}" -eq 3 ]]; then + cat "${ref_err}" >&2 + echo "::error ::Could not resolve the '${REFERENCE_BRANCH}' branch; refusing to report an API failure as a missing baseline." + exit 1 + fi + sleep $(( attempt * 5 )) + done + fetch() { + gh api "repos/${{ github.repository }}/contents/${1}?ref=${REFERENCE_SHA}" \ + -H 'Accept: application/vnd.github.raw' > "${2}" 2>/dev/null \ + && [[ -s "${2}" ]] + } + if [[ -n "${REFERENCE_SHA}" ]] \ + && fetch aggregated-result.json reference/aggregated-result/aggregated-result.json \ + && fetch gnu-result.json reference/test-summary/gnu-result.json; then + ## 'meta.json' says which commit the reference was built from; it is + ## informational, so tolerate it being absent or unparsable. + meta=$(gh api "repos/${{ github.repository }}/contents/meta.json?ref=${REFERENCE_SHA}" \ + -H 'Accept: application/vnd.github.raw' 2>/dev/null || true) + published_sha=$(jq -r '.sha // empty' <<< "${meta}" 2>/dev/null || true) + published_at=$(jq -r '.published_at // empty' <<< "${meta}" 2>/dev/null || true) + echo "Reference: ${{ env.DEFAULT_BRANCH }}@${published_sha:-} (published ${published_at:-}, ${REFERENCE_BRANCH}@${REFERENCE_SHA})" + published_epoch='' + if [[ -n "${published_at}" ]]; then + published_epoch=$(date -u -d "${published_at}" +%s 2>/dev/null || true) + fi + if [[ -n "${published_epoch}" ]]; then + age_days=$(( ( $(date -u +%s) - published_epoch ) / 86400 )) + if [[ "${age_days}" -gt 2 ]]; then + echo "::warning ::The reference results are ${age_days} days old (${published_at}); the comparison below may report unrelated fixes and regressions." + fi + fi + else + rm -f reference/aggregated-result/aggregated-result.json reference/test-summary/gnu-result.json + echo "::warning ::No reference results found on the '${REFERENCE_BRANCH}' branch; the comparisons below will be skipped." + fi - name: Download full json results uses: actions/download-artifact@v8 with: @@ -523,3 +589,104 @@ jobs: else echo "::warning ::Skipping test summary comparison; no prior reference summary is available." fi + + publish-reference: + ## Publish this run's results as the reference that every later PR run is + ## compared against. Only runs on a push to the default branch, so a fork + ## PR can never reach it. + needs: aggregate + if: > + always() + && github.event_name == 'push' + && github.ref_name == github.event.repository.default_branch + && needs.aggregate.result != 'cancelled' + && needs.aggregate.result != 'skipped' + permissions: + actions: read # to download this run's own result artifacts + contents: write # to push to the reference branch + name: Publish reference GNU test results + runs-on: ubuntu-latest + ## Only one run may publish at a time. The step below reads the currently + ## published run id and then force-pushes; two overlapping runs could + ## interleave those, letting an older one overwrite a newer reference. + concurrency: + group: publish-gnu-reference + cancel-in-progress: false + steps: + - name: Download aggregated json results + continue-on-error: true ## 'aggregate' may have failed before uploading + uses: actions/download-artifact@v8 + with: + name: aggregated-result + path: publish + - name: Download test results summary + continue-on-error: true ## 'aggregate' may have failed before uploading + uses: actions/download-artifact@v8 + with: + name: test-summary + path: publish + - name: Publish to the reference branch + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + ## Publish to the reference branch + ## The branch is a single-commit orphan history, force-pushed on every + ## run, so it never accumulates history. Runs on the default branch can + ## finish out of order, so refuse to overwrite a reference published by a + ## *newer* run (run ids are monotonic). + if [[ ! -s publish/aggregated-result.json || ! -s publish/gnu-result.json ]]; then + echo "::warning ::This run produced no results to publish; leaving the '${REFERENCE_BRANCH}' branch untouched." + exit 0 + fi + ## Fail closed: only a 404 means nothing has been published yet. A + ## suppressed error read as run id 0 would let this run force-push over + ## a reference a newer run had already placed. + published_run=0 + meta_err="${RUNNER_TEMP:-/tmp}/published-meta.err" + for attempt in 1 2 3; do + if published_meta=$(gh api "repos/${{ github.repository }}/contents/meta.json?ref=${REFERENCE_BRANCH}" \ + -H 'Accept: application/vnd.github.raw' 2>"${meta_err}"); then + published_run=$(jq -r '.run_id // 0' <<< "${published_meta}" 2>/dev/null || echo 0) + break + fi + if grep -q 'HTTP 404' "${meta_err}"; then + break + fi + if [[ "${attempt}" -eq 3 ]]; then + cat "${meta_err}" >&2 + echo "::error ::Could not read the published reference; refusing to force-push over a possibly newer one." + exit 1 + fi + sleep $(( attempt * 5 )) + done + if [[ "${published_run}" =~ ^[0-9]+$ ]] && (( published_run > GITHUB_RUN_ID )); then + echo "::notice ::Run ${published_run} already published a newer reference; nothing to do." + exit 0 + fi + jq -n --arg sha "${GITHUB_SHA}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg branch "${GITHUB_REF_NAME}" \ + --arg published_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{sha: $sha, run_id: $run_id, branch: $branch, published_at: $published_at}' \ + > publish/meta.json + printf '%s\n' \ + '# GNU test reference results' \ + '' \ + 'Machine-maintained branch: the GNU test results of the latest run of' \ + 'the `GnuTests` workflow on the default branch. Every pull request run' \ + 'is compared against the files here, so that it reports only the test' \ + 'changes the pull request is itself responsible for.' \ + '' \ + 'Do not commit here by hand; the branch is force-pushed on every run.' \ + > publish/README.md + cd publish + git init -q -b "${REFERENCE_BRANCH}" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -q -m "GNU test results for ${GITHUB_SHA}" + git push -q --force \ + "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:refs/heads/${REFERENCE_BRANCH}" + echo "::notice ::Published reference results for ${GITHUB_SHA}." diff --git a/.github/workflows/NotifyTracking.yml b/.github/workflows/NotifyTracking.yml new file mode 100644 index 00000000000..ef7edd7d604 --- /dev/null +++ b/.github/workflows/NotifyTracking.yml @@ -0,0 +1,81 @@ +name: NotifyTracking + +# spell-checker:ignore zizmor +# spell-checker:ignore (misc) busybox toybox multisize + +## Tell uutils/coreutils-tracking to refresh as soon as a workflow that +## produces one of its inputs has finished on the default branch, so its graphs +## follow the data rather than wait for its scheduled refresh. +## +## 'GnuTests' publishes 'test-summary' and 'aggregated-result'; 'make' publishes +## 'busybox-result.json', 'toybox-result.json', 'size-result' and +## 'individual-size-result'. The tracking workflow picks up whatever is newest +## for each of them, so a notification from either is enough to run it. + +on: + workflow_run: + workflows: ["GnuTests", "make"] + types: + - completed # zizmor: ignore[dangerous-triggers] + +permissions: {} + +jobs: + dispatch: + name: Trigger the tracking refresh + ## Nothing is checked out and no result is read here, so the untrusted + ## content a 'workflow_run' trigger can carry is never executed. + ## + ## A failed run still counts. 'GnuTests' uploads its results and only then + ## fails the comparison when it finds a new failure, and 'publish-reference' + ## publishes regardless; requiring success here would leave the graphs + ## un-refreshed for exactly the regressions they exist to show. + if: > + github.repository == 'uutils/coreutils' + && github.event.workflow_run.event == 'push' + && github.event.workflow_run.head_branch == github.event.repository.default_branch + && github.event.workflow_run.conclusion != 'cancelled' + && github.event.workflow_run.conclusion != 'skipped' + runs-on: ubuntu-latest + ## The 'secrets' context is not available in a step's 'if:', so fold the + ## check into a job-level env var, which is. + env: + HAVE_APP_CREDENTIALS: ${{ secrets.TRACKING_APP_ID != '' && secrets.TRACKING_APP_PRIVATE_KEY != '' }} + steps: + ## A GITHUB_TOKEN is scoped to the repository it runs in, so writing to a + ## sibling repository needs a credential of its own even within the same + ## organization. This uses an organization-owned GitHub App rather than a + ## personal access token: it is not tied to an individual account and does + ## not silently expire. Install it on uutils/coreutils-tracking only, with + ## 'contents: write', which is what the repository_dispatch endpoint needs. + - name: Mint a token for the tracking repository + id: app-token + if: env.HAVE_APP_CREDENTIALS == 'true' + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.TRACKING_APP_ID }} + private-key: ${{ secrets.TRACKING_APP_PRIVATE_KEY }} + owner: uutils + repositories: coreutils-tracking + + - name: Send the repository_dispatch + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + SOURCE_WORKFLOW: ${{ github.event.workflow_run.name }} + SOURCE_RUN_ID: ${{ github.event.workflow_run.id }} + SOURCE_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + ## Send the repository_dispatch + ## A missing token is not an error: the tracking repository still + ## refreshes on its own schedule, this only makes it prompt. + if [[ -z "${GH_TOKEN}" ]]; then + echo "::warning ::TRACKING_APP_ID / TRACKING_APP_PRIVATE_KEY are not set; leaving the tracking repository to its scheduled refresh." + exit 0 + fi + gh api --method POST 'repos/uutils/coreutils-tracking/dispatches' \ + -f 'event_type=refresh-data' \ + -f "client_payload[workflow]=${SOURCE_WORKFLOW}" \ + -f "client_payload[run_id]=${SOURCE_RUN_ID}" \ + -f "client_payload[sha]=${SOURCE_SHA}" + echo "::notice ::Asked uutils/coreutils-tracking to refresh after '${SOURCE_WORKFLOW}' (${SOURCE_SHA})." diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 457706b2965..0d20ba79ee6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -75,7 +75,7 @@ jobs: echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Collect information about runner diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 88d263cc9ec..d92ecc7bd07 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -13,7 +13,7 @@ jobs: audit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: rustsec/audit-check@v2 diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index b85f10c5c76..ac8e6e4afa4 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -5,9 +5,16 @@ name: Benchmarks on: pull_request: + paths: &bench_paths + - 'src/uu/**' + - 'src/uucore/**' + - 'src/uucore_procs/**' + - 'Cargo.toml' + - 'Cargo.lock' push: branches: - main + paths: *bench_paths permissions: contents: read # to fetch code (actions/checkout) @@ -18,7 +25,47 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: + detects-benchmarks: + name: Detect changes on crates + runs-on: ubuntu-latest + outputs: + package: ${{ steps.detect.outputs.package }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + + - id: detect + shell: bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + base="${{ github.event.pull_request.base.sha }}" + else + # for push diff of the previous + base="${{ github.event.before }}" + fi + + changed=$(git diff --name-only "$base" "${{ github.sha }}") + + if echo "$changed" | grep -qE '^(Cargo\.(toml|lock)|src/(uucore|uucore_procs)/)'; then + candidates=$(ls src/uu) + else + candidates=$(echo "$changed" | awk -F'/' '/^src\/uu\// {print $3}' | sort -u) + fi + + package=$(for pkg in $candidates; + do + if [ -d "src/uu/$pkg/benches" ]; then + echo "uu_$pkg" + fi + done | sort -u | jq -R . | jq -sc .) + + echo "package=$package" >> "$GITHUB_OUTPUT" + benchmarks: + needs: detects-benchmarks + if: needs.detects-benchmarks.outputs.package != '[]' name: Run ${{ matrix.type }} benchmarks for ${{ matrix.package }} (CodSpeed) runs-on: ubuntu-latest timeout-minutes: 90 @@ -30,55 +77,17 @@ jobs: max-parallel: 12 matrix: type: [simulation, memory] - package: [ - uu_base64, - uu_cksum, - uu_cp, - uu_cat, - uu_cut, - uu_dd, - uu_df, - uu_du, - uu_echo, - uu_expand, - uu_expr, - uu_fold, - uu_hostname, - uu_join, - uu_ls, - uu_mv, - uu_nl, - uu_numfmt, - uu_rm, - uu_seq, - uu_shuf, - uu_sort, - uu_split, - uu_tee, - uu_timeout, - uu_tr, - uu_tsort, - uu_unexpand, - uu_uniq, - uu_wc, - uu_factor, - uu_date, - uu_csplit, - uu_true, - uu_false - ] + package: ${{ fromJson(needs.detects-benchmarks.outputs.package) }} steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -104,7 +113,7 @@ jobs: cargo codspeed build -m ${{ matrix.type }} -p ${{ matrix.package }} - name: Run ${{ matrix.type }} benchmarks for ${{ matrix.package }} - uses: CodSpeedHQ/action@v4 + uses: CodSpeedHQ/action@v5 env: CODSPEED_LOG: debug with: diff --git a/.github/workflows/check-toml.yml b/.github/workflows/check-toml.yml index ba0c85654c5..ff130fb7693 100644 --- a/.github/workflows/check-toml.yml +++ b/.github/workflows/check-toml.yml @@ -18,7 +18,7 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Install Tombi diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index d273d5a940c..806dfec0e06 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -1,7 +1,7 @@ name: Code Quality # spell-checker:ignore (people) juliangruber pell reactivecircus taiki-e taplo -# spell-checker:ignore (misc) TERMUX noaudio pkill swiftshader esac sccache pcoreutils shopt subshell dequote libsystemd +# spell-checker:ignore (misc) TERMUX noaudio pkill swiftshader esac sccache pcoreutils shopt subshell dequote libsystemd wasi wasip on: pull_request: @@ -33,7 +33,7 @@ jobs: job: - { os: ubuntu-latest , features: feat_os_unix } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Initialize workflow variables @@ -75,8 +75,9 @@ jobs: - { os: macos-latest , features: all , workspace: true } - { os: windows-latest , features: feat_os_windows } - { os: ubuntu-latest , features: feat_wasm , target: wasm32-wasip1 } + - { os: ubuntu-latest , features: feat_wasm , target: wasm32-wasip2 } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@master @@ -87,7 +88,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -149,12 +150,17 @@ jobs: style_spellcheck: name: Style/spelling runs-on: ${{ matrix.job.os }} + env: + # Spelling is enforced locally by the cspell pre-commit hook, where it is + # cheap to fix. Here it is advisory: unknown words show up as annotations + # on the diff instead of blocking the merge. + STYLE_FAIL_ON_FAULT: false strategy: matrix: job: - { os: ubuntu-latest , features: feat_os_unix } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Initialize workflow variables @@ -187,12 +193,28 @@ jobs: S=$(cspell ${CSPELL_CFG_OPTION} --no-summary --no-progress .) && printf "%s\n" "$S" || { printf "%s\n" "$S" ; printf "%s" "$S" | sed -E -n "s/^(.*):([0-9]+):([0-9]+) - Unknown word \(([^)]+)\).*$/::${fault_type} file=\1,line=\2,col=\3::${fault_type^^}: \`cspell\`: Unknown word '\4' (file:'\1', line:\2)/p" ; fault=true ; true ; } if [ -n "${{ steps.vars.outputs.FAIL_ON_FAULT }}" ] && [ -n "$fault" ]; then exit 1 ; fi + style_empty_assertions: + name: Style/Empty assertions check + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - name: Fail if empty stdout/stderr assertions are found + shell: bash + run: | + if grep -rnE '(stdout_is|stderr_is)\(""\)' tests/; then + echo "::error::Found empty stdout/stderr assertions (use specific checks instead of \"\") in tests/" + exit 1 + fi + python: name: Style/Python runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: persist-credentials: false @@ -216,32 +238,27 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: persist-credentials: false - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@master - with: - toolchain: stable - components: rustfmt, clippy - - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 - - name: Install pre-commit - run: pip install pre-commit - - - name: Install cspell - run: npm install -g cspell + - name: Install prek + run: pip install prek - - name: Cache pre-commit environments + - name: Cache prek environments uses: actions/cache@v6 with: - path: ~/.cache/pre-commit - key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} + path: ~/.cache/prek + key: prek-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} restore-keys: | - pre-commit-${{ runner.os }}- + prek-${{ runner.os }}- - - name: Run pre-commit - run: pre-commit run + - name: Run prek + env: + # cspell is enforced locally and reported by Style/spelling; the rust + # hooks are already covered by the Style/format and Style/lint jobs. + SKIP: cspell,rust-linting,rust-clippy,cargo-lock-check + run: prek run --all-files --show-diff-on-failure diff --git a/.github/workflows/copilot-code-review.yml b/.github/workflows/copilot-code-review.yml new file mode 100644 index 00000000000..f6c5fdb4966 --- /dev/null +++ b/.github/workflows/copilot-code-review.yml @@ -0,0 +1,23 @@ +# spell-checker:ignore Swatinem +name: Copilot Code Review + +# Declares the runner used by Copilot code review. Without this file, the +# review can fail to start before its timeout because no runner is assigned. +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/copilot-code-review.yml + +permissions: + contents: read + +jobs: + copilot-setup-steps: + # Copilot code review only supports Ubuntu x64 runners. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index e7175a4007c..1418c4e8187 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Run test in devcontainer diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index d55e29eaf8f..1d83c070326 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: persist-credentials: false @@ -35,9 +35,6 @@ jobs: shell: bash run: sudo apt-get -y update ; sudo apt-get -y install libselinux1-dev libsystemd-dev libacl1-dev libattr1-dev libcap-dev - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - name: Download tldr run: curl -L https://github.com/tldr-pages/tldr/releases/latest/download/tldr.zip -o docs/tldr.zip diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 8e3340f91e8..95a80c29893 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -23,27 +23,28 @@ concurrency: jobs: style: name: Style and Lint - runs-on: ${{ matrix.job.os }} + runs-on: ubuntu-latest timeout-minutes: 45 strategy: fail-fast: false matrix: job: - - { os: ubuntu-24.04 , features: unix } + - { features: unix } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Prepare, build and test - uses: vmactions/freebsd-vm@v1.3.9 + uses: vmactions/freebsd-vm@v1.5.6 with: usesh: true sync: rsync copyback: false # We need jq and GNU coreutils to run show-utils.sh and bash to use inline shell string replacement - prepare: pkg install -y curl sudo jq coreutils bash python3 + prepare: pkg install -y rust sudo jaq coreutils bash python3 run: | ## Prepare, build, and test + sudo ln -s $(command -v jaq) /usr/local/bin/jq # implementation modelled after ref: # * NOTE: All steps need to be run in this block, otherwise, we are operating back on the mac host set -e @@ -61,9 +62,6 @@ jobs: sudo -i -u ${TEST_USER} bash << EOF set -e whoami - curl https://sh.rustup.rs -sSf --output rustup.sh - sh rustup.sh -y -c rustfmt,clippy --profile=minimal -t stable - . ${HOME}/.cargo/env ## VARs setup cd "${WORKSPACE}" unset FAIL_ON_FAULT ; case '${{ env.STYLE_FAIL_ON_FAULT }}' in @@ -111,17 +109,17 @@ jobs: test: name: Tests - runs-on: ${{ matrix.job.os }} + runs-on: ubuntu-latest timeout-minutes: 45 strategy: fail-fast: false matrix: job: - - { os: ubuntu-24.04 , features: unix } + - { features: unix } env: mem: 4096 steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Avoid no space left on device (Ubuntu runner) @@ -133,9 +131,10 @@ jobs: usesh: true sync: rsync copyback: false - prepare: pkg install -y curl gmake sudo jq + prepare: pkg install -y gmake nextest rust sudo jaq run: | ## Prepare, build, and test + sudo ln -s $(command -v jaq) /usr/local/bin/jq # implementation modelled after ref: # * NOTE: All steps need to be run in this block, otherwise, we are operating back on the mac host set -e @@ -156,12 +155,6 @@ jobs: sudo -i -u ${TEST_USER} sh << EOF set -e whoami - curl https://sh.rustup.rs -sSf --output rustup.sh - sh rustup.sh -y --profile=minimal - . $HOME/.cargo/env - # Install nextest - mkdir -p ~/.cargo/bin - curl -LsSf https://get.nexte.st/latest/freebsd | tar zxf - -C ~/.cargo/bin ## Info # environment echo "## environment" @@ -183,6 +176,8 @@ jobs: unset FAULT export CARGO_INCREMENTAL=0 export RUSTFLAGS="-C strip=symbols" # for disk space + # vendored OpenSSL takes many times to build. Use system one (LibreSSL is supported). + export OPENSSL_NO_VENDOR=1 OPENSSL_DIR=/usr cargo build || FAULT=1 export PATH=~/.cargo/bin:${PATH} export RUST_BACKTRACE=1 diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml index 5242e9e9bd7..0c67b0484ff 100644 --- a/.github/workflows/fuzzing.yml +++ b/.github/workflows/fuzzing.yml @@ -8,9 +8,18 @@ env: on: pull_request: + paths: &fuzz_paths + - 'fuzz/**' + - 'src/uu/**' + - 'src/uucore/**' + - 'src/uucore_procs/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/fuzzing.yml' push: branches: - '*' + paths: *fuzz_paths permissions: contents: read # to fetch code (actions/checkout) @@ -21,13 +30,97 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: + detect: + name: Detect changes on crates + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.detect.outputs.targets }} + all: ${{ steps.detect.outputs.all }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + + - id: detect + shell: bash + run: | + # target:should_pass:owning crate (a src/uu package, or "uucore" for + # the ones that fuzz shared parsing code) + TARGETS=" + fuzz_test:true:test + fuzz_date:true:date + fuzz_expr:true:expr + fuzz_printf:true:printf + fuzz_echo:true:echo + fuzz_parse_glob:true:uucore + fuzz_parse_size:false:uucore + fuzz_parse_time:false:uucore + fuzz_seq_parse_number:false:seq + fuzz_seq:false:seq + fuzz_sort:false:sort + fuzz_wc:false:wc + fuzz_cut:false:cut + fuzz_split:false:split + fuzz_tr:false:tr + fuzz_env:false:env + fuzz_cksum:false:cksum + fuzz_non_utf8_paths:true:uucore + fuzz_dirname:true:dirname + " + + if [ "${{ github.event_name }}" = "pull_request" ]; then + base="${{ github.event.pull_request.base.sha }}" + else + # for push diff of the previous + base="${{ github.event.before }}" + fi + + if ! git cat-file -e "$base^{commit}" 2>/dev/null; then + # new branch, force push, ...: no usable base, fuzz everything + changed="" + all=true + else + changed=$(git diff --name-only "$base" "${{ github.sha }}") + # anything shared (uucore, the fuzzers themselves, the manifests or + # this workflow) can affect every target + if echo "$changed" | grep -qE '^(fuzz/|Cargo\.(toml|lock)$|src/(uucore|uucore_procs)/|\.github/workflows/fuzzing\.yml$)'; then + all=true + else + all=false + fi + fi + + if [ "$all" = true ]; then + selected=$(echo "$TARGETS" | awk -F: 'NF==3') + else + crates=$(echo "$changed" | awk -F/ '/^src\/uu\// {print $3}' | sort -u) + selected=$(echo "$TARGETS" | awk -F: -v crates="$crates" ' + BEGIN { n = split(crates, c, "\n"); for (i = 1; i <= n; i++) want[c[i]] = 1 } + NF == 3 && ($3 in want)') + fi + + targets=$(echo "$selected" | jq -Rsc ' + split("\n") | map(select(length > 0) | split(":")) + | map({name: .[0], should_pass: .[1]})') + + echo "all=$all" >> "$GITHUB_OUTPUT" + echo "targets=$targets" >> "$GITHUB_OUTPUT" + echo "Fuzzing everything: $all" + echo "Targets: $targets" + uufuzz-examples: name: Build and test uufuzz examples + needs: detect + if: needs.detect.outputs.all == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false + - uses: Swatinem/rust-cache@v2 + with: + workspaces: "fuzz -> target" - name: Build uufuzz library run: | cd fuzz/uufuzz @@ -52,15 +145,20 @@ jobs: fuzz-build: name: Build the fuzzers + needs: detect + if: needs.detect.outputs.all == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Install `cargo-fuzz` uses: taiki-e/install-action@v2 with: tool: cargo-fuzz + - uses: Swatinem/rust-cache@v2 + with: + workspaces: "fuzz -> target" - name: Emulate a nightly toolchain run: | echo "RUSTC_BOOTSTRAP=1" >> "${GITHUB_ENV}" @@ -70,142 +168,113 @@ jobs: run: cargo fuzz build --target host-tuple fuzz-run: - needs: fuzz-build - name: Fuzz (${{ matrix.group.name }}) + name: Fuzz (${{ matrix.target.name }}) + needs: detect + if: needs.detect.outputs.targets != '[]' runs-on: ubuntu-latest timeout-minutes: 15 env: RUN_FOR: 60 strategy: + fail-fast: false + # Stagger the jobs: all the targets at once hammer the GitHub cache API. + max-parallel: 10 matrix: - group: - - name: smoke - targets: | - fuzz_test:true - fuzz_date:true - fuzz_expr:true - fuzz_printf:true - fuzz_echo:true - - name: parsers - targets: | - fuzz_parse_glob:true - fuzz_parse_size:false - fuzz_parse_time:false - fuzz_seq_parse_number:false - - name: commands-a - targets: | - fuzz_seq:false - fuzz_sort:false - fuzz_wc:false - fuzz_cut:false - fuzz_split:false - - name: commands-b - targets: | - fuzz_tr:false - fuzz_env:false - fuzz_cksum:false - fuzz_non_utf8_paths:true - fuzz_dirname:true + target: ${{ fromJson(needs.detect.outputs.targets) }} steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Install `cargo-fuzz` uses: taiki-e/install-action@v2 with: tool: cargo-fuzz + - uses: Swatinem/rust-cache@v2 + with: + workspaces: "fuzz -> target" - name: Emulate a nightly toolchain run: | echo "RUSTC_BOOTSTRAP=1" >> "${GITHUB_ENV}" - - name: Run ${{ matrix.group.name }} fuzz targets + - name: Run ${{ matrix.target.name }} shell: bash + env: + TARGET: ${{ matrix.target.name }} + SHOULD_PASS: ${{ matrix.target.should_pass }} run: | set -o pipefail mkdir -p fuzz/stats - REQUIRED_TARGET_FAILED=0 - - while IFS=: read -r TARGET SHOULD_PASS; do - [ -n "$TARGET" ] || continue - - STATS_FILE="fuzz/stats/${TARGET}.txt" - # Force the correct target - # https://github.com/rust-fuzz/cargo-fuzz/issues/398 - set +e - cargo fuzz run --target host-tuple "$TARGET" -- -max_total_time=${{ env.RUN_FOR }} -timeout=${{ env.RUN_FOR }} -detect_leaks=0 -print_final_stats=1 2>&1 | tee "$STATS_FILE" - FUZZ_STATUS=${PIPESTATUS[0]} - set -e - - # Save should_pass value to file for summary job to use - echo "$SHOULD_PASS" > "fuzz/stats/${TARGET}.should_pass" - - # Print stats to job output for immediate visibility - echo "----------------------------------------" - echo "FUZZING STATISTICS FOR $TARGET" - echo "----------------------------------------" - 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: $SHOULD_PASS" - echo "Exit code: $FUZZ_STATUS" - if grep -q "SUMMARY: " "$STATS_FILE"; then - echo "Status: $(grep "SUMMARY: " "$STATS_FILE" | head -1)" - else - echo "Status: Completed" - fi - echo "----------------------------------------" - # Add summary to GitHub step summary - echo "### Fuzzing Results for $TARGET" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY - echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY + STATS_FILE="fuzz/stats/${TARGET}.txt" + # Force the correct target + # https://github.com/rust-fuzz/cargo-fuzz/issues/398 + set +e + cargo fuzz run --target host-tuple "$TARGET" -- -max_total_time=${{ env.RUN_FOR }} -timeout=${{ env.RUN_FOR }} -detect_leaks=0 -print_final_stats=1 2>&1 | tee "$STATS_FILE" + FUZZ_STATUS=${PIPESTATUS[0]} + set -e - if grep -q "stat::number_of_executed_units" "$STATS_FILE"; then - echo "| Runs | $(grep "stat::number_of_executed_units" "$STATS_FILE" | awk '{print $2}') |" >> $GITHUB_STEP_SUMMARY - fi + # Save should_pass value to file for summary job to use + echo "$SHOULD_PASS" > "fuzz/stats/${TARGET}.should_pass" - if grep -q "stat::average_exec_per_sec" "$STATS_FILE"; then - echo "| Execution Rate | $(grep "stat::average_exec_per_sec" "$STATS_FILE" | awk '{print $2}') execs/sec |" >> $GITHUB_STEP_SUMMARY - fi - - if grep -q "stat::new_units_added" "$STATS_FILE"; then - echo "| New Units | $(grep "stat::new_units_added" "$STATS_FILE" | awk '{print $2}') |" >> $GITHUB_STEP_SUMMARY - fi - - echo "| Should pass | $SHOULD_PASS |" >> $GITHUB_STEP_SUMMARY - echo "| Exit code | $FUZZ_STATUS |" >> $GITHUB_STEP_SUMMARY - - if grep -q "SUMMARY: " "$STATS_FILE"; then - echo "| Status | $(grep "SUMMARY: " "$STATS_FILE" | head -1) |" >> $GITHUB_STEP_SUMMARY + fuzz_stat() { + if grep -q "$1" "$STATS_FILE"; then + grep "$1" "$STATS_FILE" | awk '{print $2}' else - echo "| Status | Completed |" >> $GITHUB_STEP_SUMMARY + echo "unknown" fi - - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "$SHOULD_PASS" = "true" ] && [ "$FUZZ_STATUS" -ne 0 ]; then - REQUIRED_TARGET_FAILED=1 - fi - done <<< "${{ matrix.group.targets }}" - - exit "$REQUIRED_TARGET_FAILED" + } + if grep -q "SUMMARY: " "$STATS_FILE"; then + STATUS=$(grep "SUMMARY: " "$STATS_FILE" | head -1) + else + STATUS="Completed" + fi + + # Print stats to job output for immediate visibility + echo "----------------------------------------" + echo "FUZZING STATISTICS FOR $TARGET" + echo "----------------------------------------" + echo "Runs: $(fuzz_stat stat::number_of_executed_units)" + echo "Execution Rate: $(fuzz_stat stat::average_exec_per_sec) execs/sec" + echo "New Units: $(fuzz_stat stat::new_units_added)" + echo "Expected: $SHOULD_PASS" + echo "Exit code: $FUZZ_STATUS" + echo "Status: $STATUS" + echo "----------------------------------------" + + # Add summary to GitHub step summary + { + echo "### Fuzzing Results for $TARGET" + echo "" + echo "| Metric | Value |" + echo "|--------|-------|" + echo "| Runs | $(fuzz_stat stat::number_of_executed_units) |" + echo "| Execution Rate | $(fuzz_stat stat::average_exec_per_sec) execs/sec |" + echo "| New Units | $(fuzz_stat stat::new_units_added) |" + echo "| Should pass | $SHOULD_PASS |" + echo "| Exit code | $FUZZ_STATUS |" + echo "| Status | $STATUS |" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$SHOULD_PASS" = "true" ] && [ "$FUZZ_STATUS" -ne 0 ]; then + exit 1 + fi - name: Upload Stats if: always() uses: actions/upload-artifact@v7 with: - name: fuzz-stats-${{ matrix.group.name }} + name: fuzz-stats-${{ matrix.target.name }} path: | fuzz/stats/*.txt fuzz/stats/*.should_pass retention-days: 5 fuzz-summary: - needs: fuzz-run + needs: [detect, fuzz-run] name: Fuzzing Summary runs-on: ubuntu-latest - if: always() + if: always() && needs.detect.outputs.targets != '[]' steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Download all stats diff --git a/.github/workflows/ignore-intermittent.txt b/.github/workflows/ignore-intermittent.txt index 8e90a89c266..82761434c8f 100644 --- a/.github/workflows/ignore-intermittent.txt +++ b/.github/workflows/ignore-intermittent.txt @@ -1,6 +1,5 @@ tests/basenc/bounded-memory tests/cut/bounded-memory -tests/date/date-locale-hour tests/date/resolution tests/expand/bounded-memory tests/pr/bounded-memory diff --git a/.github/workflows/l10n.yml b/.github/workflows/l10n.yml index 890d03f86f0..c6bc7b20f8f 100644 --- a/.github/workflows/l10n.yml +++ b/.github/workflows/l10n.yml @@ -34,14 +34,14 @@ jobs: - { os: macos-latest , features: "feat_os_unix" } - { os: windows-latest , features: "feat_os_windows" } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: taiki-e/install-action@nextest - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -69,10 +69,6 @@ jobs: sudo locale-gen --keep-existing hu_HU.UTF-8 # Hungary sudo update-locale ;; - macos-*) - # needed for testing - brew install coreutils - ;; esac - name: Test l10n functionality shell: bash @@ -85,7 +81,7 @@ jobs: name: L10n/Fluent Syntax Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Install Mozilla Fluent Linter @@ -133,13 +129,13 @@ jobs: name: L10n/Clap Error Localization Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -296,13 +292,13 @@ jobs: name: L10n/French Integration Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -407,13 +403,13 @@ jobs: - { os: ubuntu-latest , features: "feat_os_unix" } - { os: macos-latest , features: "feat_os_unix" } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -428,9 +424,6 @@ jobs: ubuntu-*) sudo apt-get -y update ; sudo apt-get -y install libselinux1-dev ;; - macos-*) - brew install coreutils - ;; esac - name: Install via make and test multi-call binary shell: bash @@ -560,13 +553,13 @@ jobs: - { os: ubuntu-latest , features: "feat_os_unix" } - { os: macos-latest , features: "feat_os_unix" } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -584,9 +577,6 @@ jobs: sudo locale-gen --keep-existing fr_FR.UTF-8 locale -a | grep -i fr || echo "French locale generation may have failed" ;; - macos-*) - brew install coreutils - ;; esac - name: Test Make installation shell: bash @@ -894,13 +884,13 @@ jobs: name: L10n/Locale Support Verification runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -1126,13 +1116,13 @@ jobs: name: L10n/Embedded Locale Regression Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -1152,7 +1142,7 @@ jobs: name: L10n/Locale Embedding - Cat Utility runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 @@ -1183,7 +1173,7 @@ jobs: name: L10n/Locale Embedding - Ls Utility runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 @@ -1214,7 +1204,7 @@ jobs: name: L10n/Locale Embedding - Multicall Binary runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 @@ -1247,7 +1237,7 @@ jobs: name: L10n/Locale Embedding - Cargo Install runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 @@ -1255,7 +1245,7 @@ jobs: key: cargo-install-locale-embedding - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' diff --git a/.github/workflows/make.yml b/.github/workflows/make.yml index 6ec07978a49..6c424e4c65f 100644 --- a/.github/workflows/make.yml +++ b/.github/workflows/make.yml @@ -4,7 +4,7 @@ name: make # spell-checker:ignore (jargon) deps softprops toolchain # spell-checker:ignore (people) dawidd # spell-checker:ignore (shell/tools) bsdtar nextest pacman sccache zstd -# spell-checker:ignore (misc) bindir busytest defconfig dynamicbase DESTDIR manpages multisize noconfirm runtest testsuite toybox uutils +# spell-checker:ignore (misc) bindir busytest defconfig dynamicbase DESTDIR libopenssl manpages multisize noconfirm runtest testsuite toybox uutils env: PROJECT_NAME: coreutils @@ -45,7 +45,7 @@ jobs: job: - { os: ubuntu-latest , features: feat_os_unix } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: taiki-e/install-action@nextest @@ -53,7 +53,7 @@ jobs: # Test build on the system missing libselinux (don't install libselinux1-dev at here) - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -182,14 +182,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@v21 + uses: dawidd6/action-download-artifact@v27 with: workflow: make.yml name: individual-size-result repo: uutils/coreutils path: dl - name: Download the previous size result - uses: dawidd6/action-download-artifact@v21 + uses: dawidd6/action-download-artifact@v27 with: workflow: make.yml name: size-result @@ -287,13 +287,13 @@ jobs: job: - { os: windows-latest , features: feat_os_windows } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -311,20 +311,24 @@ jobs: # Upgrading it mid-session changes the shared memory layout (60344 -> 59320), # which causes subsequent fork() calls to fail with EAGAIN (exit code 254). shell: 'C:\msys64\usr\bin\bash.exe --login -eo pipefail {0}' + # test Cygwin & MinGW(UCRT) instead of pre-installed Rust. run: | - pacman -Sy --noconfirm --needed make rust base-devel + pacman -Sy --noconfirm --needed base-devel libopenssl make openssl-devel pkgconf rust \ + mingw-w64-ucrt-x86_64-openssl mingw-w64-ucrt-x86_64-rust env: CHERE_INVOKING: 1 - name: "`(x86_64-pc-cygwin) make install PROG_PREFIX=uu- PROFILE=release-small COMPLETIONS=n MANPAGES=n LOCALES=n`" shell: 'C:\msys64\usr\bin\bash.exe --login -eo pipefail {0}' run: | set -x + export CARGOFLAGS="--features openssl" DESTDIR=/tmp/c make install PROG_PREFIX=uu- PROFILE=release-small COMPLETIONS=n MANPAGES=n LOCALES=n - # Check that utils are built with given profile - ./target/release-small/true + # Check that cksum is linked with openssl (e.g. msys-crypto-*.dll) + # Also check that utils are built with given profile + ldd target/release-small/cksum | grep crypto # Check that the progs have prefix test -f /tmp/c/usr/local/bin/uu-tty - test -f /tmp/c/usr/local/libexec/uu-coreutils/libstdbuf.* + test -f /tmp/c/usr/local/libexec/uu-coreutils/libstdbuf.dll # Check that the manpage is not present ! test -f /tmp/c/usr/local/share/man/man1/uu-whoami.1 # Check that the completion is not present @@ -333,9 +337,10 @@ jobs: ! test -f /tmp/c/usr/local/share/fish/vendor_completions.d/uu-cat.fish # package mv target/release-small/deps/stdbuf.dll -t /tmp/c/usr/local/bin - bsdtar -caf x86_64-pc-cygwin.tar.zst -C /tmp/c/usr/local bin + bsdtar -caf individual-x86_64-pc-cygwin.tar.zst -C /tmp/c/usr/local bin env: CHERE_INVOKING: 1 + OPENSSL_NO_VENDOR: "1" RUST_BACKTRACE: "1" - name: Publish uses: softprops/action-gh-release@v3 @@ -345,17 +350,24 @@ jobs: draft: false prerelease: true files: | - x86_64-pc-cygwin.tar.zst + individual-x86_64-pc-cygwin.tar.zst env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: "`make install`" - shell: bash + - name: "`make install` (MinGW UCRT)" + shell: 'C:\msys64\usr\bin\bash.exe --login -eo pipefail {0}' + env: + CHERE_INVOKING: 1 + MSYSTEM: "UCRT64" + OPENSSL_DIR: "/ucrt64" + OPENSSL_NO_VENDOR: "1" + OPENSSL_STATIC: "0" run: | set -x + export CARGOFLAGS="--features openssl" # Check that we exclude unix programs to avoid build failure # Windows's stdbuf depends on Cygwin's libstdbuf. So make should not try to install libstdbuf make install DESTDIR=/tmp/w PREFIX=/tmp/usr MULTICALL=y COMPLETIONS=n MANPAGES=n LOCALES=n \ - SKIP_UTILS="arch b2sum base32 base64 basename basenc cat cksum comm cp csplit cut date dd df dir dircolors dirname du echo env expand expr factor false fmt fold head hostname join link ln ls md5sum mkdir mktemp more mv nl nproc numfmt od paste pathchk pr printenv printf ptx pwd readlink realpath rm rmdir seq sha1sum sha224sum sha256sum sha384sum sha512sum shred shuf sleep sort split sum sync tac tail tee test touch tr true truncate tsort tty uname unexpand uniq unlink vdir wc whoami yes" + SKIP_UTILS="arch b2sum base32 base64 basename basenc cat comm cp csplit cut date dd df dir dircolors dirname du echo env expand expr factor false fmt fold head hostname hostid join link ln ls md5sum mkdir mktemp more mv nl nproc numfmt od paste pathchk pr printenv printf ptx pwd readlink realpath rm rmdir seq sha1sum sha224sum sha256sum sha384sum sha512sum shred shuf sleep sort split sum sync tac tail tee test touch tr true truncate tsort tty uname unexpand uniq unlink vdir wc whoami yes" target/release/coreutils.exe stdbuf --version test_busybox: @@ -373,13 +385,13 @@ jobs: run: | ## VARs setup echo "TEST_SUMMARY_FILE=busybox-result.json" >> $GITHUB_OUTPUT - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' @@ -459,13 +471,13 @@ jobs: outputs() { step_id="${{ github.action }}"; for var in "$@" ; do echo steps.${step_id}.outputs.${var}="${!var}"; echo "${var}=${!var}" >> $GITHUB_OUTPUT; done; } TEST_SUMMARY_FILE="toybox-result.json" outputs TEST_SUMMARY_FILE - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 - name: Run sccache-cache id: sccache-setup - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@v0.0.11 continue-on-error: true - name: Export sccache if: steps.sccache-setup.outcome == 'success' diff --git a/.github/workflows/manpage-lint.yml b/.github/workflows/manpage-lint.yml index 17c750204e6..58ad8eb9b04 100644 --- a/.github/workflows/manpage-lint.yml +++ b/.github/workflows/manpage-lint.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7.0.0 + uses: actions/checkout@v7.0.1 with: persist-credentials: false @@ -46,9 +46,6 @@ jobs: sudo apt-get install -y mandoc locales-all sudo apt-get install -y libselinux1-dev libsystemd-dev libacl1-dev libattr1-dev libcap-dev - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - name: Build manpages (${{ matrix.locale }}) run: | # Create temporary directory for manpages diff --git a/.github/workflows/openbsd.yml b/.github/workflows/openbsd.yml index 518b9bf5fb5..73120728818 100644 --- a/.github/workflows/openbsd.yml +++ b/.github/workflows/openbsd.yml @@ -1,6 +1,6 @@ name: OpenBSD -# spell-checker:ignore sshfs usesh vmactions taiki esac fdescfs fdesc sccache nextest copyback logind bindgen libclang +# spell-checker:ignore sshfs usesh vmactions taiki esac fdescfs fdesc sccache nextest copyback logind env: # * style job configuration @@ -31,7 +31,7 @@ jobs: job: - { features: unix } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Prepare, build and test @@ -47,7 +47,7 @@ jobs: prepare: | # Clean up disk space before installing packages df -h - pkg_add curl sudo-- jq coreutils bash rust rust-clippy rust-rustfmt llvm-- python3 + pkg_add curl sudo-- jq coreutils bash rust rust-clippy rust-rustfmt python3 rm -rf /usr/share/relink/* /usr/X11R6/* /usr/share/doc/* /usr/share/man/* & # Clean up package cache after installation pkg_delete -a & @@ -72,9 +72,6 @@ jobs: sudo -i -u ${TEST_USER} bash << EOF set -e whoami - # Rust is installed from packages, no need for rustup - # Set up PATH for cargo - export PATH="/usr/local/bin:$PATH" ## VARs setup cd "${WORKSPACE}" unset FAIL_ON_FAULT ; case '${{ env.STYLE_FAIL_ON_FAULT }}' in @@ -131,7 +128,7 @@ jobs: job: - { features: unix } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Avoid no space left on device # shared with guest? @@ -148,7 +145,7 @@ jobs: sync: rsync copyback: false mem: 6144 - # Install rust and build dependencies from OpenBSD packages (llvm provides libclang for bindgen) + # Install rust and build dependencies prepare: | # Clean up disk space before installing packages df -h @@ -157,7 +154,7 @@ jobs: rsync -a --delete empty/ /usr/X11R6/ rsync -a --delete empty/ /usr/share/doc/ rsync -a --delete empty/ /usr/share/man/ - pkg_add curl gmake sudo-- jq rust llvm-- + pkg_add curl gmake sudo-- jq rust # Clean up package cache after installation pkg_delete -a df -h @@ -185,12 +182,8 @@ jobs: whoami # Increase max open files (512 by default) ulimit -n 1024 - # Rust is installed from packages, no need for rustup - # Set up PATH for cargo - export PATH="/usr/local/bin:$PATH" - # Install nextest - mkdir -p ~/.cargo/bin - # Note: nextest might not have OpenBSD builds, so we'll use regular cargo test + # todo: install nextest if official OpenBSD package was provided + # Use regular cargo test currently ## Info echo "::group::environment" echo "CI='${CI}'" @@ -210,11 +203,12 @@ jobs: cd "${WORKSPACE}" unset FAULT # 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 # Avoid filling disk space export RUSTFLAGS="-C strip=symbols" + # vendored OpenSSL takes many times to build. Use system one (LibreSSL is supported). + export OPENSSL_NO_VENDOR=1 OPENSSL_DIR=/usr # Use cargo test since nextest might not support OpenBSD if (test -z "\$FAULT"); then echo "::group::cargo test (workspace)" diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index 1edb94db595..81ba8163c20 100644 --- a/.github/workflows/wasi.yml +++ b/.github/workflows/wasi.yml @@ -1,4 +1,4 @@ -# spell-checker:ignore wasip wasmtime +# spell-checker:ignore wasip wasmtime taiki name: WASI # spell-checker:ignore TRIGGERPATH @@ -20,48 +20,62 @@ jobs: test_wasi: name: Tests runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + job: + - { target: wasm32-wasip1, rust-flags: "--cfg wasi_runner" } + - { target: wasm32-wasip2, rust-flags: "--cfg wasi_runner --cfg wasip2_runner" } steps: - - uses: actions/checkout@v7.0.0 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@stable with: - targets: wasm32-wasip1 + targets: ${{ matrix.job.target }} - uses: Swatinem/rust-cache@v2 + with: + key: "${{ matrix.job.target }}" - name: Install wasmtime - run: | - curl https://wasmtime.dev/install.sh -sSf | bash - echo "$HOME/.wasmtime/bin" >> $GITHUB_PATH + uses: taiki-e/install-action@wasmtime - name: Run unit tests env: CARGO_TARGET_WASM32_WASIP1_RUNNER: wasmtime + CARGO_TARGET_WASM32_WASIP2_RUNNER: wasmtime run: | - # Get all utilities and exclude ones that don't compile for wasm32-wasip1 - EXCLUDE="df|du|env|expr|more|tac|test" + # Get all utilities and exclude ones that don't compile for ${{ matrix.job.target }} + EXCLUDE="df|du|env|more" UTILS=$(./util/show-utils.sh | tr ' ' '\n' | grep -vE "^($EXCLUDE)$" | sed 's/^/-p uu_/' | tr '\n' ' ') - cargo test --target wasm32-wasip1 --no-default-features $UTILS + cargo test --target ${{ matrix.job.target }} --no-default-features $UTILS - name: Run integration tests via wasmtime + if: matrix.job.target == 'wasm32-wasip1' env: - RUSTFLAGS: --cfg wasi_runner + RUSTFLAGS: ${{ matrix.job.rust-flags }} run: | # Build the WASI binary - cargo build --target wasm32-wasip1 --no-default-features --features feat_wasm + cargo build --target ${{ matrix.job.target }} --no-default-features --features feat_wasm # Run host-compiled integration tests against the WASI binary. # Tests incompatible with WASI are annotated with # #[cfg_attr(wasi_runner, ignore)] in the test source files. + # The binary above is built without default features, so it has no + # caret diagnostics; the host test crate does have them, hence the + # not(wasi_runner) in the cfg guarding every `mod diagnostics`. # TODO: add integration tests for these tools as WASI support is extended: - # arch b2sum cat cksum cp csplit date dir dircolors fmt join + # arch b2sum cksum csplit dir dircolors fmt join # ls md5sum mkdir mv nproc pathchk pr printenv ptx pwd readlink # realpath rm rmdir seq sha1sum sha224sum sha256sum sha384sum - # sha512sum shred sleep sort split tail touch tsort uname uniq - # vdir yes - UUTESTS_BINARY_PATH="$(pwd)/target/wasm32-wasip1/debug/coreutils.wasm" \ + # sha512sum shred sleep sort split tsort uname uniq vdir + UUTESTS_BINARY_PATH="$(pwd)/target/${{ matrix.job.target }}/debug/coreutils.wasm" \ UUTESTS_WASM_RUNNER=wasmtime \ cargo test --test tests -- \ test_base32:: test_base64:: test_basenc:: test_basename:: \ - test_comm:: test_cut:: test_dirname:: test_echo:: \ - test_expand:: test_factor:: test_false:: test_fold:: \ - test_head:: test_link:: test_ln:: test_nl:: test_numfmt:: \ + test_cat:: test_comm:: test_cp:: test_cut:: test_date:: test_dirname:: test_echo:: \ + test_expand:: test_expr:: test_factor:: test_false:: test_fold:: \ + test_head:: test_link:: test_ln:: \ + test_ls::test_ls_a_dotdot_no_error_on_wasi \ + test_ls::test_ls_al_no_capabilities_insufficient_on_wasi \ + test_mktemp:: test_nl:: test_numfmt:: \ test_od:: test_paste:: test_printf:: test_shuf:: test_sum:: \ - test_tee:: test_tr:: test_true:: test_truncate:: \ - test_unexpand:: test_unlink:: test_wc:: + test_tail:: test_tee:: test_test:: test_touch:: test_tr:: \ + test_true:: test_truncate:: \ + test_unexpand:: test_unlink:: test_wc:: test_yes:: diff --git a/.github/workflows/wsl2.yml b/.github/workflows/wsl2.yml index 53ddbf0d74a..0c57083e060 100644 --- a/.github/workflows/wsl2.yml +++ b/.github/workflows/wsl2.yml @@ -27,11 +27,8 @@ jobs: job: - { os: windows-latest, distribution: Ubuntu-24.04, features: feat_os_unix} steps: - - uses: actions/checkout@v7.0.0 - with: - persist-credentials: false - name: Install WSL2 - uses: Vampire/setup-wsl@v6.1.0 + uses: Vampire/setup-wsl@v7.0.0 with: additional-packages: build-essential distribution: ${{ matrix.job.distribution }} @@ -42,15 +39,16 @@ jobs: run: | useradd -m -p password user - name: Set up WSL2 shell command - uses: Vampire/setup-wsl@v6.1.0 + uses: Vampire/setup-wsl@v7.0.0 with: distribution: ${{ matrix.job.distribution }} wsl-shell-command: bash -c "sudo -u user bash --noprofile --norc -euo pipefail '{0}'" - # it is required to use WSL2's linux file system, so we do a second checkout there + # it is required to use WSL2's linux file system - name: Checkout source in WSL2 shell: wsl-bash {0} run: | - git clone "$(pwd)" "$HOME/src" + mkdir -p "$HOME/src" + curl -L https://github.com/uutils/coreutils/archive/${{ github.sha }}.tar.gz | tar -xzf - --strip-components=1 -C "$HOME/src" - name: Install rust and nextest shell: wsl-bash {0} run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0090e18d9c2..a1b596bf8fb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,4 @@ -# See https://pre-commit.com for more information -# See https://pre-commit.com/hooks.html for more hooks +# See https://prek.j178.dev for more information exclude: ^tests/fixtures/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks @@ -28,6 +27,22 @@ repos: files: \.ftl$ args: [--config, .github/fluent_linter_config.yml, src/uu/] + - repo: https://github.com/streetsidesoftware/cspell-cli + rev: v10.3.0 + hooks: + - id: cspell + # Pinned so the check does not depend on the node version a contributor + # happens to have installed; cspell v10 requires node >= 22.18. + language_version: '22.18.0' + args: + - --config + - .vscode/cSpell.json + - --no-must-find-files + - --no-progress + - --no-summary + - --gitignore + - --files # must stay last: pre-commit appends the filenames here + - repo: local hooks: - id: rust-linting @@ -51,12 +66,6 @@ repos: pass_filenames: false files: 'Cargo\.(toml|lock)$' language: system - - id: cspell - name: Code spell checker (cspell) - description: Run cspell to check for spelling errors (if available). - entry: bash -c 'if command -v cspell >/dev/null 2>&1; then cspell --no-must-find-files -- "$@"; else echo "cspell not found, skipping spell check"; exit 0; fi' -- - pass_filenames: true - language: system ci: - skip: [rust-linting, rust-clippy, cargo-lock-check, cspell] + skip: [cspell, rust-linting, rust-clippy, cargo-lock-check] diff --git a/.vscode/cSpell.json b/.vscode/cSpell.json index 1d360d99076..858580955a9 100644 --- a/.vscode/cSpell.json +++ b/.vscode/cSpell.json @@ -8,9 +8,17 @@ "language": "en", // custom dictionaries - "dictionaries": ["acronyms+names", "jargon", "people", "shell", "workspace"], + "dictionaries": [ + "acronyms+names", + "dependencies", + "jargon", + "people", + "shell", + "workspace" + ], "dictionaryDefinitions": [ { "name": "acronyms+names", "path": "./cspell.dictionaries/acronyms+names.wordlist.txt" }, + { "name": "dependencies", "path": "./cspell.dictionaries/dependencies.wordlist.txt" }, { "name": "jargon", "path": "./cspell.dictionaries/jargon.wordlist.txt" }, { "name": "people", "path": "./cspell.dictionaries/people.wordlist.txt" }, { "name": "shell", "path": "./cspell.dictionaries/shell.wordlist.txt" }, @@ -29,6 +37,7 @@ "**/*.svg", "src/uu/*/locales/*.ftl", "src/uucore/locales/*.ftl", + "src/uucore/locales/*/*.ftl", ".devcontainer/**", "util/gnu-patches/**", "docs/src/release-notes/**", diff --git a/.vscode/cspell.dictionaries/dependencies.wordlist.txt b/.vscode/cspell.dictionaries/dependencies.wordlist.txt new file mode 100644 index 00000000000..185e2bd5611 --- /dev/null +++ b/.vscode/cspell.dictionaries/dependencies.wordlist.txt @@ -0,0 +1,24 @@ +aho-corasick +ariadne +bigdecimal +blake2b_simd +bstr +bytecount +byteorder +clap_mangen +foldhash +hashbrown +icu_datetime +indicatif +itertools +jiff-tzdb +memmap2 +parse_datetime +rand_chacha +rustix +tempfile +textwrap +thiserror +walkdir +winapi +zerocopy diff --git a/.vscode/cspell.dictionaries/jargon.wordlist.txt b/.vscode/cspell.dictionaries/jargon.wordlist.txt index f502ea4848b..32c9accb937 100644 --- a/.vscode/cspell.dictionaries/jargon.wordlist.txt +++ b/.vscode/cspell.dictionaries/jargon.wordlist.txt @@ -2,6 +2,7 @@ AFAICT asimd ASIMD alloc +alnum arity autogenerate autogenerated @@ -134,6 +135,7 @@ pseudoprime pseudoprimes quantiles readonly +reimplementation ROOTFS reparse rposition @@ -187,7 +189,9 @@ unintuitive unprefixed unportable unsync +unwritable urand +utimensat whitespace wordlist wordlists diff --git a/.vscode/cspell.dictionaries/workspace.wordlist.txt b/.vscode/cspell.dictionaries/workspace.wordlist.txt index 132217af393..971e99afcdb 100644 --- a/.vscode/cspell.dictionaries/workspace.wordlist.txt +++ b/.vscode/cspell.dictionaries/workspace.wordlist.txt @@ -2,11 +2,6 @@ cdylib doctest -# * crates -aho-corasick -blake2b_simd -rustix - # * uutils project uutils coreutils @@ -19,13 +14,9 @@ mkfifo urandom uchild ello -bstr -bytecount -byteorder chacha chrono conv -corasick crossterm exacl filetime @@ -34,8 +25,6 @@ fsext getopts getrandom globset -indicatif -itertools itoa iuse langid @@ -43,27 +32,20 @@ lscolors mdbook memchr multifilereader -onig ouroboros peekreader quickcheck -rand_chacha ringbuffer rlimit rstest smallvec tempdir -tempfile termion termios termsize termwidth -textwrap -thiserror unic ureq -walkdir -winapi xattr # * rust/rustc @@ -74,6 +56,7 @@ rustc rustfmt rustup rustdoc +prek # bitor # BitOr trait function bitxor # BitXor trait function @@ -83,6 +66,7 @@ powi println repr rfind +rsplit struct structs substr @@ -133,6 +117,7 @@ EBADF EBUSY EEXIST EINVAL +EISDIR ENODATA ENOENT ENOSPC @@ -306,6 +291,7 @@ LPWSTR ULONG ULONGLONG UNLEN +MSYSTEM WCHAR WSADATA errhandlingapi @@ -363,6 +349,7 @@ uutils sendfile execfn fadvise +ficlone fstatfs getcwd mkfifoat @@ -370,6 +357,7 @@ setpipe # * other auxv +btrfs getlimits weblate algs @@ -377,6 +365,8 @@ wasm wasip statx Statx +ucrt +UCRT # * stty terminal flags brkint diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..e4b654088c6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# AGENTS.md + +Writing this file is not a statement for or against using AI coding agents. +People are using them in this repository either way, so this file exists to make +sure that when they do, the project's rules are actually followed. + +If you are driving an agent here, you are responsible for its output. Read the +diff before you send it. + +Responses to reviewers should be written by a human, not an agent. + +## Start with an issue + +Before opening a PR, search for an existing issue describing the problem being +addressed. Search both open and closed issues where relevant. + +When an existing issue covers the work, reference it in the PR. + +If you have already opened a PR for the work, continue working on that PR +rather than opening a new one to replace it. Rebase or otherwise update the +existing PR's branch as needed. + +If the PR fully resolves an issue, include `Closes #XXXXX` in the PR +description so that the issue is automatically closed when the PR is merged. + +## Never read or copy GNU code + +GNU coreutils is GPLv3; this project is MIT. Any code derived from it - even a +few lines, a helper structure, a test fixture or a comment - cannot be accepted. + +## A PR needs tests + +New behavior or a bug fix comes with a test, in `tests/by-util/test_.rs` +or as a unit test next to the code. If a GNU test used to fail and now passes, +add a Rust test so it cannot silently regress. + +No tests, no merge. + +## Keep the PR description short + +Describe the problem being solved and what changed. That is all. + +- No generated walls of text, no bullet-point summaries of every hunk, no + emoji-headed sections. +- Title: `: `, e.g. `ls: fix version sort order`. +- Write issue reports, PR descriptions and replies to reviewers in your own + words. The point of review is to check that a human understands the change. + +## Read the docs already in this repo + +Before changing anything, look at the Markdown files here - they are the actual +rules, this file is only a pointer: + +- `CONTRIBUTING.md` - licensing, commit hygiene, what a PR must contain +- `DEVELOPMENT.md` - build, test and environment setup +- `README.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md` +- `docs/src/*.md` - platforms, performance, l10n, multicall, test coverage +- the per-utility `README.md` / `locales/*.ftl` files when touching a utility diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7c134c8e12..44522acafdf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,7 @@ report. A few caveats: - The package is PATCHED, so it may differ from upstream. - NEVER install this package on your system — it isn't meant for daily use. - Instead, manually download and extract the [tarball](https://launchpad.net/~bamf0/+archive/ubuntu/coreutils-reference/+files/gnu-coreutils_9.11-0ubuntu1~ppa3_amd64.deb) + Instead, manually download and extract the [tarball](https://launchpad.net/~bamf0/+archive/ubuntu/coreutils-reference/+files/gnu-coreutils_9.12-0ubuntu1~ppa1_amd64.deb) and run it from there. - Bug reports and fixes for this package itself are not accepted here. @@ -280,7 +280,9 @@ gitignore: add temporary files `Fix #1234`, but `ls: fix version sort order`. - You can prefix the title with the utility the PR concerns. - Keep PRs small and self-contained. A set of small PRs is much more likely to - get merged quickly than one large PR. + get merged quickly than one large PR. If you have a large change to submit, + consider using [`gh stack`](https://github.com/github/gh-stack) to split it + into a series of stacked PRs that are easier to review. - Make sure the CI passes (up to intermittently failing tests). - You know your code best, that's why it's best if you can solve merge conflicts on your branch yourself. diff --git a/Cargo.lock b/Cargo.lock index 2712f7b3bf2..6b7171ecbf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,17 +2,11 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -23,15 +17,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "ansi-width" version = "0.1.0" @@ -93,9 +78,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -107,22 +92,39 @@ dependencies = [ ] [[package]] -name = "arrayref" -version = "0.3.9" +name = "ariadne" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" +checksum = "8454c8a44ce2cb9cc7e7fae67fc6128465b343b92c6631e94beca3c8d1524ea5" +dependencies = [ + "unicode-width 0.2.2", + "yansi", +] [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", +] [[package]] name = "base64-simd" @@ -136,9 +138,9 @@ dependencies = [ [[package]] name = "bigdecimal" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" dependencies = [ "autocfg", "libm", @@ -147,22 +149,13 @@ dependencies = [ "num-traits", ] -[[package]] -name = "binary-heap-plus" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4551d8382e911ecc0d0f0ffb602777988669be09447d536ff4388d1def11296" -dependencies = [ - "compare", -] - [[package]] name = "bindgen" version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools 0.13.0", @@ -172,10 +165,25 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", - "syn 2.0.117", + "shlex 1.3.0", + "syn 2.0.119", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -184,15 +192,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -202,22 +210,20 @@ dependencies = [ [[package]] name = "blake2b_simd" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" dependencies = [ - "arrayref", "arrayvec", "constant_time_eq", ] [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec", "cc", "cfg-if", @@ -227,9 +233,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -245,20 +251,19 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", - "regex-automata", "serde_core", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytecount" @@ -266,12 +271,6 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "calendrical_calculations" version = "0.2.4" @@ -284,12 +283,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.59" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", - "shlex", + "jobserver", + "libc", + "shlex 2.0.1", ] [[package]] @@ -303,9 +304,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -315,31 +316,20 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", - "rand_core 0.10.0", -] - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "windows-link", + "rand_core 0.10.1", ] [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -348,18 +338,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.2" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -370,34 +360,43 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.7" +version = "4.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" dependencies = [ "clap", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "clap_mangen" -version = "0.3.0" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d82842b45bf9f6a3be090dd860095ac30728042c08e0d6261ca7259b5d850f07" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" dependencies = [ "clap", "roff", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "codspeed" -version = "5.0.1" +version = "5.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7083f253260bcb4aaa3b4aa4c52973703dabc1a85c2f193997e2689aafa8a919" +checksum = "c0379da47d0de730b2f7900e3f2d9c3b5efc21b46875282305300b4851f03e70" dependencies = [ "anyhow", "cc", @@ -413,9 +412,9 @@ dependencies = [ [[package]] name = "codspeed-divan-compat" -version = "5.0.1" +version = "5.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc1065d507e1cbab731a7976db4cef7e47e49b87b4dbc0a925df07d343558420" +checksum = "81fb193c95eb4f7e393e022c22b174c91a1fa5041b707cd07652dafc921a29ce" dependencies = [ "clap", "codspeed", @@ -426,23 +425,23 @@ dependencies = [ [[package]] name = "codspeed-divan-compat-macros" -version = "5.0.1" +version = "5.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd05482a95823ffe421e8a9ba24fa22a6a30d594e2c60455cbb43a41bf2d8fa" +checksum = "e522ba05328cbe756ce5165e50863c149417dfc4ecb80c8d8c037a28cdd4a3d1" dependencies = [ "divan-macros", "itertools 0.14.0", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "codspeed-divan-compat-walltime" -version = "5.0.1" +version = "5.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f8eae75b8fa85357020a404899c4280d590c9fd1c47640b3ce53106c62d2ee" +checksum = "8edb055b0842673f227fd0d80a1c0ef67b8575285d135f7ee1b8c4f616dbc07f" dependencies = [ "cfg-if", "clap", @@ -465,15 +464,9 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] -[[package]] -name = "compare" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120133d4db2ec47efe2e26502ee984747630c67f51974fca0b6c1340cf2368d3" - [[package]] name = "condtype" version = "1.3.0" @@ -482,9 +475,9 @@ checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" [[package]] name = "console" -version = "0.16.4" +version = "0.16.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" dependencies = [ "encode_unicode", "libc", @@ -492,44 +485,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - [[package]] name = "constant_time_eq" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "core_maths" version = "0.1.1" @@ -541,7 +502,7 @@ dependencies = [ [[package]] name = "coreutils" -version = "0.9.0" +version = "0.13.0" dependencies = [ "bytecount", "clap", @@ -560,9 +521,8 @@ dependencies = [ "phf", "phf_codegen", "pretty_assertions", - "rand 0.10.2", + "rand 0.10.3", "regex", - "rlimit", "rstest", "rstest_reuse", "rustc-hash", @@ -690,9 +650,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -726,18 +686,18 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -745,18 +705,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crossterm" @@ -764,7 +724,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "crossterm_winapi", "document-features", "filedescriptor", @@ -803,18 +763,18 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] [[package]] name = "ctor" -version = "1.0.9" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a394189d59f9befacce833f337f7b1eca5e9a91221bcdd4d28e0114d96e597b3" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" dependencies = [ "link-section", "linktime-proc-macro", @@ -833,15 +793,15 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "data-encoding-macro" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" dependencies = [ "data-encoding", "data-encoding-macro-internal", @@ -849,12 +809,12 @@ dependencies = [ [[package]] name = "data-encoding-macro-internal" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -876,7 +836,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -885,7 +845,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.21", ] [[package]] @@ -916,8 +876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", - "const-oid", - "crypto-common 0.2.1", + "crypto-common 0.2.2", ] [[package]] @@ -926,7 +885,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -934,13 +893,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -951,23 +910,14 @@ checksum = "8dc51d98e636f5e3b0759a39257458b22619cac7e96d932da6eeb052891bb67c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", + "syn 2.0.119", ] [[package]] name = "dns-lookup" -version = "3.0.1" +version = "4.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e39034cee21a2f5bbb66ba0e3689819c4bb5d00382a282006e802a7ffa6c41d" +checksum = "263c0ccfc6b3b6a93e959edad4f985779e76c0b36dbb2e13e3d27c690974adf1" dependencies = [ "cfg-if", "libc", @@ -992,9 +942,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encode_unicode" @@ -1015,32 +965,25 @@ 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]] -name = "exacl" -version = "0.13.0" +name = "fancy-regex" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" dependencies = [ - "bitflags 2.11.1", - "log", - "scopeguard", - "uuid", + "bit-set", + "regex-automata", + "regex-syntax", ] [[package]] name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "file_diff" -version = "1.0.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31a7a908b8f32538a2143e59a6e4e2508988832d5d4d6f7c156b3cbc762643a5" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "filedescriptor" @@ -1065,9 +1008,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "fixed_decimal" @@ -1082,12 +1025,10 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ - "crc32fast", - "miniz_oxide", "zlib-rs", ] @@ -1133,7 +1074,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" dependencies = [ "memchr", - "thiserror 2.0.19", + "thiserror 2.0.21", ] [[package]] @@ -1172,16 +1113,6 @@ dependencies = [ "libc", ] -[[package]] -name = "fts-sys" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c03259139c9b098dd92241b046fedfeebcd6b000d491c821b24ced28558a9c2" -dependencies = [ - "bindgen", - "libc", -] - [[package]] name = "funty" version = "2.0.0" @@ -1190,52 +1121,34 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", - "futures-macro", "futures-task", "pin-project-lite", "slab", ] -[[package]] -name = "gcd" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" - [[package]] name = "generic-array" version = "0.14.7" @@ -1266,14 +1179,14 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "half" @@ -1283,20 +1196,25 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", - "zerocopy 0.8.48", + "zerocopy", ] [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", @@ -1328,48 +1246,24 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_calendar" -version = "2.2.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b2acc6263f494f1df50685b53ff8e57869e47d5c6fe39c23d518ae9a4f3e45" +checksum = "58655df2f728e46e4eee80bddebefacf52ec3e77cdb925014b47c5a5905eb55c" dependencies = [ "calendrical_calculations", "displaydoc", "icu_calendar_data", - "icu_locale", "icu_locale_core", + "icu_locale_fallback", "icu_provider", "serde", "tinystr", @@ -1378,20 +1272,20 @@ dependencies = [ [[package]] name = "icu_calendar_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118577bcf3a0fa7c6ac0a7d6e951814da84ee56b9b1f68fb4d8d10b08cefaf4d" +checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e" [[package]] name = "icu_collator" -version = "2.2.1" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bbdf98e5e0aa827770acee171ebb568aaab975a36b56afdb5fd0e2f525750bb" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" dependencies = [ "icu_collator_data", "icu_collections", - "icu_locale", "icu_locale_core", + "icu_locale_fallback", "icu_normalizer", "icu_properties", "icu_provider", @@ -1403,15 +1297,15 @@ dependencies = [ [[package]] name = "icu_collator_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "038ed8e5817f2059c2f3efb0945ba78d060d3d25e8f1a1bea5139f821a21a2f0" +checksum = "f7d7e54efdddeb1208c08dd5d32b53a879ac00d2d3d051b2255fd26821d16368" [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1423,17 +1317,17 @@ dependencies = [ [[package]] name = "icu_datetime" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989d56ea5bbc43ae2b4e0388874b002884eaf4ed3a76c84a6c8c5ad575e04d72" +checksum = "c44df99c4c5297b05653d1a1629f13bdc9fd4044acfbbaf30b15194a1eed4cd6" dependencies = [ "displaydoc", "fixed_decimal", "icu_calendar", "icu_datetime_data", "icu_decimal", - "icu_locale", "icu_locale_core", + "icu_locale_fallback", "icu_pattern", "icu_plurals", "icu_provider", @@ -1446,21 +1340,21 @@ dependencies = [ [[package]] name = "icu_datetime_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40d3cc1b690d9703202bc319692ac8a1f3a6390686f0930ff40542450fa34f0b" +checksum = "8bb5555f6e4b42233f58564351ce16d73e9e6ebfce6bdcf29875d0cfc554d6a8" [[package]] name = "icu_decimal" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "288247df2e32aa776ac54fdd64de552149ac43cb840f2761811f0e8d09719dd4" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" dependencies = [ "displaydoc", "fixed_decimal", "icu_decimal_data", - "icu_locale", "icu_locale_core", + "icu_locale_fallback", "icu_plurals", "icu_provider", "writeable", @@ -1469,30 +1363,30 @@ dependencies = [ [[package]] name = "icu_decimal_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f14a5ca9e8af29eef62064f269078424283d90dbaffeac5225addf62aaabc22" +checksum = "e4c887fc7d4c297cf3f9436864af9e3dba7f8be9415a6c2a7f392f3b1636298c" [[package]] name = "icu_locale" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +checksum = "785f595c61ef57169a467eeed7b4b6936a66f6cacbb116a96ee86da983251bf4" dependencies = [ "icu_collections", "icu_locale_core", "icu_locale_data", + "icu_locale_fallback", "icu_provider", - "potential_utf", "tinystr", "zerovec", ] [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1504,15 +1398,35 @@ dependencies = [ [[package]] name = "icu_locale_data" -version = "2.2.0" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37d91460e362a5cf58907cd7ce871411775ee6294e49a5cc8e6cec16dfa75ea" + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1527,15 +1441,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_pattern" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c4c568054ffe735398a9f4c55aec37ad7c768844553cc0978f09cc9b933a1fb" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" dependencies = [ "displaydoc", "either", @@ -1546,12 +1460,12 @@ dependencies = [ [[package]] name = "icu_plurals" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a50023f1d49ad5c4333380328a0d4a19e4b9d6d842ec06639affd5ba47c8103" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" dependencies = [ "fixed_decimal", - "icu_locale", + "icu_locale_fallback", "icu_plurals_data", "icu_provider", "zerovec", @@ -1559,16 +1473,17 @@ dependencies = [ [[package]] name = "icu_plurals_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8485497155dc865f901decb93ecc20d3e467df67bfeceb91e3ba34e2b11e8e1d" +checksum = "1251aa39a95e1333888e499b1263e1c196447a28948acf5bdabd82c046f153bf" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1579,15 +1494,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1600,11 +1515,33 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + [[package]] name = "icu_time" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3af0c141da0a61d4f6970cd1d5f4b388b17ea22f8124f8f6049d3d5147586a" +checksum = "af37e1c3b9f3ab564b619fdf4b22de169f6381eb0f7048e3ca454290c438126b" dependencies = [ "calendrical_calculations", "displaydoc", @@ -1619,18 +1556,18 @@ dependencies = [ [[package]] name = "icu_time_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f2f8aeca682d874a5247084aa4fb7d1cef9ba45d889c21209a8818dcaaa0ec9" +checksum = "005ff86fa5851a77fc0a977b72d717f777bd9230415a247219d7898aefb1517a" [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -1648,20 +1585,20 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.1" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -1726,9 +1663,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.34" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e184d09547b80eb7e20d141ba2fb1fbac843ca53f4cf1b31210adc4c1adc6e16" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ "defmt", "jiff-core", @@ -1743,11 +1680,12 @@ dependencies = [ [[package]] name = "jiff-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ "defmt", + "log", ] [[package]] @@ -1763,21 +1701,21 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.34" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323da076b7a6faf914dc677cb05a4b907742ff7375c8322c9e7f5061e5e0e9de" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ "jiff-core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jiff-tzdb" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" [[package]] name = "jiff-tzdb-platform" @@ -1788,21 +1726,32 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", "cpufeatures", @@ -1810,9 +1759,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -1820,19 +1769,19 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.1.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "libc", ] [[package]] name = "libc" -version = "0.2.186" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libloading" @@ -1852,15 +1801,15 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "link-section" -version = "0.19.0" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e333fe507b738576d6da5bb3f1a7d7a1c80307ed9ef31624c057d844c19c93e9" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" [[package]] name = "linktime-proc-macro" -version = "0.2.0" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" [[package]] name = "linux-raw-sys" @@ -1870,9 +1819,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -1891,15 +1840,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ "hashbrown 0.16.1", ] @@ -1939,36 +1888,17 @@ dependencies = [ "libc", ] -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" -version = "1.1.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "log", @@ -1982,11 +1912,10 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", - "memoffset", ] [[package]] @@ -2014,7 +1943,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "fsevent-sys", "inotify", "kqueue", @@ -2032,7 +1961,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -2041,18 +1970,18 @@ 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]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -2063,9 +1992,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -2083,9 +2012,9 @@ dependencies = [ [[package]] name = "num-prime" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b285c575532a33ef6fdd3a57640d0b1c70e6ca48644d6df7bbd4b7a0cfbbb12d" +checksum = "d25a3c4d8a61c86376f9372bea15400e076398135c866495186972cdf5888a45" dependencies = [ "bitvec", "either", @@ -2094,7 +2023,7 @@ dependencies = [ "num-integer", "num-modular", "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -2142,35 +2071,13 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "onig" -version = "6.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" -dependencies = [ - "bitflags 2.11.1", - "libc", - "once_cell", - "onig_sys", -] - -[[package]] -name = "onig_sys" -version = "69.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "openssl" version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cfg-if", "foreign-types", "libc", @@ -2186,14 +2093,14 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "openssl-src" -version = "300.5.5+3.5.5" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] @@ -2204,6 +2111,7 @@ version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ + "aws-lc-sys", "cc", "libc", "openssl-src", @@ -2211,16 +2119,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - [[package]] name = "os_display" version = "0.1.4" @@ -2261,9 +2159,9 @@ dependencies = [ [[package]] name = "parse_datetime" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f15dc0ed203652fed0fc0e2b4de76bca160565afe16507973df55e6407b84353" +checksum = "503fd4c8b0685e5eb32a8276533611f63e3b44768e9e8724e73f6852c2542f82" dependencies = [ "jiff", "num-traits", @@ -2317,40 +2215,40 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "platform-info" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9368d62437c8cbb7c31ee37fd8c08a7d390e09a3ff75698a674953f46705ffcb" +checksum = "ba7c3f5af00d3596a148cc80178ac690152b71c283d15a8bcea0cddfea759a36" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "serde_core", "writeable", @@ -2369,7 +2267,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.48", + "zerocopy", ] [[package]] @@ -2389,7 +2287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2416,9 +2314,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" dependencies = [ - "bitflags 2.11.1", - "chrono", - "flate2", + "bitflags 2.13.2", "procfs-core", "rustix", ] @@ -2429,8 +2325,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" dependencies = [ - "bitflags 2.11.1", - "chrono", + "bitflags 2.13.2", "hex", ] @@ -2457,9 +2352,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2468,13 +2363,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -2494,7 +2389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" dependencies = [ "ppv-lite86", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -2508,9 +2403,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rayon" @@ -2538,7 +2433,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -2555,9 +2450,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2582,15 +2477,6 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" -[[package]] -name = "rlimit" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3" -dependencies = [ - "libc", -] - [[package]] name = "roff" version = "1.1.1" @@ -2599,9 +2485,9 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" [[package]] name = "rstest" -version = "0.26.1" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +checksum = "948203e6d13b83e51a90d7cf236dd58a0065f23f4b902f00f306e7eb2bd09b9c" dependencies = [ "futures-timer", "futures-util", @@ -2610,9 +2496,9 @@ dependencies = [ [[package]] name = "rstest_macros" -version = "0.26.1" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +checksum = "a8d92eaf7b6e51e12471d71ddc72608e7151573de44099aacfbb1128177c0da4" dependencies = [ "cfg-if", "glob", @@ -2622,7 +2508,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-ident", ] @@ -2633,18 +2519,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3a8fb4672e840a587a66fc577a5491375df51ddb88f2a2c2a792598c326fe14" dependencies = [ "quote", - "rand 0.8.6", - "syn 2.0.117", -] - -[[package]] -name = "rust-ini" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap", + "rand 0.8.8", + "syn 2.0.119", ] [[package]] @@ -2664,22 +2540,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -2708,13 +2584,13 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e27b533f7b8e124356edfe653a21e5a5072585b4c0a67459519844c039bc4e1" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "errno", "libc", "once_cell", "parking_lot", "selinux-sys", - "thiserror 2.0.19", + "thiserror 2.0.21", ] [[package]] @@ -2737,9 +2613,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2747,29 +2623,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -2828,6 +2704,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -2861,15 +2743,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -2888,21 +2770,21 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "smawk" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.60.2", @@ -2936,16 +2818,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "string-interner" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad3df9b59e2eded8d825c7c4363ad339a20fb6bc0b9a4778560f518f59910b15" -dependencies = [ - "hashbrown 0.16.1", - "serde", -] - [[package]] name = "strsim" version = "0.11.1" @@ -2954,9 +2826,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -2965,9 +2837,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.0" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -2976,13 +2848,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -3001,7 +2873,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3011,18 +2883,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "textwrap" -version = "0.16.2" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" dependencies = [ + "icu_segmenter", "smawk", "terminal_size", - "unicode-linebreak", "unicode-width 0.2.2", ] @@ -3037,11 +2909,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.21", ] [[package]] @@ -3052,25 +2924,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.0", + "syn 3.0.6", ] [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -3098,20 +2970,11 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -3129,9 +2992,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap", "toml_datetime", @@ -3141,9 +3004,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] @@ -3165,9 +3028,9 @@ checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unic-langid" @@ -3189,15 +3052,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-width" @@ -3243,31 +3100,31 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utmp-classic" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24c654e19afaa6b8f3877ece5d3bed849c2719c56f6752b18ca7da4fcc6e85a" +checksum = "db5a80feb3b4d6ca5e7f2075904e6ef26d335a888f28c453f94efafbc9d0e5cb" dependencies = [ "cfg-if", "libc", - "thiserror 1.0.69", + "thiserror 2.0.21", "time", "utmp-classic-raw", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] name = "utmp-classic-raw" -version = "0.1.3" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22c226537a3d6e01c440c1926ca0256dbee2d19b2229ede6fc4863a6493dd831" +checksum = "52463619959caa531fb5da45898e646000bb38ba133b31d67141e47e17e56147" dependencies = [ "cfg-if", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] name = "uu_arch" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3277,7 +3134,7 @@ dependencies = [ [[package]] name = "uu_b2sum" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3287,7 +3144,7 @@ dependencies = [ [[package]] name = "uu_base32" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3297,7 +3154,7 @@ dependencies = [ [[package]] name = "uu_base64" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3309,7 +3166,7 @@ dependencies = [ [[package]] name = "uu_basename" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3318,17 +3175,20 @@ dependencies = [ [[package]] name = "uu_basenc" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", + "codspeed-divan-compat", "fluent", + "rustix", + "tempfile", "uu_base32", "uucore", ] [[package]] name = "uu_cat" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3336,29 +3196,27 @@ dependencies = [ "memchr", "rustix", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", - "winapi-util", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "uu_chcon" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", - "fts-sys", "libc", "rustix", "selinux", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_checksum_common" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3367,26 +3225,27 @@ dependencies = [ [[package]] name = "uu_chgrp" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_chmod" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_chown" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3395,18 +3254,18 @@ dependencies = [ [[package]] name = "uu_chroot" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "rustix", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_cksum" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3417,7 +3276,7 @@ dependencies = [ [[package]] name = "uu_comm" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3426,11 +3285,10 @@ dependencies = [ [[package]] name = "uu_cp" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", - "exacl", "filetime", "fluent", "indicatif", @@ -3439,28 +3297,28 @@ dependencies = [ "rustix", "selinux", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", "walkdir", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "uu_csplit" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", "fluent", "regex", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_cut" -version = "0.9.0" +version = "0.13.0" dependencies = [ "bstr", "clap", @@ -3472,7 +3330,7 @@ dependencies = [ [[package]] name = "uu_date" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3481,46 +3339,48 @@ dependencies = [ "icu_locale", "jiff", "jiff-icu", + "jiff-tzdb", "libc", "parse_datetime", "rustix", "tempfile", + "thiserror 2.0.21", "uucore", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "uu_dd" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", "fluent", - "gcd", "libc", + "num-integer", "rustix", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_df" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", "fluent", "rustix", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "unicode-width 0.2.2", "uucore", ] [[package]] name = "uu_dir" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "uu_ls", @@ -3529,7 +3389,7 @@ dependencies = [ [[package]] name = "uu_dircolors" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3538,7 +3398,7 @@ dependencies = [ [[package]] name = "uu_dirname" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3547,7 +3407,7 @@ dependencies = [ [[package]] name = "uu_du" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3555,14 +3415,14 @@ dependencies = [ "glob", "rustc-hash", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "uu_echo" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3572,47 +3432,45 @@ dependencies = [ [[package]] name = "uu_env" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "nix", - "rust-ini", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_expand" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", "fluent", "rustix", "tempfile", - "thiserror 2.0.19", - "unicode-width 0.2.2", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_expr" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", + "fancy-regex", "fluent", "num-bigint", "num-traits", - "onig", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_factor" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3625,7 +3483,7 @@ dependencies = [ [[package]] name = "uu_false" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3635,18 +3493,17 @@ dependencies = [ [[package]] name = "uu_fmt" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", - "thiserror 2.0.19", - "unicode-width 0.2.2", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_fold" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3658,52 +3515,51 @@ dependencies = [ [[package]] name = "uu_groups" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_head" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "memchr", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_hostid" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", - "libc", + "hostname", + "rustix", "uucore", ] [[package]] name = "uu_hostname" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", - "dns-lookup", "fluent", "hostname", "tempfile", "uucore", - "windows-sys 0.61.2", ] [[package]] name = "uu_id" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3714,45 +3570,44 @@ dependencies = [ [[package]] name = "uu_install" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", - "file_diff", - "filetime", "fluent", + "rustix", "selinux", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_join" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", "fluent", "memchr", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_kill" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "libc", "rustix", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_link" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3761,18 +3616,18 @@ dependencies = [ [[package]] name = "uu_ln" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "rustix", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_logname" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3782,7 +3637,7 @@ dependencies = [ [[package]] name = "uu_ls" -version = "0.9.0" +version = "0.13.0" dependencies = [ "ansi-width", "clap", @@ -3795,14 +3650,14 @@ dependencies = [ "selinux", "tempfile", "terminal_size", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", "uutils_term_grid", ] [[package]] name = "uu_md5sum" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3812,7 +3667,7 @@ dependencies = [ [[package]] name = "uu_mkdir" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3822,7 +3677,7 @@ dependencies = [ [[package]] name = "uu_mkfifo" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3833,7 +3688,7 @@ dependencies = [ [[package]] name = "uu_mknod" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3843,19 +3698,19 @@ dependencies = [ [[package]] name = "uu_mktemp" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", - "rand 0.10.2", + "rand 0.10.3", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_more" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "crossterm", @@ -3866,36 +3721,35 @@ dependencies = [ [[package]] name = "uu_mv" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", "fluent", - "fs_extra", "indicatif", - "libc", "nix", "rustc-hash", "rustix", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "uu_nice" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "rustix", "uucore", + "windows-sys 0.60.2", ] [[package]] name = "uu_nl" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -3908,19 +3762,20 @@ dependencies = [ [[package]] name = "uu_nohup" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "libc", "rustix", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", + "windows-sys 0.60.2", ] [[package]] name = "uu_nproc" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3931,32 +3786,34 @@ dependencies = [ [[package]] name = "uu_numfmt" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", "fluent", "memchr", - "thiserror 2.0.19", + "rustix", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_od" -version = "0.9.0" +version = "0.13.0" dependencies = [ - "byteorder", "clap", + "codspeed-divan-compat", "fluent", "half", "libc", "rustix", + "tempfile", "uucore", ] [[package]] name = "uu_paste" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3965,7 +3822,7 @@ dependencies = [ [[package]] name = "uu_pathchk" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3975,7 +3832,7 @@ dependencies = [ [[package]] name = "uu_pinky" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -3984,20 +3841,20 @@ dependencies = [ [[package]] name = "uu_pr" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "itertools 0.15.0", "memchr", "regex", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_printenv" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4006,7 +3863,7 @@ dependencies = [ [[package]] name = "uu_printf" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4015,18 +3872,20 @@ dependencies = [ [[package]] name = "uu_ptx" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", + "codspeed-divan-compat", "fluent", "regex", - "thiserror 2.0.19", + "rustc-hash", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_pwd" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4035,7 +3894,7 @@ dependencies = [ [[package]] name = "uu_readlink" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4044,7 +3903,7 @@ dependencies = [ [[package]] name = "uu_realpath" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4053,7 +3912,7 @@ dependencies = [ [[package]] name = "uu_rm" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -4061,14 +3920,13 @@ dependencies = [ "indicatif", "libc", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", - "windows-sys 0.61.2", ] [[package]] name = "uu_rmdir" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4078,19 +3936,19 @@ dependencies = [ [[package]] name = "uu_runcon" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "libc", "selinux", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_seq" -version = "0.9.0" +version = "0.13.0" dependencies = [ "bigdecimal", "clap", @@ -4098,13 +3956,13 @@ dependencies = [ "fluent", "num-bigint", "num-traits", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_sha1sum" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4114,7 +3972,7 @@ dependencies = [ [[package]] name = "uu_sha224sum" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4124,7 +3982,7 @@ dependencies = [ [[package]] name = "uu_sha256sum" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4134,7 +3992,7 @@ dependencies = [ [[package]] name = "uu_sha384sum" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4144,7 +4002,7 @@ dependencies = [ [[package]] name = "uu_sha512sum" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4154,24 +4012,24 @@ dependencies = [ [[package]] name = "uu_shred" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "libc", - "rand 0.10.2", + "rand 0.10.3", "uucore", ] [[package]] name = "uu_shuf" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", "fluent", "itoa", - "rand 0.10.2", + "rand 0.10.3", "rand_chacha 0.10.0", "rustc-hash", "sha3", @@ -4180,7 +4038,7 @@ dependencies = [ [[package]] name = "uu_sleep" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4189,31 +4047,29 @@ dependencies = [ [[package]] name = "uu_sort" -version = "0.9.0" +version = "0.13.0" dependencies = [ "bigdecimal", - "binary-heap-plus", "clap", "codspeed-divan-compat", - "compare", "ctrlc", "fluent", "foldhash", "itertools 0.15.0", "libc", "memchr", - "rand 0.10.2", + "rand 0.10.3", "rayon", "rustix", "self_cell", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_split" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -4221,35 +4077,36 @@ dependencies = [ "memchr", "rustix", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_stat" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", - "thiserror 2.0.19", + "rustix", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_stdbuf" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uu_stdbuf_libstdbuf", "uucore", ] [[package]] name = "uu_stdbuf_libstdbuf" -version = "0.9.0" +version = "0.13.0" dependencies = [ "ctor", "libc", @@ -4257,7 +4114,7 @@ dependencies = [ [[package]] name = "uu_stty" -version = "0.9.0" +version = "0.13.0" dependencies = [ "cfg_aliases", "clap", @@ -4268,7 +4125,7 @@ dependencies = [ [[package]] name = "uu_sum" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4278,18 +4135,18 @@ dependencies = [ [[package]] name = "uu_sync" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", - "nix", + "rustix", "uucore", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "uu_tac" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4298,13 +4155,13 @@ dependencies = [ "memmap2", "regex", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_tail" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4313,38 +4170,38 @@ dependencies = [ "notify", "rstest", "rustix", - "same-file", "uucore", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "uu_tee" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", "fluent", "rustix", - "tempfile", "uucore", ] [[package]] name = "uu_test" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "libc", + "rustix", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", + "windows-sys 0.60.2", ] [[package]] name = "uu_timeout" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -4356,7 +4213,7 @@ dependencies = [ [[package]] name = "uu_touch" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "filetime", @@ -4366,28 +4223,28 @@ dependencies = [ "parse_datetime", "rustix", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "uu_tr" -version = "0.9.0" +version = "0.13.0" dependencies = [ "bytecount", "clap", "codspeed-divan-compat", "fluent", + "memchr", "nom 8.0.0", "rustix", - "tempfile", "uucore", ] [[package]] name = "uu_true" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -4397,7 +4254,7 @@ dependencies = [ [[package]] name = "uu_truncate" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4406,32 +4263,34 @@ dependencies = [ [[package]] name = "uu_tsort" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", "fluent", + "hashbrown 0.17.1", + "memchr", "rustc-hash", "rustix", - "string-interner", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", + "windows-sys 0.60.2", ] [[package]] name = "uu_tty" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "rustix", "uucore", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "uu_uname" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4441,20 +4300,20 @@ dependencies = [ [[package]] name = "uu_unexpand" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", "fluent", "rustix", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_uniq" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "codspeed-divan-compat", @@ -4464,7 +4323,7 @@ dependencies = [ [[package]] name = "uu_unlink" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4473,18 +4332,18 @@ dependencies = [ [[package]] name = "uu_uptime" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "jiff", - "thiserror 2.0.19", + "thiserror 2.0.21", "uucore", ] [[package]] name = "uu_users" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4494,7 +4353,7 @@ dependencies = [ [[package]] name = "uu_vdir" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "uu_ls", @@ -4503,7 +4362,7 @@ dependencies = [ [[package]] name = "uu_wc" -version = "0.9.0" +version = "0.13.0" dependencies = [ "bytecount", "clap", @@ -4512,14 +4371,14 @@ dependencies = [ "libc", "rustix", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "unicode-width 0.2.2", "uucore", ] [[package]] name = "uu_who" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4529,17 +4388,18 @@ dependencies = [ [[package]] name = "uu_whoami" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", + "rustix", "uucore", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "uu_yes" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -4550,15 +4410,15 @@ dependencies = [ [[package]] name = "uucore" -version = "0.9.0" +version = "0.13.0" dependencies = [ + "ariadne", "base64-simd", "bigdecimal", "blake2b_simd", "blake3", "bstr", "clap", - "codspeed-divan-compat", "crc-fast", "data-encoding", "data-encoding-macro", @@ -4595,49 +4455,39 @@ dependencies = [ "shake", "sm3", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.21", "time", "unic-langid", + "unicode-width 0.2.2", "unit-prefix", "utmp-classic", "uucore_procs", "walkdir", "wild", - "winapi-util", - "windows-sys 0.61.2", + "windows-sys 0.60.2", "xattr", "z85", ] [[package]] name = "uucore_procs" -version = "0.9.0" +version = "0.13.0" dependencies = [ "proc-macro2", "quote", ] -[[package]] -name = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "uutests" -version = "0.9.0" +version = "0.13.0" dependencies = [ "ctor", "libc", "nix", "pretty_assertions", - "rand 0.10.2", + "rand 0.10.3", "regex", - "rlimit", + "rustix", "tempfile", "uucore", "xattr", @@ -4688,9 +4538,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -4701,9 +4551,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4711,22 +4561,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -4772,7 +4622,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]] @@ -4781,81 +4631,19 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -4867,22 +4655,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - [[package]] name = "windows-targets" version = "0.53.5" @@ -4890,106 +4662,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "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", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -4998,9 +4722,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -5013,9 +4737,9 @@ checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" dependencies = [ "either", ] @@ -5047,9 +4771,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5058,13 +4782,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "synstructure", ] @@ -5076,71 +4800,50 @@ checksum = "c6e61e59a957b7ccee15d2049f86e8bfd6f66968fcd88f018950662d9b86e675" [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ - "byteorder", - "zerocopy-derive 0.7.35", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive 0.8.48", + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -5150,9 +4853,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "serde", "yoke", @@ -5162,13 +4865,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -5187,15 +4890,15 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" diff --git a/Cargo.toml b/Cargo.toml index 405adfc85ce..fa0934d7c18 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 foldhash serde gethostid kqueue libcrypto libselinux mangen memmap uuhelp startswith constness expl unnested logind cfgs interner getauxval +# spell-checker:ignore kqueue libcrypto libselinux startswith unnested logind cfgs [package] name = "coreutils" @@ -12,15 +12,396 @@ edition.workspace = true rust-version.workspace = true version.workspace = true license.workspace = true -homepage.workspace = true keywords.workspace = true categories.workspace = true [package.metadata.docs.rs] all-features = true +[workspace] +resolver = "3" +members = [ + ".", + "src/uu/*", + "src/uu/stdbuf/src/libstdbuf", + "src/uucore", + "src/uucore_procs", + "tests/uutests", + # "fuzz", # TODO +] + +[workspace.package] +categories = ["command-line-utilities"] +edition = "2024" +rust-version = "1.88.0" +homepage = "https://github.com/uutils/coreutils" +keywords = ["coreutils", "uutils", "cross-platform", "cli", "utility"] +license = "MIT" +readme = "README.package.md" +version = "0.13.0" + +[workspace.dependencies] +ansi-width = "0.1.0" +# The diagnostics rewrite ariadne's output by row number, so a release that +# changes the shape of a report is a breaking change here even when it is not +# one for ariadne. `uucore`'s `the_rendered_report_has_the_row_shape_the_rewriting_assumes` +# is what catches it; keep the bound tight enough that only patch releases +# arrive without one running. +ariadne = "0.6.0" +base64-simd = "0.8" +bigdecimal = "0.4" +bstr = { version = "1.9.1", default-features = false, features = ["std"] } +bytecount = "0.6.8" +cfg_aliases = "0.2.2" +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +clap_complete = "4.4" +clap_mangen = "0.3.3" +crossterm = { version = "0.29.0", default-features = false } +ctor = "1.0.0" +ctrlc = { version = "3.5.2", features = ["termination"] } +data-encoding = "2.6" +data-encoding-macro = "0.1.15" +divan = { package = "codspeed-divan-compat", version = "5.0.0" } +dns-lookup = { version = "4.0.0" } +dunce = "1.0.4" +filetime = "0.2.29" +foldhash = "0.2.0" +glob = "0.3.1" +half = "2.4.1" +hashbrown = '0.17.1' +hex-literal = "1.0.0" +hostname = "0.4" +icu_calendar = { version = "2.0.0", default-features = false } +icu_collator = { version = "2.0.0", default-features = false } +icu_datetime = { version = "2.0.0", default-features = false } +icu_decimal = { version = "2.0.0", default-features = false } +icu_locale = { version = "2.0.0", default-features = false } +icu_provider = { version = "2.0.0", default-features = false } +indicatif = "0.18.0" +itertools = "0.15.0" +itoa = "1.0.15" +jiff = "0.2.18" +jiff-icu = "0.2.2" +jiff-tzdb = "0.1" +libc = "0.2.186" +lscolors = { version = "0.21.0", default-features = false, features = [ + "gnu_legacy", +] } +memchr = "2.7.2" +memmap2 = "0.9.4" +nix = { version = "0.31.3", default-features = false } +nom = "8.0.0" +notify = { version = "8.2.0", features = ["macos_kqueue"] } +num-bigint = "0.4.4" +num-integer = "0.1.47" +num-prime = "0.6.0" +num-traits = "0.2.19" +fancy-regex = { version = "0.19.2", default-features = false, features = [ + "std", + "unicode", + "leftmost_longest", +] } +os_display = "0.1.3" +parse_datetime = "0.16.0" +phf = "0.14.0" +phf_codegen = "0.14.0" +platform-info = "2.0.3" +pretty_assertions = "1.4.1" +proc-macro2 = "1.0.81" +procfs = { version = "0.18", default-features = false } +rand = { version = "0.10.1", features = ["std_rng"] } +rand_chacha = { version = "0.10.0" } +rayon = "1.10" +regex = "1.10.4" +rstest = "0.27.0" +rstest_reuse = "0.7.0" +rustc-hash = "2.1.1" +rustix = { version = "1.1.4", default-features = false } +self_cell = "1.0.4" +selinux = "0.6" +tempfile = "3.15.0" +terminal_size = "0.4.0" +textwrap = { version = "0.16.1", features = ["terminal_size"] } +thiserror = "2.0.3" +time = { version = "0.3.36" } +quote = "1.0.36" +unicode-width = "0.2.0" +unindent = "0.2.3" +unit-prefix = "0.5" +utmp-classic = "0.2" +uutils_term_grid = "0.8" +walkdir = "2.5" +wild = "2.2.1" +windows-sys = { version = ">=0.60, <0.62", default-features = false } +xattr = { version = "1.3.1", default-features = false } +z85 = "3.0.5" +zip = { version = "8.0.0", default-features = false, features = ["deflate"] } + +hex = "0.4.3" +md-5 = { version = "0.11.0", default-features = false, features = ["alloc"] } +sha1 = { version = "0.11.0", default-features = false, features = ["alloc"] } +sha2 = { version = "0.11.0", default-features = false, features = ["alloc"] } +sha3 = { version = "0.12.0", default-features = false, features = ["alloc"] } +shake = "0.1.0" +blake2b_simd = "1.0.2" +blake3 = "1.5.1" +sm3 = { version = "0.5.0", default-features = false, features = ["alloc"] } +crc-fast = { version = "1.5.0", default-features = false } +digest = "0.11.0" +openssl = { version = "0.10" } + +# Fluent dependencies +fluent = "0.17.0" +unic-langid = "0.9.6" +fluent-syntax = "0.12.0" + +uucore = { version = "0.13.0", path = "src/uucore" } +uucore_procs = { version = "0.13.0", path = "src/uucore_procs" } +uu_ls = { version = "0.13.0", path = "src/uu/ls" } +uu_base32 = { version = "0.13.0", path = "src/uu/base32" } +uu_checksum_common = { version = "0.13.0", path = "src/uu/checksum_common" } +uutests = { version = "0.13.0", path = "tests/uutests" } + +# This is the linting configuration for all crates. +# In order to use these, all crates have `[lints] workspace = true` section. +[workspace.lints.rust] +# Introduced to suppress a warning from the from_iter_instead_of_collect lint +# which got deprecated with Rust 1.98. +# TODO reconsider whether we still need this setting when we have a MSRV >= 1.98 +renamed_and_removed_lints = { level = "allow", priority = -1 } +# 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)', + 'cfg(pgo_training)', + 'cfg(target_os, values("cygwin"))', + 'cfg(wasi_runner)', +] } +unused_qualifications = "warn" + +[workspace.lints.clippy] +# The counts were generated with this command: +# cargo clippy --all-targets --workspace --message-format=json --quiet \ +# | jq -r '.message.code.code | select(. != null and startswith("clippy::"))' \ +# | sort | uniq -c | sort -h -r +# +all = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +use_self = "warn" # nursery lint +cargo_common_metadata = "allow" # 3240 +multiple_crate_versions = "allow" # 2882 +missing_errors_doc = "allow" # 1572 +missing_panics_doc = "allow" # 946 +must_use_candidate = "allow" # 322 +cast_possible_truncation = "allow" # 122 +too_many_lines = "allow" # 101 +cast_possible_wrap = "allow" # 78 +cast_sign_loss = "allow" # 70 +struct_excessive_bools = "allow" # 68 +cast_precision_loss = "allow" # 52 +cast_lossless = "allow" # 35 +ignored_unit_patterns = "allow" # 21 +similar_names = "allow" # 20 +needless_pass_by_value = "allow" # 16 +float_cmp = "allow" # 12 +return_self_not_must_use = "allow" # 8 +inline_always = "allow" # 6 +fn_params_excessive_bools = "allow" # 6 +should_panic_without_expect = "allow" # 2 +assert_is_empty = "allow" + +doc_markdown = "allow" +unused_self = "allow" +enum_glob_use = "allow" +unnested_or_patterns = "allow" +implicit_hasher = "allow" +doc_link_with_quotes = "allow" +# TODO lint is deprecated since Rust 1.98, can be removed when we have a MSRV >= 1.98 +from_iter_instead_of_collect = "allow" +large_types_passed_by_value = "allow" + +[workspace.metadata.cargo-shear] +ignored = ["clap", "fluent", "libstdbuf"] + +[dependencies] +clap.workspace = true +clap_complete = { workspace = true, optional = true } +clap_mangen = { workspace = true, optional = true } +fluent-syntax = { workspace = true, optional = true } +itertools.workspace = true +jiff = { workspace = true, optional = true } +phf.workspace = true +regex = { workspace = true, optional = true } +textwrap.workspace = true +uucore.workspace = true +zip = { workspace = true, optional = true } + + +# * uutils +uu_test = { optional = true, version = "0.13.0", path = "src/uu/test" } +# +arch = { optional = true, version = "0.13.0", package = "uu_arch", path = "src/uu/arch" } +base32 = { optional = true, version = "0.13.0", package = "uu_base32", path = "src/uu/base32" } +base64 = { optional = true, version = "0.13.0", package = "uu_base64", path = "src/uu/base64" } +basename = { optional = true, version = "0.13.0", package = "uu_basename", path = "src/uu/basename" } +basenc = { optional = true, version = "0.13.0", package = "uu_basenc", path = "src/uu/basenc" } +cat = { optional = true, version = "0.13.0", package = "uu_cat", path = "src/uu/cat" } +chgrp = { optional = true, version = "0.13.0", package = "uu_chgrp", path = "src/uu/chgrp" } +chmod = { optional = true, version = "0.13.0", package = "uu_chmod", path = "src/uu/chmod" } +chown = { optional = true, version = "0.13.0", package = "uu_chown", path = "src/uu/chown" } +chroot = { optional = true, version = "0.13.0", package = "uu_chroot", path = "src/uu/chroot" } +cksum = { optional = true, version = "0.13.0", package = "uu_cksum", path = "src/uu/cksum" } +b2sum = { optional = true, version = "0.13.0", package = "uu_b2sum", path = "src/uu/b2sum" } +md5sum = { optional = true, version = "0.13.0", package = "uu_md5sum", path = "src/uu/md5sum" } +sha1sum = { optional = true, version = "0.13.0", package = "uu_sha1sum", path = "src/uu/sha1sum" } +sha224sum = { optional = true, version = "0.13.0", package = "uu_sha224sum", path = "src/uu/sha224sum" } +sha256sum = { optional = true, version = "0.13.0", package = "uu_sha256sum", path = "src/uu/sha256sum" } +sha384sum = { optional = true, version = "0.13.0", package = "uu_sha384sum", path = "src/uu/sha384sum" } +sha512sum = { optional = true, version = "0.13.0", package = "uu_sha512sum", path = "src/uu/sha512sum" } +comm = { optional = true, version = "0.13.0", package = "uu_comm", path = "src/uu/comm" } +cp = { optional = true, version = "0.13.0", package = "uu_cp", path = "src/uu/cp" } +csplit = { optional = true, version = "0.13.0", package = "uu_csplit", path = "src/uu/csplit" } +cut = { optional = true, version = "0.13.0", package = "uu_cut", path = "src/uu/cut" } +date = { optional = true, version = "0.13.0", package = "uu_date", path = "src/uu/date" } +dd = { optional = true, version = "0.13.0", package = "uu_dd", path = "src/uu/dd" } +df = { optional = true, version = "0.13.0", package = "uu_df", path = "src/uu/df" } +dir = { optional = true, version = "0.13.0", package = "uu_dir", path = "src/uu/dir" } +dircolors = { optional = true, version = "0.13.0", package = "uu_dircolors", path = "src/uu/dircolors" } +dirname = { optional = true, version = "0.13.0", package = "uu_dirname", path = "src/uu/dirname" } +du = { optional = true, version = "0.13.0", package = "uu_du", path = "src/uu/du" } +echo = { optional = true, version = "0.13.0", package = "uu_echo", path = "src/uu/echo" } +env = { optional = true, version = "0.13.0", package = "uu_env", path = "src/uu/env" } +expand = { optional = true, version = "0.13.0", package = "uu_expand", path = "src/uu/expand" } +expr = { optional = true, version = "0.13.0", package = "uu_expr", path = "src/uu/expr" } +factor = { optional = true, version = "0.13.0", package = "uu_factor", path = "src/uu/factor" } +false = { optional = true, version = "0.13.0", package = "uu_false", path = "src/uu/false" } +fmt = { optional = true, version = "0.13.0", package = "uu_fmt", path = "src/uu/fmt" } +fold = { optional = true, version = "0.13.0", package = "uu_fold", path = "src/uu/fold" } +groups = { optional = true, version = "0.13.0", package = "uu_groups", path = "src/uu/groups" } +head = { optional = true, version = "0.13.0", package = "uu_head", path = "src/uu/head" } +hostid = { optional = true, version = "0.13.0", package = "uu_hostid", path = "src/uu/hostid" } +hostname = { optional = true, version = "0.13.0", package = "uu_hostname", path = "src/uu/hostname" } +id = { optional = true, version = "0.13.0", package = "uu_id", path = "src/uu/id" } +install = { optional = true, version = "0.13.0", package = "uu_install", path = "src/uu/install" } +join = { optional = true, version = "0.13.0", package = "uu_join", path = "src/uu/join" } +kill = { optional = true, version = "0.13.0", package = "uu_kill", path = "src/uu/kill" } +link = { optional = true, version = "0.13.0", package = "uu_link", path = "src/uu/link" } +ln = { optional = true, version = "0.13.0", package = "uu_ln", path = "src/uu/ln" } +ls = { optional = true, version = "0.13.0", package = "uu_ls", path = "src/uu/ls" } +logname = { optional = true, version = "0.13.0", package = "uu_logname", path = "src/uu/logname" } +mkdir = { optional = true, version = "0.13.0", package = "uu_mkdir", path = "src/uu/mkdir" } +mkfifo = { optional = true, version = "0.13.0", package = "uu_mkfifo", path = "src/uu/mkfifo" } +mknod = { optional = true, version = "0.13.0", package = "uu_mknod", path = "src/uu/mknod" } +mktemp = { optional = true, version = "0.13.0", package = "uu_mktemp", path = "src/uu/mktemp" } +more = { optional = true, version = "0.13.0", package = "uu_more", path = "src/uu/more" } +mv = { optional = true, version = "0.13.0", package = "uu_mv", path = "src/uu/mv" } +nice = { optional = true, version = "0.13.0", package = "uu_nice", path = "src/uu/nice" } +nl = { optional = true, version = "0.13.0", package = "uu_nl", path = "src/uu/nl" } +nohup = { optional = true, version = "0.13.0", package = "uu_nohup", path = "src/uu/nohup" } +nproc = { optional = true, version = "0.13.0", package = "uu_nproc", path = "src/uu/nproc" } +numfmt = { optional = true, version = "0.13.0", package = "uu_numfmt", path = "src/uu/numfmt" } +od = { optional = true, version = "0.13.0", package = "uu_od", path = "src/uu/od" } +paste = { optional = true, version = "0.13.0", package = "uu_paste", path = "src/uu/paste" } +pathchk = { optional = true, version = "0.13.0", package = "uu_pathchk", path = "src/uu/pathchk" } +pinky = { optional = true, version = "0.13.0", package = "uu_pinky", path = "src/uu/pinky" } +pr = { optional = true, version = "0.13.0", package = "uu_pr", path = "src/uu/pr" } +printenv = { optional = true, version = "0.13.0", package = "uu_printenv", path = "src/uu/printenv" } +printf = { optional = true, version = "0.13.0", package = "uu_printf", path = "src/uu/printf" } +ptx = { optional = true, version = "0.13.0", package = "uu_ptx", path = "src/uu/ptx" } +pwd = { optional = true, version = "0.13.0", package = "uu_pwd", path = "src/uu/pwd" } +readlink = { optional = true, version = "0.13.0", package = "uu_readlink", path = "src/uu/readlink" } +realpath = { optional = true, version = "0.13.0", package = "uu_realpath", path = "src/uu/realpath" } +rm = { optional = true, version = "0.13.0", package = "uu_rm", path = "src/uu/rm" } +rmdir = { optional = true, version = "0.13.0", package = "uu_rmdir", path = "src/uu/rmdir" } +seq = { optional = true, version = "0.13.0", package = "uu_seq", path = "src/uu/seq" } +shred = { optional = true, version = "0.13.0", package = "uu_shred", path = "src/uu/shred" } +shuf = { optional = true, version = "0.13.0", package = "uu_shuf", path = "src/uu/shuf" } +sleep = { optional = true, version = "0.13.0", package = "uu_sleep", path = "src/uu/sleep" } +sort = { optional = true, version = "0.13.0", package = "uu_sort", path = "src/uu/sort" } +split = { optional = true, version = "0.13.0", package = "uu_split", path = "src/uu/split" } +stat = { optional = true, version = "0.13.0", package = "uu_stat", path = "src/uu/stat" } +stdbuf = { optional = true, version = "0.13.0", package = "uu_stdbuf", path = "src/uu/stdbuf" } +stty = { optional = true, version = "0.13.0", package = "uu_stty", path = "src/uu/stty" } +sum = { optional = true, version = "0.13.0", package = "uu_sum", path = "src/uu/sum" } +sync = { optional = true, version = "0.13.0", package = "uu_sync", path = "src/uu/sync" } +tac = { optional = true, version = "0.13.0", package = "uu_tac", path = "src/uu/tac" } +tail = { optional = true, version = "0.13.0", package = "uu_tail", path = "src/uu/tail" } +tee = { optional = true, version = "0.13.0", package = "uu_tee", path = "src/uu/tee" } +timeout = { optional = true, version = "0.13.0", package = "uu_timeout", path = "src/uu/timeout" } +touch = { optional = true, version = "0.13.0", package = "uu_touch", path = "src/uu/touch" } +tr = { optional = true, version = "0.13.0", package = "uu_tr", path = "src/uu/tr" } +true = { optional = true, version = "0.13.0", package = "uu_true", path = "src/uu/true" } +truncate = { optional = true, version = "0.13.0", package = "uu_truncate", path = "src/uu/truncate" } +tsort = { optional = true, version = "0.13.0", package = "uu_tsort", path = "src/uu/tsort" } +tty = { optional = true, version = "0.13.0", package = "uu_tty", path = "src/uu/tty" } +uname = { optional = true, version = "0.13.0", package = "uu_uname", path = "src/uu/uname" } +unexpand = { optional = true, version = "0.13.0", package = "uu_unexpand", path = "src/uu/unexpand" } +uniq = { optional = true, version = "0.13.0", package = "uu_uniq", path = "src/uu/uniq" } +unlink = { optional = true, version = "0.13.0", package = "uu_unlink", path = "src/uu/unlink" } +uptime = { optional = true, version = "0.13.0", package = "uu_uptime", path = "src/uu/uptime" } +users = { optional = true, version = "0.13.0", package = "uu_users", path = "src/uu/users" } +vdir = { optional = true, version = "0.13.0", package = "uu_vdir", path = "src/uu/vdir" } +wc = { optional = true, version = "0.13.0", package = "uu_wc", path = "src/uu/wc" } +who = { optional = true, version = "0.13.0", package = "uu_who", path = "src/uu/who" } +whoami = { optional = true, version = "0.13.0", package = "uu_whoami", path = "src/uu/whoami" } +yes = { optional = true, version = "0.13.0", package = "uu_yes", path = "src/uu/yes" } + +[dev-dependencies] +ctor.workspace = true +filetime.workspace = true +glob.workspace = true +jiff.workspace = true +libc.workspace = true +bytecount.workspace = true +num-prime.workspace = true +pretty_assertions.workspace = true +rand.workspace = true +regex.workspace = true +rustc-hash.workspace = true +sha1 = { workspace = true } +tempfile.workspace = true +time = { workspace = true, features = ["local-offset"] } +unicode-width.workspace = true +unindent.workspace = true +uutests.workspace = true +uucore = { workspace = true, features = [ + "mode", + "entries", + "pipes", + "process", + "signals", + "utmpx", +] } +walkdir.workspace = true +hex-literal.workspace = true +rstest.workspace = true +rstest_reuse.workspace = true + +[target.'cfg(all(any(target_os = "linux", all(target_os = "android", target_pointer_width = "64")), not(target_env = "musl")))'.dependencies] +# raw-backend's linux_execfn is not supported on linux < 6.4 if /proc is not mounted. So use-libc-auxv is needed +rustix = { workspace = true, features = ["param", "use-libc-auxv"] } + +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +selinux = { workspace = true, optional = true } + +# * uutils +chcon = { optional = true, version = "0.13.0", package = "uu_chcon", path = "src/uu/chcon" } +runcon = { optional = true, version = "0.13.0", package = "uu_runcon", path = "src/uu/runcon" } + +# * pinned transitive dependencies +# Not needed for now. Keep as examples: +#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()`) + +[target.'cfg(unix)'.dev-dependencies] +nix = { workspace = true, features = ["fs", "signal"] } +rustix = { workspace = true, features = ["net", "process"] } + +[build-dependencies] +phf_codegen.workspace = true + [features] -default = ["feat_common_core"] +default = ["feat_common_core", "feat_diagnostics"] ## OS feature shortcodes unix = ["feat_os_unix"] windows = ["feat_os_windows"] @@ -30,17 +411,15 @@ expensive_tests = [] # checksum utilities instead of the pure-Rust crates. Provides large speedups # on CPUs without SHA-NI. # -# Enable with: `cargo build --release --features unix,openssl` +# Enable with: `cargo build --release --features openssl` # # By default OpenSSL is built from source and statically linked (via the -# `vendored` feature of the `openssl` crate), mirroring how `expr` links -# oniguruma. To link against the system libcrypto/libssl dynamically instead, -# set `OPENSSL_NO_VENDOR=1` at build time. +# `vendored` feature of the `openssl` crate). To link against the system +# libcrypto/libssl dynamically instead, set `OPENSSL_NO_VENDOR=1` at build time. # # The `?/openssl` entries propagate the choice into each standalone checksum # crate only when that crate is itself enabled, so disabling this feature -# genuinely turns OpenSSL off everywhere. On Windows the feature is a no-op -# and the pure-Rust implementations are used regardless. +# genuinely turns OpenSSL off everywhere. openssl = [ "cksum?/openssl", "md5sum?/openssl", @@ -62,7 +441,17 @@ uudoc = [ "dep:regex", "dep:zip", ] +selinux = ["dep:selinux"] ## features +# "feat_diagnostics" == render errors against the argument list with a caret +# when stderr is a terminal. On by default; build with `--no-default-features` +# (plus a `feat_os_*` selection) to compile the rendering — and its ariadne +# dependency — out. Utilities then keep their plain one-line messages. +# Enabling `uucore/diagnostics` is enough: cargo unifies features, so every +# utility in the build sees it. The matching `diagnostics` feature each `uu_*` +# crate declares is there for building that crate on its own, and is +# deliberately not listed here. +feat_diagnostics = ["uucore/diagnostics"] ## Optional feature for stdbuf # "feat_external_libstdbuf" == use an external libstdbuf.so for stdbuf instead of embedding it feat_external_libstdbuf = ["stdbuf/feat_external_libstdbuf"] @@ -74,11 +463,6 @@ feat_systemd_logind = [ "uucore/feat_systemd_logind", "who/feat_systemd_logind", ] -# "feat_acl" == enable support for ACLs (access control lists; by using`--features feat_acl`) -# NOTE: -# * On linux, the posix-acl/acl-sys crate requires `libacl` headers and shared library to be accessible in the C toolchain at compile time. -# * On FreeBSD and macOS this is not required. -feat_acl = ["cp/feat_acl"] # "feat_selinux" == enable support for SELinux Security Context (by using `--features feat_selinux`) # NOTE: # * The selinux(-sys) crate requires `libselinux` headers and shared library to be accessible in the C toolchain at compile time. @@ -110,6 +494,7 @@ feat_smack = [ ## (common/core and Tier1) feature sets # "feat_common_core" == baseline core set of utilities which can be built/run on most targets feat_common_core = [ + "arch", "base32", "base64", "basename", @@ -118,6 +503,7 @@ feat_common_core = [ "cksum", "b2sum", "md5sum", + "nice", "sha1sum", "sha224sum", "sha256sum", @@ -129,13 +515,10 @@ feat_common_core = [ "cut", "date", "dd", - "df", "dir", "dircolors", "dirname", - "du", "echo", - "env", "expand", "expr", "factor", @@ -143,13 +526,13 @@ feat_common_core = [ "fmt", "fold", "head", + "hostid", "join", "link", "ln", "ls", "mkdir", "mktemp", - "more", "mv", "nl", "numfmt", @@ -182,6 +565,7 @@ feat_common_core = [ "truncate", "tsort", "tty", + "uname", "unexpand", "uniq", "unlink", @@ -192,100 +576,28 @@ 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", + "df", + "du", + "env", "hostname", + "kill", + "nohup", "nproc", + "more", "sync", - "uname", + "timeout", + "uptime", "whoami", ] ## (primary platforms) feature sets # "feat_wasm" == set of utilities which can be built for wasm target # We don't need to support all of wasm targets. So the ambiguous name is used at here -# It is bit complex to deduplicate with other lists -feat_wasm = [ - "arch", - "base32", - "base64", - "basename", - "basenc", - "cat", - "comm", - "cp", - "csplit", - "cut", - "date", - "dd", - "dir", - "dircolors", - "dirname", - "echo", - "expand", - "factor", - "false", - "fmt", - "fold", - "head", - "join", - "link", - "ln", - "ls", - "mkdir", - "mktemp", - "mv", - "nl", - "nproc", - "numfmt", - "od", - "paste", - "pathchk", - "pr", - "printenv", - "printf", - "ptx", - "pwd", - "readlink", - "realpath", - "rm", - "rmdir", - "seq", - "shred", - "shuf", - "sleep", - "sort", - "split", - "sum", - "tail", - "tee", - "touch", - "tr", - "true", - "truncate", - "tsort", - "tty", - "uname", - "unexpand", - "uniq", - "unlink", - "vdir", - "wc", - "yes", - # cksum family - "cksum", - "b2sum", - "md5sum", - "sha1sum", - "sha224sum", - "sha256sum", - "sha384sum", - "sha512sum", -] +feat_wasm = ["feat_common_core", "nproc"] # "feat_os_unix" == set of utilities which can be built/run on modern/usual *nix platforms. feat_os_unix = [ "feat_Tier1", # "feat_require_unix", - "feat_require_unix_hostid", "feat_require_unix_utmpx", ] # "feat_os_unix_musl" == set of utilities which can be built/run on targets binding to the "musl" library (ref: ) @@ -294,7 +606,6 @@ feat_os_unix_musl = [ "feat_Tier1", # "feat_require_unix_musl", - "feat_require_unix_hostid", "feat_require_unix_utmpx", ] # "feat_os_windows" == set of utilities which can be built/run on modern windows platforms @@ -305,7 +616,6 @@ feat_os_unix_gnueabihf = [ "feat_Tier1", # "feat_require_unix", - "feat_require_unix_hostid", "feat_require_unix_utmpx", ] feat_os_unix_android = [ @@ -330,21 +640,15 @@ feat_require_unix_core = [ "groups", "id", "install", - "kill", "logname", "mkfifo", "mknod", - "nice", - "nohup", "stat", "stty", - "timeout", ] # "feat_require_unix_utmpx" == set of utilities requiring unix utmp/utmpx support # * ref: feat_require_unix_utmpx = ["pinky", "uptime", "users", "who"] -# "feat_require_unix_hostid" == set of utilities requiring gethostid in libc (only some unixes provide) -feat_require_unix_hostid = ["hostid"] # "feat_require_selinux" == set of utilities depending on SELinux. feat_require_selinux = ["chcon", "runcon"] ## (alternate/newer/smaller platforms) feature sets @@ -357,13 +661,10 @@ feat_os_unix_fuchsia = [ "chown", "du", "groups", - "hostid", "install", "logname", "mkfifo", "mknod", - "nice", - "uname", "unlink", ] # "feat_os_unix_redox" == set of utilities which can be built/run on "Redox OS" (refs: ; ) @@ -372,387 +673,13 @@ feat_os_unix_redox = [ # "chmod", "stat", - "uname", ] ## # * bypass/override ~ translate 'test' feature name to avoid dependency collision with rust core 'test' crate (o/w surfaces as compiler errors during testing) test = ["uu_test"] -[workspace] -resolver = "3" -members = [ - ".", - "src/uu/*", - "src/uu/stdbuf/src/libstdbuf", - "src/uucore", - "src/uucore_procs", - "tests/uutests", - # "fuzz", # TODO -] - -[workspace.package] -categories = ["command-line-utilities"] -edition = "2024" -rust-version = "1.88.0" -homepage = "https://github.com/uutils/coreutils" -keywords = ["coreutils", "uutils", "cross-platform", "cli", "utility"] -license = "MIT" -readme = "README.package.md" -version = "0.9.0" - -[workspace.dependencies] -ansi-width = "0.1.0" -bigdecimal = "0.4" -binary-heap-plus = "0.5.0" -bstr = "1.9.1" -bytecount = "0.6.8" -byteorder = "1.5.0" -clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } -clap_complete = "4.4" -clap_mangen = "0.3" -compare = "0.1.0" -crossterm = { version = "0.29.0", default-features = false } -ctor = "1.0.0" -ctrlc = { version = "3.5.2", features = ["termination"] } -divan = { package = "codspeed-divan-compat", version = "5.0.0" } -dns-lookup = { version = "3.0.0" } -exacl = "0.13.0" -file_diff = "1.0.0" -filetime = "0.2.29" -foldhash = "0.2.0" -fs_extra = "1.3.0" -fts-sys = "0.2.16" -gcd = "2.3" -glob = "0.3.1" -half = "2.4.1" -hostname = "0.4" -icu_calendar = { version = "2.0.0", default-features = false } -icu_collator = { version = "2.0.0", default-features = false } -icu_datetime = { version = "2.0.0", default-features = false } -icu_decimal = { version = "2.0.0", default-features = false } -icu_locale = { version = "2.0.0", default-features = false } -icu_provider = { version = "2.0.0", default-features = false } -indicatif = "0.18.0" -itertools = "0.15.0" -itoa = "1.0.15" -jiff = "0.2.18" -jiff-icu = "0.2.2" -libc = "0.2.186" -lscolors = { version = "0.21.0", default-features = false, features = [ - "gnu_legacy", -] } -memchr = "2.7.2" -memmap2 = "0.9.4" -nix = { version = "0.31.3", default-features = false } -nom = "8.0.0" -notify = { version = "8.2.0", features = ["macos_kqueue"] } -num-bigint = "0.4.4" -num-prime = "0.5.0" -num-traits = "0.2.19" -onig = { version = "~6.5.1", default-features = false } -parse_datetime = "0.15.0" -phf = "0.14.0" -phf_codegen = "0.14.0" -platform-info = "2.0.3" -pretty_assertions = "1.4.1" -procfs = "0.18" -rand = { version = "0.10.1", features = ["std_rng"] } -rand_chacha = { version = "0.10.0" } -rayon = "1.10" -regex = "1.10.4" -rlimit = "0.11.0" -rstest = "0.26.0" -rstest_reuse = "0.7.0" -rustc-hash = "2.1.1" -rust-ini = "0.21.0" -# raw-backend's linux_execfn is not supported on linux < 6.4 if /proc is not mounted. So use-libc-auxv is needed -rustix = { version = "1.1.4", features = ["param", "use-libc-auxv", "time"] } -same-file = "1.0.6" -self_cell = "1.0.4" -selinux = "0.6" -string-interner = "0.20.0" -tempfile = "3.15.0" -terminal_size = "0.4.0" -textwrap = { version = "0.16.1", features = ["terminal_size"] } -thiserror = "2.0.3" -time = { version = "0.3.36" } -unicode-width = "0.2.0" -unit-prefix = "0.5" -utmp-classic = "0.1.6" -uutils_term_grid = "0.8" -walkdir = "2.5" -winapi-util = "0.1.8" -windows-sys = { version = "0.61.0", default-features = false } -xattr = "1.3.1" -zip = { version = "8.0.0", default-features = false, features = ["deflate"] } - -hex = "0.4.3" -md-5 = "0.11.0" -sha1 = "0.11.0" -sha2 = "0.11.0" -sha3 = "0.12.0" -shake = "0.1.0" -blake2b_simd = "1.0.2" -blake3 = "1.5.1" -sm3 = "0.5.0" -crc-fast = { version = "1.5.0", default-features = false } -digest = "0.11.0" -openssl = { version = "0.10", features = ["vendored"] } - -# Fluent dependencies -fluent = "0.17.0" -unic-langid = "0.9.6" -fluent-syntax = "0.12.0" - -uucore = { version = "0.9.0", path = "src/uucore" } -uucore_procs = { version = "0.9.0", path = "src/uucore_procs" } -uu_ls = { version = "0.9.0", path = "src/uu/ls" } -uu_base32 = { version = "0.9.0", path = "src/uu/base32" } -uu_checksum_common = { version = "0.9.0", path = "src/uu/checksum_common" } -uutests = { version = "0.9.0", path = "tests/uutests" } - -# 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 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)', - 'cfg(target_os, values("cygwin"))', - 'cfg(wasi_runner)', -] } -unused_qualifications = "warn" - -[workspace.lints.clippy] -collapsible_if = { level = "allow", priority = 127 } # remove me -# The counts were generated with this command: -# cargo clippy --all-targets --workspace --message-format=json --quiet \ -# | jq -r '.message.code.code | select(. != null and startswith("clippy::"))' \ -# | sort | uniq -c | sort -h -r -# -all = { level = "warn", priority = -1 } -cargo = { level = "warn", priority = -1 } -pedantic = { level = "warn", priority = -1 } -use_self = "warn" # nursery lint -cargo_common_metadata = "allow" # 3240 -multiple_crate_versions = "allow" # 2882 -missing_errors_doc = "allow" # 1572 -missing_panics_doc = "allow" # 946 -must_use_candidate = "allow" # 322 -match_same_arms = "allow" # 204 -cast_possible_truncation = "allow" # 122 -too_many_lines = "allow" # 101 -cast_possible_wrap = "allow" # 78 -cast_sign_loss = "allow" # 70 -struct_excessive_bools = "allow" # 68 -cast_precision_loss = "allow" # 52 -cast_lossless = "allow" # 35 -ignored_unit_patterns = "allow" # 21 -similar_names = "allow" # 20 -needless_pass_by_value = "allow" # 16 -float_cmp = "allow" # 12 -return_self_not_must_use = "allow" # 8 -inline_always = "allow" # 6 -fn_params_excessive_bools = "allow" # 6 -used_underscore_items = "allow" # 2 -should_panic_without_expect = "allow" # 2 - -doc_markdown = "allow" -unused_self = "allow" -enum_glob_use = "allow" -unnested_or_patterns = "allow" -implicit_hasher = "allow" -doc_link_with_quotes = "allow" -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 = ["clap", "fluent", "libstdbuf"] - -[dependencies] -clap.workspace = true -clap_complete = { workspace = true, optional = true } -clap_mangen = { workspace = true, optional = true } -fluent-syntax = { workspace = true, optional = true } -itertools.workspace = true -jiff = { workspace = true, optional = true } -phf.workspace = true -regex = { workspace = true, optional = true } -textwrap.workspace = true -uucore.workspace = true -zip = { workspace = true, optional = true } - - -# * uutils -uu_test = { optional = true, version = "0.9.0", path = "src/uu/test" } -# -arch = { optional = true, version = "0.9.0", package = "uu_arch", path = "src/uu/arch" } -base32 = { optional = true, version = "0.9.0", package = "uu_base32", path = "src/uu/base32" } -base64 = { optional = true, version = "0.9.0", package = "uu_base64", path = "src/uu/base64" } -basename = { optional = true, version = "0.9.0", package = "uu_basename", path = "src/uu/basename" } -basenc = { optional = true, version = "0.9.0", package = "uu_basenc", path = "src/uu/basenc" } -cat = { optional = true, version = "0.9.0", package = "uu_cat", path = "src/uu/cat" } -chcon = { optional = true, version = "0.9.0", package = "uu_chcon", path = "src/uu/chcon" } -chgrp = { optional = true, version = "0.9.0", package = "uu_chgrp", path = "src/uu/chgrp" } -chmod = { optional = true, version = "0.9.0", package = "uu_chmod", path = "src/uu/chmod" } -chown = { optional = true, version = "0.9.0", package = "uu_chown", path = "src/uu/chown" } -chroot = { optional = true, version = "0.9.0", package = "uu_chroot", path = "src/uu/chroot" } -cksum = { optional = true, version = "0.9.0", package = "uu_cksum", path = "src/uu/cksum" } -b2sum = { optional = true, version = "0.9.0", package = "uu_b2sum", path = "src/uu/b2sum" } -md5sum = { optional = true, version = "0.9.0", package = "uu_md5sum", path = "src/uu/md5sum" } -sha1sum = { optional = true, version = "0.9.0", package = "uu_sha1sum", path = "src/uu/sha1sum" } -sha224sum = { optional = true, version = "0.9.0", package = "uu_sha224sum", path = "src/uu/sha224sum" } -sha256sum = { optional = true, version = "0.9.0", package = "uu_sha256sum", path = "src/uu/sha256sum" } -sha384sum = { optional = true, version = "0.9.0", package = "uu_sha384sum", path = "src/uu/sha384sum" } -sha512sum = { optional = true, version = "0.9.0", package = "uu_sha512sum", path = "src/uu/sha512sum" } -comm = { optional = true, version = "0.9.0", package = "uu_comm", path = "src/uu/comm" } -cp = { optional = true, version = "0.9.0", package = "uu_cp", path = "src/uu/cp" } -csplit = { optional = true, version = "0.9.0", package = "uu_csplit", path = "src/uu/csplit" } -cut = { optional = true, version = "0.9.0", package = "uu_cut", path = "src/uu/cut" } -date = { optional = true, version = "0.9.0", package = "uu_date", path = "src/uu/date" } -dd = { optional = true, version = "0.9.0", package = "uu_dd", path = "src/uu/dd" } -df = { optional = true, version = "0.9.0", package = "uu_df", path = "src/uu/df" } -dir = { optional = true, version = "0.9.0", package = "uu_dir", path = "src/uu/dir" } -dircolors = { optional = true, version = "0.9.0", package = "uu_dircolors", path = "src/uu/dircolors" } -dirname = { optional = true, version = "0.9.0", package = "uu_dirname", path = "src/uu/dirname" } -du = { optional = true, version = "0.9.0", package = "uu_du", path = "src/uu/du" } -echo = { optional = true, version = "0.9.0", package = "uu_echo", path = "src/uu/echo" } -env = { optional = true, version = "0.9.0", package = "uu_env", path = "src/uu/env" } -expand = { optional = true, version = "0.9.0", package = "uu_expand", path = "src/uu/expand" } -expr = { optional = true, version = "0.9.0", package = "uu_expr", path = "src/uu/expr" } -factor = { optional = true, version = "0.9.0", package = "uu_factor", path = "src/uu/factor" } -false = { optional = true, version = "0.9.0", package = "uu_false", path = "src/uu/false" } -fmt = { optional = true, version = "0.9.0", package = "uu_fmt", path = "src/uu/fmt" } -fold = { optional = true, version = "0.9.0", package = "uu_fold", path = "src/uu/fold" } -groups = { optional = true, version = "0.9.0", package = "uu_groups", path = "src/uu/groups" } -head = { optional = true, version = "0.9.0", package = "uu_head", path = "src/uu/head" } -hostid = { optional = true, version = "0.9.0", package = "uu_hostid", path = "src/uu/hostid" } -hostname = { optional = true, version = "0.9.0", package = "uu_hostname", path = "src/uu/hostname" } -id = { optional = true, version = "0.9.0", package = "uu_id", path = "src/uu/id" } -install = { optional = true, version = "0.9.0", package = "uu_install", path = "src/uu/install" } -join = { optional = true, version = "0.9.0", package = "uu_join", path = "src/uu/join" } -kill = { optional = true, version = "0.9.0", package = "uu_kill", path = "src/uu/kill" } -link = { optional = true, version = "0.9.0", package = "uu_link", path = "src/uu/link" } -ln = { optional = true, version = "0.9.0", package = "uu_ln", path = "src/uu/ln" } -ls = { optional = true, version = "0.9.0", package = "uu_ls", path = "src/uu/ls" } -logname = { optional = true, version = "0.9.0", package = "uu_logname", path = "src/uu/logname" } -mkdir = { optional = true, version = "0.9.0", package = "uu_mkdir", path = "src/uu/mkdir" } -mkfifo = { optional = true, version = "0.9.0", package = "uu_mkfifo", path = "src/uu/mkfifo" } -mknod = { optional = true, version = "0.9.0", package = "uu_mknod", path = "src/uu/mknod" } -mktemp = { optional = true, version = "0.9.0", package = "uu_mktemp", path = "src/uu/mktemp" } -more = { optional = true, version = "0.9.0", package = "uu_more", path = "src/uu/more" } -mv = { optional = true, version = "0.9.0", package = "uu_mv", path = "src/uu/mv" } -nice = { optional = true, version = "0.9.0", package = "uu_nice", path = "src/uu/nice" } -nl = { optional = true, version = "0.9.0", package = "uu_nl", path = "src/uu/nl" } -nohup = { optional = true, version = "0.9.0", package = "uu_nohup", path = "src/uu/nohup" } -nproc = { optional = true, version = "0.9.0", package = "uu_nproc", path = "src/uu/nproc" } -numfmt = { optional = true, version = "0.9.0", package = "uu_numfmt", path = "src/uu/numfmt" } -od = { optional = true, version = "0.9.0", package = "uu_od", path = "src/uu/od" } -paste = { optional = true, version = "0.9.0", package = "uu_paste", path = "src/uu/paste" } -pathchk = { optional = true, version = "0.9.0", package = "uu_pathchk", path = "src/uu/pathchk" } -pinky = { optional = true, version = "0.9.0", package = "uu_pinky", path = "src/uu/pinky" } -pr = { optional = true, version = "0.9.0", package = "uu_pr", path = "src/uu/pr" } -printenv = { optional = true, version = "0.9.0", package = "uu_printenv", path = "src/uu/printenv" } -printf = { optional = true, version = "0.9.0", package = "uu_printf", path = "src/uu/printf" } -ptx = { optional = true, version = "0.9.0", package = "uu_ptx", path = "src/uu/ptx" } -pwd = { optional = true, version = "0.9.0", package = "uu_pwd", path = "src/uu/pwd" } -readlink = { optional = true, version = "0.9.0", package = "uu_readlink", path = "src/uu/readlink" } -realpath = { optional = true, version = "0.9.0", package = "uu_realpath", path = "src/uu/realpath" } -rm = { optional = true, version = "0.9.0", package = "uu_rm", path = "src/uu/rm" } -rmdir = { optional = true, version = "0.9.0", package = "uu_rmdir", path = "src/uu/rmdir" } -runcon = { optional = true, version = "0.9.0", package = "uu_runcon", path = "src/uu/runcon" } -seq = { optional = true, version = "0.9.0", package = "uu_seq", path = "src/uu/seq" } -shred = { optional = true, version = "0.9.0", package = "uu_shred", path = "src/uu/shred" } -shuf = { optional = true, version = "0.9.0", package = "uu_shuf", path = "src/uu/shuf" } -sleep = { optional = true, version = "0.9.0", package = "uu_sleep", path = "src/uu/sleep" } -sort = { optional = true, version = "0.9.0", package = "uu_sort", path = "src/uu/sort" } -split = { optional = true, version = "0.9.0", package = "uu_split", path = "src/uu/split" } -stat = { optional = true, version = "0.9.0", package = "uu_stat", path = "src/uu/stat" } -stdbuf = { optional = true, version = "0.9.0", package = "uu_stdbuf", path = "src/uu/stdbuf" } -stty = { optional = true, version = "0.9.0", package = "uu_stty", path = "src/uu/stty" } -sum = { optional = true, version = "0.9.0", package = "uu_sum", path = "src/uu/sum" } -sync = { optional = true, version = "0.9.0", package = "uu_sync", path = "src/uu/sync" } -tac = { optional = true, version = "0.9.0", package = "uu_tac", path = "src/uu/tac" } -tail = { optional = true, version = "0.9.0", package = "uu_tail", path = "src/uu/tail" } -tee = { optional = true, version = "0.9.0", package = "uu_tee", path = "src/uu/tee" } -timeout = { optional = true, version = "0.9.0", package = "uu_timeout", path = "src/uu/timeout" } -touch = { optional = true, version = "0.9.0", package = "uu_touch", path = "src/uu/touch" } -tr = { optional = true, version = "0.9.0", package = "uu_tr", path = "src/uu/tr" } -true = { optional = true, version = "0.9.0", package = "uu_true", path = "src/uu/true" } -truncate = { optional = true, version = "0.9.0", package = "uu_truncate", path = "src/uu/truncate" } -tsort = { optional = true, version = "0.9.0", package = "uu_tsort", path = "src/uu/tsort" } -tty = { optional = true, version = "0.9.0", package = "uu_tty", path = "src/uu/tty" } -uname = { optional = true, version = "0.9.0", package = "uu_uname", path = "src/uu/uname" } -unexpand = { optional = true, version = "0.9.0", package = "uu_unexpand", path = "src/uu/unexpand" } -uniq = { optional = true, version = "0.9.0", package = "uu_uniq", path = "src/uu/uniq" } -unlink = { optional = true, version = "0.9.0", package = "uu_unlink", path = "src/uu/unlink" } -uptime = { optional = true, version = "0.9.0", package = "uu_uptime", path = "src/uu/uptime" } -users = { optional = true, version = "0.9.0", package = "uu_users", path = "src/uu/users" } -vdir = { optional = true, version = "0.9.0", package = "uu_vdir", path = "src/uu/vdir" } -wc = { optional = true, version = "0.9.0", package = "uu_wc", path = "src/uu/wc" } -who = { optional = true, version = "0.9.0", package = "uu_who", path = "src/uu/who" } -whoami = { optional = true, version = "0.9.0", package = "uu_whoami", path = "src/uu/whoami" } -yes = { optional = true, version = "0.9.0", package = "uu_yes", path = "src/uu/yes" } - -[dev-dependencies] -ctor.workspace = true -filetime.workspace = true -glob.workspace = true -jiff.workspace = true -libc.workspace = true -bytecount.workspace = true -num-prime.workspace = true -pretty_assertions.workspace = true -rand.workspace = true -regex.workspace = true -rustc-hash.workspace = true -sha1 = { workspace = true } -tempfile.workspace = true -time = { workspace = true, features = ["local-offset"] } -unicode-width.workspace = true -unindent = "0.2.3" -uutests.workspace = true -uucore = { workspace = true, features = [ - "mode", - "entries", - "pipes", - "process", - "signals", - "utmpx", -] } -walkdir.workspace = true -hex-literal = "1.0.0" -rstest.workspace = true -rstest_reuse.workspace = true - -[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] -rustix.workspace = true -selinux = { workspace = true, optional = true } - -# 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" } - -# -# * pinned transitive dependencies -# Not needed for now. Keep as examples: -#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()`) - -[target.'cfg(unix)'.dev-dependencies] -nix = { workspace = true, features = [ - "process", - "signal", - "socket", - "term", - "user", -] } -rlimit = { workspace = true } -rustix.workspace = true - - -[build-dependencies] -phf_codegen.workspace = true +[lints] +workspace = true [[bin]] name = "coreutils" @@ -780,6 +707,3 @@ strip = true inherits = "release" panic = "unwind" debug = true - -[lints] -workspace = true diff --git a/Cross.toml b/Cross.toml index 90d824e61aa..9f3f8f1c76e 100644 --- a/Cross.toml +++ b/Cross.toml @@ -1,4 +1,5 @@ # spell-checker:ignore (misc) dpkg noninteractive tzdata + [build] pre-build = [ "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install tzdata", diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 833f258b63c..bbe3ccaf6ed 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -50,16 +50,17 @@ You will need these to [run uutils against the GNU test suite locally](#comparin For MacOS and Windows platform specific setup please check [MacOS GNU utils](#macos-gnu-utils) and [Windows GNU utils](#windows-gnu-utils) sections respectfully. -### pre-commit hooks +### prek hooks -A configuration for `pre-commit` is provided in the repository. It allows -automatically checking every git commit you make to ensure it compiles, and -passes `clippy` and `rustfmt` without warnings. +A configuration for `prek` is provided in the repository. It reads the existing +`.pre-commit-config.yaml` natively and allows automatically checking every git +commit you make to ensure it compiles, and passes `clippy` and `rustfmt` +without warnings. To use the provided hook: -1. [Install `pre-commit`](https://pre-commit.com/#install) -1. Run `pre-commit install` while in the repository directory +1. [Install `prek`](https://prek.j178.dev/installation/) +1. Run `prek install` while in the repository directory Your git commits will then automatically be checked. If a check fails, an error message will explain why, and your commit will be canceled. You can then make @@ -196,7 +197,7 @@ make CARGOFLAGS='--no-fail-fast' UTILS='UTILITY_1 UTILITY_2' nextest ### Run Busybox Tests -This testing functionality is only available on *nix operating systems and +This testing functionality is only available on Unix-like operating systems and requires `make`. To run busybox tests for all utilities for which busybox has tests @@ -290,7 +291,7 @@ brew install \ gnu-sed \ m4 \ bison \ - pre-commit \ + prek \ findutils ``` diff --git a/GNUmakefile b/GNUmakefile index 5423adca733..6da9a80b20e 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -84,7 +84,7 @@ ifeq (,$(findstring windows,$(OS))) else FEATURE_EXTRACT_UTILS := windows endif -PROGS := $(shell cargo tree --depth 1 --features $(FEATURE_EXTRACT_UTILS) --format "{p}" --prefix none | sed -E -n 's/^uu_([^ ]+).*/\1/p') +PROGS := $(shell cargo tree --depth 1 --features $(FEATURE_EXTRACT_UTILS) --format "{lib}" --prefix none | sed -n 's/^uu_//p') ifeq ($(SELINUX_ENABLED),1) PROGS += $(SELINUX_PROGS) @@ -236,6 +236,12 @@ locales: for locale_file in "$(BASEDIR)"/src/uucore/locales/*.ftl; do \ $(INSTALL) -m 644 "$$locale_file" "$(BUILDDIR)/locales/uucore/"; \ done; \ + if [ -d "$(BASEDIR)/src/uucore/locales/errors" ]; then \ + $(INSTALL) -d "$(BUILDDIR)/locales/uucore/errors"; \ + for locale_file in "$(BASEDIR)"/src/uucore/locales/errors/*.ftl; do \ + $(INSTALL) -m 644 "$$locale_file" "$(BUILDDIR)/locales/uucore/errors/"; \ + done; \ + fi; \ fi; \ # Copy utility-specific locales @for prog in $(INSTALLEES); do \ @@ -255,6 +261,24 @@ INSTALLEES_WITH_EXTRA_LOCALE = \ $(INSTALLEES) \ $(if $(findstring sum, $(INSTALLEES)),checksum_common, ) install-locales: + @# Install common locales shared by all utilities + @if [ -d "$(BASEDIR)/src/uucore/locales" ]; then \ + $(INSTALL) -d "$(DESTDIR)$(DATAROOTDIR)/locales/uucore"; \ + for locale_file in "$(BASEDIR)"/src/uucore/locales/*.ftl; do \ + if [ "$$(basename "$$locale_file")" != "en-US.ftl" ]; then \ + $(INSTALL) -m 644 "$$locale_file" "$(DESTDIR)$(DATAROOTDIR)/locales/uucore/"; \ + fi; \ + done; \ + fi + @# Install lazy error locales shared by all utilities + @if [ -d "$(BASEDIR)/src/uucore/locales/errors" ]; then \ + $(INSTALL) -d "$(DESTDIR)$(DATAROOTDIR)/locales/uucore/errors"; \ + for locale_file in "$(BASEDIR)"/src/uucore/locales/errors/*.ftl; do \ + if [ "$$(basename "$$locale_file")" != "en-US.ftl" ]; then \ + $(INSTALL) -m 644 "$$locale_file" "$(DESTDIR)$(DATAROOTDIR)/locales/uucore/errors/"; \ + fi; \ + done; \ + fi @for prog in $(INSTALLEES_WITH_EXTRA_LOCALE); do \ if [ -d "$(BASEDIR)/src/uu/$$prog/locales" ]; then \ $(INSTALL) -d "$(DESTDIR)$(DATAROOTDIR)/locales/$$prog"; \ diff --git a/README.md b/README.md index 7f4be4e17f4..fea644f011c 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,13 @@ are treated as bugs. Our key objectives include: - Matching GNU's output (stdout and error code) exactly -- Better error messages +- Better error messages: at a terminal, a parse error is shown as a + compiler-style report with a caret under the argument at fault, where GNU + prints a single line (see [error diagnostics](docs/src/extensions-errors.md)); + scripts and pipes still get the plain GNU message - Providing comprehensive internationalization support (UTF-8) -- Improved performances +- Continuously improving performance; significant slowdowns relative to other coreutils implementations + are treated as bugs - [Extensions](docs/src/extensions.md) when relevant (example: --progress) uutils aims to work on as many platforms as possible, to be able to use the same @@ -136,17 +140,16 @@ To speed up the checksum utilities (`md5sum`, `sha1sum`, `sha224sum`, `sha256sum `sha384sum`, `sha512sum`, and `cksum`) by using OpenSSL's `libcrypto` instead of the pure-Rust digest crates, enable the `openssl` feature: ``` -cargo build --release --features unix,openssl +cargo build --release --features openssl ``` By default OpenSSL is built from source and statically linked into the -binary (mirroring how `expr` links `oniguruma`), so no runtime dependency -on system libcrypto/libssl is added. To link dynamically against the system -libcrypto instead, set `OPENSSL_NO_VENDOR=1` at build time. - -The speedup is largest on CPUs without SHA-NI hardware acceleration. The -feature is a no-op on Windows (the pure-Rust implementations are always used -there) and is automatically bypassed at runtime for any algorithm libcrypto -refuses (for example, MD5 in strict FIPS mode). +binary, so no runtime dependency on system libcrypto/libssl is added. To link +dynamically against the system libcrypto instead, set `OPENSSL_NO_VENDOR=1` at build time. +MinGW (Windows) needs `OPENSSL_STATIC=0` too for dynamic linkage. + +The speedup is largest on CPUs without SHA-NI hardware acceleration. +`aws-lc` backend is used on MSVC. Setting `AWS_LC_SYS_NO_JITTER_ENTROPY=1` +at build time is recommended to reduce size of binary. If you don't want to build every utility available on your platform into the final binary, you can also specify which ones you want to build manually. For diff --git a/SECURITY.md b/SECURITY.md index c9b8bac7fa4..5cc8caae394 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -32,9 +32,12 @@ privilege boundary on the local system. re-resolved paths) so a symlink swapped in mid-walk cannot redirect the operation outside the intended tree. - **Unintended destructive actions** - operations that delete, overwrite, or - signal something the user did not ask for, including security-relevant - divergences from GNU behavior that scripts rely on (a parsing bug that "fails - open" instead of erroring). + signal something the user did not ask for, **where the input that triggers it + reaches the utility across a trust boundary**: an attacker-supplied filename + or file content that a privileged script, cron job, or automated pipeline + processes. A divergence from GNU makes such a bug real and worth fixing, but + it is the boundary crossing that makes it a security issue - see "What makes a + report a vulnerability" below. - Memory-safety issues, integer overflow, or unbounded allocation reachable from untrusted input. @@ -45,6 +48,47 @@ privilege boundary on the local system. not change which files or processes are affected. - Issues requiring an already-privileged or already-malicious local actor who could achieve the same effect directly. +- **A divergence from GNU on its own.** Differing from GNU - including failing + where GNU errors, or erroring where GNU succeeds - is a bug we want reported, + but it is not by itself a vulnerability. The question is what the divergence + lets an attacker *do* that they could not do already. For example: an + argument-parsing difference that makes a utility treat an attacker-chosen + filename as an option is a real bug, but if the result stays inside the + caller's own permissions and the operands the caller already named, no + boundary was crossed - it belongs in a public issue. +- **Data loss or a destructive action with no boundary crossing** - a utility + destroying the caller's own files, under the caller's own permissions, from + the caller's own arguments. +- **An absent or ineffective mitigation that grants no access.** A control that + fails open leaves the user where they would have been without it. That is a + bug to fix promptly, not an advisory, unless the failure itself hands someone + access they did not have. +- **Availability bounded to the caller's own job** - a hang, an unbounded loop, + or resource use that affects only the invocation that asked for it (see below). + +### What makes a report a vulnerability + +One test decides it: **name the attacker and what they gain.** + +- *Who is the attacker?* If the only party in the reproduction is the operator + running their own command in their own environment, there is no boundary and + no vulnerability. What *could* happen in a hypothetical hostile deployment + does not supply one. +- *What do they control?* Inputs - a filename, file contents, a directory a + privileged process walks, the environment a caller passes in. Not the + privileged process's own code, arguments, or configuration. +- *What do they end up with that they did not have before?* If the end state + gives them no access, no privilege, and no reach beyond what they already had + - if it is merely *less* of something the operator wanted - it is a bug. + +If you cannot answer all three, please open a normal public issue instead. That +is not a lesser outcome: it gets the bug fixed faster, in the open, and you are +credited the same way. **Declining an advisory is never declining the fix** - if +we judge a report to be a bug rather than a vulnerability, we will say so +plainly, file it publicly, credit you, and fix it. + +If you are unsure, report it privately anyway. We would much rather triage a +borderline report than miss a real one. ### Local denial-of-service diff --git a/build.rs b/build.rs index 2b61b84d2e4..923b2f44d1a 100644 --- a/build.rs +++ b/build.rs @@ -21,6 +21,7 @@ pub fn main() { // Check for tldr.zip when building uudoc to warn users once at build time // instead of repeatedly at runtime for each utility + println!("cargo:rerun-if-changed=docs/tldr.zip"); 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." @@ -35,17 +36,18 @@ pub fn main() { println!("cargo:rustc-cfg=build={profile:?}"); } + let mut crates = Vec::new(); + let out_dir = env::var("OUT_DIR").unwrap(); - let mut crates = Vec::new(); + let target_os = env::var_os("CARGO_CFG_TARGET_OS").unwrap(); for (key, val) in env::vars() { if val == "1" && key.starts_with(ENV_FEATURE_PREFIX) { let krate = key[ENV_FEATURE_PREFIX.len()..].to_lowercase(); // Allow this as we have a bunch of info in the comments #[allow(clippy::match_same_arms)] match krate.as_ref() { - #[cfg(not(any(target_os = "linux", target_os = "android")))] - "chcon" | "runcon" => { + "chcon" | "runcon" if !(target_os == "linux" || target_os == "android") => { continue; } "default" | "macos" | "unix" | "windows" | "selinux" | "zip" | "clap_complete" diff --git a/deny.toml b/deny.toml index fca5054eabd..711fe67f662 100644 --- a/deny.toml +++ b/deny.toml @@ -54,28 +54,8 @@ highlight = "all" # introduces it. # spell-checker: disable skip = [ - # various crates - { name = "windows-sys", version = "0.59.0" }, - # various crates + # notify { name = "windows-sys", version = "0.60.2" }, - # parking_lot_core - { name = "windows-targets", version = "0.52.6" }, - # windows-targets - { name = "windows_aarch64_gnullvm", version = "0.52.6" }, - # windows-targets - { name = "windows_aarch64_msvc", version = "0.52.6" }, - # windows-targets - { name = "windows_i686_gnu", version = "0.52.6" }, - # windows-targets - { name = "windows_i686_gnullvm", version = "0.52.6" }, - # windows-targets - { name = "windows_i686_msvc", version = "0.52.6" }, - # windows-targets - { name = "windows_x86_64_gnu", version = "0.52.6" }, - # windows-targets - { name = "windows_x86_64_gnullvm", version = "0.52.6" }, - # windows-targets - { name = "windows_x86_64_msvc", version = "0.52.6" }, # ansi-width { name = "unicode-width", version = "0.1.13" }, # filedescriptor, utmp-classic @@ -86,8 +66,8 @@ skip = [ { name = "itertools", version = "0.13.0" }, # codspeed-divan-compat-macros { name = "itertools", version = "0.14.0" }, - # ordered-multimap - { name = "hashbrown", version = "0.14.5" }, + # lru (via num-prime) + { name = "hashbrown", version = "0.16.1" }, # cexpr (via bindgen) { name = "nom", version = "7.1.3" }, # const-random-macro, rand_core @@ -98,16 +78,14 @@ skip = [ { name = "rand_chacha", version = "0.3.1" }, # rand { name = "rand_core", version = "0.6.4" }, - # utmp-classic - { name = "zerocopy", version = "0.7.35" }, - # zerocopy - { name = "zerocopy-derive", version = "0.7.35" }, # crc-fast { name = "digest", version = "0.10.7" }, # digest { name = "crypto-common", version = "0.1.7" }, # various crates { name = "syn", version = "2.0.117" }, + # bindgen + { name = "shlex", version = "1.3.0" }, ] # spell-checker: enable diff --git a/docs/compiles_table.py b/docs/compiles_table.py index 884b0bc39e1..ec0aa2144b5 100755 --- a/docs/compiles_table.py +++ b/docs/compiles_table.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -import multiprocessing -import subprocess import argparse import csv +import multiprocessing +import subprocess import sys from collections import defaultdict from pathlib import Path @@ -52,6 +52,10 @@ ] +class MissingToolchainError(Exception): + """A toolchain or SDK needed to check a target is not installed.""" + + class Target(str): def __new__(cls, content): obj = super().__new__(cls, content) @@ -105,36 +109,38 @@ def check(self, binary): f"--target={self}", ] - res = subprocess.run(args, capture_output=True) + res = subprocess.run(args, capture_output=True, check=False) return res.returncode # Validate that the dependencies for running this target are met def is_installed(self): # check IOS sdk is installed, raise exception otherwise if "ios" in self: - res = subprocess.run(["which", "xcrun"], capture_output=True) + res = subprocess.run(["which", "xcrun"], capture_output=True, check=False) if len(res.stdout) == 0: - raise Exception( + raise MissingToolchainError( "Error: IOS sdk does not seem to be installed. Please do that manually" ) if not self.requires_nightly(): # check std toolchains are installed toolchains = subprocess.run( - ["rustup", "target", "list"], capture_output=True + ["rustup", "target", "list"], capture_output=True, check=False ) toolchains = toolchains.stdout.decode("utf-8").split("\n") if "installed" not in next(filter(lambda x: self in x, toolchains)): - raise Exception( + raise MissingToolchainError( f"Error: the {self} target is not installed. Please do that manually" ) else: # check nightly toolchains are installed toolchains = subprocess.run( - ["rustup", "+nightly", "target", "list"], capture_output=True + ["rustup", "+nightly", "target", "list"], + capture_output=True, + check=False, ) toolchains = toolchains.stdout.decode("utf-8").split("\n") if "installed" not in next(filter(lambda x: self in x, toolchains)): - raise Exception( + raise MissingToolchainError( f"Error: the {self} nightly target is not installed. Please do that manually" ) return True @@ -143,13 +149,12 @@ def is_installed(self): def install_targets(): cmd = ["rustup", "target", "add"] + TARGETS print(" ".join(cmd)) - ret = subprocess.run(cmd) + ret = subprocess.run(cmd, check=False) assert ret.returncode == 0 def get_all_bins(): - bins = map(lambda x: x.name, BINS_PATH.iterdir()) - return sorted(list(bins)) + return sorted(path.name for path in BINS_PATH.iterdir()) def get_targets(selection): @@ -180,7 +185,7 @@ def test_all_targets(targets, bins): def save_csv(file, table): targets = get_targets(table.keys()) # preserve order in CSV - bins = list(list(table.values())[0].keys()) + bins = list(next(iter(table.values())).keys()) with open(file, "w") as csvfile: header = ["target"] + bins writer = csv.DictWriter(csvfile, fieldnames=header) @@ -215,8 +220,8 @@ def merge_tables(old, new): def render_md(fd, table, headings: str, row_headings: Target): - def print_row(lst, lens=[]): - lens = lens + [0] * (len(lst) - len(lens)) + def print_row(lst, lens=None): + lens = (lens or []) + [0] * (len(lst) - len(lens or [])) for e, lmd in zip(lst, lens): fmt = "|{}" if lmd == 0 else "|{:>%s}" % len(header[0]) fd.write(fmt.format(e)) @@ -227,8 +232,8 @@ def cell_render(target, bin): # add some 'hard' padding to specific columns lens = [ - max(map(lambda x: len(x.os), row_headings)) + 2, - max(map(lambda x: len(x.arch), row_headings)) + 2, + max(len(target.os) for target in row_headings) + 2, + max(len(target.arch) for target in row_headings) + 2, ] header = Target.get_heading() header[0] = ("{:#^%d}" % lens[0]).format(header[0]) @@ -236,11 +241,11 @@ def cell_render(target, bin): header += headings print_row(header) - lines = list(map(lambda x: "-" * len(x), header)) + lines = ["-" * len(column) for column in header] print_row(lines) for t in row_headings: - row = list(map(lambda b: cell_render(t, b), headings)) + row = [cell_render(t, b) for b in headings] row = t.get_row_heading() + row print_row(row) diff --git a/docs/src/extensions-errors.css b/docs/src/extensions-errors.css new file mode 100644 index 00000000000..4dc21a28168 --- /dev/null +++ b/docs/src/extensions-errors.css @@ -0,0 +1,41 @@ +/* Styling for the error reports shown on this page. The colors are the ones + the utilities actually emit, so the examples read here the way they do in a + terminal; they match the palette used on uutils.org. */ + +pre.diag { + margin: 1rem 0; + padding: 0.9rem 1.1rem; + border-radius: 0.6em; + background: #1e1e2e; + color: #cdd6f4; + line-height: 1.5; + overflow-x: auto; + white-space: pre; +} + +/* The prompt of the command being run. */ +pre.diag .a-p { + color: #89b4fa; +} + +/* ANSI colors as emitted by the utilities: frame, source line, gutter, + the span at fault, and the Help: label. */ +pre.diag .a-d { + color: #9399b2; +} + +pre.diag .a-s { + color: #cdd6f4; +} + +pre.diag .a-f { + color: #7f849c; +} + +pre.diag .a-e { + color: #f38ba8; +} + +pre.diag .a-h { + color: #a6e3a1; +} diff --git a/docs/src/extensions-errors.md b/docs/src/extensions-errors.md new file mode 100644 index 00000000000..bee23e03bd8 --- /dev/null +++ b/docs/src/extensions-errors.md @@ -0,0 +1,524 @@ + + + +# Error diagnostics + +GNU coreutils reports every error as a single line on stderr. That line +answers *what* went wrong, but Not *where*. For utilities whose arguments form +a small language of their own - a `test` expression, a `chmod` mode, a `sort` +key - the interesting question is usually which argument, or which character +inside an argument, broke the parse. + +When stderr is a terminal, uutils renders these errors as a compiler-style +report instead: the command line is echoed back as a source line, and a caret +points at the part that is at fault, often with a line of advice. Everywhere +else - a script, a pipe, a test harness - the plain one-line message is kept, +so nothing that reads stderr can tell the difference. + + + +## Before and after + +Each utility below shows the plain one-line message first - what uutils +prints when stderr is not a terminal, and in most cases exactly what GNU +prints - then the report rendered on a terminal, colors included. + +### `test` + +Before: + +
$ test 7 -eq zap
+test: invalid integer 'zap'
+ +After: + +
$ test 7 -eq zap
+test: invalid integer 'zap'
+   ╭─[ test:1:7 ]
+   │
+ 1 │ 7 -eq zap
+   │       ───
+   │
+   │ Help: -eq, -ne, -lt, -le, -gt and -ge compare integers; use =, !=, < or > to compare strings
+   │       -eq equal, -ne not equal, -lt less than, -le less than or equal, -gt greater than, -ge greater than or equal
+───╯
+ +[Try it in the playground](https://uutils.org/playground/?cmd=test+7+-eq+zap). + +### `cut` + +A list of ranges is often long, and only one item in it is wrong: + +Before: + +
$ cut -f 1,4-2,9-12 notes.txt
+cut: invalid decreasing range
+ +After: + +
$ cut -f 1,4-2,9-12 notes.txt
+cut: invalid decreasing range
+   ╭─[ cut:1:10 ]
+   │
+ 1 │ cut -f 1,4-2,9-12 notes.txt
+   │          ─┬─
+   │           ╰─── this range ends before it starts
+   │
+   │ Help: a list is N, N-M, N- or -M, separated by commas, as in -f1,4-6,9-
+───╯
+ +### `expr` + +Before: + +
$ expr 9 + foo
+expr: non-integer argument
+ +After: + +
$ expr 9 + foo
+expr: non-integer argument
+   ╭─[ expr:1:5 ]
+   │
+ 1 │ 9 + foo
+   │     ───
+   │
+   │ Help: arithmetic operators need integers; use = or != to compare strings instead
+───╯
+ +[Try it in the playground](https://uutils.org/playground/?cmd=expr+9+%2B+foo). + +### `chmod` + +The caret can point *inside* an argument, at the exact character that broke +the parse: + +Before: + +
$ chmod 'g+rw?x' notes.txt
+chmod: invalid operator (expected +, -, or =, but found ?)
+ +After: + +
$ chmod 'g+rw?x' notes.txt
+chmod: invalid operator (expected +, -, or =, but found ?)
+   ╭─[ chmod:1:5 ]
+   │
+ 1 │ g+rw?x notes.txt
+   │     ─
+   │
+   │ Help: a mode is either octal, as in 644, or clauses such as u+rwx,go-w
+───╯
+ +### `tr` + +Before: + +
$ tr 'qw[y-b]' x
+tr: range-endpoints of 'y-b' are in reverse collating sequence order
+ +After: + +
$ tr 'qw[y-b]' x
+tr: range-endpoints of 'y-b' are in reverse collating sequence order
+   ╭─[ tr:1:7 ]
+   │
+ 1 │ tr qw[y-b] x
+   │       ─┬─
+   │        ╰─── did you mean 'b-y'?
+   │
+   │ Help: a range goes from the lower character to the higher one, as in a-z
+───╯
+ +[Try it in the playground](https://uutils.org/playground/?cmd=tr+%27qw%5By-b%5D%27+x). + +### `csplit` + +A pattern is a small language of its own, and the regex engine already knows +which character of it it choked on - so the caret can say so too: + +Before: + +
$ csplit notes.txt '/a{2,1}/'
+csplit: '/a{2,1}/': invalid pattern
+ +After: + +
$ csplit notes.txt '/a{2,1}/'
+csplit: '/a{2,1}/': invalid pattern
+   ╭─[ csplit:1:20 ]
+   │
+ 1 │ csplit notes.txt /a{2,1}/
+   │                    ──┬──
+   │                      ╰──── invalid repetition count range, the start must be <= the end
+   │
+   │ Help: a pattern is a line number N, /REGEXP/[OFFSET] or %REGEXP%[OFFSET], each optionally followed by {N} or {*}
+───╯
+ +The label is quoted from the regex engine rather than translated, since that +is the only place the wording exists. + +### `sort` + +Before: + +
$ sort -k2.3x notes.txt
+sort: stray character in field spec: invalid field specification '2.3x'
+ +After: + +
$ sort -k2.3x notes.txt
+sort: stray character in field spec: invalid field specification '2.3x'
+   ╭─[ sort:1:11 ]
+   │
+ 1 │ sort -k2.3x notes.txt
+   │           ─
+   │
+   │ Help: a key is FIELD[.CHAR][OPTS][,FIELD[.CHAR][OPTS]], as in -k2.3,4nr
+───╯
+ +[Try it in the playground](https://uutils.org/playground/?cmd=sort+-k2.3x+fruits.txt). + +### `numfmt` + +Before: + +
$ numfmt --format=%q 1000
+numfmt: invalid format '%q', directive must be %[0]['][-][N][.][N]f
+ +After: + +
$ numfmt --format=%q 1000
+numfmt: invalid format '%q', directive must be %[0]['][-][N][.][N]f
+   ╭─[ numfmt:1:18 ]
+   │
+ 1 │ numfmt --format=%q 1000
+   │                  ┬
+   │                  ╰── f is the only conversion numfmt has; %d, %e, %g and the other C conversions are not accepted
+   │
+   │ Help: a format is [PREFIX]%[0]['][-][WIDTH][.PRECISION]f[SUFFIX], as in "%'-10.2f"
+───╯
+ +[Try it in the playground](https://uutils.org/playground/?cmd=numfmt+--format%3D%25q+1000). + +### `printf` + +Before: + +
$ printf %5.2c q
+printf: %5.2c: invalid conversion specification
+ +After: + +
$ printf %5.2c q
+printf: %5.2c: invalid conversion specification
+   ╭─[ printf:1:8 ]
+   │
+ 1 │ printf %5.2c q
+   │        ─────
+   │
+   │ Help: %d, %s, %x, %f and the other C conversions are accepted, plus %b and %q; a literal % is written %%
+───╯
+ +The same goes for a broken escape: `printf 'a\xzb'` puts the caret under the +`\x` that is missing its hexadecimal digits. + +[Try it in the playground](https://uutils.org/playground/?cmd=printf+%255.2c+q). + +### `env` + +`env -S` takes a whole command line and splits it as a shell would. Its +messages name an offset, which is precisely the thing a caret can show +instead: + +Before: + +
$ env -S 'echo ${1FOO}'
+env: only ${VARNAME} expansion is supported, error at: ${1FOO}
+ +After: + +
$ env -S 'echo ${1FOO}'
+env: only ${VARNAME} expansion is supported, error at: ${1FOO}
+   ╭─[ env:1:14 ]
+   │
+ 1 │ env -S 'echo ${1FOO}'
+   │              ─┬─
+   │               ╰─── a variable name cannot start with a digit
+   │
+   │ Help: only $NAME and ${NAME} are expanded; the other shell forms are not
+───╯
+ +Note that the `-S` string holds spaces, so it is echoed back quoted - and the +caret still points inside it. + +### `head` + +A SIZE is a number and a unit, and the caret says which of the two was +rejected: + +Before: + +
$ head -c 1fb notes.txt
+head: invalid number of bytes: '1fb'
+ +After: + +
$ head -c 1fb notes.txt
+head: invalid number of bytes: '1fb'
+   ╭─[ head:1:10 ]
+   │
+ 1 │ head -c 1fb notes.txt
+   │          ─┬
+   │           ╰── not a known unit
+   │
+   │ Help: a size is a number and an optional unit: K, M, G and so on for 1024, KB, MB, GB for 1000
+───╯
+ +### `dd` + +Every `dd` operand is a `KEY=VALUE` pair, and a value can be a comma-separated +list of flags, so there are three things the caret can pick out: the key, the +whole value, or one flag inside the list. A flag is underlined where it sits +in the list rather than wherever its text first turns up, and the advice names +the flags that operand accepts: + +Before: + +
$ dd conv=ucase,zap
+dd: invalid conversion: 'zap'
+ +After: + +
$ dd conv=ucase,zap
+dd: invalid conversion: 'zap'
+   ╭─[ dd:1:15 ]
+   │
+ 1 │ dd conv=ucase,zap
+   │               ─┬─
+   │                ╰─── not a known conversion
+   │
+   │ Help: conv= is one of ascii, ebcdic, ibm, lcase, ucase, block, unblock, swab, sync, noerror, sparse, excl, nocreat, notrunc, fdatasync or fsync
+───╯
+ +`iflag=` and `oflag=` are reported apart, so an output flag is no longer +blamed on the input, and each lists its own flags: + +Before: + +
$ dd oflag=zap
+dd: invalid output flag: 'zap'
+ +After: + +
$ dd oflag=zap
+dd: invalid output flag: 'zap'
+   ╭─[ dd:1:10 ]
+   │
+ 1 │ dd oflag=zap
+   │          ─┬─
+   │           ╰─── not a known output flag
+   │
+   │ Help: oflag= is one of direct, directory, dsync, sync, nocache, nonblock, noatime, noctty, nofollow, append or seek_bytes
+───╯
+ +An unknown key is underlined without its value, since the value is not what +was rejected: + +Before: + +
$ dd zap=1
+dd: unrecognized operand 'zap=1'
+ +After: + +
$ dd zap=1
+dd: unrecognized operand 'zap=1'
+   ╭─[ dd:1:4 ]
+   │
+ 1 │ dd zap=1
+   │    ───
+   │
+   │ Help: an operand is KEY=VALUE, as in if=file bs=4k count=10
+───╯
+ +A number that does not fit is rejected rather than quietly clamped, and the +caret covers the whole value: + +Before: + +
$ dd count=99999999999999999999999
+dd: invalid number: '99999999999999999999999': Value too large for defined data type
+ +After: + +
$ dd count=99999999999999999999999
+dd: invalid number: '99999999999999999999999': Value too large for defined data type
+   ╭─[ dd:1:10 ]
+   │
+ 1 │ dd count=99999999999999999999999
+   │          ───────────────────────
+   │
+   │ Help: a number may be followed by a multiplier: c, w, b, then K, M, G and so on for 1024, kB, MB, GB for 1000
+───╯
+ +[Try it in the playground](https://uutils.org/playground/?cmd=dd+conv%3Ducase%2Czap). + +## Compatibility + +This is strictly an interactive nicety; nothing that reads our output can tell +the difference: + +- Reports are only rendered when **stderr is a terminal**, unless `UUTILS_DIAG` + says otherwise (see below). In a script, a pipe, or a test harness, the + utility keeps printing its plain one-line message, so existing scripts that + match on stderr keep working. +- A report is drawn only when the error can be **tied to something on the + command line**. When it cannot - the error is not about any one argument, or + the operand it names was rewritten or consumed before it could be located - + the plain one-line message is printed instead, even at a terminal. A utility + in the table below therefore reports the parse errors it can place, not + every error it has. +- Exit codes are unchanged, and so is the `Try '... --help'` hint: an error + that is a usage error still prints it under the report. +- Colors follow the usual conventions: they are used only on a terminal, and + [`NO_COLOR`](https://no-color.org/) disables them. +- All messages, labels and help lines are localized like the rest of uutils + (see [Localization](l10n.md)). +- The rendering can be compiled out entirely: it sits behind the + `feat_diagnostics` cargo feature, which is on by default. Building with + `--no-default-features` (plus a `feat_os_*` selection) drops the renderer + and its `ariadne` dependency, and every utility keeps its plain one-line + messages. + +## Turning it on and off + +The default keys off stderr being a terminal, and nothing else - which is +usually what you want, but not always. `UUTILS_DIAG` overrides it: + +| Value | Effect | +| ----- | ------ | +| `always` | Draw the report even when stderr is a file or a pipe. | +| `never` | Keep the plain one-line message even at a terminal. | +| `auto`, unset, anything else | Decide from stderr, as above. | + +An unrecognized value is deliberately not an error - this is the kind of +variable that gets exported from a shell profile once and forgotten, and no +spelling of it should be able to make a utility fail. + +`always` is the one to reach for when the error has to leave the terminal +it happened in - a CI log, or a report to paste into a bug: + +``` +$ UUTILS_DIAG=always sort -k2.3x notes.txt 2> parse.log +``` + +Colors are a separate question, and one the terminal still answers: a report +forced into a file is written without them, so nothing has to strip escape +sequences back out. [`NO_COLOR`](https://no-color.org/) is the middle setting +at a terminal - the report is still drawn, just in plain text. + +Without the variable, both directions are still one command away. To get the +plain line while sitting at a terminal, send stderr somewhere that is not one: + +``` +$ sort -k2.3x notes.txt 2>&1 | cat +sort: stray character in field spec: invalid field specification '2.3x' +``` + +And to get a report out of a command that has to run under a real terminal, +give it a pty with `script -qec "..." /dev/null`, or `unbuffer` from expect. + +## Supported utilities + +| Utility | What the caret points at | Try it | +| -------- | ------------------------ | ------ | +| `test` | the argument that made the expression fail | [`test 7 -eq zap`](https://uutils.org/playground/?cmd=test+7+-eq+zap) | +| `expr` | the argument that made the expression fail | [`expr 9 + foo`](https://uutils.org/playground/?cmd=expr+9+%2B+foo) | +| `chmod` | the failing clause (or character) of an invalid symbolic or octal mode | [`chmod 'g+rw?x' fruits.txt`](https://uutils.org/playground/?cmd=chmod+%27g%2Brw%3Fx%27+fruits.txt) | +| `mkdir` | the failing part of the mode given to `-m`/`--mode` | [`mkdir -m u+q mydir`](https://uutils.org/playground/?cmd=mkdir+-m+u%2Bq+mydir) | +| `mkfifo` | the failing part of the mode given to `-m`/`--mode` | [`mkfifo -m u+q mypipe`](https://uutils.org/playground/?cmd=mkfifo+-m+u%2Bq+mypipe) | +| `mknod` | the failing part of the mode given to `-m`/`--mode` | [`mknod -m u+q mydev c 1 3`](https://uutils.org/playground/?cmd=mknod+-m+u%2Bq+mydev+c+1+3) | +| `install`| the failing part of the mode given to `-m`/`--mode` | [`install -m u+q fruits.txt dest`](https://uutils.org/playground/?cmd=install+-m+u%2Bq+fruits.txt+dest) | +| `tr` | the part of a set that is at fault (bad class, backwards range, bad repeat count, …) | [`tr 'qw[y-b]' x`](https://uutils.org/playground/?cmd=tr+%27qw%5By-b%5D%27+x) | +| `sort` | the failing part of a `-k`/`--key` or field specification, or of the SIZE given to `-S` | [`sort -k2.3x fruits.txt`](https://uutils.org/playground/?cmd=sort+-k2.3x+fruits.txt) | +| `numfmt` | the failing part of a `--field` or `--format` specification | [`numfmt --format=%q 1000`](https://uutils.org/playground/?cmd=numfmt+--format%3D%25q+1000) | +| `printf` | the failing conversion or escape in the format string | [`printf %5.2c q`](https://uutils.org/playground/?cmd=printf+%255.2c+q) | +| `seq` | the failing conversion in the format given to `-f`/`--format` | [`seq -f %5.2c 1 3`](https://uutils.org/playground/?cmd=seq+-f+%255.2c+1+3) | +| `stat` | the failing directive of a `-c`/`--format` or `--printf` format | [`stat -c %d%.3 fruits.txt`](https://uutils.org/playground/?cmd=stat+-c+%25d%25.3+fruits.txt) | +| `env` | the failing part of a `-S`/`--split-string` string | [`env -S 'echo ${1FOO}'`](https://uutils.org/playground/?cmd=env+-S+%27echo+%24%7B1FOO%7D%27) | +| `dd` | the failing key, value or flag of a `KEY=VALUE` operand | [`dd conv=ucase,zap`](https://uutils.org/playground/?cmd=dd+conv%3Ducase%2Czap) | +| `join` | the failing field of the output format given to `-o` | [`join -o 1.2,2.x fruits.txt fruits.txt`](https://uutils.org/playground/?cmd=join+-o+1.2%2C2.x+fruits.txt+fruits.txt) | +| `cut` | the failing range in the list given to `-b`, `-c`, `-f` or `-F` | [`cut -f 1,4-2 fruits.txt`](https://uutils.org/playground/?cmd=cut+-f+1%2C4-2+fruits.txt) | +| `csplit` | the failing pattern operand, the character of its regex that broke, or the format given to `-b`/`-n` | [`csplit fruits.txt '/a(b/'`](https://uutils.org/playground/?cmd=csplit+fruits.txt+%27%2Fa%28b%2F%27) | +| `split` | the failing part of the SIZE given to `-b`, `-C` or `-l` | [`split -b 7zq fruits.txt`](https://uutils.org/playground/?cmd=split+-b+7zq+fruits.txt) | +| `shred` | the failing part of the SIZE given to `-s`/`--size` | [`shred -s 4vv fruits.txt`](https://uutils.org/playground/?cmd=shred+-s+4vv+fruits.txt) | +| `head` | the failing part of the SIZE given to `-c` or `-n` | [`head -c 1fb fruits.txt`](https://uutils.org/playground/?cmd=head+-c+1fb+fruits.txt) | +| `tail` | the failing part of the SIZE given to `-c` or `-n` | [`tail -c 1fb fruits.txt`](https://uutils.org/playground/?cmd=tail+-c+1fb+fruits.txt) | +| `truncate` | the failing part of the SIZE given to `-s`/`--size` | [`truncate -s 10fb fruits.txt`](https://uutils.org/playground/?cmd=truncate+-s+10fb+fruits.txt) | +| `od` | the failing part of the SIZE given to `-j`, `-N`, `-S` or `-w` | [`od -N 3zz fruits.txt`](https://uutils.org/playground/?cmd=od+-N+3zz+fruits.txt) | +| `du` | the failing part of the SIZE given to `-B`/`--block-size` or `-t`/`--threshold` | [`du -B 1fb`](https://uutils.org/playground/?cmd=du+-B+1fb) | +| `df` | the failing part of the SIZE given to `-B`/`--block-size` | [`df -B 1fb`](https://uutils.org/playground/?cmd=df+-B+1fb) | +| `ls` | the failing part of the SIZE given to `--block-size` (also `dir` and `vdir`) | [`ls --block-size=1fb`](https://uutils.org/playground/?cmd=ls+--block-size%3D1fb) | +| `stdbuf` | the failing part of the buffering mode given to `-i`, `-o` or `-e` | [`stdbuf -o 6pq head`](https://uutils.org/playground/?cmd=stdbuf+-o+6pq+head) | + +## How it works + +The rendering lives in `uucore::features::diagnostics` and is built on +[ariadne](https://crates.io/crates/ariadne). A utility that opts in: + +1. Takes a `Snapshot` of its argument list. The snapshot joins the arguments + into one line (quoting where needed, and rendering non-UTF-8 arguments the + way the shell would) and remembers the byte range of each argument. +2. Maps its own error type to a position, and optionally a label and a line + of advice, in a small per-utility `diagnostics.rs` module. A label is only + used when it adds something the message does not say - an expectation, or + a fix such as tr's `did you mean 'b-y'?` - never to restate it; with no + label the span is drawn as a bare underline. Everything user-facing is + passed in already localized. +3. Locates the argument the operand came from - with + `Snapshot::index_of_value` for an option's value in whatever spelling it + was given (`-k 2.3x`, `-k2.3x`, `-rk2.3x`, `--key 2.3x`, `--key=2.3x`), + `Snapshot::index_of_positional` for a positional operand, or an index the + utility tracked itself - and calls `Snapshot::render` to point at the + whole argument, or `Snapshot::render_inside_at` to point at a byte range + *inside* the operand it carries. Because the argument is named rather than + searched for, a file, another option, or the program name that happens to + share the operand's text can never take the caret. + `Snapshot::render_option_value` does the two steps in one, which is what + most option values want. + +An argument holding a space is echoed back quoted, and the caret still points +inside it: the quotes only wrap the operand, so its bytes are found where they +were printed and offsets count from there. An argument that could not be +printed as-is - a non-UTF-8 one, or one whose quoting had to be broken up - is +underlined as a whole instead, since no offset into it would line up with what +the reader sees. + +Callers check `diagnostics::enabled()` first, so the non-interactive path +costs nothing and keeps its GNU-compatible message. + +Errors that come out of a shared parser are rendered by a shared helper, so +that a syntax common to several utilities is explained the same way in all of +them, and its labels live in the common locale strings rather than being +repeated per utility. Three parsers work this way: + +- **Modes** (`uucore::mode`), for `chmod`, `mkdir`, `mkfifo`, `mknod` and + `install`. +- **Range lists** (`uucore::ranges`), for `cut`'s `-b`, `-c` and `-f` and for + `numfmt --field`. `Range::from_list` reports which item of the list failed + and where it sat. +- **Sizes** (`uucore::parser::parse_size`), for `head`, `tail`, `truncate`, + `split`, `shred`, `stdbuf`, `sort`, `od`, `du`, `df` and `ls` (with `dir` + and `vdir`) today, and available to the other callers of the parser. + `ParseSizeError::span` works out from the operand which of its two parts - + the number or the unit - was rejected, so the error type keeps the shape its + callers build by hand. + +Taking modes as the worked example: the parser reports errors as a structured +`ModeError` carrying the byte range at fault, and its rendering places the +caret for every utility that takes a mode - `ModeError::render_option_value` +finds the mode as the value of `-m`/`--mode` for `mkdir`, `mkfifo`, `mknod` +and `install`, while `chmod`, which accepts modes clap cannot see (such as +`chmod -w -r file`), tracks where each mode operand sat and passes the index +to `ModeError::render_at`. diff --git a/docs/src/extensions.md b/docs/src/extensions.md index bb0dfff0617..489886394e2 100644 --- a/docs/src/extensions.md +++ b/docs/src/extensions.md @@ -31,27 +31,54 @@ Our `coreutils` calls utility by `coreutils utility-name` and has `--list` to ru 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`. +## `base32/base64/basenc` + +Just like on macOS, `base32/base64/basenc` provides `-D` to decode data. + +## `cp` + +`cp` can display a progress bar when the `-g`/`--progress` flag is set. + +## `du` + +`du` allows `birth` and `creation` as values for the `--time` argument to show the creation time. It +also provides a `-v`/`--verbose` flag. + ## `env` GNU `env` allows the empty string to be used as an environment variable name. This is unsupported by uutils, which will show a warning for any such assignment. - `env` has an additional `-f`/`--file` flag that can -parse `.env` files and set variables accordingly. This feature is adopted from `dotenv` style -packages. +## `expand` -## `cp` +`expand` offers the `-U`/`--no-utf8` option to interpret input files as 8-bit ASCII instead of UTF-8. -`cp` can display a progress bar when the `-g`/`--progress` flag is set. +## `fmt` -## `mv` +`fmt` has additional flags for prefixes: `-P`/`--skip-prefix`, `-x`/`--exact-prefix`, and +`-X`/`--exact-skip-prefix`. With `-m`/`--preserve-headers`, an attempt is made to detect and preserve +mail headers in the input. `-q`/`--quick` breaks lines more quickly. And `-T`/`--tab-width` defines the +number of spaces representing a tab when determining the line length. -`mv` can display a progress bar when the `-g`/`--progress` flag is set. +## `id` -## `rm` +`id` has three additional flags: +* `-P` displays the id as a password file entry +* `-p` makes the output human-readable +* `-A` displays the process audit user ID -`rm` can display a progress bar when the `-g`/`--progress` flag is set. +## `install` + +`install` offers FreeBSD's `-U` unprivileged option to not change the owner, the group, or the file flags of the destination. + +## `ls` + +GNU `ls` provides two ways to use a long listing format: `-l` and `--format=long`. We support a +third way: `--long`. + +GNU `ls --sort=VALUE` only supports special non-default sort orders. +We support `--sort=name`, which makes it possible to override an earlier value. ## `more` @@ -60,17 +87,9 @@ coreutils. We do not aim for full compatibility with the `more` utility from `util-linux`. Features from more modern pagers (like `less` and `bat`) are therefore welcomed. -## `cut` - -`cut` can separate fields by whitespace (Space and Tab) with `-w` flag. This -feature is adopted from [FreeBSD](https://www.freebsd.org/cgi/man.cgi?cut). - -## `fmt` +## `mv` -`fmt` has additional flags for prefixes: `-P`/`--skip-prefix`, `-x`/`--exact-prefix`, and -`-X`/`--exact-skip-prefix`. With `-m`/`--preserve-headers`, an attempt is made to detect and preserve -mail headers in the input. `-q`/`--quick` breaks lines more quickly. And `-T`/`--tab-width` defines the -number of spaces representing a tab when determining the line length. +`mv` can display a progress bar when the `-g`/`--progress` flag is set. ## `printf` @@ -137,6 +156,10 @@ printf "%.32a\n" 0.1 ***Note: The architecture-specific behavior on non-x86(-64) platforms may change in the future.*** +## `rm` + +`rm` can display a progress bar when the `-g`/`--progress` flag is set. + ## `seq` Unlike GNU coreutils, `seq` always uses arbitrary precision decimal numbers, no @@ -161,36 +184,6 @@ double floating point numbers. Extremely large or small values can still overflow or underflow to infinity or zero, see note in `seq`. -## `ls` - -GNU `ls` provides two ways to use a long listing format: `-l` and `--format=long`. We support a -third way: `--long`. - -GNU `ls --sort=VALUE` only supports special non-default sort orders. -We support `--sort=name`, which makes it possible to override an earlier value. - -## `du` - -`du` allows `birth` and `creation` as values for the `--time` argument to show the creation time. It -also provides a `-v`/`--verbose` flag. - -## `id` - -`id` has three additional flags: -* `-P` displays the id as a password file entry -* `-p` makes the output human-readable -* `-A` displays the process audit user ID - -## `uptime` - -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` - -Just like on macOS, `base32/base64/basenc` provides `-D` to decode data. - ## `unexpand` GNU `unexpand` provides `--first-only` to convert only leading sequences of blanks. We support a @@ -198,10 +191,8 @@ second way: `-f` like busybox. With `-U`/`--no-utf8`, you can interpret input files as 8-bit ASCII rather than UTF-8. -## `expand` - -`expand` also offers the `-U`/`--no-utf8` option to interpret input files as 8-bit ASCII instead of UTF-8. - -## `install` +## `uptime` -`install` offers FreeBSD's `-U` unprivileged option to not change the owner, the group, or the file flags of the destination. +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 diff --git a/docs/src/l10n.md b/docs/src/l10n.md index bb00aa2041c..4d7bf34e66c 100644 --- a/docs/src/l10n.md +++ b/docs/src/l10n.md @@ -1,8 +1,8 @@ -# 🌍 Localization (L10n) in uutils coreutils +# Localization (L10n) in uutils coreutils This guide explains how localization (L10n) is implemented in the **Rust-based coreutils project**, detailing the use of [Fluent](https://projectfluent.org/) files, runtime behavior, and developer integration. -## 🏗️ Architecture Overview +## Architecture Overview **English (US) locale files (`en-US.ftl`) are embedded directly in the binary**, ensuring that English always works regardless of how the software is installed. Other language locale files are loaded from the filesystem at runtime. @@ -15,7 +15,7 @@ This guide explains how localization (L10n) is implemented in the **Rust-based c --- -## 📁 Fluent File Layout +## Fluent File Layout Each utility has its own set of translation files under: @@ -37,7 +37,7 @@ a different locale to verify that they work. --- -## ⚙️ Initialization +## Initialization Localization must be explicitly initialized at runtime using: @@ -53,7 +53,7 @@ The string parameter determines the lookup path for Fluent files. **English alwa --- -## 🌐 Locale Detection +## Locale Detection Locale selection is automatic and performed via: @@ -77,7 +77,7 @@ You can override the locale at runtime by running: --- -## 📥 Retrieving Messages +## Retrieving Messages We have a single macro to handle translations. It can be used in two ways: @@ -115,7 +115,7 @@ Variables must match the Fluent placeholder keys (`$error`, `$name`, `$count`, e --- -## 📦 Fluent Syntax Example +## Fluent Syntax Example ``` id-greeting = Hello, world! @@ -130,7 +130,7 @@ Use plural rules and inline variables to adapt messages dynamically. --- -## 🧪 Testing Localization +## Testing Localization Run all localization-related unit tests with: @@ -148,7 +148,7 @@ Tests include: --- -## 🧵 Thread-local Storage +## Thread-local Storage Localization is stored per thread using a `OnceLock`. Each thread must call `setup_localization()` individually. @@ -156,7 +156,7 @@ Initialization is **one-time-only** per thread — re-initialization results in --- -## 🧪 Development vs Release Mode +## Development vs Release Mode During development (`cfg(debug_assertions)`), paths are resolved relative to the crate source: @@ -178,7 +178,7 @@ If external locale files aren't found, the system falls back to embedded English --- -## 🔤 Unicode Isolation Handling +## Unicode Isolation Handling By default, the Fluent system wraps variables with Unicode directional isolate characters (`U+2068`, `U+2069`) to protect against visual reordering issues in bidirectional text (e.g., mixing Arabic and English). @@ -204,7 +204,7 @@ Fluent default (disabled here): --- -## 🔧 Embedded English Locales +## Embedded English Locales English locale files are always embedded directly in the binary during the build process. This ensures that: diff --git a/docs/src/packaging.md b/docs/src/packaging.md index 2ea1696687c..222c8c5de78 100644 --- a/docs/src/packaging.md +++ b/docs/src/packaging.md @@ -1,6 +1,6 @@ # Packaging coreutils - + > **Note**: This page is intended as a guide for packaging the uutils coreutils > for package maintainers. Normal users probably do not need to read this. If you @@ -72,6 +72,100 @@ It is also possible to split the debuginfo into a separate package. See the [`split-debuginfo`](https://doc.rust-lang.org/cargo/reference/profiles.html#split-debuginfo) option in `cargo`. +## Profile-Guided Optimization (PGO) + +The release binaries we publish are built with [Profile-Guided +Optimization](https://doc.rust-lang.org/rustc/profile-guided-optimization.html) +on every target whose build machine can run the binary it produces: Linux +x86_64 and aarch64, macOS x86_64 and arm64, and Windows x86_64 and aarch64 +(msvc). Packagers are encouraged to do the same: it costs nothing but build +time and needs no source change. + +`util/build-pgo.sh` drives the whole process. It runs on Linux, macOS and +Windows: + +```bash +rustup component add llvm-tools # provides llvm-profdata +./util/build-pgo.sh --features unix +``` + +It performs the four usual PGO steps: + +1. build an instrumented multicall binary (`-Cprofile-generate`), +2. run a set of representative workloads (sort, wc, cat, cut, hashing, cp/mv/ls, + ...) against a corpus it generates itself, +3. merge the raw profiles with `llvm-profdata`, +4. rebuild with `-Cprofile-use`, producing + `target/coreutils-pgo//release/coreutils[.exe]`. + +Useful options: + +- `--features LIST` — feature set to build with (defaults to `unix`); pass the + same value you use for the real package build (`feat_os_windows` on Windows). +- `--target TRIPLE` — target to build for; defaults to the host. The + instrumented binary still has to run on the build machine (see below). +- `--target-dir DIR` — where the instrumented build, the corpus and the merged + profile go (defaults to `target/coreutils-pgo`). +- `--train-only` — stop after step 3, so you can feed the profile to your own + build command. This is what our CI does: + + ```bash + ./util/build-pgo.sh --features unix --train-only + export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-Cprofile-use=$(cat target/coreutils-pgo/profdata-path.txt)" + cargo build --release --features unix + ``` + + Use this if your build system already owns the final `cargo build` (extra + rustflags, a different profile, a `DESTDIR` install step, ...). + `profdata-path.txt` holds the absolute path of the merged profile, spelled the + way the native toolchain expects it — on Windows that matters, since git-bash + paths (`/d/a/...`) mean nothing to `rustc`. +- `--llvm-profdata PATH` — explicit path to `llvm-profdata`. + +Things to know before wiring it into a package build: + +- **`llvm-profdata` must match the toolchain that instrumented the binary.** By + default the script picks the one from the active `rustc` sysroot + (`rustup component add llvm-tools`). A distribution `llvm-profdata` from a + different LLVM major version will fail to read the profiles; point at the + matching one with `--llvm-profdata`. +- **Training runs the freshly built binary**, so the build machine must be able + to execute it. That rules out emulator-less cross-builds (the script checks + and fails early rather than producing an empty profile); a foreign target only + works where the host can run it, such as x86_64 on an arm64 macOS with + Rosetta. For a true cross-build, either skip PGO or train on the target + architecture and carry the `.profdata` over. +- **The corpus is generated from scratch** (no reading of `/etc/passwd`, + `/usr/share/dict/words`, ...) so the profile does not depend on the contents + of the build machine, and it uses no external tools beyond bash itself. The + workloads do read this checkout's `src/uu` tree for the `cp`/`ls`/`du` + training. +- **For reproducible builds, freeze the profile.** The merged `.profdata` is an + input to the final build, so re-training on another machine can change the + resulting code layout. Generate it once, ship it as a source artifact, and + build with `-Cprofile-use=` instead of re-running the training. +- **Train with the same `lto` and `codegen-units` as the final build.** This is + the easy way to get a profile that makes things *slower*. Inlining happens + before instrumentation, so a training build with different settings records + counters for a call graph the final build no longer has. Our `[profile.release]` + uses `lto = "fat"` and `codegen-units = 1`, and the script trains with them; if + you use `--train-only` and then run your own `cargo build`, keep those two + values identical. When they did not match, `wc -w` came out **57% slower** than + a plain non-PGO release build, while the profile itself looked perfectly + healthy. +- **A bad profile fails the build rather than silently degrading it**: the + script refuses to continue if the merged profile covers fewer than 500 + functions, which is what an environment where the training workloads did not + actually run looks like. Note that this only catches a profile that is + *missing*, not one that is *mismatched*: the mismatch above produced a profile + covering more functions (4098) than the correct one (1883), since a non-LTO + build still has all the symbols that whole-program codegen later merges away. + If you change the training or build settings, measure the result. +- It costs a second full build plus the training run, so expect the package + build to take noticeably longer. On a 24-core x86_64 machine the full script + takes about 3 minutes for the `unix` feature set, and the resulting binary is + ~0.8% larger. + ## Additional artifacts This project supports automatically generating manpages and shell completion diff --git a/docs/src/performance.md b/docs/src/performance.md index 975a743abe6..5542559d17a 100644 --- a/docs/src/performance.md +++ b/docs/src/performance.md @@ -149,3 +149,41 @@ samply record -r 10000 target/profiling/ls -lR /var .git .git .git > /dev/null ```bash hyperfine --export-markdown file.md [...] ``` + +## Profile-Guided Optimization + +Our released binaries are built with PGO, driven by `util/build-pgo.sh` (Linux, +macOS and Windows alike). When comparing against a release build, keep in mind +that a plain `cargo build --release` is *not* what we ship. To reproduce it: + +```bash +rustup component add llvm-tools +./util/build-pgo.sh --features unix +# -> target/coreutils-pgo//release/coreutils +``` + +See [packaging](packaging.md#profile-guided-optimization-pgo) for the details +and the options. + +How much it buys varies a lot by utility. Measured on x86_64 with `hyperfine` +against a plain `cargo build --release`, the text-processing utilities gain the +most, while utilities dominated by syscalls or by a single hand-tuned loop +barely move: + +| Workload | Change | +| -------- | ------ | +| `cat -n`, `uniq -c`, `nl`, `fold -w` | -22% to -29% | +| `sort`, `sort -n` | -17% | +| `wc`, `sort -k` | -11% to -15% | +| `ls -lR`, `head` | -4% to -8% | +| `cut`, `seq`, `sha256sum`, `base64`, process startup | no change | + +Two things to keep in mind when benchmarking a PGO build: + +- The workloads in `util/build-pgo.sh` are what the profile is trained on. A + utility or a mode that is not exercised there gets no benefit, and measuring + one tells you little about the utilities that are. +- Wall-clock and instruction counts can disagree, since PGO changes code layout + as well as the code itself. `wc` gets 15% faster while executing *more* + instructions. If you use `valgrind --tool=cachegrind` for a noise-free + comparison, confirm the result with `hyperfine` before believing it. diff --git a/docs/src/platforms.md b/docs/src/platforms.md index d33500860c9..4ac6ab0a180 100644 --- a/docs/src/platforms.md +++ b/docs/src/platforms.md @@ -27,7 +27,7 @@ The platforms in tier 1 and the platforms that we test in CI are listed below. | **FreeBSD** | `x86_64-unknown-freebsd` | | **OpenBSD** | `x86_64-unknown-openbsd` | | **Android** | `x86_64-linux-android` | -| **wasm32** | `wasm32-wasip1` | +| **wasm32** | `wasm32-wasip1`
`wasm32-wasip2` | The platforms in tier 2 are more vague, but include: diff --git a/docs/src/release-notes/0.0.18.md b/docs/src/release-notes/0.0.18.md index 50c090ecd40..c374a6155a0 100644 --- a/docs/src/release-notes/0.0.18.md +++ b/docs/src/release-notes/0.0.18.md @@ -5,7 +5,7 @@ ## Release Notes -🎉 We're excited to announce the new release of Rust coreutils! This release brings several improvements and new features, including updates to the GNU test suite compatibility, and refinements in various utilities such as cksum, chmod, chroot, comm, cp, cut, and many more. +We're excited to announce the new release of Rust coreutils! This release brings several improvements and new features, including updates to the GNU test suite compatibility, and refinements in various utilities such as cksum, chmod, chroot, comm, cp, cut, and many more. ## General changes * GNU/coreutils: update reference to 9.2 by @cakebaker in https://github.com/uutils/coreutils/pull/4601 diff --git a/docs/src/release-notes/0.0.19.md b/docs/src/release-notes/0.0.19.md index 625ef9b572f..5e0dee95279 100644 --- a/docs/src/release-notes/0.0.19.md +++ b/docs/src/release-notes/0.0.19.md @@ -5,7 +5,7 @@ ## Release Notes -🎊 Delighted to unveil the fresh release of Rust coreutils! This iteration offers an array of enhancements and novel functionalities. Key updates include enhancements to the GNU test suite compatibility, and finessed operations in a variety of utilities such as cksum, chmod, chroot, comm, cp, cut, and a multitude of others. +Delighted to unveil the fresh release of Rust coreutils! This iteration offers an array of enhancements and novel functionalities. Key updates include enhancements to the GNU test suite compatibility, and finessed operations in a variety of utilities such as cksum, chmod, chroot, comm, cp, cut, and a multitude of others. We're making strides towards a production-ready toolset. diff --git a/docs/src/release-notes/0.0.20.md b/docs/src/release-notes/0.0.20.md index cddf4f6998c..a5ab3cb2ff4 100644 --- a/docs/src/release-notes/0.0.20.md +++ b/docs/src/release-notes/0.0.20.md @@ -5,7 +5,7 @@ ## Release Notes -🎉 Get ready for the newest release of Rust Coreutils! Top highlights of this release are substantial improvements in GNU test suite compatibility and refined operations in multiple utilities including cksum, chmod, chroot, comm, cp, cut, among others. +Get ready for the newest release of Rust Coreutils! Top highlights of this release are substantial improvements in GNU test suite compatibility and refined operations in multiple utilities including cksum, chmod, chroot, comm, cp, cut, among others. Supporting our development is as easy as sponsoring us on GitHub! Your contributions can help us afford a more robust Continuous Integration process through GitHub Actions. To sponsor, please visit https://github.com/sponsors/uutils. ## GNU test suite compatibility diff --git a/docs/src/release-notes/0.0.21.md b/docs/src/release-notes/0.0.21.md index 461836f91fa..f387ab58997 100644 --- a/docs/src/release-notes/0.0.21.md +++ b/docs/src/release-notes/0.0.21.md @@ -5,7 +5,7 @@ ## Release Notes -📦 Announcing Rust Coreutils version 0.0.21. This release showcases enhanced compatibility with the GNU test suite, particularly following our upgrade to GNU Coreutils 9.4. We've made technical improvements across various utilities, ensuring more robust and efficient operations. As always, we prioritize precision and performance in our updates. Dive into the detailed changelog for a comprehensive overview. 🛠️ +Announcing Rust Coreutils version 0.0.21. This release showcases enhanced compatibility with the GNU test suite, particularly following our upgrade to GNU Coreutils 9.4. We've made technical improvements across various utilities, ensuring more robust and efficient operations. As always, we prioritize precision and performance in our updates. Dive into the detailed changelog for a comprehensive overview. We have a fancy new website powered by [`oranda`](https://opensource.axo.dev/oranda/)! Check it out here: https://uutils.github.io. diff --git a/docs/src/release-notes/0.0.22.md b/docs/src/release-notes/0.0.22.md index 9ae87d09f2d..2daff5c96f1 100644 --- a/docs/src/release-notes/0.0.22.md +++ b/docs/src/release-notes/0.0.22.md @@ -5,10 +5,10 @@ ## Release Notes -📦 **Rust Coreutils 0.0.22 Release:** +**Rust Coreutils 0.0.22 Release:** We've got +11 more GNU tests passing than in our last release. -This release also adds support for NetBSD and requires a minimum Rust version of 1.70. 🛠️ +This release also adds support for NetBSD and requires a minimum Rust version of 1.70. We are also collaborating more and more with the [nushell](https://github.com/nushell/nushell/) team. Future versions of nushell will rely on the Rust Coreutils. 24 developers contributed to this release (11 new contributors started with this release). diff --git a/docs/src/release-notes/0.0.23.md b/docs/src/release-notes/0.0.23.md index 5104f3fd419..d42f1f31bc2 100644 --- a/docs/src/release-notes/0.0.23.md +++ b/docs/src/release-notes/0.0.23.md @@ -5,7 +5,7 @@ ## Release Notes -📦 **Rust Coreutils 0.0.23 Release:** +**Rust Coreutils 0.0.23 Release:** We've got +10 more GNU tests passing than in our last release. diff --git a/docs/src/release-notes/0.0.24.md b/docs/src/release-notes/0.0.24.md index 6a56455e56b..782326f9565 100644 --- a/docs/src/release-notes/0.0.24.md +++ b/docs/src/release-notes/0.0.24.md @@ -5,7 +5,7 @@ ## Release Notes -📦 **Rust Coreutils 0.0.24 Release:** +**Rust Coreutils 0.0.24 Release:** We've got +29 more GNU tests passing than in our last release! We are getting closer to version 1.0: each test passed is a step forward in ensuring that our users have a reliable and robust set of utilities that align closely with GNU standards. diff --git a/docs/src/release-notes/0.0.25.md b/docs/src/release-notes/0.0.25.md index 3a25acaa134..13141cda644 100644 --- a/docs/src/release-notes/0.0.25.md +++ b/docs/src/release-notes/0.0.25.md @@ -5,7 +5,7 @@ ## Release Notes -📦 **Rust Coreutils 0.0.25 Release:** +**Rust Coreutils 0.0.25 Release:** We're thrilled to announce version 0.0.25. We added +15 GNU passing tests since our last update! More and more programs have a full compatibility with the GNU testsuite. diff --git a/docs/src/release-notes/0.0.26.md b/docs/src/release-notes/0.0.26.md index 35f35070ae7..6f5a878516d 100644 --- a/docs/src/release-notes/0.0.26.md +++ b/docs/src/release-notes/0.0.26.md @@ -5,7 +5,7 @@ ## Release Notes -📦 **Rust Coreutils 0.0.26 Release:** +**Rust Coreutils 0.0.26 Release:** We are happy to announce the release of Rust Coreutils 0.0.26! This version comes with several significant advancements, including the initial support for AIX, ensuring broader compatibility and reach. Despite the short period since our last release, we've managed to fix numerous compatibility issues, further aligning our tools with the latest GNU testsuite, now version 9.5. diff --git a/docs/src/release-notes/0.0.27.md b/docs/src/release-notes/0.0.27.md index d0b8bd3bf5b..c2244f61c46 100644 --- a/docs/src/release-notes/0.0.27.md +++ b/docs/src/release-notes/0.0.27.md @@ -5,7 +5,7 @@ ## Release Notes -📦 **Rust Coreutils 0.0.27 Release:** +**Rust Coreutils 0.0.27 Release:** We are excited to announce the release of Rust Coreutils 0.0.27! This version continues to improve the compatibility. diff --git a/docs/src/release-notes/0.0.28.md b/docs/src/release-notes/0.0.28.md index 32daa5a4188..80937a5f0fb 100644 --- a/docs/src/release-notes/0.0.28.md +++ b/docs/src/release-notes/0.0.28.md @@ -5,7 +5,7 @@ ## Release Notes -### 📦 **Rust Coreutils 0.0.28 Release:** +### **Rust Coreutils 0.0.28 Release:** We are thrilled to announce the release of **Rust Coreutils 0.0.28**! This release brings significant improvements in compatibility and stability, continuing our journey to provide a robust alternative to GNU Coreutils. diff --git a/docs/src/release-notes/0.0.29.md b/docs/src/release-notes/0.0.29.md index 9304601abe6..8b8d98e6a3b 100644 --- a/docs/src/release-notes/0.0.29.md +++ b/docs/src/release-notes/0.0.29.md @@ -5,7 +5,7 @@ ## Release Notes -### 📦 **Rust Coreutils 0.0.29 Release:** +### **Rust Coreutils 0.0.29 Release:** We are excited to announce the release of **Rust Coreutils 0.0.29**! --- diff --git a/docs/src/release-notes/0.0.30.md b/docs/src/release-notes/0.0.30.md index 146fbf27924..da3700b590c 100644 --- a/docs/src/release-notes/0.0.30.md +++ b/docs/src/release-notes/0.0.30.md @@ -5,7 +5,7 @@ ## Release Notes -### 📦 **Rust Coreutils 0.0.30 Release:** +### **Rust Coreutils 0.0.30 Release:** We are excited to announce the release of **Rust Coreutils 0.0.30**! --- ### Highlights: diff --git a/docs/src/release-notes/0.1.0.md b/docs/src/release-notes/0.1.0.md index b82f535ac37..c47f4767f3e 100644 --- a/docs/src/release-notes/0.1.0.md +++ b/docs/src/release-notes/0.1.0.md @@ -5,7 +5,7 @@ ## Release Notes -### 📦 **Rust Coreutils 0.1.0 Release:** +### **Rust Coreutils 0.1.0 Release:** We are excited to announce the release of **Rust Coreutils 0.1.0** — our first 0.1 milestone! This release brings **major performance gains**, **SELinux support**, and expanded **GNU compatibility**. @@ -47,8 +47,8 @@ This release brings **major performance gains**, **SELinux support**, and expand ### Call to Action: -🚀 **Sponsor us on GitHub** to help us grow faster: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) -🔗 Download the latest release & learn more: [https://uutils.github.io](https://uutils.github.io) +**Sponsor us on GitHub** to help us grow faster: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) +Download the latest release & learn more: [https://uutils.github.io](https://uutils.github.io) ## What's Changed diff --git a/docs/src/release-notes/0.10.0.md b/docs/src/release-notes/0.10.0.md new file mode 100644 index 00000000000..142ad15f29e --- /dev/null +++ b/docs/src/release-notes/0.10.0.md @@ -0,0 +1,919 @@ +### **Rust Coreutils 0.10.0 Release:** + +We are happy to announce the release of **Rust Coreutils 0.10.0** - a release focused on **GNU compatibility and robustness**. The GNU test suite results improved sharply this cycle (**645 passing, 29 failing**, down from 56), we landed a **second wave of security hardening** (TOCTOU races, safe recursive descent, SELinux labeling at creation, backup guards), and we ran a broad **anti-panic campaign** across the utilities. On top of that: new features (`mv --exchange`, an **OpenSSL backend** for the checksum utilities), more zero-copy/read-ahead **performance** work, and **wasm32-wasip2** support. + +--- + +### GNU Test Suite Compatibility: + +| Result | 0.9.0 | 0.10.0 | Change 0.9.0 to 0.10.0 | % Total 0.9.0 | % Total 0.10.0 | % Change 0.9.0 to 0.10.0 | +|---------------|-------|--------|-------------------------|---------------|----------------|---------------------------| +| Pass | 625 | 645 | +20 | 90.58% | 93.48% | +2.90% | +| Skip | 8 | 15 | +7 | 1.16% | 2.17% | +1.01% | +| Fail | 56 | 29 | -27 | 8.12% | 4.20% | -3.92% | +| Error | 1 | 1 | 0 | 0.14% | 0.14% | 0% | +| Total | 690 | 690 | 0 | | | | + +> **Note:** 0.9.0 bumped the GNU reference to 9.11, which added 25 new tests and temporarily pushed the failure count up. This cycle absorbed most of that debt: **failures dropped from 56 to 29** and passes reached an all-time high of **645**. +As we contributed many tests to the GNU implementation, we are also the source of some of these regressions! + +--- + +![GNU testsuite evolution](https://github.com/uutils/coreutils-tracking/blob/main/gnu-results.svg?raw=true) + +--- + +### Highlights: + +- **GNU Compatibility** + - **645 passing GNU tests (+20)** and **27 fewer failures**, the largest single-cycle drop we have had + - Dozens of behavior alignments: `pr` (`-w`/`-W`/`-l`/`-e`/`-o`, merge mode, page ranges), `nproc` (cgroups v2 quota, affinity mask, offline cores, `OMP_*` handling), `date` (out-of-range years, `-d -`, `O` modifier, timezone strings), `numfmt`, `ls`, `du`, `install`, `od`, `truncate`, `fold`, `head`/`tail`, `sum`, `stat` + - Many error messages now carry the file name and the real OS error instead of a vague or stripped message + +- **Security Hardening** + - Continued the TOCTOU work started in 0.9.0: `touch` no longer uses `O_TRUNC` on create, `mkfifo` drops the path-based `chmod`, `head` checks the open fd instead of the path, `split` hardens the output-open path, `chcon` anchors recursive relabeling to the traversal dirfd + - Safe recursive descent fixes for `chmod`/`chown` (symlink cycles, `--preserve-root` re-checked during the descent), and `cp` no longer descends into a destination subdirectory that is a symlink + - SELinux labels are now applied **at creation** in `mkdir`, `mkfifo` and `mknod` instead of being applied afterwards + - `mv` fails closed when recreating a cross-device directory destination, compares file identity in the `--backup=simple` guard, and strips setuid/setgid when a cross-device copy cannot preserve ownership + - `chroot` chroots exactly the path `--skip-chdir` validated; `stdbuf` refuses a `libstdbuf` path that cannot be represented in `LD_PRELOAD`; `install` finalizes ownership before mode + - Added `SECURITY.md` documenting our threat model, scope and reporting process + +- **Robustness: Anti-Panic Campaign** + - Removed panics and aborts across a long list of utilities: `shuf`, `printf`, `numfmt`, `fmt`, `pr`, `chroot`, `dircolors`, `install`, `ln`, `touch`, `stat`, `split`, `expand`, `more`, `kill`, `cp`, `du`, `rm`, `runcon`, `sort`, `chown`/`chgrp`, `dirname`, `expr`, `cksum`, `dd`, `truncate`, `hashsum` + - Recurring classes fixed: write failures on `/dev/full`, char-boundary panics on multibyte input, integer overflow on extreme widths/precisions/sizes, and unbounded allocations for huge arguments + - `expr` now uses an iterative parser, so deeply nested expressions no longer overflow the stack + +- **New Features** + - `mv --exchange` to atomically swap two paths + - `install` supports reflink, and `install -C` now works with `--preserve-timestamps` + - **OpenSSL backend** for the faster checksum utilities + - `cut -O` as a short option for `--output-delimiter` + - `rm --one-file-system` + - `timeout` now works on Windows, and `tail -f`/`-F` finally follows files on Windows + +- **Performance** + - Read-ahead hints across the `cksum` family, `sum`, `split`, `expand`/`unexpand` and the `base*` utilities + - `tee` zero-copy fast path; `cat` throughput improvements; faster non-reflink `cp`; cross-device `mv` via the fast copy path + - `expr`: `index` went from O(N*M) to O(N+M); `uniq` and `du` improvements; `tsort` now buffers its output + - New benchmarks for `expr`, `tr`, `cut`, `uniq` and locale-aware `sort` merges + +- **Internationalization** + - `cut` supports multibyte characters in non-UTF-8 locales + - `ls` uses Unicode quotes for the `locale`/`clocale` styles in UTF-8 locales and escapes non-ASCII control characters + - Fixed `C.UTF-8` being detected as ASCII, and incomplete Fluent fallback bundles + - `cksum` quotes file names in a locale-aware way + +- **Cross-platform Reach** + - New **wasm32-wasip2** target support + - Windows: `timeout` implementation, sparse copy support in `cp`, `tail -f`, `cksum` CRLF fix + - `ls --numeric-uid-gid` on non-unix platforms, `uptime` uses `CLOCK_BOOTTIME` on more platforms + +- **Code Quality** + - `clippy::collapsible_if` and `clippy::match_same_arms` are now clean and enabled workspace-wide + - Continued `nix` → `rustix` migration (`dd`, `kill`, `sync`, `uptime`) and further removal of `unsafe` and `to_string_lossy` + +- **Contributions**: This release was made possible by **50 new contributors** joining our community + +--- + +### Call to Action: + +**Try it in your browser** - [Online Playground](https://uutils.github.io/playground/) powered by WebAssembly +**Help us translate** - Contribute to [Rust Coreutils on Weblate](https://hosted.weblate.org/projects/rust-coreutils/) +**Sponsor us on GitHub** to accelerate development: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) + +## What's Changed + +## arch +* arch: remove .to_string_lossy() by @oech3 in https://github.com/uutils/coreutils/pull/13355 + +## base32, base64, basenc +* baseNM: read ahead by @oech3 in https://github.com/uutils/coreutils/pull/12570 +* Fix base32/base64 error for file paths ending in slash by @TheAdamWarlock in https://github.com/uutils/coreutils/pull/12726 + +## cat +* cat: drop fuse workaround by @oech3 in https://github.com/uutils/coreutils/pull/12821 +* cat: improve throughput & simplify (split PR) by @oech3 in https://github.com/uutils/coreutils/pull/12850 +* cat: don't call io::stdin() twice by @oech3 in https://github.com/uutils/coreutils/pull/13043 +* cat: change is_unsafe_override to is_safe_override by @oech3 in https://github.com/uutils/coreutils/pull/13133 + +## chgrp +* chgrp: unstripped error on operation not permitted error by @Devel08 in https://github.com/uutils/coreutils/pull/13361 +* chgrp: fix error message for `--from nonexisting_group GROUP` by @cakebaker in https://github.com/uutils/coreutils/pull/13490 +* chgrp: make `test_reference` less strict on Linux by @cakebaker in https://github.com/uutils/coreutils/pull/13650 + +## chmod +* chmod: always call chmod(2) even when mode bits are unchanged by @sylvestre in https://github.com/uutils/coreutils/pull/13213 +* test_chmod.rs: remove an ignored test covered by check-safe-traversal.sh by @oech3 in https://github.com/uutils/coreutils/pull/13298 +* chmod: fix 2 clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13509 +* chmod: report the real error when a target's metadata is inaccessible by @0xfandom in https://github.com/uutils/coreutils/pull/13637 + +## chown +* chown: -h on symlink to directory operates on link, not target by @rossilor95 in https://github.com/uutils/coreutils/pull/12522 +* chown: restrict no-dereference symlink ctime test to Linux by @rossilor95 in https://github.com/uutils/coreutils/pull/12556 +* chown, chgrp: report a verbose stdout write error instead of panicking by @leeewee in https://github.com/uutils/coreutils/pull/13381 +* fix(chown,chgrp): print --verbose success lines to stdout by @l46983284-cpu in https://github.com/uutils/coreutils/pull/13419 + +## chroot +* chroot: don't panic on --skip-chdir with a non-resolving NEWROOT by @leeewee in https://github.com/uutils/coreutils/pull/12732 +* chroot: avoid unwrap-like operation by @oech3 in https://github.com/uutils/coreutils/pull/13306 + +## cksum +* cksum family: read ahead file by @oech3 in https://github.com/uutils/coreutils/pull/12549 +* cksum: fix collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/12942 +* feat: add OpenSSL backend for checksum utilities by @sylvestre in https://github.com/uutils/coreutils/pull/12412 +* checksum: handle stdin before directory checks by @wtcpython in https://github.com/uutils/coreutils/pull/13394 +* fix(cksum): reject absurdly large SHAKE --length instead of aborting by @koopatroopa787 in https://github.com/uutils/coreutils/pull/13083 +* cksum: fix checksum giving false fails on windows due to crlf -> lf conversion by @nikolalukovic in https://github.com/uutils/coreutils/pull/13431 +* cksum: Locale aware filename quoting in not-found messages by @RenjiSann in https://github.com/uutils/coreutils/pull/13733 + +## comm +* comm: remove overhead by .is_dir() by @oech3 in https://github.com/uutils/coreutils/pull/13371 + +## cp +* cp: replace match by is_ok_and by @oech3 in https://github.com/uutils/coreutils/pull/12688 +* cp: simplify bool by @oech3 in https://github.com/uutils/coreutils/pull/12689 +* cp: remove unuseful .map(|_|()) by @oech3 in https://github.com/uutils/coreutils/pull/12700 +* cp: remove unnecessary cfgs in Linux module by @cakebaker in https://github.com/uutils/coreutils/pull/12692 +* cp: Added error handling for printing debug message by @max-amb in https://github.com/uutils/coreutils/pull/12710 +* cp: simplify return type of `backup_dest()` by @cakebaker in https://github.com/uutils/coreutils/pull/12719 +* cp: handle --parents source ending in parent dir by @nkgotcode in https://github.com/uutils/coreutils/pull/12639 +* cp: propagate the --attributes-only destination open error by @leeewee in https://github.com/uutils/coreutils/pull/12625 +* cp: improve perf for non-reflink copy by @oech3 in https://github.com/uutils/coreutils/pull/13057 +* minorbug(cp): io error messages vague when using -g by @Devel08 in https://github.com/uutils/coreutils/pull/13110 +* cp: fix --remove-destination deleting source when dest is the same file under a different path by @LoukasPap in https://github.com/uutils/coreutils/pull/13135 +* cp: implement sparse copy support for windows by @nikolalukovic in https://github.com/uutils/coreutils/pull/12686 +* fix(cp): remove overrides_with_all from flag group by @BAMF0 in https://github.com/uutils/coreutils/pull/13258 +* cp: don't panic on write error when printing --verbose output by @Chaganti-Reddy in https://github.com/uutils/coreutils/pull/13310 +* cp: fix --reflink=never by @oech3 in https://github.com/uutils/coreutils/pull/13065 +* fix(cp): change error to align with GNU by @BAMF0 in https://github.com/uutils/coreutils/pull/13382 +* cp: dedup match & remove GNU version from comment by @oech3 in https://github.com/uutils/coreutils/pull/13556 +* cp: don't hang forever when the backup destination is a fifo by @Chaganti-Reddy in https://github.com/uutils/coreutils/pull/13344 +* cp: fix 2 clippy expections by @oech3 in https://github.com/uutils/coreutils/pull/13537 +* cp: remove feat_acl & exacl by @oech3 in https://github.com/uutils/coreutils/pull/13626 +* cp: simplify loop by while let by @oech3 in https://github.com/uutils/coreutils/pull/13741 + +## csplit +* csplit: include the file name in output-file errors by @eyupcanakman in https://github.com/uutils/coreutils/pull/13138 +* csplit: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13512 + +## cut +* cut: support `-O` as short for `--output-delimiter` by @cakebaker in https://github.com/uutils/coreutils/pull/13011 +* cut: simplify type of `COMPLEX_SEQUENCE` in test by @cakebaker in https://github.com/uutils/coreutils/pull/12601 +* cut: add a benchmark for a range inside long multibyte lines by @sylvestre in https://github.com/uutils/coreutils/pull/13732 +* cut: support multibyte characters in non-UTF-8 locales by @sylvestre in https://github.com/uutils/coreutils/pull/12635 +* cut: refactor part of `uumain` by @cakebaker in https://github.com/uutils/coreutils/pull/13757 + +## date +* date: reject excessive format widths by @puneetdixit200 in https://github.com/uutils/coreutils/pull/12497 +* date: document the format-width cap and cover its boundary by @sylvestre in https://github.com/uutils/coreutils/pull/12658 +* date: parse j as a local time of day by @eyupcanakman in https://github.com/uutils/coreutils/pull/12747 +* date: reject a date string with two timezone abbreviations by @mistaste in https://github.com/uutils/coreutils/pull/12883 +* date: rename variable `form` to `fmt` by @cakebaker in https://github.com/uutils/coreutils/pull/12898 +* Date parse datetime 0.15 fixes by @sylvestre in https://github.com/uutils/coreutils/pull/13284 +* test(date): validate locale formats via runtime expansion by @naoNao89 in https://github.com/uutils/coreutils/pull/9831 +* date: preserve invalid timezone format strings by @wtcpython in https://github.com/uutils/coreutils/pull/13442 +* date: treat -d - as midnight by @wtcpython in https://github.com/uutils/coreutils/pull/13511 +* date: simplify code & fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13513 +* date: support out-of-range years in default output by @abhishekpradhan in https://github.com/uutils/coreutils/pull/13516 +* date: dedup boundary check for bytes by @oech3 in https://github.com/uutils/coreutils/pull/13697 +* date: strip the O strftime modifier in the C locale by @igorgbr in https://github.com/uutils/coreutils/pull/13702 +* date: use `%Od` instead of `%Ol` in test by @cakebaker in https://github.com/uutils/coreutils/pull/13725 + +## dd +* dd: replace nix by rustix by @oech3 in https://github.com/uutils/coreutils/pull/12567 +* dd: support nocache on android, freebsd by @oech3 in https://github.com/uutils/coreutils/pull/12602 +* dd: propagate truncate errors on regular files by @sylvestre in https://github.com/uutils/coreutils/pull/12667 +* fix(dd): reject oversized block sizes instead of crashing by @miniex in https://github.com/uutils/coreutils/pull/12887 +* test_dd.rs: remove needless printf by @oech3 in https://github.com/uutils/coreutils/pull/13467 +* dd: keep short reads from corrupting re-blocked output by @relative23 in https://github.com/uutils/coreutils/pull/13459 +* dd: derive the count_bytes read budget from bytes read by @relative23 in https://github.com/uutils/coreutils/pull/13435 +* dd use .map_err by @oech3 in https://github.com/uutils/coreutils/pull/13631 +* dd: simplify match by @oech3 in https://github.com/uutils/coreutils/pull/13628 + +## df +* df: Syncing when paths are provided by @max-amb in https://github.com/uutils/coreutils/pull/12707 +* df: simplify bool returning by @oech3 in https://github.com/uutils/coreutils/pull/12946 +* df: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13518 +* refactor uu_df so crate users can call df without printing to stdout. by @philocalyst in https://github.com/uutils/coreutils/pull/12281 +* df: make the public paths argument inferable and pin the test block size by @sylvestre in https://github.com/uutils/coreutils/pull/13611 + +## dir +* dir: report stdout write errors by @wtcpython in https://github.com/uutils/coreutils/pull/13495 + +## dircolors +* dircolors: remove commented out code & make two functions private by @cakebaker in https://github.com/uutils/coreutils/pull/12514 +* dircolors: improve test by @cakebaker in https://github.com/uutils/coreutils/pull/12529 +* dircolors: turn integration test into unit test & remove unsafe code by @cakebaker in https://github.com/uutils/coreutils/pull/12547 +* dircolors: don't panic on an invalid TERM/COLORTERM glob by @leeewee in https://github.com/uutils/coreutils/pull/12728 +* dircolors: reduce nested code by @oech3 in https://github.com/uutils/coreutils/pull/12811 +* dircolors: replace `if`/`else` chain with `match` by @cakebaker in https://github.com/uutils/coreutils/pull/12822 + +## dirname +* dirname: fix dirname . >/dev/full panic by @oech3 in https://github.com/uutils/coreutils/pull/12810 + +## du +* du: don't deduplicate --files0-from entries by @sylvestre in https://github.com/uutils/coreutils/pull/13177 +* du: don't panic when writing to stdout fails by @muscar in https://github.com/uutils/coreutils/pull/10689 +* du: do not deduplicate --files0-from / operand names by name by @sylvestre in https://github.com/uutils/coreutils/pull/12750 +* du: Get rid of the last `print!` calls by @RenjiSann in https://github.com/uutils/coreutils/pull/13437 +* du: our implementation is slower than GNU's by @wilmerdooley in https://github.com/uutils/coreutils/pull/13296 +* du: fix Windows dead-code lint by @relative23 in https://github.com/uutils/coreutils/pull/13517 +* du: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13520 +* du(fix): print latest time with --time by @hlsxx in https://github.com/uutils/coreutils/pull/13543 +* du: Refactor loop into while let by @RenjiSann in https://github.com/uutils/coreutils/pull/13739 + +## env +* env: GNU-compatibility fixes by @sylvestre in https://github.com/uutils/coreutils/pull/12657 +* env: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13521 + +## expand +* expand: stream tab spaces to avoid abort on large --tabs values by @TheAdamWarlock in https://github.com/uutils/coreutils/pull/13005 +* (un)expand: set default of FILE by clap by @oech3 in https://github.com/uutils/coreutils/pull/13122 +* (un)expand: read ahead file by @oech3 in https://github.com/uutils/coreutils/pull/13123 +* expand: single-pass fast path check with early exit by @devnexen in https://github.com/uutils/coreutils/pull/10924 +* expand/unexpand: fix display width of 3- and 4-byte UTF-8 characters by @sylvestre in https://github.com/uutils/coreutils/pull/13352 + +## expr +* expr: add a benchmark by @sylvestre in https://github.com/uutils/coreutils/pull/12578 +* expr: speed up index from O(N*M) to O(N+M) by @sylvestre in https://github.com/uutils/coreutils/pull/12580 +* fix(expr): make the parser iterative to avoid a stack overflow by @abendrothj in https://github.com/uutils/coreutils/pull/13333 +* expr: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13522 + +## fmt +* fmt: large width value causes a panic by @Devel08 in https://github.com/uutils/coreutils/pull/12757 +* fmt: measure -w in UTF-8 bytes to match GNU by @sankalpsthakur in https://github.com/uutils/coreutils/pull/13651 + +## fold +* fold: fix crash when reading from pseudo-devices by @lavafroth in https://github.com/uutils/coreutils/pull/11314 +* fold: insert fold newline at read-buffer boundary by @sylvestre in https://github.com/uutils/coreutils/pull/12682 +* gnucompability(fold): uutils fold accept width 0 but gnu fold rejects it by @Devel08 in https://github.com/uutils/coreutils/pull/12744 +* fold: continue processing files after an open error by @sylvestre in https://github.com/uutils/coreutils/pull/12668 + +## groups +* groups: simplify by .map_err by @oech3 in https://github.com/uutils/coreutils/pull/13708 + +## hashsum +* fix(hashsum): always report malformed input with --status (fixes #12867) by @koopatroopa787 in https://github.com/uutils/coreutils/pull/12973 + +## head +* head: deduplicate flush() workaround by @oech3 in https://github.com/uutils/coreutils/pull/12563 +* fix(head): avoid overflow panic on huge negative -n/-c values by @koopatroopa787 in https://github.com/uutils/coreutils/pull/12854 +* head: set default of FILE to - by clap by @oech3 in https://github.com/uutils/coreutils/pull/13354 +* head/tail: parse zero byte counts with size suffix as 0 bytes by @MeGaurav4 in https://github.com/uutils/coreutils/pull/13706 + +## hostid +* hostid: add the french locale by @sylvestre in https://github.com/uutils/coreutils/pull/12913 + +## id +* id: don't exit 1 when uid/gid name lookup fails in default output by @c-tonneslan in https://github.com/uutils/coreutils/pull/12341 + +## install +* fix install file with invalid uid by @zhw2101024 in https://github.com/uutils/coreutils/pull/7789 +* install: don't panic when the strip program is killed by a signal by @leeewee in https://github.com/uutils/coreutils/pull/12730 +* minor fix for error message install to /proc/self/mem by @zhw2101024 in https://github.com/uutils/coreutils/pull/12718 +* fix install same file with backup flag by @zhw2101024 in https://github.com/uutils/coreutils/pull/12716 +* install: warn --strip-program without -s by @oech3 in https://github.com/uutils/coreutils/pull/12956 +* install: include missing source path in stat error by @1mpossible-code in https://github.com/uutils/coreutils/pull/13030 +* install: fix dangerous index access by @oech3 in https://github.com/uutils/coreutils/pull/13140 +* test_install.rs: remove binaries from repo by @oech3 in https://github.com/uutils/coreutils/pull/13159 +* test_install.rs: remove cfg(not(windows)) by @oech3 in https://github.com/uutils/coreutils/pull/13173 +* test_install.rs: remove unnecessary cfg by @oech3 in https://github.com/uutils/coreutils/pull/13182 +* install: allow -C with --preserve-timestamps by @sylvestre in https://github.com/uutils/coreutils/pull/13176 +* install: add error with -d when an argument is not dir by @LoukasPap in https://github.com/uutils/coreutils/pull/13175 +* install: use early return to remove indentation level by @cakebaker in https://github.com/uutils/coreutils/pull/13244 +* install: remove dead code by @oech3 in https://github.com/uutils/coreutils/pull/13336 +* install: remove unused cfg by @oech3 in https://github.com/uutils/coreutils/pull/13337 +* install: remove an unwrap() by @oech3 in https://github.com/uutils/coreutils/pull/13342 +* install: remove unreachable cfg block by @oech3 in https://github.com/uutils/coreutils/pull/13341 +* install: simplify if & match by @oech3 in https://github.com/uutils/coreutils/pull/13339 +* install fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13364 +* install: show backup failure causes by @wtcpython in https://github.com/uutils/coreutils/pull/13349 +* install: support reflink by @oech3 in https://github.com/uutils/coreutils/pull/13059 +* install: use portable strip wrappers in tests by @wtcpython in https://github.com/uutils/coreutils/pull/13575 +* install: dedup id resolution and make root test robust by @sylvestre in https://github.com/uutils/coreutils/pull/12680 +* test_install.rs: strip myself instead of strip_source_file by @oech3 in https://github.com/uutils/coreutils/pull/13705 + +## join +* join: apply the -e filler to empty output fields by @dhruv-15-03 in https://github.com/uutils/coreutils/pull/13718 + +## kill +* kill: replace nix::sys::signal with rustix::process by @mattsu2020 in https://github.com/uutils/coreutils/pull/12326 +* kill: use safe rustix Signal constructor, fix spell-checker comment by @sylvestre in https://github.com/uutils/coreutils/pull/12636 +* kill: reject out-of-range obsolete -N signal instead of sending SIGTERM by @sylvestre in https://github.com/uutils/coreutils/pull/13116 +* kill: move `Ok(())` outside of `match` by @cakebaker in https://github.com/uutils/coreutils/pull/13129 +* kill: accept SIGIO as alias for SIGPOLL by @favilances in https://github.com/uutils/coreutils/pull/13294 +* kill: remove useless fn rustix_to_io by @oech3 in https://github.com/uutils/coreutils/pull/13304 +* kill: report write errors when listing signals instead of panicking by @leeewee in https://github.com/uutils/coreutils/pull/13300 +* kill: integrate thiserror for error handling by @xtqqczze in https://github.com/uutils/coreutils/pull/13397 + +## ln +* ln: accept non-utf8 source names in target-dir modes by @can1357 in https://github.com/uutils/coreutils/pull/11403 +* ln: don't panic on --relative with a parentless target by @leeewee in https://github.com/uutils/coreutils/pull/12631 +* ln: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13523 +* ln: improve readability of `relative_path` fn by @cakebaker in https://github.com/uutils/coreutils/pull/13585 +* ln: remove unreachable code by @cakebaker in https://github.com/uutils/coreutils/pull/13599 +* ln: don't refuse --backup when src/dst are different names for the same inode by @Chaganti-Reddy in https://github.com/uutils/coreutils/pull/13345 +* ln: include destination in symbolic link failure message by @MeGaurav4 in https://github.com/uutils/coreutils/pull/13673 + +## ls +* test_ls.rs: Add test case for -T by @oech3 in https://github.com/uutils/coreutils/pull/12840 +* ls, du: don't panic on an empty time style after posix- strip by @leeewee in https://github.com/uutils/coreutils/pull/12634 +* ls: use Unicode quotes for locale/clocale styles in UTF-8 locales by @sylvestre in https://github.com/uutils/coreutils/pull/12675 +* ls: escape non-ASCII control chars in UTF-8 locale quoting by @sylvestre in https://github.com/uutils/coreutils/pull/13150 +* ls: treat '.' and '..' command-line args as directories, not symlinks by @sylvestre in https://github.com/uutils/coreutils/pull/12656 +* bug(ls): when --tabsize is a negative number ls successeds instead of rejecting it by @Devel08 in https://github.com/uutils/coreutils/pull/13278 +* ls: simplify tab size parsing and loop the tabsize tests by @sylvestre in https://github.com/uutils/coreutils/pull/13283 +* ls: allow detached --sort, --format, --time, --block-size by @bachorp in https://github.com/uutils/coreutils/pull/13281 +* ls: avoid leading quote-alignment space for colored names by @paolo-losi in https://github.com/uutils/coreutils/pull/12433 +* ls: Improve quote alignement check by @RenjiSann in https://github.com/uutils/coreutils/pull/13433 +* ls: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13519 +* test(ls): regression test for -aRv recursing into ./.. (#13501) by @Gooh456 in https://github.com/uutils/coreutils/pull/13515 +* ls: simplify bool by @oech3 in https://github.com/uutils/coreutils/pull/13634 +* ls: fix clippy::match_same_arms by @oech3 in https://github.com/uutils/coreutils/pull/13644 +* ls: support numeric-uid-gid on non-unix platforms by @eduardomourar in https://github.com/uutils/coreutils/pull/13663 +* ls: simplify match by .filter by @oech3 in https://github.com/uutils/coreutils/pull/13696 + +## mkdir +* mkdir: use `map_err` instead of `match` by @cakebaker in https://github.com/uutils/coreutils/pull/13546 + +## mkfifo +* mkfifo: handle required arg by clap by @oech3 in https://github.com/uutils/coreutils/pull/13504 +* mkfifo: stop hardcoding "File exists" as the error message by @Gooh456 in https://github.com/uutils/coreutils/pull/13551 + +## mktemp +* mktemp: use `map_err` instead of `match` by @cakebaker in https://github.com/uutils/coreutils/pull/12489 + +## more +* more: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13524 +* more: avoid overflow when -n is the maximum u16 value by @SAY-5 in https://github.com/uutils/coreutils/pull/12970 +* test(more): add integration test for -n u16::MAX regression by @sylvestre in https://github.com/uutils/coreutils/pull/13565 +* more: replace is_tty with is_terminal by @xtqqczze in https://github.com/uutils/coreutils/pull/13638 + +## mv +* mv: use copy_stream for fast cross-device move by @oech3 in https://github.com/uutils/coreutils/pull/13046 +* mv: ban eprintln! to avoid SIGABRT by @oech3 in https://github.com/uutils/coreutils/pull/13208 +* mv: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13525 +* mv: add --exchange to atomically swap two paths by @sylvestre in https://github.com/uutils/coreutils/pull/13215 + +## nice +* nice: strip errno by @oech3 in https://github.com/uutils/coreutils/pull/12911 +* nice: remove 1 unwrap() using match by @oech3 in https://github.com/uutils/coreutils/pull/12909 +* nice: robust clamping overflow by @oech3 in https://github.com/uutils/coreutils/pull/12914 +* nice: simplify `is_prefix_of()` by @cakebaker in https://github.com/uutils/coreutils/pull/12915 +* nice: remove unuseful fn by @oech3 in https://github.com/uutils/coreutils/pull/12924 +* nice: untangle complex `match` by @cakebaker in https://github.com/uutils/coreutils/pull/12916 +* nice: add newline to warning message by @Devel08 in https://github.com/uutils/coreutils/pull/12929 + +## nohup +* nohup: report command execution errors by @wtcpython in https://github.com/uutils/coreutils/pull/13391 + +## nproc +* nproc: let default value of --ignore 0 by clap by @oech3 in https://github.com/uutils/coreutils/pull/12823 +* nproc: reduce a nest by if by @oech3 in https://github.com/uutils/coreutils/pull/12849 +* nproc: reject quota with some sched if OMP_NUM_THREADS=NaN by @oech3 in https://github.com/uutils/coreutils/pull/12853 +* nproc: simplify by value_parser! by @oech3 in https://github.com/uutils/coreutils/pull/12856 +* nproc: use chain for OMP_THREAD_LIMIT by @oech3 in https://github.com/uutils/coreutils/pull/12857 +* nproc: honor affinity mask by @oech3 in https://github.com/uutils/coreutils/pull/12859 +* nproc: clamp OMP_NUM_THREADS overflow by @oech3 in https://github.com/uutils/coreutils/pull/12861 +* nproc: PASS nproc-quota-systemd.sh (manually parse cgroups2 quota) by @oech3 in https://github.com/uutils/coreutils/pull/12871 +* nproc: simplify OMP_NUM_THREADS parsing by @oech3 in https://github.com/uutils/coreutils/pull/12880 +* nproc: don't show offline cores by @oech3 in https://github.com/uutils/coreutils/pull/12890 +* nproc: clamp --ignore overflow by @oech3 in https://github.com/uutils/coreutils/pull/12863 +* nproc: strip errno by @oech3 in https://github.com/uutils/coreutils/pull/12908 +* nproc: simplify core count by @oech3 in https://github.com/uutils/coreutils/pull/12753 + +## numfmt +* numfmt: prevent panic if float precision specifier > 65535 by @Devel08 in https://github.com/uutils/coreutils/pull/12600 +* numfmt: bound --format precision for a zero value by @leeewee in https://github.com/uutils/coreutils/pull/12939 +* numfmt: require equals for --header by @bachorp in https://github.com/uutils/coreutils/pull/13273 +* numfmt: reject an oversized --from-unit/--to-unit instead of panicking by @leeewee in https://github.com/uutils/coreutils/pull/13484 +* numfmt: do not panic on a multibyte whitespace field separator by @leeewee in https://github.com/uutils/coreutils/pull/13485 +* numfmt: add --from=auto test for multibyte whitespace separator by @kushals256 in https://github.com/uutils/coreutils/pull/13487 +* numfmt: prevent panic for --format precision > 65535 on remaining paths by @SAY-5 in https://github.com/uutils/coreutils/pull/13514 +* numfmt: simplify bool & fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13526 + +## od +* od: bad error message whenever the argument is a directory by @Devel08 in https://github.com/uutils/coreutils/pull/13386 +* fix: regression upstream gnu od (#11613) by @uselessgoddess in https://github.com/uutils/coreutils/pull/13396 +* od: dedup match by @oech3 in https://github.com/uutils/coreutils/pull/13558 +* od: avoid dangerous index access pattern by @oech3 in https://github.com/uutils/coreutils/pull/13592 +* od: skip past end of seekable inputs without error by @sylvestre in https://github.com/uutils/coreutils/pull/12646 +* od: fix clippy by @oech3 in https://github.com/uutils/coreutils/pull/13710 +* od: restore skip-past-end handling for seekable special files by @sylvestre in https://github.com/uutils/coreutils/pull/13713 +* od: remove Box from enum CurrentReader by @oech3 in https://github.com/uutils/coreutils/pull/13714 +* od: remove enum var not used at production by @oech3 in https://github.com/uutils/coreutils/pull/13738 + +## paste +* paste: reset the delimiter list per file in serial mode by @winklemad in https://github.com/uutils/coreutils/pull/13439 +* paste: simplify 2 loop by while by @oech3 in https://github.com/uutils/coreutils/pull/13740 + +## pr +* pr: reject a non-positive expand-tab width (-e0) by @leeewee in https://github.com/uutils/coreutils/pull/12615 +* pr: reject a multibyte -e/-n argument instead of panicking by @leeewee in https://github.com/uutils/coreutils/pull/12762 +* pr: uutils pr accepts -W 0 and -w 0 while gnu pr rejects it by @Devel08 in https://github.com/uutils/coreutils/pull/12779 +* pr: uutils pr accepts -l 0 and --pages 0 while GNU doesn't by @Devel08 in https://github.com/uutils/coreutils/pull/12781 +* pr: uutils pr accepts negative expand tabs argument, GNU pr doesn't by @Devel08 in https://github.com/uutils/coreutils/pull/12782 +* Fix pr -o/--indent overflow handling by @TheAdamWarlock in https://github.com/uutils/coreutils/pull/12974 +* pr: stream -o/--indent offset to avoid abort on large values by @TheAdamWarlock in https://github.com/uutils/coreutils/pull/12994 +* pr: pr panics (Option::unwrap() on None) in merge mode -m on empty input #12995 by @Devel08 in https://github.com/uutils/coreutils/pull/13080 +* pr: use `match` instead of `if`/`else` chain by @cakebaker in https://github.com/uutils/coreutils/pull/13004 +* pr: avoid parsing numeric filename suffixes as operands by @TheAdamWarlock in https://github.com/uutils/coreutils/pull/13353 +* pr :simplify match by @oech3 in https://github.com/uutils/coreutils/pull/13500 +* pr: simplify clap's FILES & drop a clone() by @oech3 in https://github.com/uutils/coreutils/pull/13499 +* pr: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13527 +* pr: report missing file as "pr: : " by @addielaruee in https://github.com/uutils/coreutils/pull/13534 +* pr: refactor IO error handling by @xtqqczze in https://github.com/uutils/coreutils/pull/13549 +* pr: report starting pages beyond page count by @wtcpython in https://github.com/uutils/coreutils/pull/13574 + +## printf +* printf: avoid panic on integer precision above u16::MAX by @SAY-5 in https://github.com/uutils/coreutils/pull/12579 +* printf, csplit: fix formatting for zero value when precision is zero by @eyupcanakman in https://github.com/uutils/coreutils/pull/13425 + +## ptx +* ptx: fix panic caused by improper assert by @WhateverAWS in https://github.com/uutils/coreutils/pull/10916 +* ptx: reject invalid sentence regexps by @TheAdamWarlock in https://github.com/uutils/coreutils/pull/12892 +* fix(ptx): missing filename in error messages by @HackingRepo in https://github.com/uutils/coreutils/pull/13251 +* ptx: use char counts for before-chunk sizing in get_output_chunks by @sylvestre in https://github.com/uutils/coreutils/pull/12685 +* ptx: simplify match by @oech3 in https://github.com/uutils/coreutils/pull/13709 + +## pwd +* pwd: ignore non-option arguments, drop getcwd disabling from pwd-long test by @sylvestre in https://github.com/uutils/coreutils/pull/12610 +* pwd: remove to_string_lossy by @oech3 in https://github.com/uutils/coreutils/pull/13366 + +## realpath +* realpath: fix 'nonexistent/./' to be treated as invalid by @LoukasPap in https://github.com/uutils/coreutils/pull/13164 +* realpath: remove to_string_lossy by @oech3 in https://github.com/uutils/coreutils/pull/13360 +* realpath: don't require read permission on a directory just to confirm a trailing slash by @Chaganti-Reddy in https://github.com/uutils/coreutils/pull/13315 + +## rm +* rm: hint to use ./-foo when removing a dash-prefixed file by @sylvestre in https://github.com/uutils/coreutils/pull/12608 +* rm: rewrite dash-hint test with original fixtures by @sylvestre in https://github.com/uutils/coreutils/pull/12737 +* rm: one-file-system by @DarthStrom in https://github.com/uutils/coreutils/pull/12819 +* rm: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13528 +* rm: do not panic when verbose output write fails by @l46983284-cpu in https://github.com/uutils/coreutils/pull/13659 +* rm: report a verbose write failure once without aborting the removal by @sylvestre in https://github.com/uutils/coreutils/pull/13679 +* rm: use `translate` macro for two error messages by @cakebaker in https://github.com/uutils/coreutils/pull/13730 + +## runcon +* runcon: report a stdout write error instead of panicking by @leeewee in https://github.com/uutils/coreutils/pull/13380 +* runcon: fix clippy::match_same_arms by @oech3 in https://github.com/uutils/coreutils/pull/13649 + +## seq +* seq, printf: avoid integer overflow on extreme precision/exponent by @sylvestre in https://github.com/uutils/coreutils/pull/13246 +* seq: simplify match by .map_err by @oech3 in https://github.com/uutils/coreutils/pull/13598 + +## shred +* shred: simplify nested matches by @cakebaker in https://github.com/uutils/coreutils/pull/12524 +* shred: fix error message when path has trailing slash by @francesco-gaglione in https://github.com/uutils/coreutils/pull/12725 +* shred: two refactorings by @cakebaker in https://github.com/uutils/coreutils/pull/12780 +* shred: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13529 +* shred: report random source open errors by @wtcpython in https://github.com/uutils/coreutils/pull/13571 +* shred: report the real error for an inaccessible file by @0xfandom in https://github.com/uutils/coreutils/pull/13640 + +## shuf +* shuf: fix panic/abort on large -i range without small --head-count by @leeewee in https://github.com/uutils/coreutils/pull/12501 +* shuf: don't truncate -o output when input or random source fails by @sylvestre in https://github.com/uutils/coreutils/pull/13113 + +## sort +* sort: fix locale collation strength for hyphen ordering by @JuanCruzMateos in https://github.com/uutils/coreutils/pull/12677 +* sort: reject --parallel non-zero by @oech3 in https://github.com/uutils/coreutils/pull/13024 +* sort: ignore `--parallel` on WASI by @cakebaker in https://github.com/uutils/coreutils/pull/13028 +* sort: test punctuation-only differences are kept by -u under locale by @sylvestre in https://github.com/uutils/coreutils/pull/13142 +* sort: add merge benchmarks for the UTF-8 locale by @sylvestre in https://github.com/uutils/coreutils/pull/13196 +* sort: fix panic on non-UTF-8 filenames with --files0-from by @ppmpreetham in https://github.com/uutils/coreutils/pull/11593 +* sort: use os_string_from_vec for --files0-from names by @leeewee in https://github.com/uutils/coreutils/pull/12773 +* sort: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13530 +* sort: simplify match of chars by @oech3 in https://github.com/uutils/coreutils/pull/13536 +* sort: do not panic when a write fails during a merge by @mgravell in https://github.com/uutils/coreutils/pull/13654 +* sort: do not panic when --check stops at the first disorder by @mgravell in https://github.com/uutils/coreutils/pull/13652 +* sort: make return value of `loop` explicit by @cakebaker in https://github.com/uutils/coreutils/pull/13674 + +## split +* split: read ahead input by @oech3 in https://github.com/uutils/coreutils/pull/12662 +* split.rs: dedup io::copy by @oech3 in https://github.com/uutils/coreutils/pull/12663 +* split: remove commented out code by @cakebaker in https://github.com/uutils/coreutils/pull/12666 +* split: use `map_err` instead of `match` by @cakebaker in https://github.com/uutils/coreutils/pull/12664 +* split: reject excessive suffix lengths by @wondr-wclabs in https://github.com/uutils/coreutils/pull/12609 +* split: reject a zero chunk count for -n l/N and r/N by @leeewee in https://github.com/uutils/coreutils/pull/12613 +* split: simplify matches! and loop by @oech3 in https://github.com/uutils/coreutils/pull/13052 +* split: fix --verbose >/dev/full panic by @oech3 in https://github.com/uutils/coreutils/pull/13054 +* split: remove buffering by @oech3 in https://github.com/uutils/coreutils/pull/13069 +* split: dedup if by @oech3 in https://github.com/uutils/coreutils/pull/13078 +* split: simplify parsing io-blksize by @oech3 in https://github.com/uutils/coreutils/pull/13082 +* split: remove Box by @oech3 in https://github.com/uutils/coreutils/pull/13077 +* split: replace OPT_ by options:: by @oech3 in https://github.com/uutils/coreutils/pull/13070 +* split: simplify match bool by @oech3 in https://github.com/uutils/coreutils/pull/13067 +* split: remove dummy std::io::Cursor allocation by @oech3 in https://github.com/uutils/coreutils/pull/13079 +* split: reduce strong BufRead dependency by @oech3 in https://github.com/uutils/coreutils/pull/13099 +* split: replace match by map_err by @oech3 in https://github.com/uutils/coreutils/pull/13111 +* split: remove unused clippy:: by @oech3 in https://github.com/uutils/coreutils/pull/13101 +* split: add `platform/wasi.rs` by @cakebaker in https://github.com/uutils/coreutils/pull/13076 +* split: remove condition by moving reset by @cakebaker in https://github.com/uutils/coreutils/pull/13062 +* split: extract proper errno from OS by @oech3 in https://github.com/uutils/coreutils/pull/13112 +* split: remove io::copy for LineChunkWriter by @oech3 in https://github.com/uutils/coreutils/pull/13141 +* split: remove BufReader if possible by @oech3 in https://github.com/uutils/coreutils/pull/13179 +* split: extract error message from os at open_file_error by @oech3 in https://github.com/uutils/coreutils/pull/13411 +* split: allow writing to symlink to nonseekable by @oech3 in https://github.com/uutils/coreutils/pull/13416 +* split: translate! creating file by @oech3 in https://github.com/uutils/coreutils/pull/13100 +* split: fix I/O error handling for device full conditions by @sylvestre in https://github.com/uutils/coreutils/pull/11239 + +## stat +* stat: fix char-boundary panic on an invalid directive after a multibyte char by @leeewee in https://github.com/uutils/coreutils/pull/12617 +* stat: bad error message when directory doesn't exist by @Devel08 in https://github.com/uutils/coreutils/pull/13026 +* chore(l10n): fix typo in stat -L size by @BAMF0 in https://github.com/uutils/coreutils/pull/13429 +* stat: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13531 +* stat: use .filter & dedup check for char by @oech3 in https://github.com/uutils/coreutils/pull/13535 +* stat: fix clippy::match_same_arms by @oech3 in https://github.com/uutils/coreutils/pull/13648 + +## stty +* stty: avoid overflow when rounding max baud rate by @sjh9714 in https://github.com/uutils/coreutils/pull/13318 +* stty: fix trailing semicolon in macro used in expression position by @sylvestre in https://github.com/uutils/coreutils/pull/13468 + +## sum +* sum: read ahead for perf for 1st run by @oech3 in https://github.com/uutils/coreutils/pull/12569 +* sum: when / is appended to a regular file, uutils sum reports incorrect error messages by @Devel08 in https://github.com/uutils/coreutils/pull/12870 +* sum: sum /proc/self/mem error lacks file path by @Devel08 in https://github.com/uutils/coreutils/pull/12990 +* sum: disable test on OpenBSD by @cakebaker in https://github.com/uutils/coreutils/pull/13044 +* test_sum.rs: simplify cfg by @oech3 in https://github.com/uutils/coreutils/pull/13051 +* sum: remove Box by @oech3 in https://github.com/uutils/coreutils/pull/13074 +* sum: extract proper open & read error from OS by @oech3 in https://github.com/uutils/coreutils/pull/13367 + +## sync +* sync: drop unuseful comment by @oech3 in https://github.com/uutils/coreutils/pull/12705 +* sync: replace match by ok_or_else by @oech3 in https://github.com/uutils/coreutils/pull/12706 +* sync: use map_err by @oech3 in https://github.com/uutils/coreutils/pull/12704 +* sync: simplify if block by @oech3 in https://github.com/uutils/coreutils/pull/13375 +* sync: replace nix by rustix by @oech3 in https://github.com/uutils/coreutils/pull/13590 + +## tac +* tac: set .default_value for FILES by @oech3 in https://github.com/uutils/coreutils/pull/13503 +* tac: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13532 +* tac: simplify .filter by @oech3 in https://github.com/uutils/coreutils/pull/13690 + +## tail +* tail: replace .ok().is_some_and by .is_ok_and by @oech3 in https://github.com/uutils/coreutils/pull/12690 +* tail: remove unnecessary .as_fd() by @oech3 in https://github.com/uutils/coreutils/pull/12739 +* tail: drop outdated comment about <&- by @oech3 in https://github.com/uutils/coreutils/pull/12752 +* tail: reduce code by @oech3 in https://github.com/uutils/coreutils/pull/12755 +* tail: fix a collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13107 +* tail: treat a watched file replaced by a symlink as untailable by @sylvestre in https://github.com/uutils/coreutils/pull/12661 +* tail: remove .to_string_lossy() by @oech3 in https://github.com/uutils/coreutils/pull/13356 +* tail: continue processing files after error by @lauchimoon in https://github.com/uutils/coreutils/pull/13445 +* tail, touch: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13533 +* tail: remove commented out line by @cakebaker in https://github.com/uutils/coreutils/pull/13539 +* tail: simplify match by @oech3 in https://github.com/uutils/coreutils/pull/13563 +* tail: fix -f/-F not following files on Windows (#4827) by @crutkas in https://github.com/uutils/coreutils/pull/13248 +* tail: remove an unused var: old_md by @oech3 in https://github.com/uutils/coreutils/pull/13636 +* tail: replace matches! by is_some_and, avoid negative name by @oech3 in https://github.com/uutils/coreutils/pull/13635 +* tail: remove an unwrap() by @oech3 in https://github.com/uutils/coreutils/pull/13707 +* tests(tail): fix race in untailable-symlink test by @eduardomourar in https://github.com/uutils/coreutils/pull/13704 +* tail(test): Attempt to fix flaky tail pipe test by increasing byte count by @Xylphy in https://github.com/uutils/coreutils/pull/13701 + +## tee +* tee: avoid 0fill & heap alloc by @oech3 in https://github.com/uutils/coreutils/pull/12527 +* tee: fix incorrect comment about flush by @oech3 in https://github.com/uutils/coreutils/pull/12540 +* tee: remove ErrorKind::Other, simplify already discarded Result by @oech3 in https://github.com/uutils/coreutils/pull/12953 +* tee: simplify aborted bool by @oech3 in https://github.com/uutils/coreutils/pull/12988 +* tee: add zero-copy fast-path by @oech3 in https://github.com/uutils/coreutils/pull/11943 +* tee: avoid returning double negative Result by @oech3 in https://github.com/uutils/coreutils/pull/13407 + +## timeout +* fix(timeout, uucore): use sigwait and timers for waiting by @Alonely0 in https://github.com/uutils/coreutils/pull/13237 +* timeout: Implemention for windows by @nikolalukovic in https://github.com/uutils/coreutils/pull/13363 + +## touch +* touch: do not hang on a reader-less FIFO by @sylvestre in https://github.com/uutils/coreutils/pull/12735 +* touch: fix char-boundary panic on a multibyte char in a -t timestamp by @leeewee in https://github.com/uutils/coreutils/pull/12619 +* touch: report original input in -t timestamp error message by @wtcpython in https://github.com/uutils/coreutils/pull/13321 +* touch: remove unnecessary error msg by @cakebaker in https://github.com/uutils/coreutils/pull/13325 +* touch: parse unpadded dates in local time by @wtcpython in https://github.com/uutils/coreutils/pull/13340 +* touch: simplify bool, remove to_string_lossy by @oech3 in https://github.com/uutils/coreutils/pull/13365 +* touch: update times on owned files with no read/write permission by @sylvestre in https://github.com/uutils/coreutils/pull/12638 + +## tr +* tr: add codspeed benchmark by @parasol-aser in https://github.com/uutils/coreutils/pull/12189 + +## true +* true, false: strip errno & cleanup use by @oech3 in https://github.com/uutils/coreutils/pull/13591 + +## truncate +* truncate: process all files when one fails by @sylvestre in https://github.com/uutils/coreutils/pull/12734 +* truncate: remove duplicate test by @cakebaker in https://github.com/uutils/coreutils/pull/12756 +* truncate: simplify fn is_modifier by @oech3 in https://github.com/uutils/coreutils/pull/13412 +* truncate: a few trivial refactorings by @cakebaker in https://github.com/uutils/coreutils/pull/13413 +* fix(truncate): reject relative size overflow by @ThomasHabets in https://github.com/uutils/coreutils/pull/13427 +* truncate: missing input value in error when -s flag is empty by @Devel08 in https://github.com/uutils/coreutils/pull/13588 + +## tsort +* tsort: simplify code by @oech3 in https://github.com/uutils/coreutils/pull/12550 +* Add buffered printing and lock output stream to tsort by @mrkalling in https://github.com/uutils/coreutils/pull/12733 +* tsort simplify cfg by @oech3 in https://github.com/uutils/coreutils/pull/13219 +* tsort: remove unreachable error case by @cakebaker in https://github.com/uutils/coreutils/pull/13238 +* tsort: remove unnecessary uses of unsafe by @mrkalling in https://github.com/uutils/coreutils/pull/13323 +* tsort: improve expect error message by @xtqqczze in https://github.com/uutils/coreutils/pull/13566 + +## unexpand +* unexpand: reject too-large --tabs increment instead of overflowing by @santhreal in https://github.com/uutils/coreutils/pull/13383 + +## uniq +* uniq: add benchmarks for -w/--check-chars by @sylvestre in https://github.com/uutils/coreutils/pull/13247 +* Uniq: improve performances by @sylvestre in https://github.com/uutils/coreutils/pull/13249 +* uniq: remove `write_line_terminator` macro by @cakebaker in https://github.com/uutils/coreutils/pull/13632 + +## uptime +* uptime: fix leading zero in the hours field by @eyupcanakman in https://github.com/uutils/coreutils/pull/13098 +* uucore, uptime: replace unsafe libc::clock_gettime by rustix by @Devel08 in https://github.com/uutils/coreutils/pull/13291 +* uptime: use CLOCK_BOOTTIME on additional platforms by @xtqqczze in https://github.com/uutils/coreutils/pull/13461 +* uptime: drop extra spaces by @oech3 in https://github.com/uutils/coreutils/pull/13498 + +## users +* users: fix clippy::collapsible_if (OpenBSD) by @oech3 in https://github.com/uutils/coreutils/pull/13547 + +## wc +* wc: cleanup cfg by @oech3 in https://github.com/uutils/coreutils/pull/12559 +* wc: remove unnecessary .as_fd() by @oech3 in https://github.com/uutils/coreutils/pull/12738 +* wc: remove a nest by cfg by @oech3 in https://github.com/uutils/coreutils/pull/12802 +* wc: splice non FIFO stream too by @oech3 in https://github.com/uutils/coreutils/pull/12736 + +## who +* fix(who): add missing rustix feature by @xtqqczze in https://github.com/uutils/coreutils/pull/13681 + +## yes +* yes: improve throughput & simplify code by @oech3 in https://github.com/uutils/coreutils/pull/12650 +* yes: match with GNU's strace behaviour by @oech3 in https://github.com/uutils/coreutils/pull/12717 +* yes: prepare supporting invalid unicode by @oech3 in https://github.com/uutils/coreutils/pull/12935 +* yes: fix clippy::unnecessary_wraps by @oech3 in https://github.com/uutils/coreutils/pull/13003 + +## uucore +* simplify pipe wrapper by @oech3 in https://github.com/uutils/coreutils/pull/12528 +* pipes:rs: replace unnecessary splice by read-ahead hint by @oech3 in https://github.com/uutils/coreutils/pull/12548 +* pipes.rs: fix typo by @oech3 in https://github.com/uutils/coreutils/pull/12554 +* uucore: remove unnecessary .as_fd() by @oech3 in https://github.com/uutils/coreutils/pull/12740 +* uucore: supress clippy::collapsible_if warning by @oech3 in https://github.com/uutils/coreutils/pull/12940 +* uucore: handle SIGRTMIN+N and SIGRTMAX-N notation by @enr0n in https://github.com/uutils/coreutils/pull/12149 +* uucore: remove dead code by @oech3 in https://github.com/uutils/coreutils/pull/13183 +* uucore: rename copy_stream to copy_fast to avoid confusion by @oech3 in https://github.com/uutils/coreutils/pull/13289 +* locale: fix incomplete fallback bundles by @BAMF0 in https://github.com/uutils/coreutils/pull/13257 +* locale: simplify partial-resource fallback with unwrap_or_else by @sylvestre in https://github.com/uutils/coreutils/pull/13301 +* perms.rs: replace unnecessary to_string_lossy by @oech3 in https://github.com/uutils/coreutils/pull/13359 +* uucore: replace unnecessary to_string_lossy by @oech3 in https://github.com/uutils/coreutils/pull/13358 +* uucore/fsxattr: simplify two matches by @cakebaker in https://github.com/uutils/coreutils/pull/13362 +* fn starts_with_plus by @oech3 in https://github.com/uutils/coreutils/pull/13357 +* backup_control.rs: remove unsafe & Mutex by @oech3 in https://github.com/uutils/coreutils/pull/12290 +* uucore parser: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13510 +* signals.rs: fix clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13508 +* uucore: fix C.UTF-8 locale incorrectly detected as ASCII by @mufeedali in https://github.com/uutils/coreutils/pull/12525 +* lib.rs: simplify fn read_yes by @oech3 in https://github.com/uutils/coreutils/pull/13692 +* uucore: document strip_errno with an example by @sylvestre in https://github.com/uutils/coreutils/pull/13724 +* uucore: use workspace dependencies by @xtqqczze in https://github.com/uutils/coreutils/pull/13729 +* error: clarify UUsageError documentation by @xtqqczze in https://github.com/uutils/coreutils/pull/13737 + +## Security +* Fix recursive descent by @sylvestre in https://github.com/uutils/coreutils/pull/12541 +* chmod/chown: symlink cycles detection by @nikolalukovic in https://github.com/uutils/coreutils/pull/11805 +* touch: drop O_TRUNC on create to close TOCTOU race (#10019) by @sylvestre in https://github.com/uutils/coreutils/pull/12171 +* head: Fixed TOCTOU bug in checking of metadata by @max-amb in https://github.com/uutils/coreutils/pull/12439 +* head: add TOCTOU regression tests and fix comment typo by @sylvestre in https://github.com/uutils/coreutils/pull/12637 +* mkfifo: drop path-based chmod to close TOCTOU race (#10020) by @sylvestre in https://github.com/uutils/coreutils/pull/12174 +* util: move touch/head TOCTOU checks into check-toctou.sh by @sylvestre in https://github.com/uutils/coreutils/pull/12643 +* split: harden output open path against TOCTOU target swaps by @sylvestre in https://github.com/uutils/coreutils/pull/12565 +* chcon: anchor recursive relabel resolution to traversal dirfd by @sylvestre in https://github.com/uutils/coreutils/pull/12566 +* mv: regression test + strace check for cross-device O_NOFOLLOW (#10015) by @sylvestre in https://github.com/uutils/coreutils/pull/12468 +* cp: don't open FIFOs/devices when preserving timestamps by @sylvestre in https://github.com/uutils/coreutils/pull/13351 +* head: check directory on open fd, not via path is_dir() (#11972) by @sylvestre in https://github.com/uutils/coreutils/pull/12173 +* chmod: re-check --preserve-root during the recursive descent by @sylvestre in https://github.com/uutils/coreutils/pull/13621 +* uucore: add O_NOFOLLOW to DirFd::open_file_at by @sylvestre in https://github.com/uutils/coreutils/pull/13623 +* cp: do not descend into a destination subdirectory that is a symlink by @sylvestre in https://github.com/uutils/coreutils/pull/13624 +* install: finalize ownership before mode, and skip the mode if chown fails by @sylvestre in https://github.com/uutils/coreutils/pull/13629 +* mv: compare file identity in the --backup=simple safety guard by @sylvestre in https://github.com/uutils/coreutils/pull/13660 +* mknod: label the node at creation instead of relabelling it after by @sylvestre in https://github.com/uutils/coreutils/pull/13668 +* Backup guard: improve support for mv, cp & install by @sylvestre in https://github.com/uutils/coreutils/pull/13672 +* stdbuf: refuse a libstdbuf path that cannot be represented in LD_PRELOAD by @sylvestre in https://github.com/uutils/coreutils/pull/13622 +* mv: fail closed when recreating a cross-device directory destination by @sylvestre in https://github.com/uutils/coreutils/pull/13627 +* mkdir, mkfifo: label at creation instead of relabelling after by @sylvestre in https://github.com/uutils/coreutils/pull/13676 +* chroot: chroot the path that --skip-chdir validated by @sylvestre in https://github.com/uutils/coreutils/pull/13678 +* mv: strip setuid/setgid when the cross-device copy cannot preserve ownership by @sylvestre in https://github.com/uutils/coreutils/pull/13675 + +## Code Quality & Cleanup +* remove some collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/12537 +* remove some collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/12539 +* Fix clippy by @zhw2101024 in https://github.com/uutils/coreutils/pull/12645 +* ignore a ln wasm test by @sylvestre in https://github.com/uutils/coreutils/pull/12679 +* uufuzz: simplify by @oech3 in https://github.com/uutils/coreutils/pull/12745 +* Simplify if-guard-then-bool returns by @sylvestre in https://github.com/uutils/coreutils/pull/12702 +* test: simplify code by @oech3 in https://github.com/uutils/coreutils/pull/12888 +* workspace.wordlist.txt: remove hashsum by @oech3 in https://github.com/uutils/coreutils/pull/12907 +* test: remove to_string_lossy by @oech3 in https://github.com/uutils/coreutils/pull/12938 +* Cargo.toml: drop unused feat_os_windows_legacy by @oech3 in https://github.com/uutils/coreutils/pull/13055 +* Use Option::ok_or with ? instead of let...else returning Err by @sylvestre in https://github.com/uutils/coreutils/pull/13102 +* Stabilize some benchmarks by @sylvestre in https://github.com/uutils/coreutils/pull/13254 +* clippy: fix lints by @xtqqczze in https://github.com/uutils/coreutils/pull/13329 +* fuzz_date.rs: remove to_string_lossy by @oech3 in https://github.com/uutils/coreutils/pull/13369 +* Cargo.toml: enable clippy::used_underscore_items by @oech3 in https://github.com/uutils/coreutils/pull/13542 +* complete fix for clippy::collapsible_if by @oech3 in https://github.com/uutils/coreutils/pull/13538 +* fix more ruff warnings by @sylvestre in https://github.com/uutils/coreutils/pull/13560 +* refactor: use core::ffi types by @xtqqczze in https://github.com/uutils/coreutils/pull/13568 +* Cargo.toml: drop unnecessary comment by @oech3 in https://github.com/uutils/coreutils/pull/13589 +* clippy: fix with_capacity_zero beta lint by @xtqqczze in https://github.com/uutils/coreutils/pull/13602 +* fix: nightly clippy redundant else block lint by @HackingRepo in https://github.com/uutils/coreutils/pull/13601 +* clippy: allow missing_const_for_thread_local false positives by @xtqqczze in https://github.com/uutils/coreutils/pull/12333 +* test: fix clippy::match_same_arms by @oech3 in https://github.com/uutils/coreutils/pull/13548 +* test: fix `unreachable_patterns` lint on Windows by @cakebaker in https://github.com/uutils/coreutils/pull/13616 +* fix some clippy::match_same_arms's (split #13541) by @oech3 in https://github.com/uutils/coreutils/pull/13617 +* test: widen several linux-only tests to run on all unix platforms by @eduardomourar in https://github.com/uutils/coreutils/pull/13666 +* test: fix platform assumptions in du and realpath tests by @eduardomourar in https://github.com/uutils/coreutils/pull/13665 +* enable clippy::match_same_arms globally by @oech3 in https://github.com/uutils/coreutils/pull/13689 +* cargo: move lints section after dependencies and features by @xtqqczze in https://github.com/uutils/coreutils/pull/13715 +* benchmarks: prepare args outside benchmark by @xtqqczze in https://github.com/uutils/coreutils/pull/10388 +* test: fix comparison of integers wider than i128 by @dhruv-15-03 in https://github.com/uutils/coreutils/pull/13719 + +## CI & Build +* CICD: remove an unused variable by @oech3 in https://github.com/uutils/coreutils/pull/12534 +* openbsd.yml: improve perf & sync disk space cleanup by @oech3 in https://github.com/uutils/coreutils/pull/12583 +* openbsd.yml: get additional free disk space by @oech3 in https://github.com/uutils/coreutils/pull/12627 +* GnuTests (SMACK): bump busybox URL by @oech3 in https://github.com/uutils/coreutils/pull/12754 +* Potential fix for code scanning alert no. 5: Workflow does not contain permissions by @sylvestre in https://github.com/uutils/coreutils/pull/12655 +* make.yml: reduce binary size by @oech3 in https://github.com/uutils/coreutils/pull/12247 +* GnuTests: remove LD_PRELOAD from nproc-quota.sh & avoid 0 period by @oech3 in https://github.com/uutils/coreutils/pull/13255 +* util: re-point GNU test harness at current checkout in run-gnu-test.sh by @sylvestre in https://github.com/uutils/coreutils/pull/13163 +* make.yml: install uutils to CI image by @oech3 in https://github.com/uutils/coreutils/pull/9634 +* CICD.yml: Publish both of coreutils and individual MSVC *.exe by @oech3 in https://github.com/uutils/coreutils/pull/11270 +* Group fuzz targets in fuzzing workflow by @mattsu2020 in https://github.com/uutils/coreutils/pull/12450 +* Revert "make.yml: install uutils to CI image" by @oech3 in https://github.com/uutils/coreutils/pull/13406 +* ci(FixPR): add explicit least-privilege permissions block by @kobihikri in https://github.com/uutils/coreutils/pull/13402 +* fuzzing.yml: simplify host-tuple by @oech3 in https://github.com/uutils/coreutils/pull/13392 +* make.yml: don't publish cygwin bins (hot fix for CI) by @oech3 in https://github.com/uutils/coreutils/pull/13456 +* benchmarks: reduce CodSpeed CI load to avoid cache API rate limits by @sylvestre in https://github.com/uutils/coreutils/pull/13462 +* GNUmakefile: don't try to install libstdbuf for Windows target by @oech3 in https://github.com/uutils/coreutils/pull/12533 +* CI: remove actions/setup-python by @oech3 in https://github.com/uutils/coreutils/pull/13465 +* ci: fix Cygwin build failure due to Windows Mandatory ASLR by @sylvestre in https://github.com/uutils/coreutils/pull/13470 +* fix(ci): keep coverage profraw writable under umask 0777 tests by @l46983284-cpu in https://github.com/uutils/coreutils/pull/13502 +* fuzzing.yml: Bump GNU coreutils to 9.11 by @oech3 in https://github.com/uutils/coreutils/pull/13201 +* Revert "fuzzing.yml: Bump GNU coreutils to 9.11" by @sylvestre in https://github.com/uutils/coreutils/pull/13559 +* GnuTests: backport cat/splice.sh by @oech3 in https://github.com/uutils/coreutils/pull/13387 +* Disable Redox target in CICD workflow by @sylvestre in https://github.com/uutils/coreutils/pull/13657 +* ci: Change the spell detection strategy by @sylvestre in https://github.com/uutils/coreutils/pull/13472 +* ci: skip cspell on pre-commit.ci by @sylvestre in https://github.com/uutils/coreutils/pull/13661 +* fix the CI by @sylvestre in https://github.com/uutils/coreutils/pull/13711 + +## Documentation +* CONTRIBUTING: link to the review guidelines website by @sylvestre in https://github.com/uutils/coreutils/pull/12523 +* installation.md: fix broken ubuntu link by @cakebaker in https://github.com/uutils/coreutils/pull/12515 +* installation.md: bump Ubuntu version by @oech3 in https://github.com/uutils/coreutils/pull/12526 +* release notes for 0.9.0 by @sylvestre in https://github.com/uutils/coreutils/pull/12531 +* SECURITY.md: document threat model, scope, and GitHub reporting by @sylvestre in https://github.com/uutils/coreutils/pull/12724 +* we have a domain name now by @sylvestre in https://github.com/uutils/coreutils/pull/13114 +* CONTRIBUTING.md: Add URL to get latest GNU coreutils by @oech3 in https://github.com/uutils/coreutils/pull/13233 +* CONTRIBUTING.md: improve wording of reference GNU coreutils section by @sylvestre in https://github.com/uutils/coreutils/pull/13269 +* README: fix gnu vs musl note by @JohannesSchilling in https://github.com/uutils/coreutils/pull/13303 +* docs: fix macOS tier 1 targets and platform compatibility table by @wtcpython in https://github.com/uutils/coreutils/pull/13305 +* chore: fix markdown rendering typo by @HackingRepo in https://github.com/uutils/coreutils/pull/13586 + +## Platform Support +* chore: restrict Android cfg too 64-bit to match `linux_execfn` by @YumeYuka in https://github.com/uutils/coreutils/pull/12416 +* wasm: support for wasm32-wasip2 target by @eduardomourar in https://github.com/uutils/coreutils/pull/12653 + +## Dependency Updates +* chore(deps): update rust crate libfuzzer-sys to v0.4.13 by @renovate[bot] in https://github.com/uutils/coreutils/pull/12606 +* chore(deps): update codecov/codecov-action action to v7 by @renovate[bot] in https://github.com/uutils/coreutils/pull/12678 +* chore(deps): update rust crate regex to v1.12.4 by @renovate[bot] in https://github.com/uutils/coreutils/pull/12746 +* chore(deps): update rust crate memchr to v2.8.2 by @renovate[bot] in https://github.com/uutils/coreutils/pull/12796 +* Bump `time` & adapt `pinky` and `who` to API change by @cakebaker in https://github.com/uutils/coreutils/pull/12872 +* chore(deps): update actions/checkout action to v7 by @renovate[bot] in https://github.com/uutils/coreutils/pull/12966 +* Bump `getrandom` from `0.4.2` to `0.4.3` by @cakebaker in https://github.com/uutils/coreutils/pull/12957 +* chore(deps): update rust crate jiff to v0.2.29 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13008 +* Bump `itertools` from `0.14.0` to `0.15.0` by @cakebaker in https://github.com/uutils/coreutils/pull/12987 +* chore(deps): update rust crate quote to v1.0.46 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13021 +* chore(deps): update rust crate time to v0.3.51 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13022 +* chore(deps): update rust crate memmap2 to v0.9.11 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13033 +* chore(deps): update rust crate divan to v5 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13050 +* chore(deps): update actions/cache action to v6 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13049 +* chore(deps): update rust crate bstr to v1.12.2 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13071 +* chore(deps): update rust crate icu_collator to v2.2.1 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13072 +* chore(deps): update rust crate bstr to v1.12.3 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13081 +* chore(deps): update rust crate divan to v5.0.1 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13106 +* Bump `phf` & `phf_codegen` from `0.13.1` to `0.14.0` by @cakebaker in https://github.com/uutils/coreutils/pull/13048 +* chore(deps): update rust crate openssl to v0.10.81 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13188 +* chore(deps): update rust crate jiff to v0.2.31 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13214 +* chore(deps): update rust crate indicatif to v0.18.5 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13211 +* chore(deps): update rust crate time to v0.3.52 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13229 +* chore(deps): update rust crate clap_complete to v4.6.6 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13236 +* chore(deps): update rust crate time to v0.3.53 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13239 +* chore(deps): update rust crate console to v0.16.4 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13242 +* chore(deps): update rust crate indicatif to v0.18.6 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13243 +* chore(deps): update rust crate clap_complete to v4.6.7 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13245 +* chore(deps): update rust crate rand to v0.10.2 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13253 +* chore(deps): update actions/checkout action to v7 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13189 +* chore(deps): update rust crate selinux to v0.6.3 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13262 +* chore(deps): update rust crate rustc-hash to v2.1.3 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13261 +* chore(deps): update davidanson/markdownlint-cli2-action action to v24 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13266 +* chore(deps): update reactivecircus/android-emulator-runner action to v2.38.0 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13277 +* chore(deps): update astral-sh/ruff-action action to v4.1.0 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13279 +* Bump `defmt` from `1.1.0` to `1.1.1` by @cakebaker in https://github.com/uutils/coreutils/pull/13271 +* chore(deps): update rust crate ctor to v1.0.8 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13285 +* deny.toml: remove `winnow` from skip list by @cakebaker in https://github.com/uutils/coreutils/pull/13290 +* Bump `crossbeam-epoch` from `0.9.18` to `0.9.20` by @cakebaker in https://github.com/uutils/coreutils/pull/13299 +* chore(deps): update rust crate memchr to v2.8.3 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13309 +* chore(deps): update rust crate jiff to v0.2.32 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13322 +* chore(deps): update rust crate regex to v1.13.0 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13328 +* chore(deps): update rust crate bstr to v1.13.0 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13405 +* fix(cargo): update filetime dependency version constraint by @xtqqczze in https://github.com/uutils/coreutils/pull/12334 +* chore(deps): update rust crate clap to v4.6.2 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13414 +* chore(deps): update rust crate regex to v1.13.1 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13415 +* chore(deps): update rust crate ctor to v1.0.9 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13417 +* chore(deps): update rust crate self_cell to v1.3.0 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13418 +* chore(deps): update rust crate cfg_aliases to v0.2.2 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13424 +* chore(deps): update rust crate quote to v1.0.47 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13448 +* chore(deps): update rust crate proc-macro2 to v1.0.107 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13447 +* chore(deps): update rust crate jiff to v0.2.33 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13446 +* Bump `thiserror` & add `syn` to skip list by @cakebaker in https://github.com/uutils/coreutils/pull/13457 +* Bump `spin` from `0.10.0` to `0.10.1` by @cakebaker in https://github.com/uutils/coreutils/pull/13455 +* chore(deps): update rust crate jiff to v0.2.34 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13463 +* chore(deps): update rust crate time to v0.3.54 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13469 +* chore(deps): update rust crate clap to v4.6.3 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13477 +* chore(deps): update actions/checkout action to v7.0.1 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13476 +* chore(deps): update rust crate libc to v0.2.187 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13486 +* chore(deps): update rust crate syn to v3.0.2 by @xtqqczze in https://github.com/uutils/coreutils/pull/13474 +* chore(deps): update rust crate libc to v0.2.188 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13489 +* chore(deps): update rust crate libc to v0.2.189 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13497 +* chore(deps): update rust crate clap to v4.6.4 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13496 +* chore(deps): update rust crate glob to v0.3.4 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13492 +* chore(deps): update rust crate ctor to v1.0.10 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13491 +* fuzz: update thiserror to 2.0.19 by @xtqqczze in https://github.com/uutils/coreutils/pull/13553 +* chore(deps): update rust crate jiff to v0.2.35 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13573 +* chore(deps): update rust crate socket2 to v0.6.5 by @xtqqczze in https://github.com/uutils/coreutils/pull/13577 +* chore(deps): update rust crate jiff-tzdb to v0.1.8 by @xtqqczze in https://github.com/uutils/coreutils/pull/13582 +* chore(deps): update rust crate num-bigint to v0.4.8 by @xtqqczze in https://github.com/uutils/coreutils/pull/13581 +* chore(deps): run cargo shear by @xtqqczze in https://github.com/uutils/coreutils/pull/13583 +* chore(deps): update rust crate ctor to v1.0.11 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13584 +* chore(deps): update rust crate zmij to 1.0.23 by @xtqqczze in https://github.com/uutils/coreutils/pull/13587 +* chore(deps): update rust crate clap_complete to v4.6.8 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13606 +* chore(deps): update rust crate jobserver to v0.1.35 by @xtqqczze in https://github.com/uutils/coreutils/pull/13607 +* chore(deps): update rust crate displaydoc to v0.2.7 by @xtqqczze in https://github.com/uutils/coreutils/pull/13618 +* chore(deps): update rust crate zerocopy to v0.8.55 by @xtqqczze in https://github.com/uutils/coreutils/pull/13619 +* chore(deps): update codspeedhq/action action to v5 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13642 +* chore(deps): update mozilla-actions/sccache-action action to v0.0.11 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13641 +* chore(deps): update rust crate ctor to v1.0.12 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13647 +* chore(deps): update rust crate clap to v4.6.5 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13677 +* chore(deps): update rust crate time to v0.3.55 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13693 +* deps: update cfg_aliases dependency to use workspace configuration by @xtqqczze in https://github.com/uutils/coreutils/pull/13712 +* chore(deps): update rust crate data-encoding-macro to v0.1.21 by @xtqqczze in https://github.com/uutils/coreutils/pull/13726 +* chore(deps): update rust crate similar to v3.1.2 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13743 + +## Version Management +* prepare release 0.10.0 by @sylvestre in https://github.com/uutils/coreutils/pull/13721 + +## New Contributors +* @leeewee made their first contribution in https://github.com/uutils/coreutils/pull/12501 +* @SAY-5 made their first contribution in https://github.com/uutils/coreutils/pull/12579 +* @puneetdixit200 made their first contribution in https://github.com/uutils/coreutils/pull/12497 +* @wondr-wclabs made their first contribution in https://github.com/uutils/coreutils/pull/12609 +* @JuanCruzMateos made their first contribution in https://github.com/uutils/coreutils/pull/12677 +* @francesco-gaglione made their first contribution in https://github.com/uutils/coreutils/pull/12725 +* @TheAdamWarlock made their first contribution in https://github.com/uutils/coreutils/pull/12726 +* @koopatroopa787 made their first contribution in https://github.com/uutils/coreutils/pull/12854 +* @mrkalling made their first contribution in https://github.com/uutils/coreutils/pull/12733 +* @mistaste made their first contribution in https://github.com/uutils/coreutils/pull/12883 +* @DarthStrom made their first contribution in https://github.com/uutils/coreutils/pull/12819 +* @nkgotcode made their first contribution in https://github.com/uutils/coreutils/pull/12639 +* @enr0n made their first contribution in https://github.com/uutils/coreutils/pull/12149 +* @1mpossible-code made their first contribution in https://github.com/uutils/coreutils/pull/13030 +* @LoukasPap made their first contribution in https://github.com/uutils/coreutils/pull/13164 +* @ppmpreetham made their first contribution in https://github.com/uutils/coreutils/pull/11593 +* @HackingRepo made their first contribution in https://github.com/uutils/coreutils/pull/13251 +* @bachorp made their first contribution in https://github.com/uutils/coreutils/pull/13273 +* @JohannesSchilling made their first contribution in https://github.com/uutils/coreutils/pull/13303 +* @favilances made their first contribution in https://github.com/uutils/coreutils/pull/13294 +* @Chaganti-Reddy made their first contribution in https://github.com/uutils/coreutils/pull/13310 +* @wtcpython made their first contribution in https://github.com/uutils/coreutils/pull/13305 +* @sjh9714 made their first contribution in https://github.com/uutils/coreutils/pull/13318 +* @muscar made their first contribution in https://github.com/uutils/coreutils/pull/10689 +* @miniex made their first contribution in https://github.com/uutils/coreutils/pull/12887 +* @parasol-aser made their first contribution in https://github.com/uutils/coreutils/pull/12189 +* @santhreal made their first contribution in https://github.com/uutils/coreutils/pull/13383 +* @kobihikri made their first contribution in https://github.com/uutils/coreutils/pull/13402 +* @ThomasHabets made their first contribution in https://github.com/uutils/coreutils/pull/13427 +* @paolo-losi made their first contribution in https://github.com/uutils/coreutils/pull/12433 +* @uselessgoddess made their first contribution in https://github.com/uutils/coreutils/pull/13396 +* @winklemad made their first contribution in https://github.com/uutils/coreutils/pull/13439 +* @l46983284-cpu made their first contribution in https://github.com/uutils/coreutils/pull/13419 +* @lauchimoon made their first contribution in https://github.com/uutils/coreutils/pull/13445 +* @relative23 made their first contribution in https://github.com/uutils/coreutils/pull/13459 +* @kushals256 made their first contribution in https://github.com/uutils/coreutils/pull/13487 +* @wilmerdooley made their first contribution in https://github.com/uutils/coreutils/pull/13296 +* @addielaruee made their first contribution in https://github.com/uutils/coreutils/pull/13534 +* @mufeedali made their first contribution in https://github.com/uutils/coreutils/pull/12525 +* @philocalyst made their first contribution in https://github.com/uutils/coreutils/pull/12281 +* @crutkas made their first contribution in https://github.com/uutils/coreutils/pull/13248 +* @abhishekpradhan made their first contribution in https://github.com/uutils/coreutils/pull/13516 +* @eduardomourar made their first contribution in https://github.com/uutils/coreutils/pull/12653 +* @Gooh456 made their first contribution in https://github.com/uutils/coreutils/pull/13515 +* @sankalpsthakur made their first contribution in https://github.com/uutils/coreutils/pull/13651 +* @0xfandom made their first contribution in https://github.com/uutils/coreutils/pull/13640 +* @mgravell made their first contribution in https://github.com/uutils/coreutils/pull/13654 +* @MeGaurav4 made their first contribution in https://github.com/uutils/coreutils/pull/13673 +* @igorgbr made their first contribution in https://github.com/uutils/coreutils/pull/13702 +* @dhruv-15-03 made their first contribution in https://github.com/uutils/coreutils/pull/13718 + +**Full Changelog**: https://github.com/uutils/coreutils/compare/0.9.0...0.10.0 diff --git a/docs/src/release-notes/0.11.0.md b/docs/src/release-notes/0.11.0.md new file mode 100644 index 00000000000..2fec9c350b9 --- /dev/null +++ b/docs/src/release-notes/0.11.0.md @@ -0,0 +1,638 @@ +### **Rust Coreutils 0.11.0 Release:** + +We are happy to announce the release of **Rust Coreutils 0.11.0** - a release focused on **error messages, performance and GNU compatibility**. +The big change is a new diagnostic engine built on [ariadne](https://codeberg.org/zesterer/ariadne). When a `chmod` mode, a `cut` list, a `sort` key or a `tr` set fails to parse, we now echo the arguments back with a caret under the character at fault, like a compiler does. See our blog post for more information: https://uutils.org/blog/2026-08-error-diagnostics/ + +Example with `cut`: +image + +or `test`: +image + + + +PGO release builds are enabled for Linux, macOS and Windows (up to 31% faster), and a lot of per-utility optimization work landed on top of that. We also now treat a utility being significantly slower than GNU as a bug. + +We continued to improve the GNU compatibility and we are now at **653 passing, 22 failing, 0 errors**. + +--- + +### GNU Test Suite Compatibility: + +| Result | 0.10.0 | 0.11.0 | Change 0.10.0 to 0.11.0 | % Total 0.10.0 | % Total 0.11.0 | % Change 0.10.0 to 0.11.0 | +|---------------|--------|--------|--------------------------|----------------|----------------|----------------------------| +| Pass | 645 | 653 | +8 | 94.44% | 95.33% | +0.89% | +| Skip | 9 | 10 | +1 | 1.32% | 1.46% | +0.14% | +| Fail | 28 | 22 | -6 | 4.10% | 3.21% | -0.89% | +| Error | 1 | 0 | -1 | 0.15% | 0% | -0.15% | +| Total | 683 | 685 | +2 (new tests) | | | | + +> **Note:** 653 passing and 23 failing are both records for us, and this is the first release with no erroring test. Note that the suite grew by 2 tests this cycle (local backport). We also [list the tests excluded](https://github.com/uutils/coreutils/pull/13891) from the pass/fail percentages now. + +--- + +![GNU testsuite evolution](https://github.com/uutils/coreutils-tracking/blob/main/gnu-results.svg?raw=true) + +--- + +### Highlights: + +- **Modern Error Diagnostics (new)** + - A new diagnostic engine based on [ariadne](https://codeberg.org/zesterer/ariadne). When stderr is a terminal, a parse error is printed as a report: the arguments are echoed back as a source line, a caret marks the culprit and a help line explains the syntax. See the [blog post](https://uutils.org/blog/2026-08-error-diagnostics/) for before/after examples + - Carets landed in `chmod`, `mkdir`, `mkfifo`, `mknod`, `install`, `tr`, `expr`, `sort`, `numfmt`, `cut`, `env`, `head`, `tail`, `truncate`, `split`, `shred`, `stdbuf`, `od`, `seq`, `dd`, `stat`, `join`, `csplit`, `ls`, `df` and `du` + - A caret can point inside a quoted argument and at a single character. The reports are localized (format, checksum and advice strings) and still print the usual `Try '... --help'` hint. A label is only added when it says something useful + - `UUTILS_DIAG` controls the behavior, and the caret rendering can be compiled out for size-constrained builds + - Diagnostics are written in a single `stderr` call, so concurrent writers cannot interleave them + +- **Performance** + - **PGO (profile-guided optimization) release builds**: up to 31% faster - now enabled for the published Linux, macOS and Windows artifacts too. + - `cp`: opens the file only once (+32.93%) and reuses the already-open destination fd when a reflink clone falls back + - `join`: 1.37x faster on paired input, 2.5x faster than GNU, by skipping the locale comparison when it cannot change the result + - `od --skip-bytes` uses `splice(2)` on non-seekable input; `tac` uses `fast_copy`; `dd` no longer faults in the whole copy buffer before reading and page-aligns its read buffer + - Fewer allocations and syscalls in `ptx` (+4% via fxhash, plus fewer per-word allocations), `nl` (hoisted per-line prefix), `tail` (no double allocation per reverse chunk, no `stat`), `expand`/`unexpand` (no `stat`/`is_dir`), `numfmt` and `sort` + - New benchmarks for `ptx`, `basenc` and the `numfmt` stdin path + - A utility being significantly slower than GNU is now considered a bug - please report it as one + +- **GNU Compatibility** + - **653 passing GNU tests (+8)**, 23 failures (-6) and no erroring test + - New GNU-matching options: `uname -A`/`--all-labeled`, `cut -F` and `--whitespace-delimited`, `pr --columns`/`--join-lines`/`-f` alias and an optional argument for `-n` + - Behavior fixes in `pr` (page length of exactly 10, columns that do not divide evenly, `-e` at the end of a cluster), `numfmt` (leading `+`, scientific notation), `uniq`/`truncate`/`paste` (a repeated option overrides the earlier one), `join -o` accumulating fields, `id` group ordering, `unexpand`/`expand` tab-list separators, `mkdir` `EEXIST`, `more -f`/`-l`, `fold` widths, `sort -t=`, `stat` nanosecond timestamps and `Block size`, `od` 16 significant digits, `date` trailing timezones + - A new `gnu/usage_vs_refs.sh` check verifies that every option we document also appears in GNU's texi docs + - More error messages carry the real OS text instead of a stripped or made up one (`rm`, `env`, `od`, `chroot`, `stdbuf`, `tee`, `cut`, `date`, `tsort`, `pathchk`, `sort`) + +- **Security Hardening** + - `mv`/`cp`: xattr TOCTOU fixed by moving to file-descriptor-based operations + - `rm`: no longer follows a symlink swapped in at the `-r` operand + - `chmod`: uses `fchmodat` for recursive mode changes and no longer follows in-tree symlinks with `-R --reference` + - `cp`: does not `chmod` through the destination symlink; `mv` does not put a symlink in its target's hardlink group + - `stdbuf`: fixed a temporary-directory leak + +- **Robustness: Anti-Panic & Anti-Hang** + - Hangs fixed: `split` endless loop, `yes` deadlock, `sort` `Lines` fuse when a chunk boundary lands exactly at EOF + - Panics and aborts fixed in `stat` and `csplit` on `/dev/full`, `ls` with an invalid `QUOTING_STYLE` on unwritable stderr, `od` on large ASCII dump widths, `printf` on large numeric and `%s`/`%c` field widths, `nl` with `-w i32::MAX` (rejection plus a 32-bit OOM fix), `pr` and `csplit` integer overflow, `dd` `u64` wraparound, `touch` leap-second year overflow, `tail -c +N` past the seek limit, `tac` `SIGBUS` on a truncated mmap, `who -q` stdout write errors + - `uucore::parse_size` no longer overflows on a large `%` size + +- **Cross-platform Reach** + - Windows: `kill` and `uptime` implementations, and `test -ef`, `-t`, `-h`/`-L`, `-r`, `-w`, `-x` + - WASI: `touch` timestamps, `cp` symlinks, `expr`, cleaned-up core platform abstractions and `cat` integration coverage + - `df` got a fallback `statfs` path using the mount table; `uptime` dropped its `/proc` dependency on more targets; `stty` and `chgrp` are now gated by target support; better iOS and Fuchsia handling + +- **Dependency Diet** + - Dropped `byteorder`, `same-file`, `filetime`, `winapi-util`, `file_diff`, `mockstream`, `binary-heap-plus`, `compare`, `arrayref`, `exacl` and `dns-lookup` in favor of `std`, `rustix` and `windows-sys` + +- **Internationalization** + - Symbolic mode parsing errors, the `chmod --verbose` messages and the new diagnostic strings are now translatable + - `ls` sorts names using locale-aware collation, `comm` compares lines with the locale collation, `nl` supports multibyte one-character section delimiters and `cut` handles multibyte fields, bytes and delimiters correctly + - `uucore` rebuilds its embedded locales when the consumer's dependencies change + +- **Contributions**: This release landed **358 pull requests** from **26 new contributors**. We also passed 1000 people having contributed to uutils coreutils. Per our [activity tracking](https://raw.githubusercontent.com/uutils/coreutils-tracking/refs/heads/main/activity-results.svg), August 2026 was our busiest month so far, with 476 commits from 57 authors + +--- + +### Call to Action: + +**Try it in your browser** - [Online Playground](https://uutils.github.io/playground/) powered by WebAssembly +**Help us translate** - Contribute to [Rust Coreutils on Weblate](https://hosted.weblate.org/projects/rust-coreutils/) +**Sponsor us on GitHub** to accelerate development: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) + +## What's Changed + +## base32 +* base32: simplify loop by @oech3 in https://github.com/uutils/coreutils/pull/13915 + +## basename +* basename: simplify handling missing operand by @oech3 in https://github.com/uutils/coreutils/pull/14202 + +## basenc +* basenc: add benchmark by @myunbin in https://github.com/uutils/coreutils/pull/14022 + +## cat +* cat: remove unnecessary impl by @oech3 in https://github.com/uutils/coreutils/pull/13819 +* cat: reduce nest & avoid accessing by index by @oech3 in https://github.com/uutils/coreutils/pull/13820 +* cat: catch 2nd+ splice's error by @oech3 in https://github.com/uutils/coreutils/pull/13847 +* cat: always exit with 1 on error by @mtvb in https://github.com/uutils/coreutils/pull/14216 + +## chmod +* chmod: only report umask-curtailed modes for option-like operands by @dhruv-15-03 in https://github.com/uutils/coreutils/pull/13731 +* chmod: don't follow in-tree symlinks with -R --reference by @Angadi56 in https://github.com/uutils/coreutils/pull/13842 +* chmod: translate chmod-verbose-neither-changed much more by @oech3 in https://github.com/uutils/coreutils/pull/13872 +* chmod: use the libc fchmodat symbol for recursive mode changes by @sylvestre in https://github.com/uutils/coreutils/pull/14121 +* chmod: remove duplicate code handling symlinks by @cakebaker in https://github.com/uutils/coreutils/pull/13873 +* chmod: simplify a nested match by @Devel08 in https://github.com/uutils/coreutils/pull/14234 + +## chroot +* chroot: strip errno by @oech3 in https://github.com/uutils/coreutils/pull/14114 +* chroot: extract proper error message from OS by @oech3 in https://github.com/uutils/coreutils/pull/13884 + +## cksum +* cksum: do not guard openssl by unix for MinGW by @oech3 in https://github.com/uutils/coreutils/pull/13821 +* cksum: fix typo in comment by @cakebaker in https://github.com/uutils/coreutils/pull/13871 + +## comm +* test(comm): enable --check-order/--nocheck-order order-checking tests by @AH64-dll in https://github.com/uutils/coreutils/pull/13969 +* comm: add missing error-write locale by @Devel08 in https://github.com/uutils/coreutils/pull/14140 +* comm: compare lines with the locale collation by @luantaraschi in https://github.com/uutils/coreutils/pull/14047 + +## cp +* cp: don't chmod through the destination symlink by @Angadi56 in https://github.com/uutils/coreutils/pull/13443 +* cp: support symlinks on WASI by @DePasqualeOrg in https://github.com/uutils/coreutils/pull/13804 +* cp: reject --attributes-only self-copies (#13161) by @alexchen-sys in https://github.com/uutils/coreutils/pull/13894 +* cp: use default suffix when backup suffix is empty by @TheAdamWarlock in https://github.com/uutils/coreutils/pull/13966 +* cp: honor --reflink=never on macOS by @nagendramohan in https://github.com/uutils/coreutils/pull/14076 +* tests_cp.rs: replace filetime by std by @oech3 in https://github.com/uutils/coreutils/pull/14174 +* cp: label the destination of -Z when it is named relatively by @sylvestre in https://github.com/uutils/coreutils/pull/14161 +* cp: reuse the open dest fd when a reflink clone falls back by @sylvestre in https://github.com/uutils/coreutils/pull/14136 +* tests_cp.rs: ensure error of ioctl is catched when --reflink=always by @oech3 in https://github.com/uutils/coreutils/pull/14270 +* test_cp.rs: fix error message of ficlone on musl by @oech3 in https://github.com/uutils/coreutils/pull/14273 +* cp: open file only once (+32.93%) + add tests for mv & cp by @sylvestre in https://github.com/uutils/coreutils/pull/14272 + +## csplit +* csplit: avoid negate-with-overflow on an i32::MIN regex line offset by @leeewee in https://github.com/uutils/coreutils/pull/13753 +* csplit: don't panic on /dev/full stdout by @Devel08 in https://github.com/uutils/coreutils/pull/14130 + +## cut +* cut: match GNU diagnostics for invalid ranges and options by @sylvestre in https://github.com/uutils/coreutils/pull/13925 +* cut: add -F option and --whitespace-delimited=trimmed, fix -s/-n edge cases by @sylvestre in https://github.com/uutils/coreutils/pull/13926 +* cut: char-aware multibyte field, byte and delimiter handling by @sylvestre in https://github.com/uutils/coreutils/pull/13927 +* cut: allow shortcuts for `--whitespace-delimited` by @cakebaker in https://github.com/uutils/coreutils/pull/13972 +* cut, dd: use plain text help headings by @fly1d in https://github.com/uutils/coreutils/pull/13982 +* cut: strip i/o errno by @Devel08 in https://github.com/uutils/coreutils/pull/14060 +* cut: simplify by .map_err by @oech3 in https://github.com/uutils/coreutils/pull/14074 +* cut: improve and shorten help text by @cakebaker in https://github.com/uutils/coreutils/pull/13985 +* cut: rename const `NOTHING` to `NO_PARTIAL` by @cakebaker in https://github.com/uutils/coreutils/pull/14042 + +## date +* date: strip errno when write to stdout fails by @Devel08 in https://github.com/uutils/coreutils/pull/14206 +* date: update parse_datetime to 0.16.0 to accept \v and \f as whitespace by @wtcpython in https://github.com/uutils/coreutils/pull/14221 +* date: reject a trailing timezone when the input already has one by @ARMeeru in https://github.com/uutils/coreutils/pull/14168 + +## dd +* dd: give the delayed reader test more slack between writes by @sylvestre in https://github.com/uutils/coreutils/pull/13945 +* dd: report transfer statistics when the copy fails by @sylvestre in https://github.com/uutils/coreutils/pull/13856 +* dd: stop faulting in the whole copy buffer before reading by @luantaraschi in https://github.com/uutils/coreutils/pull/14044 +* dd: page-align read buffer by @chrboe in https://github.com/uutils/coreutils/pull/12143 +* dd: u64 wraparound fix by @mtvb in https://github.com/uutils/coreutils/pull/14200 + +## df +* df: simplify fn find_mount_point by @oech3 in https://github.com/uutils/coreutils/pull/13916 +* df: add a fallback path for statfs with a mount table by @Gelbpunkt in https://github.com/uutils/coreutils/pull/12072 +* df: split platform specific code by @nikolalukovic in https://github.com/uutils/coreutils/pull/14080 + +## dircolors +* dircolors: turn integration test into unit tests by @cakebaker in https://github.com/uutils/coreutils/pull/12507 + +## dirname +* dirname: simplify handling missing operand by @oech3 in https://github.com/uutils/coreutils/pull/14203 + +## env +* env: extract error message from OS instead of showing unknown error by @oech3 in https://github.com/uutils/coreutils/pull/13960 +* env: avoid accessing by index & simplify loop by @oech3 in https://github.com/uutils/coreutils/pull/14107 + +## expand +* expand: simplify match by @oech3 in https://github.com/uutils/coreutils/pull/14082 +* expand: remove .is_dir() overhead by @oech3 in https://github.com/uutils/coreutils/pull/13203 +* expand: accept tab as tab-list separator by @cakebaker in https://github.com/uutils/coreutils/pull/14204 + +## expr +* expr: remove redundant about text from after-help by @alexchen-sys in https://github.com/uutils/coreutils/pull/13987 +* expr: fix bug-prone index access pattern by @oech3 in https://github.com/uutils/coreutils/pull/13596 + +## fmt +* fmt: remove condition that is always true by @cakebaker in https://github.com/uutils/coreutils/pull/13809 +* fmt: simplify match by @oech3 in https://github.com/uutils/coreutils/pull/14093 + +## fold +* fold: match GNU error messages for an invalid -w/--width value by @AlejandroCoronadoN in https://github.com/uutils/coreutils/pull/14056 +* fold: do not fold a bytewise line that is exactly the width by @Socialpranker in https://github.com/uutils/coreutils/pull/14189 + +## hashsum +* *sum: hide the legacy --text from document by @oech3 in https://github.com/uutils/coreutils/pull/14131 + +## head +* head: fix flaky `test_zero_bytes_with_suffix` by @cakebaker in https://github.com/uutils/coreutils/pull/13768 +* head: propagate a write error from the -v filename header by @MsfPablo in https://github.com/uutils/coreutils/pull/13996 +* head, tail: keep leading zeros in an invalid count message by @arbelonson-source in https://github.com/uutils/coreutils/pull/14249 + +## hostname +* hostname: remove dns-lookup dependency by @xtqqczze in https://github.com/uutils/coreutils/pull/14198 + +## id +* id: order groups= by effective gid and -G by real gid, like GNU by @sylvestre in https://github.com/uutils/coreutils/pull/14103 + +## install +* install: allow non-directory special files as targets by @MsfPablo in https://github.com/uutils/coreutils/pull/13791 +* install: strip "(os error N)" from --strip-program failure by @MsfPablo in https://github.com/uutils/coreutils/pull/13914 +* install: remove filetime from dep by @oech3 in https://github.com/uutils/coreutils/pull/13947 +* install, comm: remove file_diff in favor of uucore::fs::are_files_identical by @wtcpython in https://github.com/uutils/coreutils/pull/14113 +* test_install.rs: test for ficlone (reflink) by @oech3 in https://github.com/uutils/coreutils/pull/13580 +* install: reduce nest by if & match by @oech3 in https://github.com/uutils/coreutils/pull/13346 +* install: remove a to_string_lossy by @oech3 in https://github.com/uutils/coreutils/pull/13338 +* tests_install.rs: replace filetime by std by @oech3 in https://github.com/uutils/coreutils/pull/14172 + +## join +* join: let -o accumulate fields across occurrences like GNU by @AlejandroCoronadoN in https://github.com/uutils/coreutils/pull/14070 +* join: report the field number the user gave by @MsfPablo in https://github.com/uutils/coreutils/pull/13995 +* join: 1.37x faster on paired input, 2.5x faster than GNU, by skipping the locale compare by @sylvestre in https://github.com/uutils/coreutils/pull/14213 +* join, od: report a hyphen-leading value instead of rejecting it by @arbelonson-source in https://github.com/uutils/coreutils/pull/14257 + +## kill +* kill: implementation for Windows by @nikolalukovic in https://github.com/uutils/coreutils/pull/13604 +* kill: skip flaky realtime signal test on i686 by @sylvestre in https://github.com/uutils/coreutils/pull/13943 +* kill: skip realtime signal tests when the signal is ignored by @sylvestre in https://github.com/uutils/coreutils/pull/13953 +* kill: fix for accepted duplicate signal prefixes by @mtvb in https://github.com/uutils/coreutils/pull/14178 + +## ln +* hard link creation failure has src and dst swapped in error message by @Devel08 in https://github.com/uutils/coreutils/pull/13745 +* ln: swap dest & source in french locale by @Devel08 in https://github.com/uutils/coreutils/pull/13905 +* ln: remove pub to improve the public API by @oech3 in https://github.com/uutils/coreutils/pull/14225 +* ln: avoid accessing by index & simplify if by @oech3 in https://github.com/uutils/coreutils/pull/14197 +* ln: clarify exclusive pattern by @oech3 in https://github.com/uutils/coreutils/pull/14266 + +## ls +* ls: use locale-aware collation for name sort by @sylvestre in https://github.com/uutils/coreutils/pull/11841 +* ls: only track listed directories when recursing by @sylvestre in https://github.com/uutils/coreutils/pull/13855 +* ls: reduce cfg for fn symlink by @oech3 in https://github.com/uutils/coreutils/pull/13911 +* ls: simplify long if block by unwrap_or by @oech3 in https://github.com/uutils/coreutils/pull/13917 +* ls: exit with code 2 on write error by @Devel08 in https://github.com/uutils/coreutils/pull/14111 +* ls: fix QUOTING_STYLE=invalid ls 2>/dev/full SIGABRT by @oech3 in https://github.com/uutils/coreutils/pull/13209 +* ls: test that an invalid QUOTING_STYLE does not abort on unwritable stderr by @sylvestre in https://github.com/uutils/coreutils/pull/14138 + +## mkdir +* mkdir: return a proper error mesage when dir is inexistent by @Devel08 in https://github.com/uutils/coreutils/pull/13836 +* strip errno when argument is not a directory but an existing file by @Devel08 in https://github.com/uutils/coreutils/pull/13890 +* mkdir: do not ignore EEXIST on non-recursive creation by @alexchen-sys in https://github.com/uutils/coreutils/pull/13986 + +## mkfifo +* mkfifo: use std::io::Result, remove manual conversion by @oech3 in https://github.com/uutils/coreutils/pull/13777 +* mkfifo: fix mismatched types error on freebsd by @xtqqczze in https://github.com/uutils/coreutils/pull/14259 + +## mktemp +* mktemp: remove the temp file when its name cannot be printed by @sylvestre in https://github.com/uutils/coreutils/pull/13853 + +## more +* more: swap -f and -l short flags to match GNU/util-linux by @c-tonneslan in https://github.com/uutils/coreutils/pull/12337 + +## mv +* mv: use GNU default error when overwriting non-empty directory by @aaron-ang in https://github.com/uutils/coreutils/pull/10078 +* tests_mv.rs: replace filetime by std by @oech3 in https://github.com/uutils/coreutils/pull/13948 +* mv: don't put a symlink in its target's hardlink group by @sylvestre in https://github.com/uutils/coreutils/pull/14170 + +## nl +* nl: continue processing files after error by @lauchimoon in https://github.com/uutils/coreutils/pull/13778 +* nl: support multibyte one-char section delimiters by @cakebaker in https://github.com/uutils/coreutils/pull/13848 +* nl: simplify match by @oech3 in https://github.com/uutils/coreutils/pull/13921 +* nl: hoist the per-line blank-line prefix allocation by @haydonryan in https://github.com/uutils/coreutils/pull/13920 +* nl: reject -w values > i32::MAX to avoid capacity-overflow panic by @koopatroopa787 in https://github.com/uutils/coreutils/pull/13348 +* nl: avoid OOM on 32-bit when -w i32::MAX is used by @sylvestre in https://github.com/uutils/coreutils/pull/14078 +* nl: drop unused translation by @oech3 in https://github.com/uutils/coreutils/pull/14072 + +## numfmt +* numfmt: benchmark the stdin path, not only arguments by @sylvestre in https://github.com/uutils/coreutils/pull/13993 +* numfmt: name the only conversion in a bad --format directive by @sylvestre in https://github.com/uutils/coreutils/pull/14001 +* numfmt: improve performances by @sylvestre in https://github.com/uutils/coreutils/pull/14003 +* numfmt: reject a leading + and scientific notation to match GNU by @AlejandroCoronadoN in https://github.com/uutils/coreutils/pull/13899 + +## od +* od: avoid panic on large ASCII dump widths by @0Whisperos in https://github.com/uutils/coreutils/pull/13787 +* od: replace hardcoded error massage by OS's message by @oech3 in https://github.com/uutils/coreutils/pull/13959 +* od: improve perf for --skip-bytes non-seekable input by splice(2) by @oech3 in https://github.com/uutils/coreutils/pull/13748 +* od: remove mockstream which is not used at production by @oech3 in https://github.com/uutils/coreutils/pull/13968 +* od: match GNU's 16 significant digits for f64 output by @sylvestre in https://github.com/uutils/coreutils/pull/13965 +* od: simplify a match in input_decoder.rs by @Devel08 in https://github.com/uutils/coreutils/pull/14006 +* od: remove unused properties from `InputDecoder` by @cakebaker in https://github.com/uutils/coreutils/pull/14023 +* od, ls: simplify match by @oech3 in https://github.com/uutils/coreutils/pull/14100 +* od: replace byteorder with std endian methods by @wtcpython in https://github.com/uutils/coreutils/pull/14094 + +## pathchk +* pathchk: readable error message without scrolling terminal by @oech3 in https://github.com/uutils/coreutils/pull/13220 +* pathchk: reject missing operand by clap by @oech3 in https://github.com/uutils/coreutils/pull/14147 +* pathchk: prepend pathchk: to error message & improve error for long name by @oech3 in https://github.com/uutils/coreutils/pull/14146 + +## pinky +* pinky: make tests resilient to the idle column changing between runs by @sylvestre in https://github.com/uutils/coreutils/pull/14155 + +## pr +* pr: fix overflow issues by @MuntasirSZN in https://github.com/uutils/coreutils/pull/13770 +* pr: accept -e at the end of a short flag cluster by @m0g3r in https://github.com/uutils/coreutils/pull/13949 +* pr: treat a page length of exactly 10 as omitting the header by @luantaraschi in https://github.com/uutils/coreutils/pull/14045 +* pr: stop dropping lines the columns do not divide evenly by @luantaraschi in https://github.com/uutils/coreutils/pull/14088 +* pr: make -n/--number-lines argument optional to match GNU by @AlejandroCoronadoN in https://github.com/uutils/coreutils/pull/13900 +* pr: rename `--column` to `--columns` by @cakebaker in https://github.com/uutils/coreutils/pull/14133 +* pr: add `--join-lines` as long option for `-J` by @cakebaker in https://github.com/uutils/coreutils/pull/14135 +* pr: show alias `-f` in help by @cakebaker in https://github.com/uutils/coreutils/pull/14132 + +## printf +* printf: omit errno from write errors by @floze-the-genius in https://github.com/uutils/coreutils/pull/13852 +* printf: diagnose a failed write of unterminated output by @sylvestre in https://github.com/uutils/coreutils/pull/13854 +* fix(printf): compute asterisk width magnitude in unsigned arithmetic by @bolverk in https://github.com/uutils/coreutils/pull/13800 +* printf: avoid panic on %s/%c field width above u16::MAX by @AlejandroCoronadoN in https://github.com/uutils/coreutils/pull/13883 +* printf: use plain text help headings by @nagendramohan in https://github.com/uutils/coreutils/pull/14075 +* printf: fix for \c inside %b by @mtvb in https://github.com/uutils/coreutils/pull/14165 +* printf: avoid panic on large numeric field widths by @Air-crypto in https://github.com/uutils/coreutils/pull/13857 + +## ptx +* ptx: handle -g by clap & reject 0 by @oech3 in https://github.com/uutils/coreutils/pull/13796 +* ptx: add benchmark by @Aisha630 in https://github.com/uutils/coreutils/pull/13480 +* ptx: refactor to avoid multiple string heap allocations by @haydonryan in https://github.com/uutils/coreutils/pull/13897 +* ptx: perf +4% by fxhash by @oech3 in https://github.com/uutils/coreutils/pull/13946 +* ptx: reduce per-word allocation overhead and speedup line scans by @Aisha630 in https://github.com/uutils/coreutils/pull/14021 +* ptx: accept invalid UTF-8 input and trailing backslash regexps by @sylvestre in https://github.com/uutils/coreutils/pull/14032 + +## readlink +* readlink: reject missing operand by clap by @oech3 in https://github.com/uutils/coreutils/pull/14151 + +## rm +* rm: replace a hardcoded error message by @oech3 in https://github.com/uutils/coreutils/pull/13956 +* rm: extract permission denied message from OS by @oech3 in https://github.com/uutils/coreutils/pull/13967 +* rm: do not follow a symlink swapped in at the -r operand by @sylvestre in https://github.com/uutils/coreutils/pull/14122 + +## sleep +* sleep: reject missing time by clap by @oech3 in https://github.com/uutils/coreutils/pull/14149 + +## sort +* sort: avoid reopening non-seekable inputs by @wtcpython in https://github.com/uutils/coreutils/pull/13494 +* sort: replace if block by .filter by @oech3 in https://github.com/uutils/coreutils/pull/13773 +* sort: simplify loop & 2 if blocks by @oech3 in https://github.com/uutils/coreutils/pull/13909 +* sort: reject --batch-size arguments above the fd soft limit by @sylvestre in https://github.com/uutils/coreutils/pull/14033 +* sort: refuse a random source shorter than the salt by @luantaraschi in https://github.com/uutils/coreutils/pull/14067 +* sort: replace binary-heap-plus and compare with std::collections::BinaryHeap by @wtcpython in https://github.com/uutils/coreutils/pull/14106 +* sort: fix for Lines fuse if chunk boundary is exactly at EOF #14126 by @mtvb in https://github.com/uutils/coreutils/pull/14127 +* sort: report read failures with context by @arcusbuilds in https://github.com/uutils/coreutils/pull/14005 +* sort: run read_error_message only on gnu glibc linux by @Devel08 in https://github.com/uutils/coreutils/pull/14141 +* sort: support attached '-t=' to select '=' as field separator by @MadeNavaneeth in https://github.com/uutils/coreutils/pull/14144 +* sort: accept musl's EIO text in read_error_message test by @sylvestre in https://github.com/uutils/coreutils/pull/14150 + +## split +* split: restore --io-blocksize & remove io::copy by @oech3 in https://github.com/uutils/coreutils/pull/13643 +* Split enters endless loop fix #14118 by @mtvb in https://github.com/uutils/coreutils/pull/14123 + +## stat +* stat . > /dev/full panics by @Devel08 in https://github.com/uutils/coreutils/pull/13401 +* stat: don't depend on /dev/stdin for unmounted /dev by @oech3 in https://github.com/uutils/coreutils/pull/13810 +* stat: truncate string precision on a byte boundary to avoid a panic by @leeewee in https://github.com/uutils/coreutils/pull/13752 +* stat: fix "unnecessary qualification" clippy error by @cakebaker in https://github.com/uutils/coreutils/pull/13877 +* stat: preserve nanosecond timestamp precision by @kevinburke in https://github.com/uutils/coreutils/pull/13976 +* stat: print the format up to a bad directive by @sylvestre in https://github.com/uutils/coreutils/pull/14102 +* stat: remove unnecessary `#[cfg(unix)]` by @cakebaker in https://github.com/uutils/coreutils/pull/14109 +* stat: reject missing operand by clap by @oech3 in https://github.com/uutils/coreutils/pull/14152 +* stat: print "Block size" in file system mode by @Socialpranker in https://github.com/uutils/coreutils/pull/14188 + +## stdbuf +* stdbuf: move LIBSTDBUF_DIR from config.toml for crate.io by @oech3 in https://github.com/uutils/coreutils/pull/13889 +* stdbuf: extract error message from OS & strip errno by @oech3 in https://github.com/uutils/coreutils/pull/13955 +* stdbuf: fix tmpdir leak by @Ecordonnier in https://github.com/uutils/coreutils/pull/14059 + +## tac +* tac: read regular files instead of mmap to avoid SIGBUS on truncation by @DarthStrom in https://github.com/uutils/coreutils/pull/12971 +* tac: replace io::copy with fast_copy by @oech3 in https://github.com/uutils/coreutils/pull/13837 +* tac: simplify error propagation by @Devel08 in https://github.com/uutils/coreutils/pull/14083 + +## tail +* tail: warn when --pid=0 is ignored by @wtcpython in https://github.com/uutils/coreutils/pull/13572 +* tail: reduce a nest by @oech3 in https://github.com/uutils/coreutils/pull/13918 +* tail: avoid a double allocation per reverse chunk by @haydonryan in https://github.com/uutils/coreutils/pull/13919 +* tail: remove overhead by stat by @oech3 in https://github.com/uutils/coreutils/pull/14129 +* tail: do not abort on -c +N when the start offset is past the seek limit by @SAY-5 in https://github.com/uutils/coreutils/pull/14091 +* tail: replace same-file with rustix and drop the dependency by @wtcpython in https://github.com/uutils/coreutils/pull/14208 + +## tee +* tee: show_err instead of writing directly in stderr and strip error by @Devel08 in https://github.com/uutils/coreutils/pull/14125 +* tee: align error message with GNU by @oech3 in https://github.com/uutils/coreutils/pull/14134 + +## test +* test: support -N, -G and -O on Windows by @sylvestre in https://github.com/uutils/coreutils/pull/13658 +* test: implement -ef, -t, -h/-L, -r, -w and -x on Windows by @nikolalukovic in https://github.com/uutils/coreutils/pull/14030 + +## touch +* touch: support timestamps on WASI by @DePasqualeOrg in https://github.com/uutils/coreutils/pull/13803 +* touch: move platform-specific code into platform/ by @sylvestre in https://github.com/uutils/coreutils/pull/13906 +* touch: Fix leap second year overflow by @mtvb in https://github.com/uutils/coreutils/pull/14214 + +## tr +* tr: simplify handling missing operand by @oech3 in https://github.com/uutils/coreutils/pull/14267 + +## true +* false, true: use show_err instead of writing to stderr by @Devel08 in https://github.com/uutils/coreutils/pull/14193 + +## truncate +* truncate, paste, uniq: let a repeated option override the earlier one by @arbelonson-source in https://github.com/uutils/coreutils/pull/14258 + +## tsort +* tsort: include filename in error message by @abhijeetvichare76 in https://github.com/uutils/coreutils/pull/14253 + +## uname +* uname: add -A/--all-labeled, following GNU master by @sylvestre in https://github.com/uutils/coreutils/pull/13912 +* uname: move label separators into locale strings for -A/--all-labeled by @sylvestre in https://github.com/uutils/coreutils/pull/13954 + +## unexpand +* unexpand: simplify loop by while let by @oech3 in https://github.com/uutils/coreutils/pull/13876 +* unexpand: remove overhead of stat call by @oech3 in https://github.com/uutils/coreutils/pull/14108 +* unexpand: accept a blank as a tab-list separator like GNU by @AlejandroCoronadoN in https://github.com/uutils/coreutils/pull/14057 + +## uniq +* uniq: simplify loop by while by @oech3 in https://github.com/uutils/coreutils/pull/13908 +* uniq: accept a repeated -D/--all-repeated, last one wins by @AlejandroCoronadoN in https://github.com/uutils/coreutils/pull/13898 + +## uptime +* uptime: remove /proc dependency from more targets by @oech3 in https://github.com/uutils/coreutils/pull/13293 +* uptime: windows implementation by @nikolalukovic in https://github.com/uutils/coreutils/pull/13874 +* uptime: follow up of #13874 by @sylvestre in https://github.com/uutils/coreutils/pull/14024 +* uptime: exclude Android from utmpx paths by @sylvestre in https://github.com/uutils/coreutils/pull/14081 +* uptime: disable some tests on Android by @cakebaker in https://github.com/uutils/coreutils/pull/14097 +* uptime: fix cfg for get_uptime by @xtqqczze in https://github.com/uutils/coreutils/pull/14201 + +## who +* who: do not abort on a stdout write error with -q by @costajohnt in https://github.com/uutils/coreutils/pull/13952 + +## yes +* yes: catch 2nd+ splice's error by @oech3 in https://github.com/uutils/coreutils/pull/13840 +* yes: deadlock fix by @mtvb in https://github.com/uutils/coreutils/pull/14210 +* yes: simplify if block by @oech3 in https://github.com/uutils/coreutils/pull/14218 + +## Error Diagnostics +* uucore: translate symbolic mode parsing errors by @sylvestre in https://github.com/uutils/coreutils/pull/13789 +* Introduce ariadne to provide better error messages + chmod support by @sylvestre in https://github.com/uutils/coreutils/pull/13785 +* Refactor a bit uucore/diag by @sylvestre in https://github.com/uutils/coreutils/pull/13825 +* mkdir: point at the failing part of an invalid mode by @sylvestre in https://github.com/uutils/coreutils/pull/13826 +* mkfifo: point at the failing part of an invalid mode by @sylvestre in https://github.com/uutils/coreutils/pull/13827 +* mknod: point at the failing part of an invalid mode by @sylvestre in https://github.com/uutils/coreutils/pull/13828 +* install: point at the failing part of an invalid mode by @sylvestre in https://github.com/uutils/coreutils/pull/13829 +* expr, tr, sort, numfmt: point a caret at the argument that is at fault by @sylvestre in https://github.com/uutils/coreutils/pull/13929 +* uucore: locate diagnostic operands by argument, not by text by @sylvestre in https://github.com/uutils/coreutils/pull/13930 +* diagnostics: label carets only when they add, and span format errors by @sylvestre in https://github.com/uutils/coreutils/pull/13931 +* diagnostics: let the caret rendering be compiled out by @sylvestre in https://github.com/uutils/coreutils/pull/13932 +* diagnostics: localize the format, checksum and advice-line strings by @sylvestre in https://github.com/uutils/coreutils/pull/13933 +* diagnostics: point inside quoted arguments, share the locating by @sylvestre in https://github.com/uutils/coreutils/pull/13934 +* env, cut, head, tail, truncate: point at the failing part of a value by @sylvestre in https://github.com/uutils/coreutils/pull/13935 +* split: point the caret at the failing part of a SIZE by @sylvestre in https://github.com/uutils/coreutils/pull/14008 +* shred: point the caret at the failing part of a SIZE by @sylvestre in https://github.com/uutils/coreutils/pull/14009 +* stdbuf: point the caret at the failing part of a buffering mode by @sylvestre in https://github.com/uutils/coreutils/pull/14010 +* sort: point the caret at the failing part of the -S size by @sylvestre in https://github.com/uutils/coreutils/pull/14011 +* od: point the caret at the failing part of a SIZE by @sylvestre in https://github.com/uutils/coreutils/pull/14012 +* seq: point the caret at the failing part of a format by @sylvestre in https://github.com/uutils/coreutils/pull/14013 +* dd: point the caret at the failing part of an operand by @sylvestre in https://github.com/uutils/coreutils/pull/14014 +* stat: point the caret at the failing directive of a format by @sylvestre in https://github.com/uutils/coreutils/pull/14015 +* join: point the caret at the failing field of -o by @sylvestre in https://github.com/uutils/coreutils/pull/14016 +* uucore: keep the usage hint when a caret replaced the message by @sylvestre in https://github.com/uutils/coreutils/pull/14017 +* uucore: share the caret diagnostic plumbing across the utilities by @sylvestre in https://github.com/uutils/coreutils/pull/14068 +* Improve the modern error message + add UUTILS_DIAG to control it by @sylvestre in https://github.com/uutils/coreutils/pull/14195 +* csplit: use the modern error diag by @sylvestre in https://github.com/uutils/coreutils/pull/14236 +* ls/df//du: use modern reporting too by @sylvestre in https://github.com/uutils/coreutils/pull/14237 +* uucore: write each diagnostic in a single stderr call by @haydonryan in https://github.com/uutils/coreutils/pull/14220 + +## Performance (PGO) +* perf: PGO release builds - up to 31% faster on Linux x86_64/aarch64 by @sylvestre in https://github.com/uutils/coreutils/pull/14235 +* Enable PGO on Mac builds too by @sylvestre in https://github.com/uutils/coreutils/pull/14243 +* build-pgo.sh: drop wrong comment by @oech3 in https://github.com/uutils/coreutils/pull/14268 +* build-pgo.sh: instrument with the same LTO settings as the final build by @sylvestre in https://github.com/uutils/coreutils/pull/14279 +* ci: build the published macOS and Windows artifacts with PGO by @sylvestre in https://github.com/uutils/coreutils/pull/14246 + +## Security +* mv,cp: fix xattr TOCTOU by using file descriptor-based operations by @sylvestre in https://github.com/uutils/coreutils/pull/10545 + +## uucore +* uucore: fix parse_size overflow on a large `%` size by @MsfPablo in https://github.com/uutils/coreutils/pull/13742 +* uucore: fix clippy::match_same_arms without => _, by @oech3 in https://github.com/uutils/coreutils/pull/13831 +* uucore::pipes: simplify by @oech3 in https://github.com/uutils/coreutils/pull/13858 +* uucore::pipes::drain_pipe: catch 2nd+ splice's error by @oech3 in https://github.com/uutils/coreutils/pull/13859 +* encoding.rs: simplify many match by @oech3 in https://github.com/uutils/coreutils/pull/14096 +* validate.rs: simplify errors by @oech3 in https://github.com/uutils/coreutils/pull/14099 +* uucore, cat: remove winapi-util dependency in favor of windows-sys and std by @wtcpython in https://github.com/uutils/coreutils/pull/14110 +* validate.rs: extract actual error message from OS by @oech3 in https://github.com/uutils/coreutils/pull/14098 +* uucore: rebuild embedded locales when the consumer's dependencies change by @sylvestre in https://github.com/uutils/coreutils/pull/14233 +* fsext: do not confuse f_bsize with f_frsize on Linux by @Socialpranker in https://github.com/uutils/coreutils/pull/14192 + +## Testing +* uutests: add stderr_as_displayed to compare terminal output by @sylvestre in https://github.com/uutils/coreutils/pull/13843 +* Bench drop runner sensitive benches by @sylvestre in https://github.com/uutils/coreutils/pull/13760 +* tests: replace GNU-derived material with original coverage by @sylvestre in https://github.com/uutils/coreutils/pull/14004 +* dd: move the test-only parse shim into the unit tests by @sylvestre in https://github.com/uutils/coreutils/pull/14039 +* tests: use convenience functions to ensure empty output by @cakebaker in https://github.com/uutils/coreutils/pull/14115 +* improve the env fuzzer by @sylvestre in https://github.com/uutils/coreutils/pull/14173 + +## Code Quality & Cleanup +* coreutils: remove invalid fallback for symlinks from binary_path by @oech3 in https://github.com/uutils/coreutils/pull/13088 +* simplify return None by @oech3 in https://github.com/uutils/coreutils/pull/13776 +* simplify return None by @oech3 in https://github.com/uutils/coreutils/pull/13793 +* use std::io::Result<*> instead of Result<*,std::io::Error> by @oech3 in https://github.com/uutils/coreutils/pull/13811 +* fix unused_qualifications lint by @xtqqczze in https://github.com/uutils/coreutils/pull/13814 +* uufuzz: simplify match by @oech3 in https://github.com/uutils/coreutils/pull/13907 +* clippy: fix drain_collect lint by @xtqqczze in https://github.com/uutils/coreutils/pull/14048 +* clippy: fix needless_late_init lint by @xtqqczze in https://github.com/uutils/coreutils/pull/14050 +* clippy: fix chunks_exact_to_as_chunks lint by @xtqqczze in https://github.com/uutils/coreutils/pull/14046 +* remove rust-version from fuzz by @oech3 in https://github.com/uutils/coreutils/pull/14095 +* clippy: add deprecation comment for `from_iter_instead_of_collect` lint by @cakebaker in https://github.com/uutils/coreutils/pull/14062 +* clippy: add `renamed_and_removed_lints` setting by @cakebaker in https://github.com/uutils/coreutils/pull/14064 +* remove unnecessary .as_fd() by @oech3 in https://github.com/uutils/coreutils/pull/14265 +* drop unused code by @sylvestre in https://github.com/uutils/coreutils/pull/14269 +* avoid unnecessary clone by @xtqqczze in https://github.com/uutils/coreutils/pull/14250 + +## CI & Build +* publish.sh: adjust to the new API by @sylvestre in https://github.com/uutils/coreutils/pull/13761 +* fetch-gnu.sh: remove unnecessary 2 spaces by @oech3 in https://github.com/uutils/coreutils/pull/13792 +* ci: trust preinstalled Homebrew taps on macOS by @xtqqczze in https://github.com/uutils/coreutils/pull/13782 +* uucore/Cargo.toml: don't guard openssl by unix by @oech3 in https://github.com/uutils/coreutils/pull/13824 +* fix cspell by @oech3 in https://github.com/uutils/coreutils/pull/13861 +* fetch-gnu.sh: remove incorrect exit 0 (pass tests/cat/splice) by @oech3 in https://github.com/uutils/coreutils/pull/13860 +* Revert "ci: trust preinstalled Homebrew taps on macOS" by @xtqqczze in https://github.com/uutils/coreutils/pull/13875 +* Update automake download URL and add checksum verification by @sylvestre in https://github.com/uutils/coreutils/pull/13922 +* ci: add copilot-code-review.yml to declare a runner for Copilot code review by @sylvestre in https://github.com/uutils/coreutils/pull/13942 +* gnu: list the tests excluded from the pass/fail percentages by @sylvestre in https://github.com/uutils/coreutils/pull/13891 +* Add empty-backup-suffix.sh to backport list by @sylvestre in https://github.com/uutils/coreutils/pull/13973 +* add misc/getopt_vs_usage.sh to the GNU unfixable tests by @sylvestre in https://github.com/uutils/coreutils/pull/13979 +* wasm: add expr & test to feat_wasm by @sylvestre in https://github.com/uutils/coreutils/pull/13981 +* gnu/usage_vs_refs.sh checks that all options appear in GNU's texi docs. by @sylvestre in https://github.com/uutils/coreutils/pull/13997 +* Cargo.toml: move 3 utils to feat_Tier1 by @oech3 in https://github.com/uutils/coreutils/pull/14027 +* GnuTests: remove LD_PRELOAD from dd/fail-ftruncate-fstat by @oech3 in https://github.com/uutils/coreutils/pull/14053 +* CI: pin the GNU test comparison to the latest main reference run by @sylvestre in https://github.com/uutils/coreutils/pull/14112 +* ci: add check for empty output assertions by @cakebaker in https://github.com/uutils/coreutils/pull/14116 +* CI: don't use the lagging server-side status filter by @sylvestre in https://github.com/uutils/coreutils/pull/14137 +* build cygwin cksum with openssl on the CI by @oech3 in https://github.com/uutils/coreutils/pull/14079 +* CI: remove unnecessary rust-toolchain by @oech3 in https://github.com/uutils/coreutils/pull/14142 +* ci: fix benchmarks workflow to only run on changed crates by @BipulLamsal in https://github.com/uutils/coreutils/pull/14124 +* try to fix publishing tag/latest-commit by @oech3 in https://github.com/uutils/coreutils/pull/14143 +* GnuTests: fix hardcoded pathes to GNU executables by @oech3 in https://github.com/uutils/coreutils/pull/14190 +* build: use "selinux" instead of "feat_selinux" feature name by @xtqqczze in https://github.com/uutils/coreutils/pull/12431 + +## Documentation +* Add AGENTS.md - not a statement for or against AI agents by @sylvestre in https://github.com/uutils/coreutils/pull/13790 +* add the 0.10.0 release notes by @sylvestre in https://github.com/uutils/coreutils/pull/13762 +* docs: recommend gh stack for large changes by @sylvestre in https://github.com/uutils/coreutils/pull/13835 +* test: explain more typical errors by @sylvestre in https://github.com/uutils/coreutils/pull/13844 +* docs: remove `-w` flag of `cut` from the extensions by @cakebaker in https://github.com/uutils/coreutils/pull/13962 +* docs/extensions: list utils alphabetically by @cakebaker in https://github.com/uutils/coreutils/pull/13312 +* man pages: fix typo, merged lists and wrong indentations by @matttbe in https://github.com/uutils/coreutils/pull/14038 +* Document the PGO builds by @sylvestre in https://github.com/uutils/coreutils/pull/14277 + +## Platform Support +* wasi: clean up core platform abstractions by @DePasqualeOrg in https://github.com/uutils/coreutils/pull/12503 +* uucore: fix uptime build on Android and wasm32 by @sylvestre in https://github.com/uutils/coreutils/pull/14058 +* tests: enable cat integration coverage on WASI by @DePasqualeOrg in https://github.com/uutils/coreutils/pull/13802 +* simplify by cfg(windows) by @oech3 in https://github.com/uutils/coreutils/pull/14224 +* prefer target_vendor apple to cover iOS by @oech3 in https://github.com/uutils/coreutils/pull/14226 +* cfg out uucore::signals on fuchsia by @xtqqczze in https://github.com/uutils/coreutils/pull/14166 +* order target_vendor before target_os in cfg by @xtqqczze in https://github.com/uutils/coreutils/pull/14230 +* stty: gate by target support by @xtqqczze in https://github.com/uutils/coreutils/pull/14238 +* order target_vendor before target_os in cfg by @xtqqczze in https://github.com/uutils/coreutils/pull/14240 +* chgrp: gate by target support by @xtqqczze in https://github.com/uutils/coreutils/pull/14242 + +## Dependency Updates +* chore(deps): update rust crate blake3 to v1.8.6 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13765 +* chore(deps): update rust crate clap_mangen to v0.3.1 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13759 +* build(deps): update rust crates by @xtqqczze in https://github.com/uutils/coreutils/pull/13763 +* chore(deps): update rust crate clap_mangen to v0.3.2 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13783 +* chore(deps): update rust crate clap_complete to v4.6.9 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13781 +* chore(deps): update rust crate thiserror to v2.0.20 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13807 +* chore(deps): update endbug/add-and-commit action to v11 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13817 +* chore(deps): update rust crate ctor to v1.0.13 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13838 +* chore(deps): update rust crate bstr to v1.13.1 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13846 +* chore(deps): update rust crate clap_mangen to v0.3.3 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13904 +* chore(deps): update rust crate icu_datetime to v2.3.0 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13940 +* chore(deps): update rust crate icu_collator to v2.3.0 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13944 +* chore(deps): update dawidd6/action-download-artifact action to v22 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13961 +* chore(deps): update dawidd6/action-download-artifact action to v23 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13963 +* chore(deps): update rust crate cc to v1.4.3 by @xtqqczze in https://github.com/uutils/coreutils/pull/13971 +* chore(deps): update rust crate similar to v3.2.0 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13984 +* chore(deps): update rust crate blake2b_simd to v1.0.5 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14035 +* chore(deps): update rust crate blake3 to v1.8.7 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14036 +* fix(deps): refactor RustCrypto crates package configuration by @xtqqczze in https://github.com/uutils/coreutils/pull/14037 +* Cargo.lock: remove `arrayref` by @cakebaker in https://github.com/uutils/coreutils/pull/14041 +* chore(deps): update rust crate icu_locale to v2.3.1 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14055 +* chore(deps): update rust crate icu_collator to v2.3.1 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14054 +* chore(deps): update rust crate icu_provider to v2.3.1 by @renovate[bot] in https://github.com/uutils/coreutils/pull/13941 +* chore(deps): update dawidd6/action-download-artifact action to v24 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14061 +* Cargo.toml: remove unused exacl workspace dependency by @wtcpython in https://github.com/uutils/coreutils/pull/14089 +* chore(deps): update rust crate dns-lookup to v4 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14194 + +## Version Management +* prepare release 0.11.0 by @sylvestre in https://github.com/uutils/coreutils/pull/14247 + +## New Contributors +* @MsfPablo made their first contribution in https://github.com/uutils/coreutils/pull/13742 +* @0Whisperos made their first contribution in https://github.com/uutils/coreutils/pull/13787 +* @MuntasirSZN made their first contribution in https://github.com/uutils/coreutils/pull/13770 +* @Angadi56 made their first contribution in https://github.com/uutils/coreutils/pull/13443 +* @floze-the-genius made their first contribution in https://github.com/uutils/coreutils/pull/13852 +* @Aisha630 made their first contribution in https://github.com/uutils/coreutils/pull/13480 +* @haydonryan made their first contribution in https://github.com/uutils/coreutils/pull/13897 +* @bolverk made their first contribution in https://github.com/uutils/coreutils/pull/13800 +* @costajohnt made their first contribution in https://github.com/uutils/coreutils/pull/13952 +* @AH64-dll made their first contribution in https://github.com/uutils/coreutils/pull/13969 +* @m0g3r made their first contribution in https://github.com/uutils/coreutils/pull/13949 +* @fly1d made their first contribution in https://github.com/uutils/coreutils/pull/13982 +* @AlejandroCoronadoN made their first contribution in https://github.com/uutils/coreutils/pull/13898 +* @myunbin made their first contribution in https://github.com/uutils/coreutils/pull/14022 +* @luantaraschi made their first contribution in https://github.com/uutils/coreutils/pull/14045 +* @Gelbpunkt made their first contribution in https://github.com/uutils/coreutils/pull/12072 +* @mtvb made their first contribution in https://github.com/uutils/coreutils/pull/14123 +* @nagendramohan made their first contribution in https://github.com/uutils/coreutils/pull/14076 +* @chrboe made their first contribution in https://github.com/uutils/coreutils/pull/12143 +* @arcusbuilds made their first contribution in https://github.com/uutils/coreutils/pull/14005 +* @MadeNavaneeth made their first contribution in https://github.com/uutils/coreutils/pull/14144 +* @Air-crypto made their first contribution in https://github.com/uutils/coreutils/pull/13857 +* @Socialpranker made their first contribution in https://github.com/uutils/coreutils/pull/14189 +* @arbelonson-source made their first contribution in https://github.com/uutils/coreutils/pull/14258 +* @abhijeetvichare76 made their first contribution in https://github.com/uutils/coreutils/pull/14253 +* @ARMeeru made their first contribution in https://github.com/uutils/coreutils/pull/14168 + +**Full Changelog**: https://github.com/uutils/coreutils/compare/0.10.0...0.11.0 diff --git a/docs/src/release-notes/0.12.0.md b/docs/src/release-notes/0.12.0.md new file mode 100644 index 00000000000..dd63a5dd5f9 --- /dev/null +++ b/docs/src/release-notes/0.12.0.md @@ -0,0 +1,440 @@ +### **Rust Coreutils 0.12.0 Release:** + +We are happy to announce the release of **Rust Coreutils 0.12.0** - a release focused on **compatibility fixes for our Ubuntu friends**. +Ubuntu ships uutils coreutils as its default coreutils implementation, and this cycle was driven by what their users and maintainers reported: the bugs they asked us to prioritize are all fixed here, along with a long tail of smaller divergences from GNU. Few of these are glamorous, but they are exactly what breaks a script that has worked for twenty years. + +Alongside that, we removed the last C dependency of `expr` (`onig` is replaced by the pure-Rust [fancy-regex](https://github.com/fancy-regex/fancy-regex)), continued the Windows port (`nice`, `nohup` and `hostid` now build there, and `uucore`'s filesystem layer was rewritten with safe Win32 wrappers) and landed another round of security hardening on `ls`, `sort` and `install`. +Also, some contributions had carried comments and code over from GNU coreutils, against our own rule +that uutils is an original implementation. We reviewed the tree and rewrote what we found, +in comments and in the few functions that followed the C too closely; no behaviour change. +The GNU coreutils maintainers have been notified. + +[GNU coreutils 9.12](https://savannah.gnu.org/news/?id=10932) was released during our cycle (kudos to the team!), and some of the work went that way too. Chasing compatibility tends to turn up things worth fixing or improving on both sides. + +We also made it official: a utility being significantly slower than GNU is now treated as a bug, not a wishlist item. If you hit one, please report it as a bug. + +We are now at **653 passing, 21 failing, 0 errors** on the GNU test suite. + +--- + +### GNU Test Suite Compatibility: + +| Result | 0.11.0 | 0.12.0 | Change 0.11.0 to 0.12.0 | % Total 0.11.0 | % Total 0.12.0 | % Change 0.11.0 to 0.12.0 | +|---------------|--------|--------|--------------------------|----------------|----------------|----------------------------| +| Pass | 653 | 653 | 0 | 95.33% | 94.36% | -0.97% | +| Skip | 10 | 18 | +8 | 1.46% | 2.60% | +1.14% | +| Fail | 22 | 21 | -1 | 3.21% | 3.03% | -0.18% | +| Error | 0 | 0 | 0 | 0% | 0% | 0% | +| Total | 685 | 692 | +7 (new tests) | | | | + +> **Note:** the suite grew by 7 tests (backport of GNU tests) and 8 more tests are skipped on our reference runner, so the raw pass count is flat while the failures went down again. Counting only the tests that actually run, we are at **653/674 (96.9%)**, our best ratio so far, and the second release in a row with no erroring test. We are comparing against Coreutils 9.11. + +--- + +![GNU testsuite evolution](https://github.com/uutils/coreutils-tracking/blob/main/gnu-results.svg?raw=true) + +--- + +### Highlights: + +- **Ubuntu Compatibility** + - The issues Ubuntu asked us to fix first are all fixed: data loss in a parallel `install -D` ([#12355](https://github.com/uutils/coreutils/issues/12355)), `du` on 32-bit architectures ([#11848](https://github.com/uutils/coreutils/issues/11848)), `ls` ACL dereferencing ([#13234](https://github.com/uutils/coreutils/issues/13234)), `cp -R` name collisions ([LP#2167118](https://bugs.launchpad.net/ubuntu/+source/rust-coreutils/+bug/2167118)) and `chmod -R` through symlinks ([LP#2167122](https://bugs.launchpad.net/ubuntu/+source/rust-coreutils/+bug/2167122)) + - A whole family of options now accept a hyphen-leading value, like GNU does: `csplit --suffix-format`, `paste -d`, `join -t`, `stat --format`/`--printf`/`-c` and all of `numfmt`'s options. A `-` in a delimiter or a format string is data, not a flag + - `rm` no longer segfaults on very deeply nested directories + - `sort -n` applies to the key instead of the whole line, echoes a disorder line verbatim instead of reformatting it as a number, and no longer keeps every input file open while sorting + - `basename` trailing-separator handling for dot components, `realpath` exit status in quiet mode, `ln` keeping the destination when a forced link fails, `csplit` always creating the final split even when empty, `fmt -x`/`-X`, `printf` treating a `--` past the format as an argument, `test` rejecting an `-a`/`-o` that ends the expression, `echo -e` no longer expanding `\u`, `\U` and `\"` + - `ls`: `dired` fixes, a tie in the time sort broken by the name, name sort for `-u`/`-c` in long format, capability markers that respect the dereference config; `dir` and `vdir` match the GNU default quoting + - `cp`: `--no-preserve` is retained over a later `--preserve`, and `--attributes-only` gives the destination the source's type + - Error messages and exit codes aligned with GNU in `uniq`, `chgrp`, `factor`, `date`, `expr`, `od`, `tty`, `tsort` and `pathchk` + - `truncate` finally implements `--io-blocks`, and `date` accepts multiple `--reference` + +- **Security Hardening** + - `ls`: report `stat()` failures on directory entries instead of silently showing wrong metadata + - `sort`: temporary files are created with mode 0600 instead of the umask, the `-o` output copy is kept private too, and a failure to truncate the output file is now reported + - `install`: the fd-based destination is created exclusively and at 0600 + - `rm`, `chmod`, `chown`, `chgrp`: `--preserve-root` compares `(st_dev, st_ino)` rather than the path + - `chcon` walks directories with `DirFd` instead of `fts` + - `SECURITY.md` now requires a boundary crossing, not just a GNU divergence, for a report to be treated as a vulnerability + +- **Robustness** + - Panics and overflows fixed in `stat` (out-of-range octal escape), `ls` (a color style with no attributes), `split` (a huge `-n l/K/N` chunk count), `truncate` (a size above `i64::MAX` no longer creates the file), `cksum` (an over-length BLAKE2b digest in a check file) and `uucore` (invalid input lines) + - `tsort` handles invalid UTF-8 input and flushes stdout properly; `date` reports invalid UTF-8 lines in `-f` input; `truncate` supports non-UTF-8 paths for `--reference` + +- **Dependency Diet** + - `expr` replaces `onig` with [fancy-regex](https://github.com/fancy-regex/fancy-regex), removing a C dependency and the CI hacks that came with it. As a side effect, `expr` switched to leftmost-longest regex matching, matching GNU + - `mv` replaces `fs_extra` with std directory sizing; `fts-sys` is gone; `cksum` uses aws-lc instead of vendored OpenSSL on MSVC, so a Perl install is no longer needed to build on Windows + - `bstr` default features disabled, `uucore`'s hand-rolled process functions dropped in favor of `rustix` + +- **Cross-platform Reach** + - Windows: `nice` (with a Priority Class mapping), `nohup` and `hostid` now build, `dirname` handles backslashes, user locale detection landed, checksum paths use forward slashes, and `mv`, `sync`, `hostname`, `touch` and `rm` moved to safe std APIs + - `uucore` on Windows: `fsext` rewritten with safe Win32 wrappers, real free space and mount paths; `df` resolves paths by mount prefix + - New and improved targets: `aarch64-unknown-linux-ohos`, AIX (`fsext`), Haiku (`fs`), Hurd (`signals`), NetBSD (`safe-traversal`), plus wider `process`, `utmpx` and `entries` support + - WASI: `cp` attribute preservation and reflink handling, `mktemp` temporary directories, `tail` integration coverage + - macOS: `tail` stdin-redirect, pid and follow-name tests re-enabled + +- **Performance** + - `sort` gained benchmarks for spilling, checking and shared prefixes; `du` benchmarks now warm the OS page cache before timing + - `cp` avoids resolving the cwd for absolute recursive copies; `tsort` gives the OS a sequential access hint + - A significant performance gap with GNU is now stated in the README as a bug, and we handle it as one + +- **Contributions**: This release landed **191 pull requests** from **16 new contributors** + +--- + +### Call to Action: + +**Try it in your browser** - [Online Playground](https://uutils.github.io/playground/) powered by WebAssembly +**Help us translate** - Contribute to [Rust Coreutils on Weblate](https://hosted.weblate.org/projects/rust-coreutils/) +**Sponsor us on GitHub** to accelerate development: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) + +## What's Changed + +## basename +* basename: fix trailing-separator handling for dot components by @wangyusheng1985 in https://github.com/uutils/coreutils/pull/14365 + +## chcon +* chcon: walk directories with DirFd instead of fts by @sylvestre in https://github.com/uutils/coreutils/pull/14413 + +## chgrp +* rm, chmod, chown, chgrp: compare (st_dev, st_ino) for --preserve-root by @sylvestre in https://github.com/uutils/coreutils/pull/14411 +* chgrp: strip error when changing group of file fails by @Devel08 in https://github.com/uutils/coreutils/pull/14403 + +## chmod +* rm, chmod, chown, chgrp: compare (st_dev, st_ino) for --preserve-root by @sylvestre in https://github.com/uutils/coreutils/pull/14411 +* chmod: fix clippy lints by @xtqqczze in https://github.com/uutils/coreutils/pull/14447 +* chmod: do not check umask on skipped symlinks during recursion by @okaythi in https://github.com/uutils/coreutils/pull/14512 +* chmod: gate by target support by @xtqqczze in https://github.com/uutils/coreutils/pull/14585 + +## chown +* rm, chmod, chown, chgrp: compare (st_dev, st_ino) for --preserve-root by @sylvestre in https://github.com/uutils/coreutils/pull/14411 + +## cksum +* cksum: use aws-lc instead of vendored openssl on MSVC to avoid perl by @oech3 in https://github.com/uutils/coreutils/pull/14559 +* cksum: do not abort on an over-length BLAKE2b digest in a check file by @Dijaa in https://github.com/uutils/coreutils/pull/14508 + +## cp +* cp: avoid resolving cwd for absolute recursive copies by @varranvar in https://github.com/uutils/coreutils/pull/14336 +* fix(wasi): cp attribute preservation and reflink handling by @eduardomourar in https://github.com/uutils/coreutils/pull/13685 +* cp: fix copying entries sharing parent directory name with -R . by @okaythi in https://github.com/uutils/coreutils/pull/14530 +* cp: retain --no-preserve over later --preserve by @wtcpython in https://github.com/uutils/coreutils/pull/14555 +* cp: --attributes-only must give the destination the source's type by @sylvestre in https://github.com/uutils/coreutils/pull/14622 + +## csplit +* csplit: accept a hyphen-leading --suffix-format value by @arbelonson-source in https://github.com/uutils/coreutils/pull/14320 +* csplit: always create the final split, even when empty by @yuefdev in https://github.com/uutils/coreutils/pull/14565 + +## date +* date: report invalid UTF-8 lines in -f file/stdin input by @alexchen-sys in https://github.com/uutils/coreutils/pull/14280 +* date: wrap errors with thiserror by @Devel08 in https://github.com/uutils/coreutils/pull/14333 +* date: exit with 1 for expected file, got directory by @Devel08 in https://github.com/uutils/coreutils/pull/14435 +* date: rewrite test to remove `thread::sleep` by @cakebaker in https://github.com/uutils/coreutils/pull/14535 +* date: allow multiple `--reference` by @cakebaker in https://github.com/uutils/coreutils/pull/14546 + +## dd +* dd: simplify windows stdin in match guard by @hlsxx in https://github.com/uutils/coreutils/pull/14347 +* dd: fix broken man page formatting by @hlsxx in https://github.com/uutils/coreutils/pull/14348 + +## dir +* dir, vdir: match GNU default quoting by @ChihweiLHBird in https://github.com/uutils/coreutils/pull/14538 + +## dirname +* dirname: Fix backslash support on Windows by @lhecker in https://github.com/uutils/coreutils/pull/14479 + +## du +* du: warm OS page cache before timing in benchmarks by @sylvestre in https://github.com/uutils/coreutils/pull/13958 +* du: fix EOVERFLOW on 32-bit Linux by @sylvestre in https://github.com/uutils/coreutils/pull/14548 + +## echo +* echo: don't expand \u, \U and \" in -e by @SichenLiang in https://github.com/uutils/coreutils/pull/14475 + +## env +* env: trace the environment changes under -v by @Socialpranker in https://github.com/uutils/coreutils/pull/14401 +* Revert "env: trace the environment changes under -v" by @cakebaker in https://github.com/uutils/coreutils/pull/14493 + +## expr +* expr: replace onig with fancy-regex by @wtcpython in https://github.com/uutils/coreutils/pull/14329 +* expr: re-enable test_regex_caret on Windows arm64 by @wtcpython in https://github.com/uutils/coreutils/pull/14429 +* expr: exit with on a write error by @Devel08 in https://github.com/uutils/coreutils/pull/14500 +* expr: switch to leftmost-longest regex matching behavior by @keith-hall in https://github.com/uutils/coreutils/pull/14527 + +## factor +* factor: strip error code in stdin error message by @Devel08 in https://github.com/uutils/coreutils/pull/14511 + +## fmt +* fmt: honor -x and -X instead of always matching prefixes exactly by @MaxFreedomPollard in https://github.com/uutils/coreutils/pull/14417 + +## head +* head, tail: reject multiplier suffixes that GNU rejects by @kennywillbe in https://github.com/uutils/coreutils/pull/14228 + +## hostid +* hostid: remove unuseful lock() by @oech3 in https://github.com/uutils/coreutils/pull/14426 +* hostid: build for Windows & remove unsafe by @oech3 in https://github.com/uutils/coreutils/pull/14427 + +## hostname +* mv, sync, hostname, touch, rm: use safe std APIs on Windows by @wtcpython in https://github.com/uutils/coreutils/pull/14445 +* hostname: gate by target support by @xtqqczze in https://github.com/uutils/coreutils/pull/14612 + +## install +* install: fix EEXIST race losing files in parallel install -D by @eyupcanakman in https://github.com/uutils/coreutils/pull/12429 +* test_install.rs: add test case for eCryptfs by @oech3 in https://github.com/uutils/coreutils/pull/14545 +* install: create the fd-based destination exclusively and at 0600 by @sylvestre in https://github.com/uutils/coreutils/pull/14625 + +## join +* join: accept a hyphen-leading -t separator, quote it in the error by @arbelonson-source in https://github.com/uutils/coreutils/pull/14318 + +## link +* link: add tests for zero args and existing destination by @anihal in https://github.com/uutils/coreutils/pull/11119 + +## ln +* ln: keep the destination when a forced link fails by @nagendramohan in https://github.com/uutils/coreutils/pull/14574 + +## logname +* logname: use no_flush by @oech3 in https://github.com/uutils/coreutils/pull/14364 + +## ls +* ls: fix panic on a color style with no attributes by @harshasiddartha in https://github.com/uutils/coreutils/pull/14373 +* ls: avoid accessing by index by @oech3 in https://github.com/uutils/coreutils/pull/14380 +* ls: apply dir and vdir format defaults in Config::from by @ChihweiLHBird in https://github.com/uutils/coreutils/pull/14450 +* ls: break a tie in the time sort with the name by @luantaraschi in https://github.com/uutils/coreutils/pull/14049 +* ls: fix two dired bugs by @sylvestre in https://github.com/uutils/coreutils/pull/14499 +* fix(ls): ensure ACL and capability markers respect dereference config by @sachinskss in https://github.com/uutils/coreutils/pull/13368 +* ls: use name sort for -u/-c in long format by @ChihweiLHBird in https://github.com/uutils/coreutils/pull/14573 +* ls: report stat() failures on directory entries (GHSA-fhfr-qj69-pq5m) by @sylvestre in https://github.com/uutils/coreutils/pull/14553 + +## mknod +* mknod: use dev_t type to avoid casting by @xtqqczze in https://github.com/uutils/coreutils/pull/14569 + +## mktemp +* fix(mktemp): add wasi platform support for temporary directory handling by @eduardomourar in https://github.com/uutils/coreutils/pull/13786 + +## mv +* mv: avoid accessing by index by @oech3 in https://github.com/uutils/coreutils/pull/14379 +* mv: test broken symlink file/dir as target by @hlsxx in https://github.com/uutils/coreutils/pull/10739 +* mv, sync, hostname, touch, rm: use safe std APIs on Windows by @wtcpython in https://github.com/uutils/coreutils/pull/14445 +* mv: replace fs_extra with std directory sizing by @wtcpython in https://github.com/uutils/coreutils/pull/14432 +* generalize some mv code for ln, cp by @sylvestre in https://github.com/uutils/coreutils/pull/14623 + +## nice +* nice: prepare supporting Windows by @oech3 in https://github.com/uutils/coreutils/pull/14452 +* nice: map Windows Priority Class by @oech3 in https://github.com/uutils/coreutils/pull/14457 + +## nohup +* nohup: simplify by .into() by @oech3 in https://github.com/uutils/coreutils/pull/14476 +* nohup: build for Windows by @oech3 in https://github.com/uutils/coreutils/pull/14461 +* nohup: move platform code into platform/{unix,windows}.rs by @sylvestre in https://github.com/uutils/coreutils/pull/14525 + +## numfmt +* numfmt: accept hyphen-leading -d/--suffix/--padding values by @arbelonson-source in https://github.com/uutils/coreutils/pull/14322 +* numfmt: accept hyphen-leading values for the remaining options by @sylvestre in https://github.com/uutils/coreutils/pull/14362 +* numfmt: zero-pad the number, not the unit suffix by @Socialpranker in https://github.com/uutils/coreutils/pull/14393 + +## od +* od: say the skip ran past the end of the combined input by @Socialpranker in https://github.com/uutils/coreutils/pull/14399 + +## paste +* paste: accept a hyphen-leading -d delimiter list by @arbelonson-source in https://github.com/uutils/coreutils/pull/14321 + +## pathchk +* pathchk: reject empty path in portability mode by @cakebaker in https://github.com/uutils/coreutils/pull/14506 +* pathchk: remove `#![allow(unused_must_use)]` by @cakebaker in https://github.com/uutils/coreutils/pull/14507 + +## pinky +* pinky: make it more rust idiomatic by @sylvestre in https://github.com/uutils/coreutils/pull/14424 + +## pr +* pr: three small refactorings by @cakebaker in https://github.com/uutils/coreutils/pull/14534 + +## printf +* printf: honor POSIXLY_CORRECT for the character-constant warning by @SichenLiang in https://github.com/uutils/coreutils/pull/14416 +* printf: treat a `--` past the format as an argument by @Socialpranker in https://github.com/uutils/coreutils/pull/14394 + +## ptx +* ptx: make the code more rust idiomatic + fix some comments by @sylvestre in https://github.com/uutils/coreutils/pull/14595 + +## realpath +* realpath: fix exit status in quiet mode by @ChihweiLHBird in https://github.com/uutils/coreutils/pull/14442 + +## rm +* rm, chmod, chown, chgrp: compare (st_dev, st_ino) for --preserve-root by @sylvestre in https://github.com/uutils/coreutils/pull/14411 +* mv, sync, hostname, touch, rm: use safe std APIs on Windows by @wtcpython in https://github.com/uutils/coreutils/pull/14445 +* rm: fix segfault on very deeply nested directories by @sylvestre in https://github.com/uutils/coreutils/pull/14554 + +## sort +* sort: apply -n to the key, not the whole line by @Socialpranker in https://github.com/uutils/coreutils/pull/14331 +* sort: add benchmarks for spilling, checking and shared prefixes by @sylvestre in https://github.com/uutils/coreutils/pull/14367 +* sort: echo the disorder line verbatim, not reformatted as a number by @Socialpranker in https://github.com/uutils/coreutils/pull/14389 +* sort: do not hold all inputs open while sorting by @Hekzory in https://github.com/uutils/coreutils/pull/14423 +* sort: simplify get_unit by @oech3 in https://github.com/uutils/coreutils/pull/14505 +* sort: gate tests correctly for BSD by @xtqqczze in https://github.com/uutils/coreutils/pull/14533 +* sort: report a failure to truncate the -o output file by @sylvestre in https://github.com/uutils/coreutils/pull/14617 +* sort: create temporary files with mode 0600 instead of the umask by @sylvestre in https://github.com/uutils/coreutils/pull/10647 +* sort: keep the output-file copy private too by @sylvestre in https://github.com/uutils/coreutils/pull/14624 + +## split +* split: fix overflow on a huge -n l/K/N chunk count by @harshasiddartha in https://github.com/uutils/coreutils/pull/14374 + +## stat +* stat: accept a hyphen-leading --format/--printf/-c value by @arbelonson-source in https://github.com/uutils/coreutils/pull/14323 +* stat: fix overflow on out-of-range octal escape by @harshasiddartha in https://github.com/uutils/coreutils/pull/14371 + +## stty +* test(stty): gate by target support by @xtqqczze in https://github.com/uutils/coreutils/pull/14571 + +## sync +* mv, sync, hostname, touch, rm: use safe std APIs on Windows by @wtcpython in https://github.com/uutils/coreutils/pull/14445 + +## tail +* head, tail: reject multiplier suffixes that GNU rejects by @kennywillbe in https://github.com/uutils/coreutils/pull/14228 +* test(tail): fix follow name truncate failing on macos by @eduardomourar in https://github.com/uutils/coreutils/pull/14438 +* tail: enable working stdin-redirect and pid tests on macOS by @wtcpython in https://github.com/uutils/coreutils/pull/14433 + +## test +* test: reject a `-a`/`-o` that ends the expression by @Socialpranker in https://github.com/uutils/coreutils/pull/14395 +* test: Honor PATHEXT on Windows by @lhecker in https://github.com/uutils/coreutils/pull/14481 + +## touch +* mv, sync, hostname, touch, rm: use safe std APIs on Windows by @wtcpython in https://github.com/uutils/coreutils/pull/14445 + +## truncate +* truncate: reject a size above i64::MAX instead of creating the file by @AlejandroCoronadoN in https://github.com/uutils/coreutils/pull/13902 +* fix(truncate): implement --io-blocks by @ThomasHabets in https://github.com/uutils/coreutils/pull/13432 +* truncate: group fifo-related tests in mod by @cakebaker in https://github.com/uutils/coreutils/pull/14385 +* truncate: support non-UTF8 paths for `--reference` by @cakebaker in https://github.com/uutils/coreutils/pull/14386 +* truncate: remove unreachable code by @cakebaker in https://github.com/uutils/coreutils/pull/14422 + +## tsort +* tsort stdout flushing fix and several error messages handling ameliorations by @anastygnome in https://github.com/uutils/coreutils/pull/14384 +* tsort: handle invalid utf8 in input by @anastygnome in https://github.com/uutils/coreutils/pull/14464 +* tsort: add sequential file access hint on windows and where available on unix by @anastygnome in https://github.com/uutils/coreutils/pull/14463 + +## tty +* tty: add missing newline to output by @ChrisX101010 in https://github.com/uutils/coreutils/pull/10252 +* tty: avoid exit & show write error by @oech3 in https://github.com/uutils/coreutils/pull/14366 + +## uniq +* uniq: drop the "Could not open" prefix from open errors by @abhijeetvichare76 in https://github.com/uutils/coreutils/pull/14262 + +## uptime +* uptime: accept hour or minute in pretty-print tests by @ChihweiLHBird in https://github.com/uutils/coreutils/pull/14547 + +## vdir +* dir, vdir: match GNU default quoting by @ChihweiLHBird in https://github.com/uutils/coreutils/pull/14538 + +## who +* who: make the code more rust idiomatic by @sylvestre in https://github.com/uutils/coreutils/pull/14410 + +## uucore +* uucore: update fsext to build on aix by @xtqqczze in https://github.com/uutils/coreutils/pull/14430 +* uucore: fix a fsext test by @xtqqczze in https://github.com/uutils/coreutils/pull/14446 +* uucore: Avoid panic on invalid input lines by @lhecker in https://github.com/uutils/coreutils/pull/14470 +* uucore: use libc implementations of `major`, `minor`, and `makedev` by @xtqqczze in https://github.com/uutils/coreutils/pull/14471 +* uucore: Implement user locale detection for Windows by @lhecker in https://github.com/uutils/coreutils/pull/14477 +* uucore: Use forward slashes for checksum paths on Windows by @lhecker in https://github.com/uutils/coreutils/pull/14469 +* uucore: Fix strip_errno doctest for Windows by @lhecker in https://github.com/uutils/coreutils/pull/14482 +* uucore: remove process functions by @xtqqczze in https://github.com/uutils/coreutils/pull/11158 +* uucore: os_str_from_bytes does not need to alloc by @lhecker in https://github.com/uutils/coreutils/pull/14466 +* uucore(windows): rewrite fsext with safe Win32 wrappers, real free space and mount paths; df(windows): resolve paths by mount prefix by @nikolalukovic in https://github.com/uutils/coreutils/pull/14455 +* uucore: support fs feature for haiku by @xtqqczze in https://github.com/uutils/coreutils/pull/14570 +* uucore: support process feature for more platforms by @xtqqczze in https://github.com/uutils/coreutils/pull/14579 +* chore(uucore): fix clippy lints for fsext feature by @xtqqczze in https://github.com/uutils/coreutils/pull/14581 +* uucore: fix test_crlf_across_blocks not compiling on Windows by @abhijeetvichare76 in https://github.com/uutils/coreutils/pull/14254 +* uucore: support signals feature for hurd by @xtqqczze in https://github.com/uutils/coreutils/pull/14454 +* uucore: fix entries feature by @xtqqczze in https://github.com/uutils/coreutils/pull/14601 +* uucore: support utmpx feature for more platforms by @xtqqczze in https://github.com/uutils/coreutils/pull/14584 +* uucore: escape embedded locale content instead of using a raw string by @sylvestre in https://github.com/uutils/coreutils/pull/14616 +* uucore: support safe-traversal feature for netbsd by @xtqqczze in https://github.com/uutils/coreutils/pull/14606 + +## Testing +* tests: don't pipe input into commands that exit before reading it by @Socialpranker in https://github.com/uutils/coreutils/pull/14431 + +## Code Quality & Cleanup +* fix: redundant_homepage cargo lint by @xtqqczze in https://github.com/uutils/coreutils/pull/14440 +* fix: missing_lints_inheritance cargo lint by @xtqqczze in https://github.com/uutils/coreutils/pull/14441 +* fix outdated comments about onig by @oech3 in https://github.com/uutils/coreutils/pull/14448 +* fix: needless_range_loop clippy lint by @xtqqczze in https://github.com/uutils/coreutils/pull/14444 +* fix: unused_dependencies cargo lint by @xtqqczze in https://github.com/uutils/coreutils/pull/14443 +* simplify matches! and inequalities in match by @oech3 in https://github.com/uutils/coreutils/pull/14502 +* remove some matches! & use .get() by @oech3 in https://github.com/uutils/coreutils/pull/14503 +* use .is_root() of rustix::process by @oech3 in https://github.com/uutils/coreutils/pull/14519 +* fix: missing_const_for_thread_local clippy lint by @xtqqczze in https://github.com/uutils/coreutils/pull/14462 +* chore: add missing newlines to source header by @xtqqczze in https://github.com/uutils/coreutils/pull/14572 +* chore: fix clippy missing_const_for_thread_local lint by @xtqqczze in https://github.com/uutils/coreutils/pull/14613 + +## CI & Build +* Revert "remove rust-version from fuzz" by @xtqqczze in https://github.com/uutils/coreutils/pull/14105 +* fetch-gnu.sh: fix hardcoded pathes to GNU test binary by @oech3 in https://github.com/uutils/coreutils/pull/14332 +* [pre-commit.ci] pre-commit autoupdate by @pre-commit-ci[bot] in https://github.com/uutils/coreutils/pull/14335 +* Cargo.toml: remove unused `fts-sys` by @cakebaker in https://github.com/uutils/coreutils/pull/14425 +* CICD.yml: remove a hack for onig by @oech3 in https://github.com/uutils/coreutils/pull/14428 +* [pre-commit.ci] pre-commit autoupdate by @pre-commit-ci[bot] in https://github.com/uutils/coreutils/pull/14439 +* openbsd.yml: remove C toolchain by @oech3 in https://github.com/uutils/coreutils/pull/14449 +* ci: fix `style_empty_assertions` job by @cakebaker in https://github.com/uutils/coreutils/pull/14451 +* Cargo.toml: remove timeout & kill from feat_require_unix_core by @oech3 in https://github.com/uutils/coreutils/pull/14453 +* freebsd.yml: fix warning by @oech3 in https://github.com/uutils/coreutils/pull/14488 +* Fix the GNU reference + send a notification to coreutils-tracking for data refresh by @sylvestre in https://github.com/uutils/coreutils/pull/14492 +* github copilot: Create code review guidelines for uutils/coreutils by @sylvestre in https://github.com/uutils/coreutils/pull/14510 +* cargo: use workspace inheritance for dev-dependencies by @xtqqczze in https://github.com/uutils/coreutils/pull/14517 +* CICD.yml, test_openssl: drop non-*sum & avoid release build by @oech3 in https://github.com/uutils/coreutils/pull/14473 +* CI: fix artifact name for Cygwin by @oech3 in https://github.com/uutils/coreutils/pull/14494 +* Cargo.toml: reconsider feat_{wasm, common_core,Tier1} for wasi by @oech3 in https://github.com/uutils/coreutils/pull/14543 +* CICD.yml: publish binaries with static openssl by @oech3 in https://github.com/uutils/coreutils/pull/14539 +* CI: remove brew install by @oech3 in https://github.com/uutils/coreutils/pull/14557 +* fix(build): adjust check for cross-platform building by @xtqqczze in https://github.com/uutils/coreutils/pull/14568 +* CICD.yml: drop unnecessay setup, simplify by @oech3 in https://github.com/uutils/coreutils/pull/14528 +* fix cspell by @oech3 in https://github.com/uutils/coreutils/pull/14580 +* GnuTests: autoreconf for portability by @oech3 in https://github.com/uutils/coreutils/pull/14575 +* chore(cargo): use workspace inheritance for dependencies by @xtqqczze in https://github.com/uutils/coreutils/pull/14596 +* cargo: avoid safe_traversal feature on unsupported platforms by @xtqqczze in https://github.com/uutils/coreutils/pull/14588 +* deny.toml: remove `windows-sys 0.59` and related crates from skip list by @cakebaker in https://github.com/uutils/coreutils/pull/14611 + +## Documentation +* README.md: clear statement wrt performances by @sylvestre in https://github.com/uutils/coreutils/pull/14345 +* uudoc: improve loop by @oech3 in https://github.com/uutils/coreutils/pull/14361 +* CONTRIBUTING.md: update link to DL GNU coreutils by @oech3 in https://github.com/uutils/coreutils/pull/14608 +* SECURITY.md: require a boundary crossing, not just a GNU divergence by @sylvestre in https://github.com/uutils/coreutils/pull/14615 + +## Platform Support +* tests: enable tail integration coverage on WASI by @DePasqualeOrg in https://github.com/uutils/coreutils/pull/13805 +* coreutils: replace /proc/ by /dev/ by @oech3 in https://github.com/uutils/coreutils/pull/12553 +* ohos: add aarch64-unknown-linux-ohos support (fsext/hostid/date) by @YodonTan in https://github.com/uutils/coreutils/pull/14288 + +## Dependency Updates +* build(deps): update rust crates by @xtqqczze in https://github.com/uutils/coreutils/pull/14338 +* fix(deps): refactor rustix/nix package configuration by @xtqqczze in https://github.com/uutils/coreutils/pull/14337 +* chore(deps): update rust crate rstest to 0.27.0 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14418 +* chore(deps): update vmactions/freebsd-vm action to v1.5.6 by @renovate[bot] in https://github.com/uutils/coreutils/pull/12977 +* chore(deps): update rust crate console to v0.16.6 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14491 +* Revert "chore(deps): update vmactions/freebsd-vm action to v1.5.6 (#12977)" by @xtqqczze in https://github.com/uutils/coreutils/pull/14513 +* chore(deps): update vmactions/freebsd-vm action to v1.5.5 by @xtqqczze in https://github.com/uutils/coreutils/pull/14516 +* chore(deps): update rust crate jiff to v0.2.37 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14521 +* chore(deps): update rust crate textwrap to v0.16.4 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14486 +* chore(deps): update rust crate clap to v4.6.7 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14566 +* chore(deps): update rust crate clap_complete to v4.6.10 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14567 +* chore(deps): update rust crate clap_complete to v4.6.11 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14582 +* chore(deps): update ICU derive crates by @xtqqczze in https://github.com/uutils/coreutils/pull/14587 +* chore(deps): update vmactions/freebsd-vm action to v1.5.6 by @xtqqczze in https://github.com/uutils/coreutils/pull/14589 +* chore(deps): update rust crate platform-info to v2.2.0 by @renovate[bot] in https://github.com/uutils/coreutils/pull/14597 +* deps: disable default features of `bstr` by @cakebaker in https://github.com/uutils/coreutils/pull/14605 + +## Version Management +* Prepare version 0.12.0 by @sylvestre in https://github.com/uutils/coreutils/pull/14578 + +## New Contributors +* @kennywillbe made their first contribution in https://github.com/uutils/coreutils/pull/14228 +* @ChrisX101010 made their first contribution in https://github.com/uutils/coreutils/pull/10252 +* @wangyusheng1985 made their first contribution in https://github.com/uutils/coreutils/pull/14365 +* @harshasiddartha made their first contribution in https://github.com/uutils/coreutils/pull/14371 +* @varranvar made their first contribution in https://github.com/uutils/coreutils/pull/14336 +* @SichenLiang made their first contribution in https://github.com/uutils/coreutils/pull/14416 +* @YodonTan made their first contribution in https://github.com/uutils/coreutils/pull/14288 +* @Hekzory made their first contribution in https://github.com/uutils/coreutils/pull/14423 +* @MaxFreedomPollard made their first contribution in https://github.com/uutils/coreutils/pull/14417 +* @ChihweiLHBird made their first contribution in https://github.com/uutils/coreutils/pull/14450 +* @lhecker made their first contribution in https://github.com/uutils/coreutils/pull/14470 +* @okaythi made their first contribution in https://github.com/uutils/coreutils/pull/14530 +* @keith-hall made their first contribution in https://github.com/uutils/coreutils/pull/14527 +* @sachinskss made their first contribution in https://github.com/uutils/coreutils/pull/13368 +* @yuefdev made their first contribution in https://github.com/uutils/coreutils/pull/14565 +* @Dijaa made their first contribution in https://github.com/uutils/coreutils/pull/14508 + +**Full Changelog**: https://github.com/uutils/coreutils/compare/0.11.0...0.12.0 diff --git a/docs/src/release-notes/0.2.0.md b/docs/src/release-notes/0.2.0.md index 340337315c6..a4ac70d6656 100644 --- a/docs/src/release-notes/0.2.0.md +++ b/docs/src/release-notes/0.2.0.md @@ -5,7 +5,7 @@ ## Release Notes -### 📦 **Rust Coreutils 0.2.0 Release:** +### **Rust Coreutils 0.2.0 Release:** We are excited to announce the release of **Rust Coreutils 0.2.0** — our **first fully internationalized release**! This milestone brings **comprehensive localization support**, **major performance improvements**, and **enhanced Unicode handling**. @@ -58,9 +58,9 @@ This milestone brings **comprehensive localization support**, **major performanc ### Call to Action: -🌍 **Help us translate** - Contribute translations at [Weblate](https://hosted.weblate.org/projects/rust-coreutils/) -🚀 **Sponsor us on GitHub** to accelerate development: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) -🔗 Download the latest release: [https://uutils.github.io](https://uutils.github.io) +**Help us translate** - Contribute translations at [Weblate](https://hosted.weblate.org/projects/rust-coreutils/) +**Sponsor us on GitHub** to accelerate development: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) +Download the latest release: [https://uutils.github.io](https://uutils.github.io) ## What's Changed diff --git a/docs/src/release-notes/0.3.0.md b/docs/src/release-notes/0.3.0.md index 6fbca3003c7..9c5fcf82ec1 100644 --- a/docs/src/release-notes/0.3.0.md +++ b/docs/src/release-notes/0.3.0.md @@ -1,4 +1,4 @@ -### 📦 **Rust Coreutils 0.3.0 Release:** +### **Rust Coreutils 0.3.0 Release:** We are happy to announce the release of **Rust Coreutils 0.3.0** — our latest milestone bringing **significant performance improvements**, **enhanced compatibility**, and **robust testing infrastructure**! @@ -59,9 +59,9 @@ We are happy to announce the release of **Rust Coreutils 0.3.0** — our latest ### Call to Action: -🌍 **Help us translate** - Contribute translations at [Weblate](https://hosted.weblate.org/projects/rust-coreutils/) -🚀 **Sponsor us on GitHub** to accelerate development: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) -🔗 Download the latest release: [https://uutils.github.io](https://uutils.github.io) +**Help us translate** - Contribute translations at [Weblate](https://hosted.weblate.org/projects/rust-coreutils/) +**Sponsor us on GitHub** to accelerate development: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) +Download the latest release: [https://uutils.github.io](https://uutils.github.io) ## What's Changed diff --git a/docs/src/release-notes/0.4.0.md b/docs/src/release-notes/0.4.0.md index 62334414c19..a7310c3ef58 100644 --- a/docs/src/release-notes/0.4.0.md +++ b/docs/src/release-notes/0.4.0.md @@ -1,4 +1,4 @@ -### 📦 **Rust Coreutils 0.4.0 Release:** +### **Rust Coreutils 0.4.0 Release:** We are pleased to announce the release of **Rust Coreutils 0.4.0** — continuing our journey toward full GNU compatibility with **improved test coverage**, **enhanced functionality**, and **robust implementations**! @@ -50,9 +50,9 @@ We are pleased to announce the release of **Rust Coreutils 0.4.0** — continuin ### Call to Action: -🌍 **Help us translate** - Contribute translations at [Weblate](https://hosted.weblate.org/projects/rust-coreutils/) -🚀 **Sponsor us on GitHub** to accelerate development: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) -🔗 Download the latest release: [https://uutils.github.io](https://uutils.github.io) +**Help us translate** - Contribute translations at [Weblate](https://hosted.weblate.org/projects/rust-coreutils/) +**Sponsor us on GitHub** to accelerate development: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) +Download the latest release: [https://uutils.github.io](https://uutils.github.io) ## What's Changed diff --git a/docs/src/release-notes/0.5.0.md b/docs/src/release-notes/0.5.0.md index 3f1c40c6521..1289bb33d01 100644 --- a/docs/src/release-notes/0.5.0.md +++ b/docs/src/release-notes/0.5.0.md @@ -1,4 +1,4 @@ -### 📦 **Rust Coreutils 0.5.0 Release:** +### **Rust Coreutils 0.5.0 Release:** We are excited to announce the release of **Rust Coreutils 0.5.0** — a significant milestone featuring **comprehensive platform improvements**, and **robust testing infrastructure** with continued progress toward full GNU compatibility! @@ -57,9 +57,9 @@ We are excited to announce the release of **Rust Coreutils 0.5.0** — a signifi ### Call to Action: -🌍 **Help us translate** - Contribute translations at [Weblate](https://hosted.weblate.org/projects/rust-coreutils/) -🚀 **Sponsor us on GitHub** to accelerate development: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) -🔗 Download the latest release: [https://uutils.github.io](https://uutils.github.io) +**Help us translate** - Contribute translations at [Weblate](https://hosted.weblate.org/projects/rust-coreutils/) +**Sponsor us on GitHub** to accelerate development: [github.com/sponsors/uutils](https://github.com/sponsors/uutils) +Download the latest release: [https://uutils.github.io](https://uutils.github.io) ## What's Changed diff --git a/flake.nix b/flake.nix index dc7319e5874..4f7234942fd 100644 --- a/flake.nix +++ b/flake.nix @@ -29,7 +29,7 @@ llvmPackages.bintools rustup - pre-commit + prek nodePackages.cspell # debugging diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index f59484f90b5..8ec03377f8d 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -3,18 +3,12 @@ version = 4 [[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "android_system_properties" -version = "0.1.5" +name = "aho-corasick" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ - "libc", + "memchr", ] [[package]] @@ -67,35 +61,23 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - [[package]] name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64-simd" @@ -121,38 +103,48 @@ dependencies = [ ] [[package]] -name = "binary-heap-plus" -version = "0.5.0" +name = "bit-set" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4551d8382e911ecc0d0f0ffb602777988669be09447d536ff4388d1def11296" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "compare", + "bit-vec", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" -version = "2.11.1" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "blake2b_simd" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" dependencies = [ - "arrayref", "arrayvec", "constant_time_eq", ] [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec", "cc", "cfg-if", @@ -162,9 +154,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -180,21 +172,14 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", - "regex-automata", - "serde", + "serde_core", ] -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - [[package]] name = "bytecount" version = "0.6.9" @@ -213,9 +198,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "jobserver", @@ -225,52 +210,41 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", "rand_core", ] -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "windows-link", -] - [[package]] name = "clap" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -281,9 +255,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "colorchoice" @@ -291,17 +265,11 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "compare" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120133d4db2ec47efe2e26502ee984747630c67f51974fca0b6c1340cf2368d3" - [[package]] name = "console" -version = "0.16.4" +version = "0.16.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" dependencies = [ "encode_unicode", "libc", @@ -309,44 +277,12 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - [[package]] name = "constant_time_eq" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "core_maths" version = "0.1.1" @@ -358,9 +294,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -392,20 +328,11 @@ dependencies = [ "spin", ] -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -413,24 +340,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crunchy" -version = "0.2.4" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-common" @@ -444,9 +365,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -464,15 +385,15 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "data-encoding-macro" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" dependencies = [ "data-encoding", "data-encoding-macro-internal", @@ -480,12 +401,43 @@ dependencies = [ [[package]] name = "data-encoding-macro-internal" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn", + "syn 3.0.6", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", ] [[package]] @@ -504,8 +456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", - "const-oid", - "crypto-common 0.2.1", + "crypto-common 0.2.2", ] [[package]] @@ -514,7 +465,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -522,22 +473,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "dlv-list" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" -dependencies = [ - "const-random", + "syn 3.0.6", ] [[package]] @@ -548,9 +490,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encode_unicode" @@ -558,12 +500,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "errno" version = "0.3.14" @@ -574,17 +510,28 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "fixed_decimal" @@ -597,16 +544,6 @@ dependencies = [ "writeable", ] -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "fluent" version = "0.17.0" @@ -652,42 +589,12 @@ dependencies = [ "thiserror", ] -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -700,73 +607,21 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "rand_core", - "wasip2", - "wasip3", ] [[package]] name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "hex" @@ -776,48 +631,24 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_calendar" -version = "2.2.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b2acc6263f494f1df50685b53ff8e57869e47d5c6fe39c23d518ae9a4f3e45" +checksum = "58655df2f728e46e4eee80bddebefacf52ec3e77cdb925014b47c5a5905eb55c" dependencies = [ "calendrical_calculations", "displaydoc", "icu_calendar_data", - "icu_locale", "icu_locale_core", + "icu_locale_fallback", "icu_provider", "serde", "tinystr", @@ -826,20 +657,20 @@ dependencies = [ [[package]] name = "icu_calendar_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118577bcf3a0fa7c6ac0a7d6e951814da84ee56b9b1f68fb4d8d10b08cefaf4d" +checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e" [[package]] name = "icu_collator" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b521b92a2666061ddda902769d8a4cf730b5c9529a845cc1b69770b12a6c9a71" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" dependencies = [ "icu_collator_data", "icu_collections", - "icu_locale", "icu_locale_core", + "icu_locale_fallback", "icu_normalizer", "icu_properties", "icu_provider", @@ -851,15 +682,15 @@ dependencies = [ [[package]] name = "icu_collator_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "038ed8e5817f2059c2f3efb0945ba78d060d3d25e8f1a1bea5139f821a21a2f0" +checksum = "f7d7e54efdddeb1208c08dd5d32b53a879ac00d2d3d051b2255fd26821d16368" [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -871,17 +702,17 @@ dependencies = [ [[package]] name = "icu_datetime" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989d56ea5bbc43ae2b4e0388874b002884eaf4ed3a76c84a6c8c5ad575e04d72" +checksum = "c44df99c4c5297b05653d1a1629f13bdc9fd4044acfbbaf30b15194a1eed4cd6" dependencies = [ "displaydoc", "fixed_decimal", "icu_calendar", "icu_datetime_data", "icu_decimal", - "icu_locale", "icu_locale_core", + "icu_locale_fallback", "icu_pattern", "icu_plurals", "icu_provider", @@ -894,21 +725,21 @@ dependencies = [ [[package]] name = "icu_datetime_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40d3cc1b690d9703202bc319692ac8a1f3a6390686f0930ff40542450fa34f0b" +checksum = "8bb5555f6e4b42233f58564351ce16d73e9e6ebfce6bdcf29875d0cfc554d6a8" [[package]] name = "icu_decimal" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "288247df2e32aa776ac54fdd64de552149ac43cb840f2761811f0e8d09719dd4" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" dependencies = [ "displaydoc", "fixed_decimal", "icu_decimal_data", - "icu_locale", "icu_locale_core", + "icu_locale_fallback", "icu_plurals", "icu_provider", "writeable", @@ -917,30 +748,30 @@ dependencies = [ [[package]] name = "icu_decimal_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f14a5ca9e8af29eef62064f269078424283d90dbaffeac5225addf62aaabc22" +checksum = "e4c887fc7d4c297cf3f9436864af9e3dba7f8be9415a6c2a7f392f3b1636298c" [[package]] name = "icu_locale" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +checksum = "785f595c61ef57169a467eeed7b4b6936a66f6cacbb116a96ee86da983251bf4" dependencies = [ "icu_collections", "icu_locale_core", "icu_locale_data", + "icu_locale_fallback", "icu_provider", - "potential_utf", "tinystr", "zerovec", ] [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -952,15 +783,35 @@ dependencies = [ [[package]] name = "icu_locale_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" +checksum = "d37d91460e362a5cf58907cd7ce871411775ee6294e49a5cc8e6cec16dfa75ea" + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -975,15 +826,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_pattern" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c4c568054ffe735398a9f4c55aec37ad7c768844553cc0978f09cc9b933a1fb" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" dependencies = [ "displaydoc", "either", @@ -994,12 +845,12 @@ dependencies = [ [[package]] name = "icu_plurals" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a50023f1d49ad5c4333380328a0d4a19e4b9d6d842ec06639affd5ba47c8103" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" dependencies = [ "fixed_decimal", - "icu_locale", + "icu_locale_fallback", "icu_plurals_data", "icu_provider", "zerovec", @@ -1007,16 +858,17 @@ dependencies = [ [[package]] name = "icu_plurals_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8485497155dc865f901decb93ecc20d3e467df67bfeceb91e3ba34e2b11e8e1d" +checksum = "1251aa39a95e1333888e499b1263e1c196447a28948acf5bdabd82c046f153bf" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1027,15 +879,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1050,9 +902,9 @@ dependencies = [ [[package]] name = "icu_time" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3af0c141da0a61d4f6970cd1d5f4b388b17ea22f8124f8f6049d3d5147586a" +checksum = "af37e1c3b9f3ab564b619fdf4b22de169f6381eb0f7048e3ca454290c438126b" dependencies = [ "calendrical_calculations", "displaydoc", @@ -1067,27 +919,9 @@ dependencies = [ [[package]] name = "icu_time_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f2f8aeca682d874a5247084aa4fb7d1cef9ba45d889c21209a8818dcaaa0ec9" - -[[package]] -name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] +checksum = "005ff86fa5851a77fc0a977b72d717f777bd9230415a247219d7898aefb1517a" [[package]] name = "intl-memoizer" @@ -1123,25 +957,31 @@ dependencies = [ "either", ] -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" dependencies = [ + "defmt", + "jiff-core", "jiff-static", "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", ] [[package]] @@ -1157,20 +997,21 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.24" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "jiff-tzdb" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" [[package]] name = "jiff-tzdb-platform" @@ -1183,47 +1024,29 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", + "getrandom", + "libc", ] [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", "cpufeatures", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.186" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libfuzzer-sys" @@ -1249,15 +1072,15 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "md-5" @@ -1271,19 +1094,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "nix" @@ -1291,7 +1104,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -1308,9 +1121,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -1318,9 +1131,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -1361,38 +1174,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "onig" -version = "6.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" -dependencies = [ - "bitflags", - "libc", - "once_cell", - "onig_sys", -] - -[[package]] -name = "onig_sys" -version = "69.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - [[package]] name = "os_display" version = "0.1.4" @@ -1410,68 +1191,46 @@ checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "parse_datetime" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f15dc0ed203652fed0fc0e2b4de76bca160565afe16507973df55e6407b84353" +checksum = "503fd4c8b0685e5eb32a8276533611f63e3b44768e9e8724e73f6852c2542f82" dependencies = [ "jiff", "num-traits", "winnow", ] -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "serde_core", "writeable", "zerovec", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1482,9 +1241,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" dependencies = [ - "bitflags", - "chrono", - "flate2", + "bitflags 2.13.2", "procfs-core", "rustix", ] @@ -1495,26 +1252,19 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" dependencies = [ - "bitflags", - "chrono", + "bitflags 2.13.2", "hex", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1523,12 +1273,12 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom", "rand_core", ] @@ -1560,33 +1310,34 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] [[package]] -name = "rust-ini" -version = "0.21.3" +name = "regex-syntax" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" -dependencies = [ - "cfg-if", - "ordered-multimap", -] +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -1595,27 +1346,21 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "self_cell" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" - -[[package]] -name = "semver" -version = "1.0.28" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1623,35 +1368,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "syn 3.0.6", ] [[package]] @@ -1700,31 +1432,19 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "simd-adler32" -version = "0.3.9" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "similar" -version = "3.1.1" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6505efef05804732ed8a3f2d4f279429eb485bd69d5b0cc6b19cc02005cda16" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" dependencies = [ "bstr", ] -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "sm3" version = "0.5.0" @@ -1736,15 +1456,15 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "spin" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "sponge-cursor" @@ -1766,9 +1486,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -1777,13 +1508,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -1793,7 +1524,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom", "once_cell", "rustix", "windows-sys", @@ -1811,38 +1542,29 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", + "syn 3.0.6", ] [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -1860,9 +1582,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unic-langid" @@ -1884,9 +1606,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-width" @@ -1894,12 +1616,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unit-prefix" version = "0.5.2" @@ -1926,7 +1642,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uu_checksum_common" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -1935,7 +1651,7 @@ dependencies = [ [[package]] name = "uu_cksum" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -1945,7 +1661,7 @@ dependencies = [ [[package]] name = "uu_cut" -version = "0.9.0" +version = "0.13.0" dependencies = [ "bstr", "clap", @@ -1956,7 +1672,7 @@ dependencies = [ [[package]] name = "uu_date" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -1964,16 +1680,18 @@ dependencies = [ "icu_locale", "jiff", "jiff-icu", + "jiff-tzdb", "libc", "parse_datetime", "rustix", + "thiserror", "uucore", "windows-sys", ] [[package]] name = "uu_dirname" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -1982,7 +1700,7 @@ dependencies = [ [[package]] name = "uu_echo" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -1991,32 +1709,31 @@ dependencies = [ [[package]] name = "uu_env" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "nix", - "rust-ini", "thiserror", "uucore", ] [[package]] name = "uu_expr" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", + "fancy-regex", "fluent", "num-bigint", "num-traits", - "onig", "thiserror", "uucore", ] [[package]] name = "uu_printf" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -2025,7 +1742,7 @@ dependencies = [ [[package]] name = "uu_seq" -version = "0.9.0" +version = "0.13.0" dependencies = [ "bigdecimal", "clap", @@ -2038,15 +1755,13 @@ dependencies = [ [[package]] name = "uu_sort" -version = "0.9.0" +version = "0.13.0" dependencies = [ "bigdecimal", - "binary-heap-plus", "clap", - "compare", "ctrlc", "fluent", - "foldhash 0.2.0", + "foldhash", "itertools", "libc", "memchr", @@ -2061,7 +1776,7 @@ dependencies = [ [[package]] name = "uu_split" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", @@ -2073,29 +1788,32 @@ dependencies = [ [[package]] name = "uu_test" -version = "0.9.0" +version = "0.13.0" dependencies = [ "clap", "fluent", "libc", + "rustix", "thiserror", "uucore", + "windows-sys", ] [[package]] name = "uu_tr" -version = "0.9.0" +version = "0.13.0" dependencies = [ "bytecount", "clap", "fluent", + "memchr", "nom", "uucore", ] [[package]] name = "uu_wc" -version = "0.9.0" +version = "0.13.0" dependencies = [ "bytecount", "clap", @@ -2109,7 +1827,7 @@ dependencies = [ [[package]] name = "uucore" -version = "0.9.0" +version = "0.13.0" dependencies = [ "base64-simd", "bigdecimal", @@ -2150,10 +1868,10 @@ dependencies = [ "sm3", "thiserror", "unic-langid", + "unicode-width", "unit-prefix", "uucore_procs", "wild", - "winapi-util", "windows-sys", "z85", ] @@ -2184,7 +1902,7 @@ dependencies = [ [[package]] name = "uucore_procs" -version = "0.9.0" +version = "0.13.0" dependencies = [ "proc-macro2", "quote", @@ -2192,7 +1910,7 @@ dependencies = [ [[package]] name = "uufuzz" -version = "0.9.0" +version = "0.13.0" dependencies = [ "console", "rand", @@ -2214,109 +1932,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "wild" version = "2.2.1" @@ -2326,74 +1941,12 @@ dependencies = [ "glob", ] -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2405,107 +1958,13 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "write16" version = "1.0.0" @@ -2514,18 +1973,18 @@ checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" dependencies = [ "either", ] [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2534,13 +1993,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", "synstructure", ] @@ -2561,21 +2020,21 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -2585,9 +2044,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "serde", "yoke", @@ -2597,17 +2056,11 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index eb780db0c65..a107691afa2 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -28,22 +28,22 @@ debug = true [dependencies] libfuzzer-sys = "0.4.7" rand = { version = "0.10.1", features = ["std_rng"] } -uufuzz = { path = "uufuzz" } -uucore = { path = "../src/uucore", features = ["parser"] } +uu_cksum = { path = "../src/uu/cksum" } +uu_cut = { path = "../src/uu/cut" } uu_date = { path = "../src/uu/date" } -uu_test = { path = "../src/uu/test" } +uu_dirname = { path = "../src/uu/dirname" } +uu_echo = { path = "../src/uu/echo" } +uu_env = { path = "../src/uu/env" } uu_expr = { path = "../src/uu/expr" } uu_printf = { path = "../src/uu/printf" } -uu_echo = { path = "../src/uu/echo" } uu_seq = { path = "../src/uu/seq" } uu_sort = { path = "../src/uu/sort" } -uu_wc = { path = "../src/uu/wc" } -uu_cut = { path = "../src/uu/cut" } uu_split = { path = "../src/uu/split" } +uu_test = { path = "../src/uu/test" } uu_tr = { path = "../src/uu/tr" } -uu_env = { path = "../src/uu/env" } -uu_cksum = { path = "../src/uu/cksum" } -uu_dirname = { path = "../src/uu/dirname" } +uu_wc = { path = "../src/uu/wc" } +uucore = { path = "../src/uucore", features = ["parser"] } +uufuzz = { path = "uufuzz" } [[bin]] name = "fuzz_date" diff --git a/fuzz/fuzz_targets/fuzz_cksum.rs b/fuzz/fuzz_targets/fuzz_cksum.rs index d7d794a743d..485fcc74549 100644 --- a/fuzz/fuzz_targets/fuzz_cksum.rs +++ b/fuzz/fuzz_targets/fuzz_cksum.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore chdir #![no_main] @@ -10,7 +11,7 @@ use rand::RngExt; use std::env::temp_dir; use std::ffi::OsString; use std::fs::{self, File}; -use std::io::Write; +use std::io::{self, Write}; use std::process::Command; use uu_cksum::uumain; use uufuzz::{ @@ -79,11 +80,7 @@ fn generate_cksum_args() -> Vec { args } -fn generate_checksum_file( - algo: &str, - file_path: &str, - digest_opts: &[&str], -) -> Result { +fn generate_checksum_file(algo: &str, file_path: &str, digest_opts: &[&str]) -> io::Result { let checksum_file_path = temp_dir().join("checksum_file"); let mut cmd = Command::new(CMD_PATH); cmd.arg("-a").arg(algo); diff --git a/fuzz/fuzz_targets/fuzz_cut.rs b/fuzz/fuzz_targets/fuzz_cut.rs index 8d049bc2e51..16ec679c442 100644 --- a/fuzz/fuzz_targets/fuzz_cut.rs +++ b/fuzz/fuzz_targets/fuzz_cut.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore parens #![no_main] diff --git a/fuzz/fuzz_targets/fuzz_env.rs b/fuzz/fuzz_targets/fuzz_env.rs index df752fef4f8..e7d81948dd4 100644 --- a/fuzz/fuzz_targets/fuzz_env.rs +++ b/fuzz/fuzz_targets/fuzz_env.rs @@ -2,7 +2,8 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore chdir + +// spell-checker:ignore chdir putenv #![no_main] use libfuzzer_sys::fuzz_target; @@ -17,22 +18,36 @@ use uufuzz::{ static CMD_PATH: &str = "env"; +/// A non-empty variable name. `env "=value"` is a known divergence: GNU injects +/// the empty name into environ via putenv(), we reject it on purpose. +fn generate_env_name() -> String { + let mut rng = rand::rng(); + loop { + let name = generate_random_string(rng.random_range(1..10)); + if !name.is_empty() { + return name; + } + } +} + fn generate_env_args() -> Vec { let mut rng = rand::rng(); - let mut args = Vec::new(); - let opts = ["-i", "-0", "-v", "-vv"]; - for opt in &opts { - if rng.random_bool(0.2) { - args.push(opt.to_string()); - } + // -i is mandatory: we run uumain in-process, so without it the Rust side + // sees the fuzzer's environment plus set_var leftovers from earlier + // iterations, while GNU runs as a child. + let mut args = vec![String::from("-i")]; + + if rng.random_bool(0.2) { + args.push(String::from("-0")); } + // -v/-vv skipped: we don't print GNU's "cleaning environ"/"setenv:"/"unset:". + // https://github.com/uutils/coreutils/issues/14171 + if rng.random_bool(0.3) { - args.push(format!( - "-u={}", - generate_random_string(rng.random_range(3..10)) - )); + args.push(String::from("-u")); + args.push(generate_env_name()); } if rng.random_bool(0.2) { @@ -55,7 +70,7 @@ fn generate_env_args() -> Vec { for _ in 0..rng.random_range(0..3) { args.push(format!( "{}={}", - generate_random_string(5), + generate_env_name(), generate_random_string(5) )); } diff --git a/fuzz/fuzz_targets/fuzz_expr.rs b/fuzz/fuzz_targets/fuzz_expr.rs index 9a6b66e612d..62375660ea9 100644 --- a/fuzz/fuzz_targets/fuzz_expr.rs +++ b/fuzz/fuzz_targets/fuzz_expr.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore parens #![no_main] diff --git a/fuzz/fuzz_targets/fuzz_non_utf8_paths.rs b/fuzz/fuzz_targets/fuzz_non_utf8_paths.rs index 5e838adc6ce..cdb474b8b2b 100644 --- a/fuzz/fuzz_targets/fuzz_non_utf8_paths.rs +++ b/fuzz/fuzz_targets/fuzz_non_utf8_paths.rs @@ -122,7 +122,7 @@ fn generate_non_utf8_osstring() -> OsString { OsString::from_vec(generate_non_utf8_bytes()) } -fn setup_test_files() -> Result<(PathBuf, Vec), std::io::Error> { +fn setup_test_files() -> std::io::Result<(PathBuf, Vec)> { let mut rng = rand::rng(); let temp_root = temp_dir().join(format!("utf8_test_{}", rng.random::())); fs::create_dir_all(&temp_root)?; diff --git a/fuzz/fuzz_targets/fuzz_printf.rs b/fuzz/fuzz_targets/fuzz_printf.rs index 597272fbdf3..523fba6310d 100644 --- a/fuzz/fuzz_targets/fuzz_printf.rs +++ b/fuzz/fuzz_targets/fuzz_printf.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore parens #![no_main] diff --git a/fuzz/fuzz_targets/fuzz_seq.rs b/fuzz/fuzz_targets/fuzz_seq.rs index 2d441e6742f..c01b8039059 100644 --- a/fuzz/fuzz_targets/fuzz_seq.rs +++ b/fuzz/fuzz_targets/fuzz_seq.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore parens #![no_main] diff --git a/fuzz/fuzz_targets/fuzz_seq_parse_number.rs b/fuzz/fuzz_targets/fuzz_seq_parse_number.rs index 04da6d47f99..fc58c84b4d5 100644 --- a/fuzz/fuzz_targets/fuzz_seq_parse_number.rs +++ b/fuzz/fuzz_targets/fuzz_seq_parse_number.rs @@ -2,6 +2,7 @@ // // 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; diff --git a/fuzz/fuzz_targets/fuzz_sort.rs b/fuzz/fuzz_targets/fuzz_sort.rs index 17fcd0e5c51..40449a98abe 100644 --- a/fuzz/fuzz_targets/fuzz_sort.rs +++ b/fuzz/fuzz_targets/fuzz_sort.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore parens #![no_main] diff --git a/fuzz/fuzz_targets/fuzz_split.rs b/fuzz/fuzz_targets/fuzz_split.rs index c3ce54504c6..db43a0dc029 100644 --- a/fuzz/fuzz_targets/fuzz_split.rs +++ b/fuzz/fuzz_targets/fuzz_split.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore parens #![no_main] diff --git a/fuzz/fuzz_targets/fuzz_test.rs b/fuzz/fuzz_targets/fuzz_test.rs index bb67b7e2307..502e588cc54 100644 --- a/fuzz/fuzz_targets/fuzz_test.rs +++ b/fuzz/fuzz_targets/fuzz_test.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore STRINGSTRING INTEGERINTEGER FILEFILE #![no_main] diff --git a/fuzz/fuzz_targets/fuzz_wc.rs b/fuzz/fuzz_targets/fuzz_wc.rs index b7d0b8a4de6..7bc26602edd 100644 --- a/fuzz/fuzz_targets/fuzz_wc.rs +++ b/fuzz/fuzz_targets/fuzz_wc.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore parens #![no_main] diff --git a/fuzz/uufuzz/Cargo.toml b/fuzz/uufuzz/Cargo.toml index 9068ea6158e..1dd68e9de0b 100644 --- a/fuzz/uufuzz/Cargo.toml +++ b/fuzz/uufuzz/Cargo.toml @@ -2,7 +2,7 @@ name = "uufuzz" description = "uutils ~ 'core' uutils fuzzing library" repository = "https://github.com/uutils/coreutils/tree/main/fuzz/uufuzz" -version = "0.9.0" +version = "0.13.0" edition.workspace = true rust-version.workspace = true license.workspace = true @@ -10,7 +10,9 @@ license.workspace = true [dependencies] console = "0.16.0" rand = { version = "0.10.1", features = ["std_rng"] } +rustix = { version = "1.1.4", features = ["stdio", "pipe"] } similar = "3.0.0" -uucore = { version = "0.9.0", path = "../../src/uucore", features = ["parser"] } tempfile = "3.15.0" -rustix = { version = "1.1.4", features = ["stdio", "pipe"] } +uucore = { version = "0.13.0", path = "../../src/uucore", features = [ + "parser", +] } diff --git a/fuzz/uufuzz/src/lib.rs b/fuzz/uufuzz/src/lib.rs index 4b39bc3ba6a..c43afb98813 100644 --- a/fuzz/uufuzz/src/lib.rs +++ b/fuzz/uufuzz/src/lib.rs @@ -15,12 +15,11 @@ use rustix::stdio::{dup2_stderr, dup2_stdin, dup2_stdout}; use std::env::temp_dir; use std::ffi::OsString; use std::fs::File; -use std::io::pipe; -use std::io::{Seek, SeekFrom, Write}; +use std::io::{self, Seek, SeekFrom, Write, pipe}; use std::process::{Command, Stdio}; use std::sync::atomic::Ordering; use std::sync::{Once, atomic::AtomicBool}; -use std::{io, thread}; +use std::thread; pub mod pretty_print; @@ -41,7 +40,7 @@ pub struct CommandResult { static CHECK_GNU: Once = Once::new(); static IS_GNU: AtomicBool = AtomicBool::new(false); -pub fn is_gnu_cmd(cmd_path: &str) -> Result<(), std::io::Error> { +pub fn is_gnu_cmd(cmd_path: &str) -> io::Result<()> { CHECK_GNU.call_once(|| { let version_output = Command::new(cmd_path).arg("--version").output().unwrap(); @@ -69,10 +68,8 @@ where F: FnOnce(std::vec::IntoIter) -> i32 + Send + 'static, { // Duplicate the stdout and stderr file descriptors to restore later - let original_stdout_fd_owned = - dup(std::io::stdout()).expect("Failed to duplicate STDOUT_FILENO"); - let original_stderr_fd_owned = - dup(std::io::stderr()).expect("Failed to duplicate STDERR_FILENO"); + let original_stdout_fd_owned = dup(io::stdout()).expect("Failed to duplicate STDOUT_FILENO"); + let original_stderr_fd_owned = dup(io::stderr()).expect("Failed to duplicate STDERR_FILENO"); println!("Running test {:?}", &args[0..]); let (read_pipe_stdout, write_pipe_stdout) = pipe().expect("Failed to create pipes"); @@ -90,7 +87,7 @@ where input_file.seek(SeekFrom::Start(0)).unwrap(); // Redirect stdin to read from the in-memory file - let stdin_fd = dup(std::io::stdin()).expect("Failed to duplicate STDIN"); + let stdin_fd = dup(io::stdin()).expect("Failed to duplicate STDIN"); // Redirect stdin to read from the in-memory file dup2_stdin(&input_file).expect("Failed to set up stdin redirection"); @@ -143,10 +140,8 @@ fn read_from_fd(fd: impl std::os::fd::AsFd) -> String { loop { match read(&fd, &mut read_buffer) { + Ok(0) => break, Ok(bytes_read) => { - if bytes_read == 0 { - break; - } captured_output.extend_from_slice(&read_buffer[..bytes_read]); } Err(_) => { @@ -352,7 +347,7 @@ pub fn generate_random_string(max_length: usize) -> String { } #[allow(dead_code)] -pub fn generate_random_file() -> Result { +pub fn generate_random_file() -> io::Result { let mut rng = rand::rng(); let file_name: String = (0..10) .map(|_| rng.random_range(b'a'..=b'z') as char) diff --git a/src/bin/coreutils.rs b/src/bin/coreutils.rs index f451780250a..46418112286 100644 --- a/src/bin/coreutils.rs +++ b/src/bin/coreutils.rs @@ -4,12 +4,11 @@ // file that was distributed with this source code. use clap::Command; -use coreutils::validation; +use coreutils::validation::{self, exit}; use itertools::Itertools as _; use std::cmp; use std::ffi::OsString; use std::io::{self, Write}; -use std::process; use uucore::{Args, error::strip_errno}; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -45,7 +44,7 @@ Currently defined functions: && e.kind() != io::ErrorKind::BrokenPipe { let _ = writeln!(io::stderr(), "coreutils: {}", strip_errno(&e)); - process::exit(1); + exit(1); } } @@ -57,7 +56,7 @@ fn main() { let binary = validation::binary_path(&mut args); let binary_as_util = validation::name(&binary).unwrap_or_else(|| { usage(&utils, ""); - process::exit(0); + exit(0); }); // binary name ends with util name? @@ -88,7 +87,7 @@ fn main() { // we should fail with additional args https://github.com/uutils/coreutils/issues/11383#issuecomment-4082564058 if args.next().is_some() { let _ = writeln!(io::stderr(), "coreutils: invalid argument"); - process::exit(1); + exit(1); } let mut out = io::stdout().lock(); for util in utils.keys() { @@ -96,55 +95,49 @@ fn main() { && e.kind() != io::ErrorKind::BrokenPipe { let _ = writeln!(io::stderr(), "coreutils: {}", strip_errno(&e)); - process::exit(1); + exit(1); } } - process::exit(0); + exit(0); } "--version" | "-V" => { if let Err(e) = writeln!(io::stdout(), "coreutils {VERSION} (multi-call binary)") && e.kind() != io::ErrorKind::BrokenPipe { let _ = writeln!(io::stderr(), "coreutils: {}", strip_errno(&e)); - process::exit(1); + exit(1); } - process::exit(0); + exit(0); } // Not a special command: fallthrough to calling a util _ => {} } - match utils.get(util) { - Some(&(uumain, _)) => { - // TODO: plug the deactivation of the translation - // and load the English strings directly at compilation time in the - // binary to avoid the load of the flt - // Could be something like: - // #[cfg(not(feature = "only_english"))] - validation::setup_localization_or_exit(util); - process::exit(uumain(vec![util_os].into_iter().chain(args))); - } - None => { - // GNU coreutils --help string shows help for coreutils - if util == "--help" || util == "-h" { - usage(&utils, binary_as_util); - process::exit(0); - } else if util.starts_with('-') { - // Argument looks like an option but wasn't recognized - validation::unrecognized_option(binary_as_util, &util_os); - } else { - validation::not_found(&util_os); - } - } + if let Some(&(uumain, _)) = utils.get(util) { + // TODO: plug the deactivation of the translation + // and load the English strings directly at compilation time in the + // binary to avoid the load of the flt + // Could be something like: + // #[cfg(not(feature = "only_english"))] + validation::setup_localization_or_exit(util); + exit(uumain(vec![util_os].into_iter().chain(args))); } - } else { - // GNU just fails, but busybox tests needs usage - // todo: patch the test suite instead - if binary_as_util.ends_with("box") { + // GNU coreutils --help string shows help for coreutils + if util == "--help" || util == "-h" { usage(&utils, binary_as_util); - } else { - let _ = writeln!(io::stderr(), "coreutils: missing argument"); + exit(0); + } else if util.starts_with('-') { + // Argument looks like an option but wasn't recognized + validation::unrecognized_option(binary_as_util, &util_os); } - process::exit(1); + validation::not_found(&util_os); + } + // GNU just fails, but busybox tests needs usage + // todo: patch the test suite instead + if binary_as_util.ends_with("box") { + usage(&utils, binary_as_util); + } else { + let _ = writeln!(io::stderr(), "coreutils: missing argument"); } + exit(1); } diff --git a/src/bin/uudoc.rs b/src/bin/uudoc.rs index a406dcb3d64..992526b2e17 100644 --- a/src/bin/uudoc.rs +++ b/src/bin/uudoc.rs @@ -23,7 +23,7 @@ use fluent_syntax::{ }, parser, }; -use jiff::Zoned; +use jiff::{Timestamp, Zoned, tz::TimeZone}; use regex::Regex; use textwrap::{fill, indent, termwidth}; use zip::ZipArchive; @@ -60,13 +60,12 @@ fn post_process_manpage(manpage: String, date: &str) -> String { let lines: Vec<&str> = result.lines().map(str::trim_end).collect(); let mut fixed_lines: Vec<&str> = Vec::with_capacity(lines.len()); - for i in 0..lines.len() { - let line = lines[i]; - + for (i, &line) in lines.iter().enumerate() { if line == ".br" { - let preceded_by_empty_line = i > 0 && lines[i - 1].is_empty(); - let followed_by_empty_line = i + 1 < lines.len() && lines[i + 1].is_empty(); - let followed_by_br = i + 1 < lines.len() && lines[i + 1] == ".br"; + let preceded_by_empty_line = lines.get(i - 1).is_some_and(|l| l.is_empty()); + let line_next = lines.get(i + 1); + let followed_by_empty_line = line_next.is_some_and(|l| l.is_empty()); + let followed_by_br = line_next == Some(&".br"); if preceded_by_empty_line || followed_by_empty_line || followed_by_br { // skip this ".br" @@ -152,10 +151,10 @@ fn gen_manpage( let mut cmd = util_map.get(utility).unwrap().1(); cmd.set_bin_name(utility.clone()); let mut cmd = cmd.display_name(utility); - if let Some(zip) = tldr { - if let Ok(examples) = write_zip_examples(zip, utility, false) { - cmd = cmd.after_help(examples); - } + if let Some(zip) = tldr + && let Ok(examples) = write_zip_examples(zip, utility, false) + { + cmd = cmd.after_help(examples); } cmd }; @@ -168,8 +167,15 @@ fn gen_manpage( // Convert to string for processing let manpage = String::from_utf8(buffer).expect("Invalid UTF-8 in manpage"); + // Use `SOURCE_DATE_EPOCH` for reproducible builds if set and valid; otherwise use the current time. + let now = std::env::var("SOURCE_DATE_EPOCH") + .ok() + .and_then(|s| s.parse().ok()) + .and_then(|s| Timestamp::new(s, 0).ok()) + .map_or_else(Zoned::now, |t| t.to_zoned(TimeZone::UTC)); + // Post-process the manpage to fix mandoc lint issues - let date = Zoned::now().strftime("%Y-%m-%d").to_string(); + let date = now.strftime("%Y-%m-%d").to_string(); let processed_manpage = post_process_manpage(manpage, &date); // Write the processed manpage to stdout @@ -273,8 +279,10 @@ fn main() -> io::Result<()> { * [Contributing](CONTRIBUTING.md)\n\ \t* [Development](DEVELOPMENT.md)\n\ \t* [Code of Conduct](CODE_OF_CONDUCT.md)\n\ + \t* [Localization](l10n.md)\n\ * [GNU test coverage](test_coverage.md)\n\ * [Extensions](extensions.md)\n\ + \t* [Error diagnostics](extensions-errors.md)\n\ \n\ # Reference\n\ * [Multi-call binary](multicall.md)\n", @@ -296,7 +304,7 @@ fn main() -> io::Result<()> { ) .unwrap() .trim() - .split(' ') + .split_ascii_whitespace() .map(ToString::to_string) .collect(); map.insert(platform, platform_utils); @@ -311,7 +319,7 @@ fn main() -> io::Result<()> { ) .unwrap() .trim() - .split(' ') + .split_ascii_whitespace() .map(ToString::to_string) .collect(); map.insert("linux", platform_utils); @@ -452,24 +460,23 @@ impl MDWriter<'_, '_> { value: Some(Pattern { elements }), .. }) = entry + && id.name == key { - if id.name == key { - // Simple text extraction - just concatenate text elements - let mut result = String::new(); - for element in elements { - if let TextElement { ref value } = element { - result.push_str(value); - } - if let Placeable { - expression: - Expression::Inline(InlineExpression::StringLiteral { ref value }), - } = element - { - result.push_str(value); - } + // Simple text extraction - just concatenate text elements + let mut result = String::new(); + for element in elements { + if let TextElement { ref value } = element { + result.push_str(value); + } + if let Placeable { + expression: + Expression::Inline(InlineExpression::StringLiteral { ref value }), + } = element + { + result.push_str(value); } - return Some(result); } + return Some(result); } } None @@ -559,10 +566,10 @@ impl MDWriter<'_, '_> { /// # Errors /// Returns an error if the writer fails. fn examples(&mut self) -> io::Result<()> { - if let Some(zip) = self.tldr_zip { - if let Ok(examples) = write_zip_examples(zip, self.name, true) { - writeln!(self.w, "{examples}")?; - } + if let Some(zip) = self.tldr_zip + && let Ok(examples) = write_zip_examples(zip, self.name, true) + { + writeln!(self.w, "{examples}")?; } Ok(()) } diff --git a/src/common/validation.rs b/src/common/validation.rs index 124fa1dbb64..4b84ca705a4 100644 --- a/src/common/validation.rs +++ b/src/common/validation.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 memfd_create prefixcat rsplit testcat +// spell-checker:ignore execveat fexecve memfd prefixcat rsplit testcat atexit profraw Cprofile use std::ffi::{OsStr, OsString}; use std::io::{Write, stderr}; @@ -14,6 +14,31 @@ use uucore::Args; use uucore::display::Quotable; use uucore::locale; +// The instrumented binary built by `util/build-pgo.sh` (`--cfg pgo_training`) +// has to flush its own profile counters on Windows. Every exit below goes +// through `std::process::exit`, which is `libc::exit` on Unix but +// `ExitProcess` on Windows; the latter skips the `atexit` handler the LLVM +// profiling runtime writes the counters from, so the training runs would +// leave nothing but empty `.profraw` files behind. +#[cfg(all(pgo_training, windows))] +unsafe extern "C" { + fn __llvm_profile_write_file() -> i32; +} + +/// Terminates the process with `code`, flushing the PGO counters when the +/// binary was built for profile training on Windows. +pub fn exit(code: i32) -> ! { + #[cfg(all(pgo_training, windows))] + // SAFETY: `__llvm_profile_write_file` is provided by the LLVM profiling + // runtime, which is linked in whenever this cfg is set (the same build + // passes `-Cprofile-generate`). It takes no arguments and only writes the + // counter file named by `LLVM_PROFILE_FILE`. + unsafe { + __llvm_profile_write_file(); + } + process::exit(code) +} + /// Gets all available utilities including "coreutils" #[allow(clippy::type_complexity)] pub fn get_all_utilities( @@ -31,7 +56,7 @@ pub fn not_found(util: &OsStr) -> ! { "coreutils: unknown program '{}'", util.maybe_quote() ); - process::exit(1); + exit(1); } /// Prints an "unrecognized option" error and exits @@ -41,7 +66,7 @@ pub fn unrecognized_option(binary_name: &str, option: &OsStr) -> ! { "{binary_name}: unrecognized option '{}'", option.to_string_lossy() ); - process::exit(1); + exit(1); } /// Sets up localization for a utility with proper error handling @@ -55,7 +80,7 @@ pub fn setup_localization_or_exit(util_name: &str) { } => eprintln!("Localization parse error at {snippet}: {err_msg}"), other => eprintln!("Could not init the localization system: {other}"), } - process::exit(99) + exit(99) }); } @@ -64,8 +89,7 @@ fn get_canonical_util_name(util_name: &str) -> &str { match util_name { // uu_test aliases - '[' is an alias for test "[" => "test", - "dir" => "ls", // dir is an alias for ls - "vdir" => "ls", // vdir is an alias for ls + "dir" | "vdir" => "ls", // aliases for ls // Default case - return the util name as is _ => util_name, @@ -75,20 +99,23 @@ fn get_canonical_util_name(util_name: &str) -> &str { /// Gets the binary path from command line arguments /// Panics if the binary path cannot be determined #[cfg(any( - not(any(target_os = "linux", target_os = "android")), + not(any( + target_os = "linux", + all(target_os = "android", target_pointer_width = "64") + )), target_env = "musl" ))] pub fn binary_path(args: &mut impl Iterator) -> PathBuf { - match args.next() { - Some(ref s) if !s.is_empty() => PathBuf::from(s), - // the fallback is valid only for hardlinks - _ => std::env::current_exe().unwrap(), - } + PathBuf::from(args.next().unwrap()) + // no fallback for empty args. current_exe() (/proc/self/exe) is valid only for hardlinks } /// Get actual binary path from kernel, not argv0, to prevent `env -a` from bypassing /// AppArmor, SELinux policies on hard-linked binaries #[cfg(all( - any(target_os = "linux", target_os = "android"), + any( + target_os = "linux", + all(target_os = "android", target_pointer_width = "64") + ), not(target_env = "musl") ))] pub fn binary_path(args: &mut impl Iterator) -> PathBuf { @@ -100,10 +127,12 @@ pub fn binary_path(args: &mut impl Iterator) -> PathBuf { let exec_path = Path::new(OsStr::from_bytes(execfn_bytes)); let argv0 = args.next().unwrap(); let mut shebang_buf = [0u8; 2]; - // exec_path is wrong when called from shebang or memfd_create (/proc/self/fd/*) + // exec_path is wrong when called from a shebang, or via fexecve/execveat: + // the kernel reports /dev/fd/* and memfd_create/glibc's fallback /proc/self/fd/* // argv0 is not full-path when called from PATH if execfn_bytes.rsplit(|&b| b == b'/').next() == argv0.as_bytes().rsplit(|&b| b == b'/').next() || execfn_bytes.starts_with(b"/proc/") + || execfn_bytes.starts_with(b"/dev/fd/") || (File::open(Path::new(exec_path)) .and_then(|mut f| f.read_exact(&mut shebang_buf)) .is_ok() diff --git a/src/uu/arch/Cargo.toml b/src/uu/arch/Cargo.toml index d54fa589890..0fe968b9f79 100644 --- a/src/uu/arch/Cargo.toml +++ b/src/uu/arch/Cargo.toml @@ -11,19 +11,19 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/arch.rs" test = false doctest = false [dependencies] -platform-info = { workspace = true } clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +platform-info = { workspace = true } +uucore = { workspace = true } + +[lints] +workspace = true [[bin]] name = "arch" diff --git a/src/uu/b2sum/Cargo.toml b/src/uu/b2sum/Cargo.toml index 10c94ccf35d..3acd0bbebaa 100644 --- a/src/uu/b2sum/Cargo.toml +++ b/src/uu/b2sum/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/b2sum.rs" test = false @@ -21,6 +18,7 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } uu_checksum_common = { workspace = true } uucore = { workspace = true, features = [ "checksum", @@ -28,11 +26,13 @@ uucore = { workspace = true, features = [ "sum", "hardware", ] } -fluent = { workspace = true } [dev-dependencies] uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bin]] name = "b2sum" path = "src/main.rs" diff --git a/src/uu/base32/Cargo.toml b/src/uu/base32/Cargo.toml index 665c0e48637..6d2dd4291a9 100644 --- a/src/uu/base32/Cargo.toml +++ b/src/uu/base32/Cargo.toml @@ -11,18 +11,20 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/base32.rs" doctest = false [dependencies] clap = { workspace = true } -rustix = { workspace = true } -uucore = { workspace = true, features = ["encoding"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["encoding"] } + +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))'.dependencies] +rustix = { workspace = true, features = ["fs"] } + +[lints] +workspace = true [[bin]] name = "base32" diff --git a/src/uu/base32/src/base_common.rs b/src/uu/base32/src/base_common.rs index 79f6435eb46..744eff55707 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, Write}; +use std::io::{self, BufRead, BufReader, BufWriter, Write}; use std::path::{Path, PathBuf}; use uucore::display::Quotable; use uucore::encoding::{ @@ -29,8 +29,7 @@ pub const BASE_CMD_PARSE_ERROR: i32 = 1; /// This default is only used if no "-w"/"--wrap" argument is passed pub const WRAP_DEFAULT: usize = 76; -// Fixed to 8 KiB (equivalent to `std::sys::io::DEFAULT_BUF_SIZE` on most targets) -pub const DEFAULT_BUF_SIZE: usize = 8 * 1024; +pub const DEFAULT_BUF_SIZE: usize = 32 * 1024; pub struct Config { pub decode: bool, @@ -124,6 +123,7 @@ pub fn base_app(about: String, usage: String) -> Command { .short('w') .long(options::WRAP) .value_name("COLS") + .allow_hyphen_values(true) .help(translate!("base-common-help-wrap", "default" => WRAP_DEFAULT)) .overrides_with(options::WRAP), ) @@ -194,6 +194,22 @@ pub fn handle_input(input: &mut R, format: Format, config: Config) - supports_fast_decode_and_encode_ref, config.ignore_garbage, ), + // Batch Base16's small encoded chunks to reduce write syscalls. + (Format::Base16, false) => { + let mut output = BufWriter::with_capacity(DEFAULT_BUF_SIZE, &mut stdout_lock); + let result = fast_encode::fast_encode_stream( + input, + &mut output, + supports_fast_decode_and_encode_ref, + config.wrap_cols, + ); + + match (result, output.flush()) { + (res, Ok(())) => res, + (Ok(_), Err(err)) => Err(err.into()), + (Err(original), Err(_)) => Err(original), + } + } (_, false) => fast_encode::fast_encode_stream( input, &mut stdout_lock, @@ -542,14 +558,11 @@ pub mod fast_encode { let mut encoded_buffer = VecDeque::::new(); let mut leftover_buffer = Vec::::with_capacity(encode_in_chunks_of_size); - loop { - let read_buffer = input - .fill_buf() - .map_err(|err| USimpleError::new(1, super::format_read_error(&err)))?; - if read_buffer.is_empty() { - break; - } - + while let read_buffer = input + .fill_buf() + .map_err(|e| USimpleError::new(1, super::format_read_error(&e)))? + && !read_buffer.is_empty() + { let mut consumed = 0; if !leftover_buffer.is_empty() { @@ -809,15 +822,11 @@ pub mod fast_decode { let mut buffer = Vec::with_capacity(decode_in_chunks_of_size); let mut decoded_buffer = Vec::::new(); - loop { - let read_buffer = input - .fill_buf() - .map_err(|err| USimpleError::new(1, super::format_read_error(&err)))?; - let read_len = read_buffer.len(); - if read_len == 0 { - break; - } - + while let read_buffer = input + .fill_buf() + .map_err(|e| USimpleError::new(1, super::format_read_error(&e)))? + && let read_len @ 1.. = read_buffer.len() + { for &byte in read_buffer { if byte == b'\n' || byte == b'\r' { continue; diff --git a/src/uu/base64/Cargo.toml b/src/uu/base64/Cargo.toml index b5ac572dd75..f29aa8d2dfc 100644 --- a/src/uu/base64/Cargo.toml +++ b/src/uu/base64/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/base64.rs" test = false @@ -21,15 +18,18 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["encoding"] } -uu_base32 = { workspace = true } fluent = { workspace = true } +uu_base32 = { workspace = true } +uucore = { workspace = true, features = ["encoding"] } [dev-dependencies] divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bin]] name = "base64" path = "src/main.rs" diff --git a/src/uu/base64/benches/base64_bench.rs b/src/uu/base64/benches/base64_bench.rs index b157af0c85b..0a41b36b82a 100644 --- a/src/uu/base64/benches/base64_bench.rs +++ b/src/uu/base64/benches/base64_bench.rs @@ -4,67 +4,58 @@ // file that was distributed with this source code. use divan::{Bencher, black_box}; -use std::ffi::OsString; use uu_base64::uumain; -use uucore::benchmark::{create_test_file, run_util_function, text_data}; +use uucore::benchmark::{create_test_file, get_bench_args, text_data}; -fn create_tmp_file(size_mb: usize) -> String { +fn create_tmp_file(size_mb: usize) -> std::path::PathBuf { let temp_dir = tempfile::tempdir().unwrap(); let data = text_data::generate_by_size(size_mb, 80); - let file_path = create_test_file(&data, temp_dir.path()); - String::from(file_path.to_str().unwrap()) + create_test_file(&data, temp_dir.path()) +} + +fn redirect_in(in_file: &std::path::Path) -> std::ffi::OsString { + std::iter::once(std::ffi::OsString::from(">").as_os_str()) + .chain(std::iter::once(in_file.as_os_str())) + .collect() } /// Benchmark for base64 encoding #[divan::bench()] fn b64_encode_synthetic(bencher: Bencher) { - let file_path_str = &create_tmp_file(5_000); + let file_path = create_tmp_file(5_000); - bencher.bench(|| { - black_box(run_util_function(uumain, &[file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } // Benchmark for base64 decoding #[divan::bench()] fn b64_decode_synthetic(bencher: Bencher) { let temp_dir = tempfile::tempdir().unwrap(); - let file_path_str = &create_tmp_file(5_000); + let file_path = create_tmp_file(5_000); let in_file = create_test_file(b"", temp_dir.path()); - let in_file_str = in_file.to_str().unwrap(); - uumain( - [ - OsString::from(file_path_str), - OsString::from(format!(">{in_file_str}")), - ] - .iter() - .map(|x| (*x).clone()), - ); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-d", in_file_str])); - }); + uumain([file_path.into(), redirect_in(&in_file)].into_iter()); + + bencher + .with_inputs(|| get_bench_args(&[&"-d", &in_file]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } // Benchmark different file sizes for base64 decoding ignoring garbage characters #[divan::bench()] fn b64_decode_ignore_garbage_synthetic(bencher: Bencher) { - let temp_dir = tempfile::tempdir().unwrap(); - let file_path_str = &create_tmp_file(5_000); + let tempdir = tempfile::tempdir().unwrap(); + let temp_dir = tempdir; + let file_path = create_tmp_file(5_000); let in_file = create_test_file(b"", temp_dir.path()); - let in_file_str = in_file.to_str().unwrap(); - uumain( - [ - OsString::from(file_path_str), - OsString::from(format!(">{in_file_str}")), - ] - .iter() - .map(|x| (*x).clone()), - ); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-d", "-i", in_file_str])); - }); + uumain([file_path.into(), redirect_in(&in_file)].into_iter()); + + bencher + .with_inputs(|| get_bench_args(&[&"-d", &"-i", &in_file]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/basename/Cargo.toml b/src/uu/basename/Cargo.toml index 73a0f06ef81..a7015efe2f9 100644 --- a/src/uu/basename/Cargo.toml +++ b/src/uu/basename/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/basename.rs" test = false @@ -21,8 +18,11 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true } + +[lints] +workspace = true [[bin]] name = "basename" diff --git a/src/uu/basename/src/basename.rs b/src/uu/basename/src/basename.rs index ee0c90ae0b8..6f2189d3a59 100644 --- a/src/uu/basename/src/basename.rs +++ b/src/uu/basename/src/basename.rs @@ -9,7 +9,7 @@ use clap::builder::ValueParser; use clap::{Arg, ArgAction, Command}; use std::ffi::OsString; use std::io::{Write, stdout}; -use std::path::PathBuf; +use std::path::{PathBuf, is_separator}; use uucore::display::Quotable; use uucore::error::{UResult, UUsageError}; use uucore::format_usage; @@ -33,16 +33,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let line_ending = LineEnding::from_zero_flag(matches.get_flag(options::ZERO)); + // todo: use .required(true) of clap and sed GnuTests to provide better error message let mut name_args = matches .get_many::(options::NAME) - .unwrap_or_default() + .ok_or_else(|| UUsageError::new(1, translate!("basename-error-missing-operand")))? .collect::>(); - if name_args.is_empty() { - return Err(UUsageError::new( - 1, - translate!("basename-error-missing-operand"), - )); - } let multiple_paths = matches.get_one::(options::SUFFIX).is_some() || matches.get_flag(options::MULTIPLE); let suffix = if multiple_paths { @@ -126,8 +121,20 @@ pub fn uu_app() -> Command { fn basename(fullname: &OsString, suffix: &OsString) -> UResult> { let fullname_bytes = uucore::os_str_as_bytes(fullname)?; - // Handle special case where path ends with /. - if fullname_bytes.ends_with(b"/.") { + // Path::components() normalizes a trailing `.` away. Inspect a trimmed view + // first so `foo/./` yields `.`, while using the original path below keeps + // platform-specific root paths intact. + let mut path_end = fullname_bytes; + while path_end + .last() + .is_some_and(|&byte| is_separator(char::from(byte))) + { + path_end = &path_end[..path_end.len() - 1]; + } + if path_end.len() > 1 + && path_end.ends_with(b".") + && is_separator(char::from(path_end[path_end.len() - 2])) + { return Ok(b".".into()); } diff --git a/src/uu/basenc/BENCHMARKING.md b/src/uu/basenc/BENCHMARKING.md index 7039bbec8f3..88517a1be38 100644 --- a/src/uu/basenc/BENCHMARKING.md +++ b/src/uu/basenc/BENCHMARKING.md @@ -19,6 +19,15 @@ Build the `basenc` binary using the release profile: cargo build --package uu_basenc --profile release ``` +## Base16 output benchmark + +This benchmark measures buffered Base16 output. 16 MiB input for every run. Writes to `/dev/null` +and a temporary file. Runs only on Unix. + +```Shell +cargo bench --package uu_basenc --bench basenc_bench +``` + ## Expected performance uutils' `basenc` performs streaming decoding and encoding, and therefore should perform all operations with a constant diff --git a/src/uu/basenc/Cargo.toml b/src/uu/basenc/Cargo.toml index 699623e480a..bcacf625591 100644 --- a/src/uu/basenc/Cargo.toml +++ b/src/uu/basenc/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/basenc.rs" test = false @@ -21,10 +18,25 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["encoding"] } -uu_base32 = { workspace = true } fluent = { workspace = true } +uu_base32 = { workspace = true } +uucore = { workspace = true, features = ["encoding"] } + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[target.'cfg(unix)'.dev-dependencies] +rustix = { workspace = true, features = ["fs", "stdio"] } + +[lints] +workspace = true [[bin]] name = "basenc" path = "src/main.rs" + +[[bench]] +name = "basenc_bench" +harness = false diff --git a/src/uu/basenc/benches/basenc_bench.rs b/src/uu/basenc/benches/basenc_bench.rs new file mode 100644 index 00000000000..f5a5a4c0a52 --- /dev/null +++ b/src/uu/basenc/benches/basenc_bench.rs @@ -0,0 +1,80 @@ +// 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. + +//! Base16 output benchmarks. + +#[cfg(unix)] +mod benches { + use divan::{Bencher, black_box}; + use std::fs::File; + use uu_basenc::uumain; + use uucore::benchmark::{run_util_function, setup_test_file}; + + const INPUT_SIZE: usize = 16 * 1024 * 1024; + const ENCODED_SIZE: usize = INPUT_SIZE * 2; + const OUTPUT_SIZE: u64 = + (ENCODED_SIZE + ENCODED_SIZE.div_ceil(uu_base32::base_common::WRAP_DEFAULT)) as u64; + + fn input_file() -> std::path::PathBuf { + setup_test_file(&vec![0xA5; INPUT_SIZE]) + } + + fn redirect_stdout(output: &File) -> rustix::fd::OwnedFd { + let stdout_backup = rustix::io::dup(rustix::stdio::stdout()).unwrap(); + rustix::stdio::dup2_stdout(output).unwrap(); + stdout_backup + } + + fn restore_stdout(stdout_backup: &rustix::fd::OwnedFd) { + rustix::stdio::dup2_stdout(stdout_backup).unwrap(); + } + + /// Encode Base16 to `/dev/null`. + #[divan::bench] + fn base16_encode_to_dev_null(bencher: Bencher) { + let input_path = input_file(); + let input = input_path.to_str().unwrap(); + let dev_null = std::fs::OpenOptions::new() + .write(true) + .open("/dev/null") + .unwrap(); + let stdout_backup = redirect_stdout(&dev_null); + + assert_eq!(run_util_function(uumain, &["--base16", input]), 0); + + bencher.bench_local(|| { + black_box(run_util_function(uumain, &["--base16", input])); + }); + + restore_stdout(&stdout_backup); + } + + /// Encode Base16 to a file. + #[divan::bench] + fn base16_encode_to_file(bencher: Bencher) { + let input_path = input_file(); + let input = input_path.to_str().unwrap(); + let output = tempfile::tempfile().unwrap(); + let stdout_backup = redirect_stdout(&output); + + assert_eq!(run_util_function(uumain, &["--base16", input]), 0); + assert_eq!(output.metadata().unwrap().len(), OUTPUT_SIZE); + + bencher + .with_inputs(|| { + output.set_len(0).unwrap(); + rustix::fs::seek(&output, rustix::fs::SeekFrom::Start(0)).unwrap(); + }) + .bench_local_values(|()| { + black_box(run_util_function(uumain, &["--base16", input])); + }); + + restore_stdout(&stdout_backup); + } +} + +fn main() { + divan::main(); +} diff --git a/src/uu/cat/Cargo.toml b/src/uu/cat/Cargo.toml index 9b0536be414..b3004310a5e 100644 --- a/src/uu/cat/Cargo.toml +++ b/src/uu/cat/Cargo.toml @@ -11,32 +11,30 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/cat.rs" doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } memchr = { workspace = true } -rustix = { workspace = true, features = ["fs"] } thiserror = { workspace = true } uucore = { workspace = true, features = ["fast-inc", "fs", "pipes", "signals"] } -fluent = { workspace = true } [dev-dependencies] divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true, features = ["fs"] } + [target.'cfg(windows)'.dependencies] -winapi-util = { workspace = true } windows-sys = { workspace = true, features = ["Win32_Storage_FileSystem"] } -[target.'cfg(unix)'.dev-dependencies] -rustix = { workspace = true } +[lints] +workspace = true [[bin]] name = "cat" diff --git a/src/uu/cat/src/cat.rs b/src/uu/cat/src/cat.rs index cfd236d5b8e..8d351fa4d91 100644 --- a/src/uu/cat/src/cat.rs +++ b/src/uu/cat/src/cat.rs @@ -17,7 +17,6 @@ use std::io::{self, BufWriter, ErrorKind, IsTerminal, Read, Write}; use std::os::fd::AsFd; #[cfg(unix)] use std::os::unix::fs::FileTypeExt; -use thiserror::Error; use uucore::display::Quotable; use uucore::error::{UResult, strip_errno}; use uucore::translate; @@ -75,7 +74,7 @@ impl LineNumber { } } -#[derive(Error, Debug)] +#[derive(Debug, thiserror::Error)] enum CatError { /// Wrapper around `io::Error` #[error("{}", strip_errno(.0))] @@ -99,13 +98,6 @@ enum CatError { type CatResult = Result; -#[cfg(any(unix, target_os = "wasi"))] -impl From for CatError { - fn from(value: rustix::io::Errno) -> Self { - Self::Io(value.into()) - } -} - #[derive(PartialEq)] enum NumberingMode { None, @@ -417,16 +409,15 @@ where print!("\r"); } if error_messages.is_empty() { - Ok(()) - } else { - // each next line is expected to display "cat: …" - let line_joiner = "\ncat: "; - - Err(uucore::error::USimpleError::new( - error_messages.len() as i32, - error_messages.join(line_joiner), - )) + return Ok(()); } + // each next line is expected to display "cat: …" + let line_joiner = "\ncat: "; + + Err(uucore::error::USimpleError::new( + 1, + error_messages.join(line_joiner), + )) } /// Classifies the `InputType` of file at `path` if possible @@ -445,9 +436,9 @@ fn get_input_type(path: &OsString) -> CatResult { if let Some(raw_error) = e.raw_os_error() { // On Unix-like systems, the error code for "Too many levels of symbolic links" is 40 (ELOOP). // we want to provide a proper error message in this case. - #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + #[cfg(not(any(target_vendor = "apple", target_os = "freebsd")))] let too_many_symlink_code = 40; - #[cfg(any(target_os = "macos", target_os = "freebsd"))] + #[cfg(any(target_vendor = "apple", target_os = "freebsd"))] let too_many_symlink_code = 62; if raw_error == too_many_symlink_code { return Err(CatError::TooManySymlinks); @@ -541,9 +532,9 @@ fn print_lines( }; let in_buf = &in_buf[..n]; let mut pos = 0; - while pos < n { + while let Some(in_buf_pos) = in_buf.get(pos) { // skip empty line_number enumerating them if needed - if in_buf[pos] == b'\n' { + if in_buf_pos == &b'\n' { write_new_line(&mut writer, options, state, handle.is_interactive)?; state.at_line_start = true; pos += 1; @@ -563,15 +554,16 @@ fn print_lines( // print to end of line or end of buffer let offset = write_end(&mut writer, &in_buf[pos..], options)?; - // end of buffer? - if offset + pos == in_buf.len() { + let Some(in_buf_pos_off) = in_buf.get(offset + pos) else { + // end of buffer state.at_line_start = false; break; - } - if in_buf[pos + offset] == b'\r' { + }; + + if in_buf_pos_off == &b'\r' { state.skipped_carriage_return = true; } else { - assert_eq!(in_buf[pos + offset], b'\n'); + assert_eq!(in_buf_pos_off, &b'\n'); // print suitable end of line write_end_of_line( &mut writer, @@ -657,25 +649,23 @@ fn write_to_end(in_buf: &[u8], writer: &mut W) -> io::Result { fn write_tab_to_end(mut in_buf: &[u8], writer: &mut W) -> io::Result { let mut count = 0; - loop { - if let Some(p) = in_buf - .iter() - .position(|c| *c == b'\n' || *c == b'\t' || *c == b'\r') - { - writer.write_all(&in_buf[..p])?; - if in_buf[p] == b'\t' { - writer.write_all(b"^I")?; - in_buf = &in_buf[p + 1..]; - count += p + 1; - } else { - // b'\n' or b'\r' - return Ok(count + p); - } + + while let Some(p) = in_buf + .iter() + .position(|c| *c == b'\n' || *c == b'\t' || *c == b'\r') + { + writer.write_all(&in_buf[..p])?; + if in_buf[p] == b'\t' { + writer.write_all(b"^I")?; + in_buf = &in_buf[p + 1..]; + count += p + 1; } else { - writer.write_all(in_buf)?; - return Ok(in_buf.len() + count); + // b'\n' or b'\r' + return Ok(count + p); } } + writer.write_all(in_buf)?; + Ok(in_buf.len() + count) } fn write_nonprint_to_end(in_buf: &[u8], writer: &mut W, tab: &[u8]) -> io::Result { @@ -713,7 +703,7 @@ fn write_end_of_line( fn handle_broken_pipe(error: &io::Error) { // SIGPIPE is not available on Windows. - if cfg!(target_os = "windows") && error.kind() == ErrorKind::BrokenPipe { + if cfg!(windows) && error.kind() == ErrorKind::BrokenPipe { std::process::exit(13); } } diff --git a/src/uu/cat/src/platform/mod.rs b/src/uu/cat/src/platform/mod.rs index cde1ee96cbb..77255eff14a 100644 --- a/src/uu/cat/src/platform/mod.rs +++ b/src/uu/cat/src/platform/mod.rs @@ -9,7 +9,11 @@ pub use self::unix::is_safe_overwrite; #[cfg(windows)] pub use self::windows::is_safe_overwrite; -// WASI: no fstat-based device/inode checks available; assume safe. +// WASI: when stdout is inherited from a host file descriptor, wasmtime +// reports its fstat as all-zero (st_dev == st_ino == 0), so the dev/inode +// comparison against any input file descriptor can never match. There is +// no reliable way to detect unsafe overwrite here; assume safe rather than +// risk a spurious error. #[cfg(target_os = "wasi")] pub fn is_safe_overwrite(_input: &I, _output: &O) -> bool { true diff --git a/src/uu/cat/src/platform/windows.rs b/src/uu/cat/src/platform/windows.rs index a91f66097f2..653ef52e12f 100644 --- a/src/uu/cat/src/platform/windows.rs +++ b/src/uu/cat/src/platform/windows.rs @@ -5,24 +5,24 @@ use std::ffi::OsString; use std::os::windows::ffi::OsStringExt; +use std::os::windows::io::AsRawHandle; use std::path::PathBuf; use uucore::fs::FileInformation; -use winapi_util::AsHandleRef; use windows_sys::Win32::Storage::FileSystem::{ FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW, VOLUME_NAME_NT, }; /// An unsafe overwrite occurs when the same file is used as both stdin and stdout /// and the stdout file is not empty. -pub fn is_safe_overwrite(input: &I, output: &O) -> bool { +pub fn is_safe_overwrite(input: &I, output: &O) -> bool { !is_same_file_by_path(input, output) || // Check if the output file is empty !FileInformation::from_file(output).is_ok_and(|info| info.file_size() > 0) } /// Get the file path for a file handle -fn get_file_path_from_handle(file: &F) -> Option { - let handle = file.as_raw(); +fn get_file_path_from_handle(file: &F) -> Option { + let handle = file.as_raw_handle(); let mut path_buf = vec![0u16; 4096]; // SAFETY: We should check how many bytes was written to `path_buf` @@ -43,7 +43,7 @@ fn get_file_path_from_handle(file: &F) -> Option { } /// Compare two file handles if they correspond to the same file -fn is_same_file_by_path(a: &A, b: &B) -> bool { +fn is_same_file_by_path(a: &A, b: &B) -> bool { match (get_file_path_from_handle(a), get_file_path_from_handle(b)) { (Some(path1), Some(path2)) => path1 == path2, _ => false, diff --git a/src/uu/chcon/Cargo.toml b/src/uu/chcon/Cargo.toml index 73398f0cd72..9ebfcab59cf 100644 --- a/src/uu/chcon/Cargo.toml +++ b/src/uu/chcon/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/chcon.rs" test = false @@ -22,13 +19,15 @@ doctest = false # 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 } -thiserror = { workspace = true } +fluent = { workspace = true } libc = { workspace = true } -fts-sys = { workspace = true } rustix = { workspace = true, features = ["fs"] } -fluent = { workspace = true } +selinux = { workspace = true } +thiserror = { workspace = true } +uucore = { workspace = true, features = ["entries", "fs", "perms"] } + +[lints] +workspace = true [[bin]] name = "chcon" diff --git a/src/uu/chcon/src/chcon.rs b/src/uu/chcon/src/chcon.rs index eea40398f63..5cc4a2bac23 100644 --- a/src/uu/chcon/src/chcon.rs +++ b/src/uu/chcon/src/chcon.rs @@ -17,15 +17,17 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use rustix::fs::{Mode, OFlags, openat}; use selinux::{OpaqueSecurityContext, SecurityContext}; +use uucore::safe_traversal::{DirFd, Metadata as TraversalMetadata, SymlinkBehavior}; + +use core::ffi::CStr; use std::borrow::Cow; -use std::ffi::{CStr, CString, OsStr, OsString}; +use std::collections::HashSet; +use std::ffi::{CString, OsStr, OsString}; use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}; -use std::os::raw::c_int; use std::path::{Path, PathBuf}; use std::{fs, io}; mod errors; -mod fts; use errors::{Error, Result, report_full_error}; @@ -131,8 +133,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { CommandLineMode::Custom { .. } => SELinuxSecurityContext::String(None), }; - let root_dev_ino = if options.preserve_root && options.recursive_mode.is_recursive() { - match get_root_dev_ino() { + let root_id = if options.preserve_root && options.recursive_mode.is_recursive() { + match root_identity() { Ok(r) => Some(r), Err(r) => { @@ -146,7 +148,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { None }; - let results = process_files(&options, &context, root_dev_ino); + let results = relabel_all(&options, &context, root_id); if results.is_empty() { return Ok(()); } @@ -405,18 +407,6 @@ impl RecursiveMode { | Self::RecursiveAndFollowArgDirSymLinks => true, } } - - fn fts_open_options(self) -> c_int { - match self { - Self::NotRecursive | Self::RecursiveButDoNotFollowSymLinks => fts_sys::FTS_PHYSICAL, - - Self::RecursiveAndFollowAllDirSymLinks => fts_sys::FTS_LOGICAL, - - Self::RecursiveAndFollowArgDirSymLinks => { - fts_sys::FTS_PHYSICAL | fts_sys::FTS_COMFOLLOW - } - } - } } #[derive(Debug)] @@ -435,7 +425,7 @@ enum CommandLineMode { }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct DeviceAndINode { device_id: u64, inode: u64, @@ -453,6 +443,16 @@ impl From for DeviceAndINode { } } +impl From<&TraversalMetadata> for DeviceAndINode { + fn from(md: &TraversalMetadata) -> Self { + let info = md.file_info(); + Self { + device_id: info.device(), + inode: info.inode(), + } + } +} + impl TryFrom<&libc::stat> for DeviceAndINode { type Error = Error; @@ -464,180 +464,204 @@ impl TryFrom<&libc::stat> for DeviceAndINode { } } -fn process_files( - options: &Options, - context: &SELinuxSecurityContext, - root_dev_ino: Option, -) -> Vec { - let fts_options = options.recursive_mode.fts_open_options(); - let mut fts = match fts::FTS::new(options.files.iter(), fts_options) { - Ok(fts) => fts, - Err(err) => return vec![err], - }; - - let mut errors = Vec::default(); - loop { - match fts.read_next_entry() { - Ok(true) => { - if let Err(err) = process_file(options, context, &mut fts, root_dev_ino) { - errors.push(err); - } - } +/// Whether a symlink at this position should be resolved before it is examined. +/// +/// `-P` never resolves, `-L` always does, and `-H` resolves only the operands +/// named on the command line. +fn follows_symlinks(mode: RecursiveMode, top_level: bool) -> SymlinkBehavior { + match mode { + RecursiveMode::RecursiveAndFollowAllDirSymLinks => SymlinkBehavior::Follow, + RecursiveMode::RecursiveAndFollowArgDirSymLinks => top_level.into(), + RecursiveMode::NotRecursive | RecursiveMode::RecursiveButDoNotFollowSymLinks => { + SymlinkBehavior::NoFollow + } + } +} - Ok(false) => break, +/// Whether a directory's contents were fully dealt with. +#[derive(PartialEq, Eq, Clone, Copy)] +enum Descent { + Complete, + Abandoned, +} - Err(err) => { - errors.push(err); - break; - } - } +/// Whether revisiting a directory indicates real damage rather than an ordinary +/// consequence of following symlinks. +fn cycle_is_alarming(mode: RecursiveMode, top_level: bool) -> bool { + match mode { + RecursiveMode::RecursiveAndFollowAllDirSymLinks => false, + RecursiveMode::RecursiveAndFollowArgDirSymLinks => !top_level, + RecursiveMode::NotRecursive | RecursiveMode::RecursiveButDoNotFollowSymLinks => true, } - errors } -fn process_file( +fn relabel_all( options: &Options, context: &SELinuxSecurityContext, - fts: &mut fts::FTS, - root_dev_ino: Option, -) -> Result<()> { - let (file_full_name, fts_access_path, mut result) = { - let mut entry = fts - .last_entry_ref() - .expect("last_entry_ref is present after successful read_next_entry"); - - let file_full_name = entry.path().map(PathBuf::from).ok_or_else(|| { - Error::from_io( - translate!("chcon-op-file-name-validation"), - io::ErrorKind::InvalidInput.into(), - ) - })?; - - let fts_access_path = entry.access_path().map(PathBuf::from).ok_or_else(|| { - let err = io::ErrorKind::InvalidInput.into(); - Error::from_io1( - translate!("chcon-op-file-name-validation"), - &file_full_name, - err, - ) - })?; - - let err = |s, k: io::ErrorKind| Error::from_io1(s, &file_full_name, k.into()); - - let fts_err = |s| { - let r = io::Error::from_raw_os_error(entry.errno()); - Err(Error::from_io1(s, &file_full_name, r)) - }; + root_id: Option, +) -> Vec { + let mut errors = Vec::default(); - // SAFETY: If `entry.fts_statp` is not null, then it is assumed to be valid. - let file_dev_ino: DeviceAndINode = if let Some(st) = entry.stat() { - st.try_into()? - } else { - return Err(err( - translate!("chcon-op-getting-meta-data"), - io::ErrorKind::InvalidInput, - )); - }; + // Operands are named relative to where we were started, so anchor them to a + // descriptor for that directory rather than re-resolving each path. + let start = match DirFd::open(Path::new("."), SymlinkBehavior::Follow) { + Ok(dir) => dir, + Err(err) => { + errors.push(Error::from_io1(translate!("chcon-op-accessing"), ".", err)); + return errors; + } + }; - let mut result = Ok(()); + let mut walk = Walk { + options, + context, + root_id, + // Directories reached on the way down, so a symlink loop is noticed + // instead of followed forever. + open_ancestors: HashSet::new(), + errors, + }; - match entry.flags() { - fts_sys::FTS_D if options.recursive_mode.is_recursive() => { - if root_dev_ino_check(root_dev_ino, file_dev_ino) { - // This happens e.g., with "chcon -R --preserve-root ... /" - // and with "chcon -RH --preserve-root ... symlink-to-root". - root_dev_ino_warn(&file_full_name); + for operand in &options.files { + let behavior = follows_symlinks(options.recursive_mode, true); + walk.visit(&start, operand.as_os_str(), operand, behavior, true); + } - // Tell fts not to traverse into this hierarchy. - let _ignored = fts.set(fts_sys::FTS_SKIP); + walk.errors +} - // Ensure that we do not process "/" on the second visit. - let _ignored = fts.read_next_entry(); +/// State carried down the walk. Directories are relabelled on the way back up, +/// so a context that forbids reading one is applied only once we are done with it. +struct Walk<'a> { + options: &'a Options, + context: &'a SELinuxSecurityContext<'a>, + root_id: Option, + open_ancestors: HashSet, + errors: Vec, +} - return Err(err( - translate!("chcon-op-modifying-root-path"), - io::ErrorKind::PermissionDenied, - )); - } +impl Walk<'_> { + fn fail(&mut self, op: String, display: &Path, err: io::Error) { + self.errors.push(Error::from_io1(op, display, err)); + } - return Ok(()); - } + fn reject(&mut self, op: String, display: &Path, kind: io::ErrorKind) { + self.errors.push(Error::from_io1(op, display, kind.into())); + } - fts_sys::FTS_DP if !options.recursive_mode.is_recursive() => { - return Ok(()); - } + fn visit( + &mut self, + parent: &DirFd, + name: &OsStr, + display: &Path, + behavior: SymlinkBehavior, + top_level: bool, + ) { + let meta = match parent.metadata_at(name, behavior) { + Ok(meta) => meta, + Err(err) => return self.fail(translate!("chcon-op-accessing"), display, err), + }; + let id = DeviceAndINode::from(&meta); - fts_sys::FTS_NS => { - // For a top-level file or directory, this FTS_NS (stat failed) indicator is determined - // at the time of the initial fts_open call. With programs like chmod, chown, and chgrp, - // that modify permissions, it is possible that the file in question is accessible when - // control reaches this point. So, if this is the first time we've seen the FTS_NS for - // this file, tell fts_read to stat it "again". - if entry.level() == 0 && entry.number() == 0 { - entry.set_number(1); - let _ignored = fts.set(fts_sys::FTS_AGAIN); - return Ok(()); - } + let recursive = self.options.recursive_mode.is_recursive(); - result = fts_err(translate!("chcon-op-accessing")); - } + // `--preserve-root` guards recursion only: naming `/` outright is still + // allowed, it is descending into it that is refused. + if recursive && self.root_id == Some(id) { + warn_recursive_root(display); + return self.reject( + translate!("chcon-op-modifying-root-path"), + display, + io::ErrorKind::PermissionDenied, + ); + } - fts_sys::FTS_ERR => result = fts_err(translate!("chcon-op-accessing")), + // A symlink is only descended when this position resolves symlinks; the + // metadata above already reflects that choice. + let descended = if recursive + && meta.is_dir() + && (behavior.should_follow() || !meta.file_type().is_symlink()) + { + self.descend(parent, name, display, behavior, id, top_level) + } else { + Descent::Complete + }; - fts_sys::FTS_DNR => result = fts_err(translate!("chcon-op-reading-directory")), + // A directory we could not read is reported, not relabelled: its context + // would otherwise change while its contents were left untouched. + if descended == Descent::Complete { + self.relabel(parent, name, display); + } + } - fts_sys::FTS_DC - if cycle_warning_required(options.recursive_mode.fts_open_options(), &entry) => - { - emit_cycle_warning(&file_full_name); - return Err(err( - translate!("chcon-op-reading-cyclic-directory"), - io::ErrorKind::InvalidData, - )); + fn descend( + &mut self, + parent: &DirFd, + name: &OsStr, + display: &Path, + behavior: SymlinkBehavior, + id: DeviceAndINode, + top_level: bool, + ) -> Descent { + if !self.open_ancestors.insert(id) { + // Following symlinks is expected to reach the same directory twice, so + // only a walk that stays on the physical tree treats this as damage. + if !cycle_is_alarming(self.options.recursive_mode, top_level) { + return Descent::Complete; } - - _ => {} + warn_directory_cycle(display); + self.reject( + translate!("chcon-op-reading-cyclic-directory"), + display, + io::ErrorKind::InvalidData, + ); + return Descent::Abandoned; } - if entry.flags() == fts_sys::FTS_DP - && result.is_ok() - && root_dev_ino_check(root_dev_ino, file_dev_ino) - { - root_dev_ino_warn(&file_full_name); - result = Err(err( - translate!("chcon-op-modifying-root-path"), - io::ErrorKind::PermissionDenied, - )); - } + let outcome = match parent.open_subdir(name, behavior) { + Ok(dir) => match dir.read_dir() { + Ok(children) => { + let inner = follows_symlinks(self.options.recursive_mode, false); + for child in children { + self.visit(&dir, &child, &display.join(&child), inner, false); + } + Descent::Complete + } + Err(err) => { + self.fail(translate!("chcon-op-reading-directory"), display, err); + Descent::Abandoned + } + }, + Err(err) => { + self.fail(translate!("chcon-op-reading-directory"), display, err); + Descent::Abandoned + } + }; - (file_full_name, fts_access_path, result) - }; + // Sibling subtrees may legitimately reach this directory again, so only + // the path currently being walked counts as a cycle. + self.open_ancestors.remove(&id); + outcome + } - if result.is_ok() { - if options.verbose { + fn relabel(&mut self, parent: &DirFd, name: &OsStr, display: &Path) { + if self.options.verbose { println!( "{}", - translate!("chcon-verbose-changing-context", "util_name" => "chcon", "file" => file_full_name.quote()) + translate!("chcon-verbose-changing-context", "util_name" => "chcon", "file" => display.quote()) ); } - let traversal_dir_fd = fts - .current_dir_fd() - .map_err(|r| Error::from_io1(translate!("chcon-op-accessing"), &file_full_name, r))?; - - result = change_file_context( - options, - context, - traversal_dir_fd.as_fd(), - &fts_access_path, - &file_full_name, - ); - } - - if !options.recursive_mode.is_recursive() { - let _ignored = fts.set(fts_sys::FTS_SKIP); + if let Err(err) = apply_context( + self.options, + self.context, + parent.as_fd(), + Path::new(name), + display, + ) { + self.errors.push(err); + } } - result } fn open_target_fd( @@ -650,7 +674,7 @@ fn open_target_fd( // symlink swap cannot redirect the relabel off-tree. O_PATH hands back the // entry itself without opening it for I/O: it never blocks on a FIFO and // does not require read permission, yet the SELinux get/set still work - // through /proc/self/fd (see change_file_context). O_NOFOLLOW keeps us on + // through /proc/self/fd (see apply_context). O_NOFOLLOW keeps us on // the symlink itself unless we were asked to act on its referent. let mut flags = OFlags::PATH | OFlags::CLOEXEC; if !affect_symlink_referent { @@ -662,7 +686,7 @@ fn open_target_fd( .map_err(|err| Error::from_io1(translate!("chcon-op-accessing"), display_path, err)) } -fn change_file_context( +fn apply_context( options: &Options, context: &SELinuxSecurityContext, traversal_dir_fd: BorrowedFd<'_>, @@ -693,8 +717,9 @@ fn change_file_context( range, } => { let err0 = || -> Result<()> { - // If the file doesn't have a context, and we're not setting all of the context - // components, there isn't really an obvious default. Thus, we just give up. + // Setting only part of a context needs an existing one to merge into. + // When the file carries none there is nothing sensible to assume, so + // report it rather than invent a default. let op = translate!("chcon-op-applying-partial-context"); let err = io::ErrorKind::InvalidInput.into(); Err(Error::from_io1(op, display_path, err)) @@ -800,19 +825,15 @@ pub(crate) fn os_str_to_c_string(s: &OsStr) -> Result { .map_err(|_r| Error::from_io("CString::new()", io::ErrorKind::InvalidInput.into())) } -/// Call `lstat()` to get the device and inode numbers for `/`. +/// Identify the root directory by device and inode, without following it. #[cfg(unix)] -fn get_root_dev_ino() -> Result { +fn root_identity() -> Result { fs::symlink_metadata("/") .map(DeviceAndINode::from) .map_err(|r| Error::from_io1("std::fs::symlink_metadata", "/", r)) } -fn root_dev_ino_check(root_dev_ino: Option, dir_dev_ino: DeviceAndINode) -> bool { - root_dev_ino == Some(dir_dev_ino) -} - -fn root_dev_ino_warn(dir_name: &Path) { +fn warn_recursive_root(dir_name: &Path) { if dir_name.as_os_str() == "/" { show_warning!( "{}", @@ -826,21 +847,7 @@ fn root_dev_ino_warn(dir_name: &Path) { } } -/// When `fts_read` returns [`fts_sys::FTS_DC`] to indicate a directory cycle, it may or may not indicate -/// a real problem. -/// When a program like chgrp performs a recursive traversal that requires traversing symbolic links, -/// it is *not* a problem. -/// However, when invoked with "-P -R", it deserves a warning. -/// The `fts_options` parameter records the options that control this aspect of fts behavior, -/// so test that. -fn cycle_warning_required(fts_options: c_int, entry: &fts::EntryRef) -> bool { - // When dereferencing no symlinks, or when dereferencing only those listed on the command line - // and we're not processing a command-line argument, then a cycle is a serious problem. - ((fts_options & fts_sys::FTS_PHYSICAL) != 0) - && (((fts_options & fts_sys::FTS_COMFOLLOW) == 0) || entry.level() != 0) -} - -fn emit_cycle_warning(file_name: &Path) { +fn warn_directory_cycle(file_name: &Path) { show_warning!( "{}", translate!("chcon-warning-circular-directory", "file" => file_name.quote()) diff --git a/src/uu/chcon/src/errors.rs b/src/uu/chcon/src/errors.rs index fa4ae6fee54..b3ea0ada3f4 100644 --- a/src/uu/chcon/src/errors.rs +++ b/src/uu/chcon/src/errors.rs @@ -9,13 +9,12 @@ use std::ffi::OsString; use std::fmt::Write; use std::io; -use thiserror::Error; use uucore::display::Quotable; use uucore::translate; pub(crate) type Result = std::result::Result; -#[derive(Error, Debug)] +#[derive(Debug, thiserror::Error)] pub(crate) enum Error { #[error("{}", translate!("chcon-error-no-context-specified"))] MissingContext, @@ -32,21 +31,21 @@ pub(crate) enum Error { #[error(transparent)] CommandLine(#[from] clap::Error), - #[error("{}", translate!("chcon-error-operation-failed", "operation" => operation.clone()))] + #[error("{}", translate!("chcon-error-operation-failed", "operation" => operation))] SELinux { operation: String, #[source] source: selinux::errors::Error, }, - #[error("{}", translate!("chcon-error-operation-failed", "operation" => operation.clone()))] + #[error("{}", translate!("chcon-error-operation-failed", "operation" => operation))] Io { operation: String, #[source] source: io::Error, }, - #[error("{}", translate!("chcon-error-operation-failed-on", "operation" => operation.clone(), "operand" => operand1.quote()))] + #[error("{}", translate!("chcon-error-operation-failed-on", "operation" => operation, "operand" => operand1.quote()))] Io1 { operation: String, operand1: OsString, diff --git a/src/uu/chcon/src/fts.rs b/src/uu/chcon/src/fts.rs deleted file mode 100644 index 043d3ad4c95..00000000000 --- a/src/uu/chcon/src/fts.rs +++ /dev/null @@ -1,212 +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 (vars) RDONLY CLOEXEC - -#![cfg(any(target_os = "linux", target_os = "android"))] - -use std::ffi::{CStr, CString, OsStr}; -use std::marker::PhantomData; -use std::os::fd::OwnedFd; -use std::os::raw::{c_int, c_long, c_short}; -use std::path::Path; -use std::{io, iter, ptr, slice}; - -use rustix::fs::{Mode, OFlags, open}; - -use crate::errors::{Error, Result}; -use crate::os_str_to_c_string; - -#[derive(Debug)] -pub(crate) struct FTS { - fts: ptr::NonNull, - - entry: Option>, - _phantom_data: PhantomData, -} - -impl FTS { - pub(crate) fn new(paths: I, options: c_int) -> Result - where - I: IntoIterator, - I::Item: AsRef, - { - let files_paths: Vec = paths - .into_iter() - .map(|s| os_str_to_c_string(s.as_ref())) - .collect::>()?; - - if files_paths.is_empty() { - return Err(Error::from_io( - "FTS::new()", - io::ErrorKind::InvalidInput.into(), - )); - } - - let path_argv: Vec<_> = files_paths - .iter() - .map(CString::as_ref) - .map(CStr::as_ptr) - .chain(iter::once(ptr::null())) - .collect(); - - // SAFETY: We assume calling fts_open() is safe: - // - `path_argv` is an array holding at least one path, and null-terminated. - // - `compar` is None. - let fts = unsafe { fts_sys::fts_open(path_argv.as_ptr().cast(), options, None) }; - - let fts = ptr::NonNull::new(fts) - .ok_or_else(|| Error::from_io("fts_open()", io::Error::last_os_error()))?; - - Ok(Self { - fts, - entry: None, - _phantom_data: PhantomData, - }) - } - - pub(crate) fn last_entry_ref(&mut self) -> Option> { - self.entry.map(move |entry| EntryRef::new(self, entry)) - } - - pub(crate) fn read_next_entry(&mut self) -> Result { - // SAFETY: We assume calling fts_read() is safe with a non-null `fts` - // pointer assumed to be valid. - let new_entry = unsafe { fts_sys::fts_read(self.fts.as_ptr()) }; - - self.entry = ptr::NonNull::new(new_entry); - if self.entry.is_none() { - let r = io::Error::last_os_error(); - if let Some(0) = r.raw_os_error() { - Ok(false) - } else { - Err(Error::from_io("fts_read()", r)) - } - } else { - Ok(true) - } - } - - pub(crate) fn set(&mut self, instr: c_int) -> Result<()> { - let fts = self.fts.as_ptr(); - let entry = self - .entry - .ok_or_else(|| Error::from_io("FTS::set()", io::ErrorKind::UnexpectedEof.into()))?; - - // SAFETY: We assume calling fts_set() is safe with non-null `fts` - // and `entry` pointers assumed to be valid. - if unsafe { fts_sys::fts_set(fts, entry.as_ptr(), instr) } == -1 { - Err(Error::from_io("fts_set()", io::Error::last_os_error())) - } else { - Ok(()) - } - } - - pub(crate) fn current_dir_fd(&self) -> io::Result { - open( - Path::new("."), - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, - Mode::empty(), - ) - .map_err(io::Error::from) - } -} -impl Drop for FTS { - fn drop(&mut self) { - // SAFETY: We assume calling fts_close() is safe with a non-null `fts` - // pointer assumed to be valid. - unsafe { fts_sys::fts_close(self.fts.as_ptr()) }; - } -} - -#[derive(Debug)] -pub(crate) struct EntryRef<'fts> { - pub(crate) pointer: ptr::NonNull, - - _fts: PhantomData<&'fts FTS>, - _phantom_data: PhantomData, -} - -impl<'fts> EntryRef<'fts> { - fn new(_fts: &'fts FTS, entry: ptr::NonNull) -> Self { - Self { - pointer: entry, - _fts: PhantomData, - _phantom_data: PhantomData, - } - } - - fn as_ref(&self) -> &fts_sys::FTSENT { - // SAFETY: `self.pointer` is a non-null pointer that is assumed to be valid. - unsafe { self.pointer.as_ref() } - } - - fn as_mut(&mut self) -> &mut fts_sys::FTSENT { - // SAFETY: `self.pointer` is a non-null pointer that is assumed to be valid. - unsafe { self.pointer.as_mut() } - } - - pub(crate) fn flags(&self) -> c_int { - c_int::from(self.as_ref().fts_info) - } - - pub(crate) fn errno(&self) -> c_int { - self.as_ref().fts_errno - } - - pub(crate) fn level(&self) -> c_short { - self.as_ref().fts_level - } - - pub(crate) fn number(&self) -> c_long { - self.as_ref().fts_number - } - - pub(crate) fn set_number(&mut self, new_number: c_long) { - self.as_mut().fts_number = new_number; - } - - pub(crate) fn path(&self) -> Option<&Path> { - let entry = self.as_ref(); - if entry.fts_pathlen == 0 { - return None; - } - - ptr::NonNull::new(entry.fts_path) - .map(|path_ptr| { - let path_size = usize::from(entry.fts_pathlen).saturating_add(1); - - // SAFETY: `entry.fts_path` is a non-null pointer that is assumed to be valid. - unsafe { slice::from_raw_parts(path_ptr.as_ptr().cast(), path_size) } - }) - .and_then(|bytes| CStr::from_bytes_with_nul(bytes).ok()) - .map(c_str_to_os_str) - .map(Path::new) - } - - pub(crate) fn access_path(&self) -> Option<&Path> { - ptr::NonNull::new(self.as_ref().fts_accpath) - .map(|path_ptr| { - // SAFETY: `entry.fts_accpath` is a non-null pointer that is assumed to be valid. - unsafe { CStr::from_ptr(path_ptr.as_ptr()) } - }) - .map(c_str_to_os_str) - .map(Path::new) - } - - pub(crate) fn stat(&self) -> Option<&libc::stat> { - ptr::NonNull::new(self.as_ref().fts_statp).map(|stat_ptr| { - // SAFETY: `entry.fts_statp` is a non-null pointer that is assumed to be valid. - unsafe { stat_ptr.as_ref() } - }) - } -} - -#[cfg(unix)] -fn c_str_to_os_str(s: &CStr) -> &OsStr { - use std::os::unix::ffi::OsStrExt; - - OsStr::from_bytes(s.to_bytes()) -} diff --git a/src/uu/checksum_common/Cargo.toml b/src/uu/checksum_common/Cargo.toml index 8c09afb5d72..7460741f4b2 100644 --- a/src/uu/checksum_common/Cargo.toml +++ b/src/uu/checksum_common/Cargo.toml @@ -10,22 +10,22 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/lib.rs" test = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } uucore = { workspace = true, features = [ "checksum", "encoding", "sum", "hardware", ] } -fluent = { workspace = true } + +[lints] +workspace = true # [[bench]] # name = "b2sum_bench" diff --git a/src/uu/checksum_common/locales/en-US.ftl b/src/uu/checksum_common/locales/en-US.ftl index 0dddfb19c65..19aec9248a4 100644 --- a/src/uu/checksum_common/locales/en-US.ftl +++ b/src/uu/checksum_common/locales/en-US.ftl @@ -5,7 +5,6 @@ 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 diff --git a/src/uu/checksum_common/locales/fr-FR.ftl b/src/uu/checksum_common/locales/fr-FR.ftl index 0b22519cad4..58af453087b 100644 --- a/src/uu/checksum_common/locales/fr-FR.ftl +++ b/src/uu/checksum_common/locales/fr-FR.ftl @@ -5,7 +5,6 @@ ck-common-help-algorithm = sélectionner le type de condensé à utiliser. Voir 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 diff --git a/src/uu/checksum_common/src/cli.rs b/src/uu/checksum_common/src/cli.rs index 50beb3f3b5a..084f115040b 100644 --- a/src/uu/checksum_common/src/cli.rs +++ b/src/uu/checksum_common/src/cli.rs @@ -44,7 +44,7 @@ pub trait ChecksumCommand { fn with_binary(self) -> Self; - fn with_text(self, is_default: bool) -> Self; + fn with_text(self) -> Self; fn with_tag(self, is_default: bool) -> Self; @@ -136,19 +136,15 @@ impl ChecksumCommand for Command { ) } - 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_text(self) -> Self { + self.arg( + Arg::new(options::TEXT) + .long(options::TEXT) + .short('t') + // deliberately hide the unrecommended feature from all *sum even GNU hides it only from cksum + .hide(true) + .action(ArgAction::SetTrue), + ) } fn with_tag(self, default: bool) -> Self { diff --git a/src/uu/checksum_common/src/lib.rs b/src/uu/checksum_common/src/lib.rs index 629c86d435c..c681d863749 100644 --- a/src/uu/checksum_common/src/lib.rs +++ b/src/uu/checksum_common/src/lib.rs @@ -123,7 +123,7 @@ pub fn standalone_checksum_app_with_length(about: String, usage: String) -> Comm .with_check_and_opts() .with_length() .with_tag(false) - .with_text(true) + .with_text() .with_zero() } @@ -134,7 +134,7 @@ pub fn standalone_checksum_app(about: String, usage: String) -> Command { .with_binary() .with_check_and_opts() .with_tag(false) - .with_text(true) + .with_text() .with_zero() } diff --git a/src/uu/chgrp/Cargo.toml b/src/uu/chgrp/Cargo.toml index 81a1912db99..164ca307644 100644 --- a/src/uu/chgrp/Cargo.toml +++ b/src/uu/chgrp/Cargo.toml @@ -11,19 +11,21 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/chgrp.rs" test = false doctest = false -[dependencies] +[target.'cfg(unix)'.dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["entries", "fs", "perms"] } fluent = { workspace = true } +thiserror = { workspace = true } +uucore = { workspace = true, features = ["entries", "fs", "perms"] } + +[lints] +workspace = true [[bin]] name = "chgrp" path = "src/main.rs" +required-features = ["uucore/default"] diff --git a/src/uu/chgrp/src/chgrp.rs b/src/uu/chgrp/src/chgrp.rs index 0e41a67601b..065b52ccae6 100644 --- a/src/uu/chgrp/src/chgrp.rs +++ b/src/uu/chgrp/src/chgrp.rs @@ -5,9 +5,11 @@ // spell-checker:ignore (ToDO) COMFOLLOW Chowner RFILE RFILE's derefer dgid nonblank nonprint nonprinting +#![cfg(unix)] + use uucore::display::Quotable; use uucore::entries; -use uucore::error::{FromIo, UResult, USimpleError}; +use uucore::error::{FromIo, UError, UResult, USimpleError}; use uucore::format_usage; use uucore::perms::{GidUidOwnerFilter, IfFrom, chown_base, options}; use uucore::translate; @@ -17,6 +19,14 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use std::fs; use std::os::unix::fs::MetadataExt; +#[derive(Debug, thiserror::Error)] +enum ChgrpError { + #[error("{}", translate!("chgrp-error-invalid-user", "from_group" => _0))] + InvalidUser(String), +} + +impl UError for ChgrpError {} + fn parse_gid_from_str(group: &str) -> Result { if let Some(gid_str) = group.strip_prefix(':') { // Handle :gid format @@ -67,23 +77,18 @@ fn get_dest_gid(matches: &ArgMatches) -> UResult<(Option, String)> { } fn parse_gid_and_uid(matches: &ArgMatches) -> UResult { - let (dest_gid, raw_group) = get_dest_gid(matches)?; - // Handle --from option let filter = if let Some(from_group) = matches.get_one::(options::FROM) { - match parse_gid_from_str(from_group) { - Ok(g) => IfFrom::Group(g), - Err(_) => { - return Err(USimpleError::new( - 1, - translate!("chgrp-error-invalid-user", "from_group" => from_group), - )); - } - } + let Ok(gid) = parse_gid_from_str(from_group) else { + return Err(ChgrpError::InvalidUser(from_group.clone()).into()); + }; + IfFrom::Group(gid) } else { IfFrom::All }; + let (dest_gid, raw_group) = get_dest_gid(matches)?; + Ok(GidUidOwnerFilter { dest_gid, dest_uid: None, diff --git a/src/uu/chmod/Cargo.toml b/src/uu/chmod/Cargo.toml index 35a8b90eca7..47294367626 100644 --- a/src/uu/chmod/Cargo.toml +++ b/src/uu/chmod/Cargo.toml @@ -11,22 +11,23 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/chmod.rs" doctest = false -[dependencies] +[target.'cfg(unix)'.dependencies] clap = { workspace = true } -thiserror = { workspace = true } -uucore = { workspace = true, features = ["entries", "fs", "mode", "perms"] } fluent = { workspace = true } +thiserror = { workspace = true } +uucore = { workspace = true, features = ["fs", "mode", "perms"] } + +[features] +diagnostics = ["uucore/diagnostics"] -[target.'cfg(all(unix, not(target_os = "redox")))'.dependencies] -uucore = { workspace = true, features = ["safe-traversal"] } +[lints] +workspace = true [[bin]] name = "chmod" path = "src/main.rs" +required-features = ["uucore/default"] diff --git a/src/uu/chmod/locales/en-US.ftl b/src/uu/chmod/locales/en-US.ftl index 6404519dce7..2f3f6748461 100644 --- a/src/uu/chmod/locales/en-US.ftl +++ b/src/uu/chmod/locales/en-US.ftl @@ -9,6 +9,8 @@ chmod-error-dangling-symlink = cannot operate on dangling symlink {$file} chmod-error-no-such-file = cannot access {$file}: No such file or directory chmod-error-preserve-root = it is dangerous to operate recursively on {$file} chmod: use --no-preserve-root to override this failsafe +chmod-error-preserve-root-same-as = it is dangerous to operate recursively on {$file} (same as '/') + chmod: use --no-preserve-root to override this failsafe chmod-error-permission-denied = cannot access {$file}: Permission denied chmod-error-new-permissions = {$file}: new permissions are {$actual}, not {$expected} chmod-error-changing-permissions = changing permissions of {$file}: {$err} diff --git a/src/uu/chmod/locales/fr-FR.ftl b/src/uu/chmod/locales/fr-FR.ftl index e2315c65e59..e8116f08f9c 100644 --- a/src/uu/chmod/locales/fr-FR.ftl +++ b/src/uu/chmod/locales/fr-FR.ftl @@ -21,6 +21,8 @@ chmod-error-dangling-symlink = impossible d'opérer sur le lien symbolique pendo chmod-error-no-such-file = impossible d'accéder à {$file} : Aucun fichier ou répertoire de ce type chmod-error-preserve-root = il est dangereux d'opérer récursivement sur {$file} chmod: utiliser --no-preserve-root pour outrepasser cette protection +chmod-error-preserve-root-same-as = il est dangereux d'opérer récursivement sur {$file} (identique à '/') + chmod: utiliser --no-preserve-root pour outrepasser cette protection chmod-error-permission-denied = impossible d'accéder à {$file} : Permission refusée chmod-error-new-permissions = {$file} : les nouvelles permissions sont {$actual}, pas {$expected} chmod-error-changing-permissions = changement des permissions de {$file} : {$err} diff --git a/src/uu/chmod/src/chmod.rs b/src/uu/chmod/src/chmod.rs index 6e83b4b71ef..f586ef8a681 100644 --- a/src/uu/chmod/src/chmod.rs +++ b/src/uu/chmod/src/chmod.rs @@ -5,28 +5,29 @@ // spell-checker:ignore (ToDO) Chmoder cmode fmode fperm fref ugoa RFILE RFILE's +#![cfg(unix)] + use clap::{Arg, ArgAction, Command}; use std::collections::HashSet; use std::ffi::OsString; use std::fs; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::{Path, PathBuf}; -use thiserror::Error; use uucore::display::Quotable; use uucore::error::{ ExitCode, UError, UResult, USimpleError, UUsageError, set_exit_code, strip_errno, }; -use uucore::fs::{FileInformation, display_permissions_unix}; +use uucore::fs::{FileInformation, display_permissions_unix, path_is_root_dir}; use uucore::mode; use uucore::perms::{TraverseSymlinks, configure_symlink_and_recursion}; -#[cfg(all(unix, not(target_os = "redox")))] +#[cfg(not(target_os = "redox"))] use uucore::safe_traversal::{DirFd, SymlinkBehavior}; use uucore::{format_usage, show, show_error}; use uucore::translate; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum ChmodError { #[error("{}", translate!("chmod-error-cannot-stat", "file" => _0.quote()))] CannotStat(PathBuf), @@ -36,9 +37,11 @@ enum ChmodError { NoSuchFile(PathBuf), #[error("{}", translate!("chmod-error-preserve-root", "file" => _0.quote()))] PreserveRoot(PathBuf), + #[error("{}", translate!("chmod-error-preserve-root-same-as", "file" => _0.quote()))] + PreserveRootSameAs(PathBuf), #[error("{}", translate!("chmod-error-permission-denied", "file" => _0.quote()))] PermissionDenied(PathBuf), - #[error("{}", translate!("chmod-error-new-permissions", "file" => _0.maybe_quote(), "actual" => _1.clone(), "expected" => _2.clone()))] + #[error("{}", translate!("chmod-error-new-permissions", "file" => _0.maybe_quote(), "actual" => _1, "expected" => _2))] NewPermissions(PathBuf, String, String), #[error("{}", translate!("chmod-error-changing-permissions", "file" => _0.quote(), "err" => strip_errno(_1)))] ChangingPermissions(PathBuf, std::io::Error), @@ -69,23 +72,46 @@ mod options { /// These can currently not be handled by clap. /// Therefore it might be possible that a pseudo MODE is inserted to pass clap parsing. /// The pseudo MODE is later replaced by the extracted (and joined) negative modes. -fn extract_negative_modes(mut args: impl uucore::Args) -> (Option, Vec) { +fn extract_negative_modes(args: impl uucore::Args) -> (Option, Vec, Vec) { + let is_negative_mode = |arg: &OsString| { + let Some(arg) = arg.to_str() else { + return false; + }; + arg.len() >= 2 + && arg.starts_with('-') + && matches!( + arg.chars().nth(1).unwrap(), + 'r' | 'w' | 'x' | 'X' | 's' | 't' | 'u' | 'g' | 'o' | '0'..='7' + ) + }; + // we look up the args until "--" is found // "-mode" will be extracted into parsed_cmode_vec - let (parsed_cmode_vec, pre_double_hyphen_args): (Vec, Vec) = - args.by_ref().take_while(|a| a != "--").partition(|arg| { - let arg = if let Some(arg) = arg.to_str() { - arg.to_string() - } else { - return false; - }; - arg.len() >= 2 - && arg.starts_with('-') - && matches!( - arg.chars().nth(1).unwrap(), - 'r' | 'w' | 'x' | 'X' | 's' | 't' | 'u' | 'g' | 'o' | '0'..='7' - ) - }); + let mut args = args.enumerate(); + let mut parsed_cmode_vec: Vec = Vec::new(); + // Where each extracted mode sat in the original argument list, so that a + // diagnostic can point back at it. + let mut mode_indices = Vec::new(); + let mut pre_double_hyphen_args = Vec::new(); + let mut tail = Vec::new(); + while let Some((index, arg)) = args.next() { + if arg == "--" { + if let Some((_, next)) = args.next() { + // as there is still something left in the iterator, we previously consumed the "--" + // -> add it to the args again + tail.push(OsString::from("--")); + tail.push(next); + tail.extend(args.map(|(_, arg)| arg)); + } + break; + } + if is_negative_mode(&arg) { + mode_indices.push(index); + parsed_cmode_vec.push(arg); + } else { + pre_double_hyphen_args.push(arg); + } + } let mut clean_args = Vec::new(); if !parsed_cmode_vec.is_empty() { @@ -94,14 +120,7 @@ fn extract_negative_modes(mut args: impl uucore::Args) -> (Option, Vec add it to the args again - clean_args.push("--".into()); - clean_args.push(arg); - } - clean_args.extend(args); + clean_args.extend(tail); let parsed_cmode = Some( parsed_cmode_vec @@ -111,12 +130,16 @@ fn extract_negative_modes(mut args: impl uucore::Args) -> (Option, Vec UResult<()> { - let (parsed_cmode, args) = extract_negative_modes(args.skip(1)); // skip binary name + let args: Vec = args.skip(1).collect(); // skip binary name + // Kept for the caret in mode diagnostics, which needs the mode as typed, + // before `extract_negative_modes` replaces a negative mode by a pseudo one. + let mode_args = uucore::diagnostics::capture(&args); + let (parsed_cmode, args, mode_indices) = extract_negative_modes(args.into_iter()); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; let changes = matches.get_flag(options::CHANGES); @@ -124,16 +147,19 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let verbose = matches.get_flag(options::VERBOSE); let preserve_root = matches.get_flag(options::PRESERVE_ROOT); let fmode = match matches.get_one::(options::REFERENCE) { - Some(fref) => match fs::metadata(fref) { - Ok(meta) => Some(meta.mode() & 0o7777), - Err(_) => { - return Err(ChmodError::CannotStat(fref.into()).into()); - } - }, + Some(fref) => Some( + fs::metadata(fref) + .map(|meta| meta.mode() & 0o7777) + .map_err(|_| ChmodError::CannotStat(fref.into()))?, + ), None => None, }; let modes = matches.get_one::(options::MODE); + // Whether the mode reached us as an option-like operand ("chmod -w f") rather than as an + // ordinary positional operand ("chmod -- -w f"). This decides whether the umask diagnostic + // below is emitted; see the comment on `option_like_mode`. + let option_like_mode = parsed_cmode.is_some(); let cmode = if let Some(parsed_cmode) = parsed_cmode { parsed_cmode } else { @@ -171,8 +197,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { recursive, fmode, cmode, + option_like_mode, traverse_symlinks, dereference, + args: mode_args, + mode_indices, }; chmoder.chmod(&files) @@ -268,8 +297,18 @@ struct Chmoder { recursive: bool, fmode: Option, cmode: Option, + /// Set when the mode was given as an option-like operand, e.g. `chmod -w f`, instead of as a + /// plain positional operand, e.g. `chmod -- -w f`. GNU only reports a mode whose effect was + /// curtailed by the umask for the first spelling: the second one is unambiguous, so there is + /// nothing to warn about. + option_like_mode: bool, traverse_symlinks: TraverseSymlinks, dereference: bool, + /// The arguments as typed, kept only when a diagnostic may be rendered. + args: Option>, + /// Where each extracted negative mode sat in `args`, in the order they + /// were joined into `cmode`. Empty unless `option_like_mode` is set. + mode_indices: Vec, } impl Chmoder { @@ -283,7 +322,13 @@ impl Chmoder { let mut new_mode = current_mode; let mut naively_expected_new_mode = current_mode; + // Where the clause being parsed starts inside `cmode_unwrapped`, so + // that an error can be pointed back at it. + let mut offset = 0; for mode in cmode_unwrapped.split(',') { + let clause_start = offset; + offset += mode.len() + 1; // past the clause and its comma + let result = if mode.chars().any(|c| c.is_ascii_digit()) { mode::parse_numeric(new_mode, mode, is_dir).map(|v| (v, v)) } else { @@ -301,12 +346,19 @@ impl Chmoder { new_mode = mode; naively_expected_new_mode = naive_mode; } - Err(f) => { - return if self.quiet { - Err(ExitCode::new(1)) - } else { - Err(USimpleError::new(1, f)) - }; + Err(error) => { + if self.quiet { + return Err(ExitCode::new(1)); + } + if let Some(args) = &self.args + && let Some((index, operand, offset)) = + self.locate_clause(args, &cmode_unwrapped, clause_start) + && error.render_at(args, index, &operand, offset, &error.to_string()) + { + // The diagnostic is already on stderr; exit quietly. + return Err(ExitCode::new(1)); + } + return Err(USimpleError::new(1, error.to_string())); } } } @@ -314,6 +366,36 @@ impl Chmoder { } } + /// Map `clause_start` — an offset into `cmode`, the joined mode string — + /// back to the argument the failing clause came from. + /// + /// Returns the argument's index in `args`, the mode text that argument + /// carries, and the clause's offset inside that text. A mode given as a + /// plain positional is its own argument; extracted negative modes + /// (`chmod -w -x f`) were joined with commas, so the offset is walked back + /// through the recorded [`Chmoder::mode_indices`]. + fn locate_clause( + &self, + args: &[OsString], + cmode: &str, + clause_start: usize, + ) -> Option<(usize, String, usize)> { + if !self.option_like_mode { + let index = args.iter().position(|arg| arg == cmode)?; + return Some((index, cmode.to_string(), clause_start)); + } + let mut start = 0; + for &index in &self.mode_indices { + let element = args.get(index)?.to_str()?; + // The `+ 1` is the comma the join put after this element. + if clause_start < start + element.len() + 1 { + return Some((index, element.to_string(), clause_start - start)); + } + start += element.len() + 1; + } + None + } + /// Report permission changes based on verbose and changes flags fn report_permission_change(&self, file_path: &Path, old_mode: u32, new_mode: u32) { if self.verbose || self.changes { @@ -334,35 +416,13 @@ impl Chmoder { } } - /// Handle symlinks during directory traversal based on traversal mode - #[cfg(not(unix))] - fn handle_symlink_during_traversal( - &self, - path: &Path, - is_command_line_arg: bool, - ancestors: &mut HashSet, - ) -> UResult<()> { - let should_follow_symlink = match self.traverse_symlinks { - TraverseSymlinks::All => true, - TraverseSymlinks::First => is_command_line_arg, - TraverseSymlinks::None => false, - }; - - if !should_follow_symlink { - return self.chmod_file_internal(path, false); - } - - match fs::metadata(path) { - Ok(meta) if meta.is_dir() => self.walk_dir_with_context(path, false, ancestors), - Ok(_) => { - // It's a file symlink, chmod it - self.chmod_file(path) - } - Err(_) => { - // Dangling symlink, chmod it without dereferencing - self.chmod_file_internal(path, false) - } - } + fn print_neither_changed(file: OsString) -> std::io::Result<()> { + use std::io::{Write as _, stdout}; + writeln!( + stdout(), + "{}", + translate!("chmod-verbose-neither-changed", "file" => file.quote()) + ) } fn chmod(&self, files: &[OsString]) -> UResult<()> { @@ -370,43 +430,70 @@ impl Chmoder { for filename in files { let file = Path::new(filename); - if !file.exists() { - if file.is_symlink() { - if !self.dereference && !self.recursive { - // The file is a symlink and we should not follow it - // Don't try to change the mode of the symlink itself - continue; - } - if self.recursive && self.traverse_symlinks == TraverseSymlinks::None { - continue; - } + // `Path::exists()` returns `false` when the path's metadata cannot be + // read at all (for example a parent directory without search + // permission), which made chmod misreport an existing but + // inaccessible file as "No such file or directory". Use + // `try_exists()` so a permission error is surfaced as such, matching + // GNU (issue #9789). + match file.try_exists() { + Ok(false) => { + if file.is_symlink() { + if !self.dereference && !self.recursive { + // The file is a symlink and we should not follow it + // Don't try to change the mode of the symlink itself + continue; + } + if self.recursive && self.traverse_symlinks == TraverseSymlinks::None { + continue; + } + if !self.quiet { + show!(ChmodError::DanglingSymlink(filename.into())); + set_exit_code(1); + } + + if self.verbose { + println!( + "{}", + translate!("chmod-verbose-failed-dangling", "file" => filename.quote()) + ); + } + } else if !self.quiet { + show!(ChmodError::NoSuchFile(filename.into())); + } + // GNU exits with exit code 1 even if -q or --quiet are passed + // So we set the exit code, because it hasn't been set yet if `self.quiet` is true. + set_exit_code(1); + continue; + } + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { if !self.quiet { - show!(ChmodError::DanglingSymlink(filename.into())); - set_exit_code(1); + show!(ChmodError::PermissionDenied(filename.into())); } - - if self.verbose { - println!( - "{}", - translate!("chmod-verbose-failed-dangling", "file" => filename.quote()) - ); + set_exit_code(1); + continue; + } + // Present, or an unexpected error that the chmod attempt below + // will surface with a precise message. + Ok(true) | Err(_) => { + if !self.dereference && file.is_symlink() { + // The file is a symlink and we should not follow it + // chmod 755 --no-dereference a/link + // should not change the permissions in this case + continue; } - } else if !self.quiet { - show!(ChmodError::NoSuchFile(filename.into())); } - // GNU exits with exit code 1 even if -q or --quiet are passed - // So we set the exit code, because it hasn't been set yet if `self.quiet` is true. - set_exit_code(1); - continue; - } else if !self.dereference && file.is_symlink() { - // The file is a symlink and we should not follow it - // chmod 755 --no-dereference a/link - // should not change the permissions in this case - continue; } if self.recursive && self.preserve_root && Self::is_root(file) { - return Err(ChmodError::PreserveRoot("/".into()).into()); + // Name the operand the user gave: with a bind mount of "/" it is + // not spelled "/", so say which path it is the same as. + return Err(if file.as_os_str() == "/" { + ChmodError::PreserveRoot("/".into()) + } else { + ChmodError::PreserveRootSameAs(file.into()) + } + .into()); } if self.recursive { let mut ancestors = HashSet::new(); @@ -420,18 +507,34 @@ impl Chmoder { r } + /// Whether `file` is `/`, by `(st_dev, st_ino)` rather than by name, so a + /// bind mount of `/` cannot slip past `--preserve-root` (as GNU does). fn is_root(file: impl AsRef) -> bool { - matches!(fs::canonicalize(&file), Ok(p) if p == Path::new("/")) + path_is_root_dir(file, true) + } + + /// `--preserve-root` guard re-checked at every descent: a symlink to `/` + /// (under `-L`) or a bind mount of `/` met inside the tree is still `/`, so + /// the operand-only check is not enough. GNU re-checks every entry too. + fn descends_into_root(&self, path: &Path) -> bool { + self.preserve_root && Self::is_root(path) } // Non-safe traversal implementation for platforms without safe_traversal support - #[cfg(any(not(unix), target_os = "redox"))] + #[cfg(target_os = "redox")] fn walk_dir_with_context( &self, file_path: &Path, is_command_line_arg: bool, ancestors: &mut HashSet, ) -> UResult<()> { + // Skip (and diagnose) an entry that is '/' (a symlink to it, or a bind + // mount) before touching it. + if self.descends_into_root(file_path) { + show!(ChmodError::PreserveRootSameAs(file_path.into())); + return Ok(()); + } + let mut r = self.chmod_file(file_path); // Determine whether to traverse symlinks based on context and traversal mode @@ -448,10 +551,10 @@ impl Chmoder { // don't stat the same directory twice. If it's already on the current path, // it's a cycle. let dir_info = FileInformation::from_path(file_path, true).ok(); - if let Some(info) = &dir_info { - if !ancestors.insert(info.clone()) { - return r; - } + if let Some(info) = &dir_info + && !ancestors.insert(info.clone()) + { + return r; } // We buffer all paths in this dir to not keep too many fd's open during recursion @@ -460,31 +563,13 @@ impl Chmoder { for dir_entry in file_path.read_dir()? { match dir_entry { Ok(entry) => paths_in_this_dir.push(entry.path()), - Err(err) => { - r = r.and(Err(err.into())); - continue; - } + Err(err) => r = r.and(Err(err.into())), } } for path in paths_in_this_dir { - #[cfg(not(unix))] - { - if path.is_symlink() { - r = self - .handle_symlink_during_recursion(&path, ancestors) - .and(r); - } else { - r = self - .walk_dir_with_context(path.as_path(), false, ancestors) - .and(r); - } - } - #[cfg(target_os = "redox")] - { - r = self - .walk_dir_with_context(path.as_path(), false, ancestors) - .and(r); - } + r = self + .walk_dir_with_context(path.as_path(), false, ancestors) + .and(r); } // Backtrack: remove this directory from ancestors so sibling subtrees @@ -496,13 +581,20 @@ impl Chmoder { r } - #[cfg(all(unix, not(target_os = "redox")))] + #[cfg(not(target_os = "redox"))] fn walk_dir_with_context( &self, file_path: &Path, is_command_line_arg: bool, ancestors: &mut HashSet, ) -> UResult<()> { + // Skip (and diagnose) an entry that is '/' (a symlink to it, or a bind + // mount) before touching it. + if self.descends_into_root(file_path) { + show!(ChmodError::PreserveRootSameAs(file_path.into())); + return Ok(()); + } + let mut r = self.chmod_file(file_path); // Determine whether to traverse symlinks based on context and traversal mode @@ -512,9 +604,12 @@ impl Chmoder { TraverseSymlinks::None => false, }; - // If the path is a directory (or we should follow symlinks), recurse into it using safe traversal + // Recurse via safe traversal, opening under the same symlink policy the checks + // above used: the pathname is resolved again here, so under `-P` (the `-R` + // default) O_NOFOLLOW fails the open rather than redirecting the descent into a + // swapped-in symlink. `-H`/`-L` still follow, which is what they ask for. if (!file_path.is_symlink() || should_follow_symlink) && file_path.is_dir() { - match DirFd::open(file_path, SymlinkBehavior::Follow) { + match DirFd::open(file_path, should_follow_symlink.into()) { Ok(dir_fd) => { r = self.safe_traverse_dir(&dir_fd, file_path, ancestors).and(r); } @@ -531,7 +626,7 @@ impl Chmoder { r } - #[cfg(all(unix, not(target_os = "redox")))] + #[cfg(not(target_os = "redox"))] fn safe_traverse_dir( &self, dir_fd: &DirFd, @@ -544,10 +639,11 @@ impl Chmoder { // fd. Using the fd is TOCTOU-safe (no path re-resolution through symlinks) and // avoids a redundant path walk. If it's already on the current path, it's a cycle. let dir_info = FileInformation::from_file(dir_fd).ok(); - if let Some(info) = &dir_info { - if !ancestors.insert(info.clone()) { - return r; // cycle: this directory is already an ancestor - } + if dir_info + .as_ref() + .is_some_and(|info| !ancestors.insert(info.clone())) + { + return r; // cycle: this directory is already an ancestor } let entries = dir_fd.read_dir()?; @@ -628,7 +724,7 @@ impl Chmoder { r } - #[cfg(all(unix, not(target_os = "redox")))] + #[cfg(not(target_os = "redox"))] fn handle_symlink_during_safe_recursion( &self, path: &Path, @@ -671,7 +767,7 @@ impl Chmoder { } } - #[cfg(all(unix, not(target_os = "redox")))] + #[cfg(not(target_os = "redox"))] fn safe_chmod_file( &self, file_path: &Path, @@ -700,16 +796,6 @@ impl Chmoder { Ok(()) } - #[cfg(not(unix))] - fn handle_symlink_during_recursion( - &self, - path: &Path, - ancestors: &mut HashSet, - ) -> UResult<()> { - // Use the common symlink handling logic - self.handle_symlink_during_traversal(path, false, ancestors) - } - fn chmod_file(&self, file: &Path) -> UResult<()> { self.chmod_file_internal(file, self.dereference) } @@ -725,10 +811,7 @@ impl Chmoder { // Handle dangling symlinks or other errors return if file.is_symlink() && !dereference { if self.verbose { - println!( - "neither symbolic link {} nor referent has been changed", - file.quote() - ); + Self::print_neither_changed(file.into())?; } Ok(()) // Skip dangling symlinks } else if err.kind() == std::io::ErrorKind::PermissionDenied { @@ -743,33 +826,35 @@ impl Chmoder { let (new_mode, naively_expected_new_mode) = self.calculate_new_mode(fperm, file.is_dir())?; - // Determine how to apply the permissions - if let Some(mode) = self.fmode { - self.change_file(fperm, mode, file)?; - } else { - // Special handling for symlinks when not dereferencing - if file.is_symlink() && !dereference { - // TODO: On most Unix systems, symlink permissions are ignored by the kernel, - // so changing them has no effect. We skip this operation for compatibility. - // Note that "chmod without dereferencing" effectively does nothing on symlinks. - if self.verbose { - println!( - "neither symbolic link {} nor referent has been changed", - file.quote() - ); - } - } else { - self.change_file(fperm, new_mode, file)?; - } - // if a permission would have been removed if umask was 0, but it wasn't because umask was not 0, print an error and fail - if (new_mode & !naively_expected_new_mode) != 0 { - return Err(ChmodError::NewPermissions( - file.into(), - display_permissions_unix(new_mode, false), - display_permissions_unix(naively_expected_new_mode, false), - ) - .into()); + // A symlink reached without dereferencing (for example one met while + // walking a `-R` tree) must be left alone: chmod(2) follows the link, + // so changing it would change the mode of the referent, which can live + // outside the tree. On most Unix systems, symlink permissions are + // ignored by the kernel anyway, so changing them has no effect. + if file.is_symlink() && !dereference { + if self.verbose { + Self::print_neither_changed(file.into())?; } + return Ok(()); + } + + self.change_file(fperm, self.fmode.unwrap_or(new_mode), file)?; + + // A bare mode such as `-w` is umask-relative, so the umask can keep permissions that + // the user asked to drop. GNU reports that as an error, but only when the mode was + // written in the option-like form (`chmod -w f`), where it doubles as a hint that the + // argument was consumed as a mode. After `--` the operand is unambiguous and GNU stays + // silent, so the diagnostic is suppressed here too. + if self.fmode.is_none() + && self.option_like_mode + && (new_mode & !naively_expected_new_mode) != 0 + { + return Err(ChmodError::NewPermissions( + file.into(), + display_permissions_unix(new_mode, false), + display_permissions_unix(naively_expected_new_mode, false), + ) + .into()); } Ok(()) @@ -808,25 +893,29 @@ mod tests { fn test_extract_negative_modes() { // "chmod -w -r file" becomes "chmod -w,-r file". clap does not accept "-w,-r" as MODE. // Therefore, "w" is added as pseudo mode to pass clap. - let (c, a) = extract_negative_modes(["-w", "-r", "file"].iter().map(OsString::from)); + let (c, a, i) = extract_negative_modes(["-w", "-r", "file"].iter().map(OsString::from)); assert_eq!(c, Some("-w,-r".to_string())); assert_eq!(a, ["w", "file"]); + assert_eq!(i, [0, 1]); // "chmod -w file -r" becomes "chmod -w,-r file". clap does not accept "-w,-r" as MODE. // Therefore, "w" is added as pseudo mode to pass clap. - let (c, a) = extract_negative_modes(["-w", "file", "-r"].iter().map(OsString::from)); + let (c, a, i) = extract_negative_modes(["-w", "file", "-r"].iter().map(OsString::from)); assert_eq!(c, Some("-w,-r".to_string())); assert_eq!(a, ["w", "file"]); + assert_eq!(i, [0, 2]); // "chmod -w -- -r file" becomes "chmod -w -r file", where "-r" is interpreted as file. // Again, "w" is needed as pseudo mode. - let (c, a) = extract_negative_modes(["-w", "--", "-r", "f"].iter().map(OsString::from)); + let (c, a, i) = extract_negative_modes(["-w", "--", "-r", "f"].iter().map(OsString::from)); assert_eq!(c, Some("-w".to_string())); assert_eq!(a, ["w", "--", "-r", "f"]); + assert_eq!(i, [0]); // "chmod -- -r file" becomes "chmod -r file". - let (c, a) = extract_negative_modes(["--", "-r", "file"].iter().map(OsString::from)); + let (c, a, i) = extract_negative_modes(["--", "-r", "file"].iter().map(OsString::from)); assert_eq!(c, None); assert_eq!(a, ["--", "-r", "file"]); + assert!(i.is_empty()); } } diff --git a/src/uu/chown/Cargo.toml b/src/uu/chown/Cargo.toml index 2a199b5ef95..4704f623e04 100644 --- a/src/uu/chown/Cargo.toml +++ b/src/uu/chown/Cargo.toml @@ -11,22 +11,17 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/chown.rs" doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = [ - "entries", - "fs", - "perms", - "safe-traversal", -] } fluent = { workspace = true } +uucore = { workspace = true, features = ["entries", "fs", "perms"] } + +[lints] +workspace = true [[bin]] name = "chown" diff --git a/src/uu/chown/src/chown.rs b/src/uu/chown/src/chown.rs index ac71abd3804..867b380077a 100644 --- a/src/uu/chown/src/chown.rs +++ b/src/uu/chown/src/chown.rs @@ -3,19 +3,17 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) COMFOLLOW Passwd RFILE RFILE's derefer dgid duid groupname +// spell-checker:ignore (ToDO) Passwd RFILE RFILE's derefer dgid duid groupname use uucore::display::Quotable; pub use uucore::entries::{self, Group, Locate, Passwd}; -use uucore::format_usage; -use uucore::perms::{GidUidOwnerFilter, IfFrom, chown_base, options}; -use uucore::show_warning; -use uucore::translate; - use uucore::error::{FromIo, UResult, USimpleError}; +use uucore::perms::{GidUidOwnerFilter, IfFrom, chown_base, options}; +use uucore::{format_usage, show_warning, translate}; use clap::{Arg, ArgAction, ArgMatches, Command}; +use std::ffi::OsString; use std::fs; use std::os::unix::fs::MetadataExt; @@ -34,7 +32,7 @@ fn parse_gid_uid_and_filter(matches: &ArgMatches) -> UResult let dest_uid: Option; let dest_gid: Option; let raw_owner: String; - if let Some(file) = matches.get_one::(options::REFERENCE) { + if let Some(file) = matches.get_one::(options::REFERENCE) { let meta = fs::metadata(file).map_err_context( || translate!("chown-error-failed-to-get-attributes", "file" => file.quote()), )?; @@ -132,7 +130,8 @@ pub fn uu_app() -> Command { .long(options::REFERENCE) .help(translate!("chown-help-reference")) .value_name("RFILE") - .value_hint(clap::ValueHint::FilePath), + .value_hint(clap::ValueHint::FilePath) + .value_parser(clap::value_parser!(OsString)), ) .arg( Arg::new(options::verbosity::SILENT) diff --git a/src/uu/chroot/Cargo.toml b/src/uu/chroot/Cargo.toml index d4f707d3df1..11d9cb6aff0 100644 --- a/src/uu/chroot/Cargo.toml +++ b/src/uu/chroot/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/chroot.rs" test = false @@ -21,10 +18,13 @@ doctest = false [dependencies] clap = { workspace = true } -rustix = { workspace = true } +fluent = { workspace = true } +rustix = { workspace = true, features = ["process"] } thiserror = { workspace = true } uucore = { workspace = true, features = ["entries", "fs"] } -fluent = { workspace = true } + +[lints] +workspace = true [[bin]] name = "chroot" diff --git a/src/uu/chroot/locales/en-US.ftl b/src/uu/chroot/locales/en-US.ftl index 24c109c406e..496cb8400b8 100644 --- a/src/uu/chroot/locales/en-US.ftl +++ b/src/uu/chroot/locales/en-US.ftl @@ -19,7 +19,6 @@ chroot-error-missing-newroot = Missing operand: NEWROOT chroot-error-no-group-specified = no group specified for unknown uid: { $uid } chroot-error-no-such-user = invalid user chroot-error-no-such-group = invalid group -chroot-error-no-such-directory = cannot change root directory to { $dir }: no such directory chroot-error-set-gid-failed = cannot set gid to { $gid }: { $err } chroot-error-set-groups-failed = cannot set groups: { $err } chroot-error-set-user-failed = cannot set user to { $user }: { $err } diff --git a/src/uu/chroot/locales/fr-FR.ftl b/src/uu/chroot/locales/fr-FR.ftl index caabd169b2b..04fedb47aee 100644 --- a/src/uu/chroot/locales/fr-FR.ftl +++ b/src/uu/chroot/locales/fr-FR.ftl @@ -19,7 +19,6 @@ chroot-error-missing-newroot = Opérande manquant : NOUVRACINE chroot-error-no-group-specified = aucun groupe spécifié pour l'uid inconnu : { $uid } chroot-error-no-such-user = utilisateur invalide chroot-error-no-such-group = groupe invalide -chroot-error-no-such-directory = impossible de changer le répertoire racine vers { $dir } : aucun répertoire de ce type chroot-error-set-gid-failed = impossible de définir le gid à { $gid } : { $err } chroot-error-set-groups-failed = impossible de définir les groupes : { $err } chroot-error-set-user-failed = impossible de définir l'utilisateur à { $user } : { $err } diff --git a/src/uu/chroot/src/chroot.rs b/src/uu/chroot/src/chroot.rs index 3a8d90e2ed7..dd7580b0906 100644 --- a/src/uu/chroot/src/chroot.rs +++ b/src/uu/chroot/src/chroot.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) NEWROOT Userspec pstatus chdir +// spell-checker:ignore (ToDO) NEWROOT Userspec chrooting chroots chdir pstatus repointed + mod error; use crate::error::ChrootError; @@ -38,8 +39,12 @@ enum UserSpec { } struct Options { - /// Path to the new root directory. + /// Path to the new root directory, as the caller spelled it. Used for + /// diagnostics. newroot: PathBuf, + /// The path actually passed to `chroot(2)`, when it must differ from + /// `newroot`. See the `--skip-chdir` handling in `uumain`. + chroot_target: Option, /// Whether to change to the new root directory. skip_chdir: bool, /// List of groups under which the command will be run. @@ -146,6 +151,7 @@ impl Options { .map(|s| parse_userspec(s)); Ok(Self { newroot, + chroot_target: None, skip_chdir, groups, userspec, @@ -158,30 +164,35 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result_with_exit_code(uu_app(), args, 125)?; - let options = Options::from(&matches)?; + let mut options = Options::from(&matches)?; // We are resolving the path in case it is a symlink or /. or /../ - if options.skip_chdir - && canonicalize( + // + // GNU validates the resolved path but then chroots the original spelling, + // which leaves a window: a NEWROOT symlink repointed after the check would + // pass the guard and still put the process somewhere else, with a working + // directory left outside it because --skip-chdir suppresses the chdir. + // Since the guard only succeeds when the resolution is `/`, chrooting the + // resolved path is the same destination and closes that window. + if options.skip_chdir { + let resolved = canonicalize( &options.newroot, MissingHandling::Normal, ResolveMode::Logical, ) // A NEWROOT that does not resolve is by definition not old `/`, so treat // an Err as a non-match instead of unwrapping it. - .ok() - .as_deref() - .and_then(|p| p.to_str()) - != Some("/") - { - return Err(UUsageError::new( - 125, - translate!("chroot-error-skip-chdir-only-permitted"), - )); - } - - if !options.newroot.is_dir() { - return Err(ChrootError::NoSuchDirectory(options.newroot).into()); + .ok(); + if resolved.as_deref().and_then(|p| p.to_str()) != Some("/") { + return Err(UUsageError::new( + 125, + translate!("chroot-error-skip-chdir-only-permitted"), + )); + } + // The guard proved the resolution is `/`, so chrooting it is the same + // destination, minus the window. NEWROOT is kept for diagnostics so the + // error text still names what the caller asked for. + options.chroot_target = resolved; } let mut cmd_iter = matches @@ -302,7 +313,7 @@ fn set_supplemental_gids(gids: &[libc::gid_t]) -> std::io::Result<()> { target_os = "cygwin", target_os = "netbsd" ))] - let n = gids.len() as libc::c_int; + let n = gids.len() as core::ffi::c_int; #[cfg(any(target_os = "linux", target_os = "android"))] let n = gids.len() as libc::size_t; let err = unsafe { setgroups(n, gids.as_ptr()) }; @@ -386,14 +397,14 @@ fn set_context(options: &Options) -> UResult<()> { None | Some(UserSpec::NeitherGroupNorUser) => { let strategy = Strategy::Nothing; set_supplemental_gids_with_strategy(strategy, options.groups.as_ref())?; - enter_chroot(&options.newroot, options.skip_chdir)?; + enter_chroot(options, options.skip_chdir)?; } Some(UserSpec::UserOnly(user)) => { let uid = name_to_uid(user)?; let gid = usr2gid(user).map_err(|_| ChrootError::NoGroupSpecified(uid))?; let strategy = Strategy::FromUID(uid, false); set_supplemental_gids_with_strategy(strategy, options.groups.as_ref())?; - enter_chroot(&options.newroot, options.skip_chdir)?; + enter_chroot(options, options.skip_chdir)?; set_gid(gid).map_err(|e| ChrootError::SetGidFailed(user.to_owned(), e))?; set_uid(uid).map_err(|e| ChrootError::SetUserFailed(user.to_owned(), e))?; } @@ -401,7 +412,7 @@ fn set_context(options: &Options) -> UResult<()> { let gid = name_to_gid(group)?; let strategy = Strategy::Nothing; set_supplemental_gids_with_strategy(strategy, options.groups.as_ref())?; - enter_chroot(&options.newroot, options.skip_chdir)?; + enter_chroot(options, options.skip_chdir)?; set_gid(gid).map_err(|e| ChrootError::SetGidFailed(group.to_owned(), e))?; } Some(UserSpec::UserAndGroup(user, group)) => { @@ -409,7 +420,7 @@ fn set_context(options: &Options) -> UResult<()> { let gid = name_to_gid(group)?; let strategy = Strategy::FromUID(uid, true); set_supplemental_gids_with_strategy(strategy, options.groups.as_ref())?; - enter_chroot(&options.newroot, options.skip_chdir)?; + enter_chroot(options, options.skip_chdir)?; set_gid(gid).map_err(|e| ChrootError::SetGidFailed(group.to_owned(), e))?; set_uid(uid).map_err(|e| ChrootError::SetUserFailed(user.to_owned(), e))?; } @@ -417,8 +428,12 @@ fn set_context(options: &Options) -> UResult<()> { Ok(()) } -fn enter_chroot(root: &Path, skip_chdir: bool) -> UResult<()> { - rustix::process::chroot(root).map_err(|e| ChrootError::CannotEnter(root.into(), e.into()))?; +fn enter_chroot(options: &Options, skip_chdir: bool) -> UResult<()> { + // chroot the resolved target when there is one; name the caller's spelling + // in the error either way. + let target = options.chroot_target.as_deref().unwrap_or(&options.newroot); + rustix::process::chroot(target) + .map_err(|e| ChrootError::CannotEnter(options.newroot.clone(), e.into()))?; if !skip_chdir { std::env::set_current_dir("/")?; } diff --git a/src/uu/chroot/src/error.rs b/src/uu/chroot/src/error.rs index 0592d72efad..7219fe974dc 100644 --- a/src/uu/chroot/src/error.rs +++ b/src/uu/chroot/src/error.rs @@ -2,22 +2,24 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore NEWROOT Userspec userspec + //! Errors returned by chroot. + use std::ffi::OsString; use std::io::Error; use std::path::PathBuf; -use thiserror::Error; use uucore::display::Quotable; -use uucore::error::UError; +use uucore::error::{UError, strip_errno}; use uucore::libc; use uucore::translate; /// Errors that can happen while executing chroot. -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum ChrootError { /// Failed to enter the specified directory. - #[error("{}", translate!("chroot-error-cannot-enter", "dir" => _0.quote(), "err" => _1))] + #[error("{}", translate!("chroot-error-cannot-enter", "dir" => _0.quote(), "err" => strip_errno(_1)))] CannotEnter(PathBuf, #[source] Error), /// Failed to execute the specified command. @@ -52,10 +54,6 @@ pub enum ChrootError { #[error("{}", translate!("chroot-error-no-such-group"))] NoSuchGroup, - /// The given directory does not exist. - #[error("{}", translate!("chroot-error-no-such-directory", "dir" => _0.quote()))] - NoSuchDirectory(PathBuf), - /// The call to `setgid()` failed. #[error("{}", translate!("chroot-error-set-gid-failed", "gid" => _0, "err" => _1))] SetGidFailed(String, #[source] Error), diff --git a/src/uu/cksum/Cargo.toml b/src/uu/cksum/Cargo.toml index 4081bbf5534..df7bea2cdc1 100644 --- a/src/uu/cksum/Cargo.toml +++ b/src/uu/cksum/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/cksum.rs" test = false @@ -21,22 +18,25 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } +uu_checksum_common = { workspace = true } uucore = { workspace = true, features = [ "checksum", "encoding", "sum", "hardware", ] } -uu_checksum_common = { workspace = true } -fluent = { workspace = true } - -[features] -openssl = ["uucore/openssl"] [dev-dependencies] divan = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[features] +openssl = ["uucore/openssl"] + +[lints] +workspace = true + [[bin]] name = "cksum" path = "src/main.rs" diff --git a/src/uu/cksum/benches/cksum_bench.rs b/src/uu/cksum/benches/cksum_bench.rs index 81f70bbb3d1..f96bff54dc9 100644 --- a/src/uu/cksum/benches/cksum_bench.rs +++ b/src/uu/cksum/benches/cksum_bench.rs @@ -5,7 +5,7 @@ use divan::{Bencher, black_box}; use uu_cksum::uumain; -use uucore::benchmark::{run_util_function, setup_test_file, text_data}; +use uucore::benchmark::{get_bench_args, setup_test_file, text_data}; // Macro to generate benchmarks for each algorithm macro_rules! bench_algorithm { @@ -15,12 +15,11 @@ macro_rules! bench_algorithm { let data = text_data::generate_by_size(100, 80); let file_path = setup_test_file(&data); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["--algorithm", $algo_str, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| { + get_bench_args(&[&"--algorithm", &$algo_str, &file_path]).into_iter() + }) + .bench_values(|args| black_box(uumain(args))); } }; ($algo_name:ident, $algo_str:expr, $length:expr) => { @@ -29,18 +28,18 @@ macro_rules! bench_algorithm { let data = text_data::generate_by_size(100, 80); let file_path = setup_test_file(&data); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &[ - "--algorithm", - $algo_str, - "--length", - $length, - file_path.to_str().unwrap(), - ], - )); - }); + bencher + .with_inputs(|| { + get_bench_args(&[ + &"--algorithm", + &$algo_str, + &"--length", + &$length, + &file_path, + ]) + .into_iter() + }) + .bench_values(|args| black_box(uumain(args))); } }; } @@ -114,9 +113,9 @@ fn cksum_default(bencher: Bencher) { let data = text_data::generate_by_size(100, 80); let file_path = setup_test_file(&data); - bencher.bench(|| { - black_box(run_util_function(uumain, &[file_path.to_str().unwrap()])); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark cksum with raw output format @@ -125,39 +124,25 @@ fn cksum_raw_output(bencher: Bencher) { let data = text_data::generate_by_size(100, 80); let file_path = setup_test_file(&data); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["--raw", file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"--raw", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark cksum processing multiple files #[divan::bench] fn cksum_multiple_files(bencher: Bencher) { + let data1 = text_data::generate_by_size(50, 80); + let data2 = text_data::generate_by_size(50, 80); + let data3 = text_data::generate_by_size(50, 80); + + let file1 = setup_test_file(&data1); + let file2 = setup_test_file(&data2); + let file3 = setup_test_file(&data3); + bencher - .with_inputs(|| { - let data1 = text_data::generate_by_size(50, 80); - let data2 = text_data::generate_by_size(50, 80); - let data3 = text_data::generate_by_size(50, 80); - - let file1 = setup_test_file(&data1); - let file2 = setup_test_file(&data2); - let file3 = setup_test_file(&data3); - - (file1, file2, file3) - }) - .bench_values(|(file1, file2, file3)| { - black_box(run_util_function( - uumain, - &[ - file1.to_str().unwrap(), - file2.to_str().unwrap(), - file3.to_str().unwrap(), - ], - )); - }); + .with_inputs(|| get_bench_args(&[&file1, &file2, &file3]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/cksum/locales/en-US.ftl b/src/uu/cksum/locales/en-US.ftl index aece6fc5b72..215e46c8717 100644 --- a/src/uu/cksum/locales/en-US.ftl +++ b/src/uu/cksum/locales/en-US.ftl @@ -1,14 +1,15 @@ cksum-about = Print CRC and size for each file cksum-usage = cksum [OPTIONS] [FILE]... -cksum-after-help = DIGEST determines the digest algorithm and default output format: +cksum-after-help = DIGEST determines the digest algorithm and default output + format: - - sysv: (equivalent to sum -s) - - bsd: (equivalent to sum -r) - - crc: (equivalent to cksum) - - crc32b: (only available through cksum) - - md5: (equivalent to md5sum) - - sha1: (equivalent to sha1sum) - - sha2: (equivalent to sha{"{224,256,384,512}"}sum) - - sha3: (only available through cksum) - - blake2b: (equivalent to b2sum) - - sm3: (only available through cksum) + - sysv: equivalent to sum -s + - bsd: equivalent to sum -r + - crc: equivalent to cksum + - crc32b: only available through cksum + - md5: equivalent to md5sum + - sha1: equivalent to sha1sum + - sha2: equivalent to sha{"{224,256,384,512}"}sum + - sha3: only available through cksum + - blake2b: equivalent to b2sum + - sm3: only available through cksum diff --git a/src/uu/cksum/locales/fr-FR.ftl b/src/uu/cksum/locales/fr-FR.ftl index bbc12e59cde..651fc9df19c 100644 --- a/src/uu/cksum/locales/fr-FR.ftl +++ b/src/uu/cksum/locales/fr-FR.ftl @@ -1,14 +1,15 @@ cksum-about = Afficher le CRC et la taille de chaque fichier cksum-usage = cksum [OPTION]... [FICHIER]... -cksum-after-help = DIGEST détermine l'algorithme de condensé et le format de sortie par défaut : +cksum-after-help = DIGEST détermine l'algorithme de condensé et le format de sortie + par défaut : - - sysv : (équivalent à sum -s) - - bsd : (équivalent à sum -r) - - crc : (équivalent à cksum) - - crc32b : (disponible uniquement via cksum) - - md5 : (équivalent à md5sum) - - sha1 : (équivalent à sha1sum) - - sha2: (équivalent à sha{"{224,256,384,512}"}sum) - - sha3 : (disponible uniquement via cksum) - - blake2b : (équivalent à b2sum) - - sm3 : (disponible uniquement via cksum) + - sysv : équivalent à sum -s + - bsd : équivalent à sum -r + - crc : équivalent à cksum + - crc32b : disponible uniquement via cksum + - md5 : équivalent à md5sum + - sha1 : équivalent à sha1sum + - sha2: équivalent à sha{"{224,256,384,512}"}sum + - sha3 : disponible uniquement via cksum + - blake2b : équivalent à b2sum + - sm3 : disponible uniquement via cksum diff --git a/src/uu/cksum/src/cksum.rs b/src/uu/cksum/src/cksum.rs index 9af2f6f00ff..183687ac83e 100644 --- a/src/uu/cksum/src/cksum.rs +++ b/src/uu/cksum/src/cksum.rs @@ -34,9 +34,11 @@ fn print_cpu_debug_info() { let features = SimdPolicy::detect(); // x86/x86_64 - print_feature("avx512", features.has_avx512()); - print_feature("avx2", features.has_avx2()); - print_feature("pclmul", features.has_pclmul()); + if cfg!(any(target_arch = "x86", target_arch = "x86_64")) { + print_feature("avx512", features.has_avx512()); + print_feature("avx2", features.has_avx2()); + print_feature("pclmul", features.has_pclmul()); + } // ARM aarch64 if cfg!(target_arch = "aarch64") { @@ -129,7 +131,7 @@ pub fn uu_app() -> Command { .with_raw() .with_check_and_opts() .with_base64() - .with_text(false) + .with_text() .with_binary() .with_zero() .with_debug() diff --git a/src/uu/comm/Cargo.toml b/src/uu/comm/Cargo.toml index c69c8b530da..103efe69c46 100644 --- a/src/uu/comm/Cargo.toml +++ b/src/uu/comm/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/comm.rs" test = false @@ -21,8 +18,11 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["fs"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["fs", "i18n-collator"] } + +[lints] +workspace = true [[bin]] name = "comm" diff --git a/src/uu/comm/locales/en-US.ftl b/src/uu/comm/locales/en-US.ftl index 1118c09bb52..16b19958944 100644 --- a/src/uu/comm/locales/en-US.ftl +++ b/src/uu/comm/locales/en-US.ftl @@ -18,6 +18,7 @@ comm-help-check-order = check that the input is correctly sorted, even if all in comm-help-no-check-order = do not check that the input is correctly sorted # Error messages +comm-error-write = write error comm-error-file-not-sorted = comm: file { $file_num } is not in sorted order comm-error-input-not-sorted = comm: input is not in sorted order comm-error-is-directory = Is a directory diff --git a/src/uu/comm/locales/fr-FR.ftl b/src/uu/comm/locales/fr-FR.ftl index 490a6be3501..0ff67f9e363 100644 --- a/src/uu/comm/locales/fr-FR.ftl +++ b/src/uu/comm/locales/fr-FR.ftl @@ -18,6 +18,7 @@ comm-help-check-order = vérifier que l'entrée est correctement triée, même s comm-help-no-check-order = ne pas vérifier que l'entrée est correctement triée # Messages d'erreur +comm-error-write = erreur d'écriture comm-error-file-not-sorted = comm : le fichier { $file_num } n'est pas dans l'ordre trié comm-error-input-not-sorted = comm : l'entrée n'est pas dans l'ordre trié comm-error-is-directory = Est un répertoire diff --git a/src/uu/comm/src/comm.rs b/src/uu/comm/src/comm.rs index af6d2f46466..76443000d5c 100644 --- a/src/uu/comm/src/comm.rs +++ b/src/uu/comm/src/comm.rs @@ -7,13 +7,14 @@ use std::cmp::Ordering; use std::ffi::OsString; -use std::fs::{File, metadata}; -use std::io::{self, BufRead, BufReader, BufWriter, Read, StdinLock, Write, stderr, stdin}; -use std::path::Path; +use std::fs::File; +use std::io::{self, BufRead, BufReader, BufWriter, StdinLock, Write, stderr, stdin}; use uucore::display::Quotable; use uucore::error::{FromIo, UResult, USimpleError}; use uucore::format_usage; -use uucore::fs::paths_refer_to_same_file; +use uucore::i18n::collator::{ + AlternateHandling, CollatorOptions, locale_cmp, should_use_locale_collation, try_init_collator, +}; use uucore::line_ending::LineEnding; use uucore::translate; @@ -48,11 +49,32 @@ impl FileNumber { } } +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +enum CheckOrder { + Never, + Always, + IfDiffer, +} + struct OrderChecker { last_line: Vec, file_num: FileNumber, - check_order: bool, + mode: CheckOrder, has_error: bool, + use_locale: bool, +} + +/// Compare two lines the way the input was ordered. +/// +/// `sort` orders with the locale collation, and so do `join` and `ls`. Reading +/// that order back with a byte comparison rejects input that is in order and +/// puts lines in the wrong column, so `comm` has to measure it the same way. +fn line_cmp(a: &[u8], b: &[u8], use_locale: bool) -> Ordering { + if use_locale { + locale_cmp(a, b) + } else { + a.cmp(b) + } } enum Input { @@ -97,22 +119,28 @@ impl LineReader { } impl OrderChecker { - fn new(file_num: FileNumber, check_order: bool) -> Self { + fn new(file_num: FileNumber, mode: CheckOrder, use_locale: bool) -> Self { Self { last_line: Vec::new(), file_num, - check_order, + mode, has_error: false, + use_locale, } } - fn verify_order(&mut self, current_line: &[u8]) -> bool { + fn verify_order(&mut self, current_line: &[u8], files_differ: bool) -> bool { + if self.mode == CheckOrder::IfDiffer && !files_differ { + self.last_line = current_line.to_vec(); + return true; + } + if self.last_line.is_empty() { self.last_line = current_line.to_vec(); return true; } - let is_ordered = *current_line >= *self.last_line; + let is_ordered = line_cmp(current_line, &self.last_line, self.use_locale) != Ordering::Less; if !is_ordered && !self.has_error { let _ = writeln!( stderr(), @@ -123,65 +151,7 @@ impl OrderChecker { } self.last_line = current_line.to_vec(); - is_ordered || !self.check_order - } -} - -// Check if two files are identical by comparing their contents -pub fn are_files_identical(path1: &Path, path2: &Path) -> io::Result { - // First compare file sizes - let metadata1 = metadata(path1)?; - let metadata2 = metadata(path2)?; - - if metadata1.len() != metadata2.len() { - 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)?; - - let mut reader1 = BufReader::new(file1); - let mut reader2 = BufReader::new(file2); - - let mut buffer1 = [0; 8192]; - let mut buffer2 = [0; 8192]; - - loop { - // Read from first file with EINTR retry handling - // This loop retries the read operation if it's interrupted by signals (e.g., SIGUSR1) - // 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 => {} - result => break result?, - } - }; - - // Read from second file with EINTR retry handling - // 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 => {} - result => break result?, - } - }; - - if bytes1 != bytes2 { - return Ok(false); - } - - if bytes1 == 0 { - return Ok(true); - } - - if buffer1[..bytes1] != buffer2[..bytes2] { - return Ok(false); - } + is_ordered || self.mode != CheckOrder::Always } } @@ -224,30 +194,34 @@ fn comm( let mut total_col_2 = 0; let mut total_col_3 = 0; - let check_order = opts.get_flag(options::CHECK_ORDER); - let no_check_order = opts.get_flag(options::NO_CHECK_ORDER); - - // Determine if we should perform order checking - let should_check_order = !no_check_order - && (check_order - || !(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 mode = if opts.get_flag(options::CHECK_ORDER) { + CheckOrder::Always + } else if opts.get_flag(options::NO_CHECK_ORDER) { + CheckOrder::Never + } else { + CheckOrder::IfDiffer + }; - let mut checker1 = OrderChecker::new(FileNumber::One, check_order); - let mut checker2 = OrderChecker::new(FileNumber::Two, check_order); + let use_locale = should_use_locale_collation(); + let mut checker1 = OrderChecker::new(FileNumber::One, mode, use_locale); + let mut checker2 = OrderChecker::new(FileNumber::Two, mode, use_locale); + let mut files_differ = false; let mut input_error = false; while na != 0 || nb != 0 { let ord = match (na, nb) { (0, _) => Ordering::Greater, (_, 0) => Ordering::Less, - (_, _) => ra.as_slice().cmp(rb.as_slice()), + (_, _) => line_cmp(ra, rb, use_locale), }; + if ord != Ordering::Equal { + files_differ = true; + } + match ord { Ordering::Less => { - if should_check_order && !checker1.verify_order(ra) { + if mode != CheckOrder::Never && !checker1.verify_order(ra, files_differ) { break; } if !opts.get_flag(options::COLUMN_1) { @@ -262,7 +236,7 @@ fn comm( total_col_1 += 1; } Ordering::Greater => { - if should_check_order && !checker2.verify_order(rb) { + if mode != CheckOrder::Never && !checker2.verify_order(rb, files_differ) { break; } if !opts.get_flag(options::COLUMN_2) { @@ -275,7 +249,9 @@ fn comm( total_col_2 += 1; } Ordering::Equal => { - if should_check_order && (!checker1.verify_order(ra) || !checker2.verify_order(rb)) + if mode != CheckOrder::Never + && (!checker1.verify_order(ra, files_differ) + || !checker2.verify_order(rb, files_differ)) { break; } @@ -295,7 +271,8 @@ fn comm( } // Track if we've seen any order errors - if (checker1.has_error || checker2.has_error) && !input_error && !check_order { + if (checker1.has_error || checker2.has_error) && !input_error && mode != CheckOrder::Always + { input_error = true; } } @@ -314,7 +291,7 @@ fn comm( .flush() .map_err_context(|| translate!("comm-error-write"))?; - if should_check_order && (checker1.has_error || checker2.has_error) { + if mode != CheckOrder::Never && (checker1.has_error || checker2.has_error) { // Print the input error message once at the end if input_error { let _ = writeln!(stderr(), "{}", translate!("comm-error-input-not-sorted")); @@ -331,8 +308,8 @@ fn open_file(name: &OsString, line_ending: LineEnding) -> io::Result } else { // some platforms shows different read error // try to override the error message, but failure of it is not serious - #[cfg(any(target_os = "wasi", target_os = "windows"))] - if metadata(name).is_ok_and(|m| m.is_dir()) { + #[cfg(any(target_os = "wasi", windows))] + if std::fs::metadata(name).is_ok_and(|m| m.is_dir()) { return Err(io::Error::other(translate!("comm-error-is-directory"))); } let f = File::open(name)?; @@ -343,6 +320,11 @@ fn open_file(name: &OsString, line_ending: LineEnding) -> io::Result #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + + let mut collator_opts = CollatorOptions::default(); + collator_opts.alternate_handling = Some(AlternateHandling::Shifted); + let _ = try_init_collator(collator_opts); + let line_ending = LineEnding::from_zero_flag(matches.get_flag(options::ZERO_TERMINATED)); let filename1 = matches.get_one::(options::FILE_1).unwrap(); let filename2 = matches.get_one::(options::FILE_2).unwrap(); diff --git a/src/uu/cp/Cargo.toml b/src/uu/cp/Cargo.toml index cf412e89be6..43666874ca9 100644 --- a/src/uu/cp/Cargo.toml +++ b/src/uu/cp/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/cp.rs" doctest = false @@ -21,13 +18,15 @@ doctest = false [dependencies] clap = { workspace = true } filetime = { workspace = true } +fluent = { workspace = true } +indicatif = { workspace = true } libc = { workspace = true } +thiserror = { workspace = true } uucore = { workspace = true, features = [ "backup-control", "buf-copy", "entries", "fs", - "fsxattr", "parser", "perms", "mode", @@ -35,19 +34,20 @@ uucore = { workspace = true, features = [ "update-control", ] } walkdir = { workspace = true } -indicatif = { workspace = true } -thiserror = { workspace = true } -fluent = { workspace = true } -rustix = { workspace = true } + +[target.'cfg(any(target_os = "freebsd", target_os = "hurd", target_os = "linux", target_os = "macos", target_os = "netbsd"))'.dependencies] +uucore = { workspace = true, features = ["fsxattr"] } [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] selinux = { workspace = true, optional = true } +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "wasi"))'.dependencies] +rustix = { workspace = true, features = ["fs"] } + [target.'cfg(unix)'.dependencies] -exacl = { workspace = true, optional = true } nix = { workspace = true, features = ["fs"] } -[target.'cfg(target_os = "windows")'.dependencies] +[target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = [ "Win32_Foundation", "Win32_Storage_FileSystem", @@ -67,5 +67,7 @@ name = "cp_bench" harness = false [features] -feat_selinux = ["selinux", "uucore/selinux"] -feat_acl = ["exacl"] +selinux = ["dep:selinux", "uucore/selinux"] + +[lints] +workspace = true diff --git a/src/uu/cp/benches/cp_bench.rs b/src/uu/cp/benches/cp_bench.rs index 84954f0bb54..ffc514fe3e3 100644 --- a/src/uu/cp/benches/cp_bench.rs +++ b/src/uu/cp/benches/cp_bench.rs @@ -8,7 +8,7 @@ use std::fs; use std::path::Path; use tempfile::TempDir; use uu_cp::uumain; -use uucore::benchmark::{binary_data, fs_tree, fs_utils, run_util_function}; +use uucore::benchmark::{binary_data, fs_tree, fs_utils, get_bench_args, run_util_function}; fn bench_cp_directory(bencher: Bencher, args: &[&str], setup_source: F) where @@ -87,20 +87,14 @@ fn cp_large_file(bencher: Bencher, size_mb: usize) { 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) + // Use unique destination name to avoid filesystem allocation variance + let dest = temp_dir + .path() + .join(format!("dest_{}.bin", (&raw const temp_dir).addr())); + get_bench_args(&[&source, &dest]).into_iter() }) .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(); - - black_box(run_util_function(uumain, &[source_str, dest_str])); - }); + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/cp/locales/en-US.ftl b/src/uu/cp/locales/en-US.ftl index 062a3bd4107..49fec90cc9c 100644 --- a/src/uu/cp/locales/en-US.ftl +++ b/src/uu/cp/locales/en-US.ftl @@ -12,9 +12,11 @@ cp-after-help = Do not copy a non-directory that has an existing destination wit to mirror hard links in the source. which gives more control over which existing files in the destination are replaced, and its value can be one of the following: - - all This is the default operation when an --update option is not specified, and results in all existing files in the destination being replaced. - - none This is similar to the --no-clobber option, in that no files in the destination are replaced, but also skipping a file does not induce a failure. - - older This is the default operation when --update is specified, and results in files being replaced if they're older than the corresponding source file. + - all: This is the default operation when an --update option is not specified, and results in all existing files in the destination being replaced. + + - none: This is similar to the --no-clobber option, in that no files in the destination are replaced, but also skipping a file does not induce a failure. + + - older: This is the default operation when --update is specified, and results in files being replaced if they're older than the corresponding source file. # Help messages cp-help-target-directory = copy all SOURCE arguments into target-directory @@ -82,7 +84,7 @@ cp-error-reflink-not-supported = --reflink is only supported on linux and macOS cp-error-sparse-not-supported = --sparse is only supported on linux cp-error-not-a-directory = { $path } is not a directory cp-error-selinux-not-enabled = SELinux was not enabled during the compile time! -cp-error-selinux-set-context = failed to set the security context of { $path }: { $error } +cp-error-selinux-set-context = failed to set the security context of { $path } cp-error-selinux-get-context = failed to get security context of { $path } cp-error-selinux-error = SELinux error: { $error } cp-error-selinux-context-conflict = cannot combine --context (-Z) with --preserve=context diff --git a/src/uu/cp/locales/fr-FR.ftl b/src/uu/cp/locales/fr-FR.ftl index 992049bd120..43911f8280c 100644 --- a/src/uu/cp/locales/fr-FR.ftl +++ b/src/uu/cp/locales/fr-FR.ftl @@ -12,9 +12,11 @@ cp-after-help = Ne pas copier un non-répertoire qui a une destination existante pour refléter les liens durs dans la source. ce qui donne plus de contrôle sur les fichiers existants dans la destination qui sont remplacés, et sa valeur peut être l'une des suivantes : - - all C'est l'opération par défaut lorsqu'une option --update n'est pas spécifiée, et entraîne le remplacement de tous les fichiers existants dans la destination. - - none Cela est similaire à l'option --no-clobber, en ce sens qu'aucun fichier dans la destination n'est remplacé, mais ignorer un fichier n'induit pas d'échec. - - older C'est l'opération par défaut lorsque --update est spécifié, et entraîne le remplacement des fichiers s'ils sont plus anciens que le fichier source correspondant. + - all : C'est l'opération par défaut lorsqu'une option --update n'est pas spécifiée, et entraîne le remplacement de tous les fichiers existants dans la destination. + + - none : Cela est similaire à l'option --no-clobber, en ce sens qu'aucun fichier dans la destination n'est remplacé, mais ignorer un fichier n'induit pas d'échec. + + - older : C'est l'opération par défaut lorsque --update est spécifié, et entraîne le remplacement des fichiers s'ils sont plus anciens que le fichier source correspondant. # Messages d'aide cp-help-target-directory = copier tous les arguments SOURCE dans le répertoire cible @@ -82,7 +84,7 @@ cp-error-reflink-not-supported = --reflink n'est pris en charge que sur linux et cp-error-sparse-not-supported = --sparse n'est pris en charge que sur linux cp-error-not-a-directory = { $path } n'est pas un répertoire cp-error-selinux-not-enabled = SELinux n'était pas activé lors de la compilation ! -cp-error-selinux-set-context = échec de la définition du contexte de sécurité de { $path } : { $error } +cp-error-selinux-set-context = échec de la définition du contexte de sécurité de { $path } cp-error-selinux-get-context = échec de l'obtention du contexte de sécurité de { $path } cp-error-selinux-error = Erreur SELinux : { $error } cp-error-selinux-context-conflict = impossible de combiner --context (-Z) avec --preserve=context diff --git a/src/uu/cp/src/copydir.rs b/src/uu/cp/src/copydir.rs index bef87e6aa90..2cd70b23488 100644 --- a/src/uu/cp/src/copydir.rs +++ b/src/uu/cp/src/copydir.rs @@ -2,10 +2,13 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore TODO canonicalizes direntry pathbuf symlinked IRWXO IRWXG + //! Recursively copy the contents of a directory. //! //! See the [`copy_directory`] function for more information. + #[cfg(windows)] use std::borrow::Cow; use std::collections::{HashMap, HashSet}; @@ -17,7 +20,7 @@ use std::path::{Path, PathBuf, StripPrefixError}; use indicatif::ProgressBar; use uucore::display::Quotable; -use uucore::error::UIoError; +use uucore::error::{UIoError, set_exit_code}; use uucore::fs::{ FileInformation, MissingHandling, ResolveMode, canonicalize, path_ends_with_terminator, }; @@ -44,7 +47,7 @@ struct DirNeedingPermissions { } /// Ensure a Windows path starts with a `\\?`. -#[cfg(target_os = "windows")] +#[cfg(windows)] fn adjust_canonicalization(p: &Path) -> Cow<'_, Path> { // In some cases, \\? can be missing on some Windows paths. Add it at the // beginning unless the path is prefixed with a device namespace. @@ -117,23 +120,29 @@ struct Context<'a> { root: &'a Path, } +// True if the path ends with a dot component (e.g. ".", "./", "dir/."). +fn ends_with_curdir(path: &Path) -> bool { + let mut bytes = path.as_os_str().as_encoded_bytes(); + while let [rest @ .., b'/' | b'\\'] = bytes { + bytes = rest; + } + bytes == b"." || bytes.ends_with(b"/.") || bytes.ends_with(b"\\.") +} + impl<'a> Context<'a> { fn new(root: &'a Path, target: &'a Path) -> io::Result { - let current_dir = env::current_dir()?; + let current_dir = if root.is_absolute() { + PathBuf::new() + } else { + env::current_dir()? + }; let root_path = current_dir.join(root); let target_is_file = target.is_file(); - let root_parent = - if target.exists() && !root.as_os_str().as_encoded_bytes().ends_with(b"/.") { - root_path.parent().map(ToOwned::to_owned) - } else if root == Path::new(".") && target.is_dir() { - // Special case: when copying current directory (.) to an existing directory, - // we don't want to use the parent path as root_parent because we want to - // copy the contents of the current directory directly into the target directory, - // not create a subdirectory with the current directory's name. - None - } else { - Some(root_path) - }; + let root_parent = if target.exists() && !ends_with_curdir(root) { + root_path.parent().map(ToOwned::to_owned) + } else { + Some(root_path) + }; Ok(Self { current_dir, root_parent, @@ -224,17 +233,6 @@ impl Entry { { descendant = stripped.to_path_buf(); } - } else if context.root == Path::new(".") && context.target.is_dir() { - // Special case: when copying current directory (.) to an existing directory, - // strip the current directory name from the descendant path to avoid creating - // an extra level of nesting. For example, if we're in /home/user/source_dir - // and copying . to /home/user/dest_dir, we want to copy source_dir/file.txt - // to dest_dir/file.txt, not dest_dir/source_dir/file.txt. - if let Some(current_dir_name) = context.current_dir.file_name() - && let Ok(stripped) = descendant.strip_prefix(current_dir_name) - { - descendant = stripped.to_path_buf(); - } } let local_to_target = context.target.join(descendant); @@ -248,7 +246,6 @@ impl Entry { } } -#[allow(clippy::too_many_arguments)] /// Copy a single entry during a directory traversal. /// /// # Returns @@ -256,6 +253,7 @@ impl Entry { /// Returns `Ok(true)` if this function created a new directory, `Ok(false)` otherwise. /// This information is used to determine whether default directory permissions should /// be preserved during attribute copying. +#[allow(clippy::too_many_arguments)] fn copy_direntry( progress_bar: Option<&ProgressBar>, entry: &Entry, @@ -277,10 +275,18 @@ fn copy_direntry( entry_is_dir_no_follow }; + // `exists()` resolves symlinks, so a destination entry that is itself a + // symlink to a directory would look like an already-existing directory and + // be descended into -- writing the source subtree through the link and out + // of the destination tree. GNU refuses this ("cannot overwrite + // non-directory ... with directory"), so treat a symlink at the destination + // as the non-directory it is. + let dest_is_symlink = entry.local_to_target.is_symlink(); + // If the source is a directory and the destination does not // exist, ... - if source_is_dir && !entry.local_to_target.exists() { - return if entry.target_is_file { + if source_is_dir && (dest_is_symlink || !entry.local_to_target.exists()) { + return if entry.target_is_file || dest_is_symlink { Err(translate!("cp-error-cannot-overwrite-non-directory-with-directory").into()) } else { build_dir( @@ -313,6 +319,14 @@ fn copy_direntry( false, ) { + // A file left alone by --no-clobber, --update=none or a declined prompt + // does not stop the traversal; only a declined prompt is a failure. + if let CpError::Skipped(exit_with_error) = err { + if exit_with_error { + set_exit_code(crate::EXIT_ERR); + } + return Ok(false); + } if preserve_hard_links { if !source_is_symlink { return Err(err); diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 3f942b5a61f..8afdfb28589 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) copydir ficlone fiemap ftruncate linkgs lstat nlink nlinks pathbuf pwrite reflink strs xattrs symlinked deduplicated advcpmv nushell IRWXG IRWXO IRWXU IRWXUGO IRWXU IRWXG IRWXO IRWXUGO sflag + +// spell-checker:ignore (ToDO) copydir fiemap linkgs lstat nlink nlinks pathbuf reflink strs xattrs symlinked deduplicated advcpmv nushell IRWXG IRWXO IRWXU IRWXUGO IRWXU IRWXG IRWXO IRWXUGO sflag +// spell-checker:ignore RDONLY futimens utimensat unioned use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; @@ -15,8 +17,14 @@ use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; use std::os::unix::net::UnixListener; use std::path::{Path, PathBuf, StripPrefixError}; use std::{fmt, io}; -#[cfg(all(unix, not(target_os = "android")))] -use uucore::fsxattr::{copy_acls, copy_xattrs, copy_xattrs_skip_selinux}; +#[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "macos", + target_os = "netbsd" +))] +use uucore::fsxattr::{copy_acls, copy_xattrs_fd, copy_xattrs_skip_selinux}; use uucore::translate; use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser}; @@ -24,15 +32,15 @@ use filetime::FileTime; use indicatif::{ProgressBar, ProgressStyle}; #[cfg(unix)] use nix::sys::stat::{Mode, SFlag, dev_t, mknod as nix_mknod, mode_t}; -use thiserror::Error; use platform::copy_on_write; +use uucore::backup_control::backup_would_destroy_source; use uucore::display::Quotable; use uucore::error::{UError, UResult, UUsageError, set_exit_code, strip_errno}; use uucore::fs::{ FileInformation, MissingHandling, ResolveMode, are_hardlinks_to_same_file, canonicalize, get_filename, is_symlink_loop, normalize_path, path_ends_with_terminator, - paths_refer_to_same_file, + paths_refer_to_same_file, replace_link, }; use uucore::{backup_control, update_control}; // These are exposed for projects (e.g. nushell) that want to create an `Options` value, which @@ -48,7 +56,7 @@ use crate::copydir::copy_directory; mod copydir; mod platform; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum CpError { /// Simple [`io::Error`] wrapper #[error("{0}")] @@ -62,6 +70,17 @@ pub enum CpError { #[error("{0}")] Error(String), + /// The SELinux security context of the destination could not be + /// preserved; unlike other attribute failures, this one empties the + /// destination file. + #[error("{0}")] + SelinuxContext(String), + + /// Same as [`CpError::SelinuxContext`], but keeping the errno so that an + /// ENOTSUP on a fixed-context mount can still be silenced. + #[error("{1}: {0}")] + SelinuxContextIoErr(io::Error, String), + /// Represents the state when a non-fatal error has occurred /// and not all files were copied. #[error("{}", translate!("cp-error-not-all-files-copied"))] @@ -121,7 +140,7 @@ impl Display for BackupError { write!( f, "{}", - translate!("cp-error-backup-format", "error" => self.0.clone(), "exec" => uucore::execution_phrase()) + translate!("cp-error-backup-format", "error" => self.0, "exec" => uucore::execution_phrase()) ) } } @@ -173,11 +192,11 @@ pub enum ReflinkMode { impl Default for ReflinkMode { #[allow(clippy::derivable_impls)] fn default() -> Self { - #[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] + #[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] { Self::Auto } - #[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos")))] + #[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))] { Self::Never } @@ -258,7 +277,13 @@ impl PartialOrd for Preserve { impl Ord for Preserve { fn cmp(&self, other: &Self) -> Ordering { match (self, other) { - (Self::No { .. }, Self::No { .. }) => Ordering::Equal, + // Explicit `--no-preserve` ranks higher than implicit defaults so `union()` retains it. + ( + Self::No { explicit: exp_self }, + Self::No { + explicit: exp_other, + }, + ) => exp_self.cmp(exp_other), (Self::Yes { .. }, Self::No { .. }) => Ordering::Greater, (Self::No { .. }, Self::Yes { .. }) => Ordering::Less, ( @@ -1066,10 +1091,9 @@ impl Options { let copy_mode = CopyMode::from_matches(matches); - let backup_mode = match backup_control::determine_backup_mode(matches) { - Err(e) => return Err(CpError::Backup(BackupError(format!("{e}")))), - Ok(mode) => mode, - }; + let backup_mode = + backup_control::determine_backup_mode(std::env::var("VERSION_CONTROL").ok(), matches) + .map_err(|e| CpError::Backup(BackupError(format!("{e}"))))?; let update_mode = update_control::determine_update_mode(matches); if backup_mode != BackupMode::None @@ -1077,9 +1101,9 @@ impl Options { .get_one::(update_control::arguments::OPT_UPDATE) .is_some_and(|v| v == "none" || v == "none-fail") { - return Err(CpError::InvalidArgument( - translate!("cp-error-invalid-backup-argument").to_string(), - )); + return Err(CpError::InvalidArgument(translate!( + "cp-error-invalid-backup-argument" + ))); } let backup_suffix = backup_control::determine_backup_suffix(matches); @@ -1373,7 +1397,18 @@ fn is_enotsup_error(error: &CpError) -> bool { const EOPNOTSUPP: i32 = 95; match error { - CpError::IoErr(e) | CpError::IoErrContext(e, _) => e.raw_os_error() == Some(EOPNOTSUPP), + CpError::IoErr(e) | CpError::IoErrContext(e, _) | CpError::SelinuxContextIoErr(e, _) => { + let raw = e.raw_os_error(); + // WASI's sandbox has no chmod/chown syscalls at all (not merely an + // unsupported combination of flags), so `fs::set_permissions` and + // friends always fail with ENOSYS there. Treat that the same as + // EOPNOTSUPP for optional preservation. + #[cfg(target_os = "wasi")] + if raw == Some(libc::ENOSYS) { + return true; + } + raw == Some(EOPNOTSUPP) + } _ => false, } } @@ -1383,16 +1418,17 @@ fn show_error_if_needed(error: &CpError) { match error { // When using --no-clobber, we don't want to show // an error message + #[expect(clippy::match_same_arms)] // needs comment CpError::NotAllFilesCopied => { // Need to return an error code } CpError::Skipped(_) => { // touch a b && echo "n"|cp -i a b && echo $? - // should return an error from GNU 9.2 + // should return an error } // Format IoErrContext using strip_errno to remove "(os error N)" suffix // for GNU-compatible output - CpError::IoErrContext(io_err, context) => { + CpError::IoErrContext(io_err, context) | CpError::SelinuxContextIoErr(io_err, context) => { show_error!("{context}: {}", strip_errno(io_err)); } _ => { @@ -1445,7 +1481,7 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult for source in sources { let normalized_source = normalize_path(source); - if options.backup == BackupMode::None && seen_sources.contains(&normalized_source) { + if options.backup == BackupMode::None && !seen_sources.insert(normalized_source) { let file_type = if source.symlink_metadata()?.file_type().is_dir() { "directory" } else { @@ -1501,7 +1537,6 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult copied_destinations.insert(dest.clone()); } } - seen_sources.insert(normalized_source); } if let Some(pb) = progress_bar { @@ -1625,49 +1660,40 @@ fn copy_source( // This fix adds yet another metadata read. // Should this metadata be read once and then reused throughout the execution? // https://github.com/uutils/coreutils/issues/6658 -#[allow(clippy::if_not_else)] -fn file_mode_for_interactive_overwrite( - #[cfg_attr(not(unix), allow(unused_variables))] path: &Path, -) -> Option<(String, String)> { - // Retain outer braces to ensure only one branch is included - { - #[cfg(unix)] - { - use libc::{S_IWUSR, mode_t}; - use std::os::unix::prelude::MetadataExt; - - 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 - // write permission, owner - if uucore::has!(mode, S_IWUSR) { - None - } else { - // Discard leading digits - let mode_without_leading_digits = mode & 0o7777; - - Some(( - format!("{mode_without_leading_digits:04o}"), - uucore::fs::display_permissions_unix(mode as u32, false), - )) - } - } - // TODO: How should failure to read the metadata be handled? Ignoring for now. - Err(_) => None, - } - } +#[cfg(unix)] +fn file_mode_for_interactive_overwrite(path: &Path) -> Option<(String, String)> { + use libc::{S_IWUSR, mode_t}; + use std::os::unix::prelude::MetadataExt; - #[cfg(not(unix))] - { + 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 + // write permission, owner + if !uucore::has!(mode, S_IWUSR) { + // Discard leading digits + let mode_without_leading_digits = mode & 0o7777; + + return Some(( + format!("{mode_without_leading_digits:04o}"), + uucore::fs::display_permissions_unix(mode as u32, false), + )); + } None } + // TODO: How should failure to read the metadata be handled? Ignoring for now. + Err(_) => None, } } +#[cfg(not(unix))] +fn file_mode_for_interactive_overwrite(_: &Path) -> Option<(String, String)> { + None +} + impl OverwriteMode { fn verify(self, path: &Path, debug: bool) -> CopyResult<()> { match self { @@ -1707,6 +1733,40 @@ impl OverwriteMode { /// 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." +/// Returns the source's last access and modification times. +/// +/// `filetime::FileTime::from_last_{access,modification}_time` panics on +/// WASI (the `filetime` crate has no WASI-specific backend and falls back +/// to its unimplemented generic wasm one). `Metadata::accessed`/`modified` +/// are stable and WASI-backed, so use those instead there. +// On non-wasi targets this can never fail, but the wasi branch below can. +#[cfg_attr(not(target_os = "wasi"), allow(clippy::unnecessary_wraps))] +fn source_times(source_metadata: &Metadata, context: &str) -> CopyResult<(FileTime, FileTime)> { + #[cfg(target_os = "wasi")] + { + Ok(( + FileTime::from( + source_metadata + .accessed() + .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?, + ), + FileTime::from( + source_metadata + .modified() + .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?, + ), + )) + } + #[cfg(not(target_os = "wasi"))] + { + let _ = context; + Ok(( + FileTime::from_last_access_time(source_metadata), + FileTime::from_last_modification_time(source_metadata), + )) + } +} + fn handle_preserve CopyResult<()>>(p: Preserve, f: F) -> CopyResult<()> { match p { Preserve::No { .. } => {} @@ -1733,8 +1793,7 @@ pub(crate) fn set_selinux_context(path: &Path, context: Option<&String>) -> Copy } match uucore::selinux::set_selinux_security_context(path, context) { - Ok(()) => Ok(()), - Err(uucore::selinux::SeLinuxError::OperationNotSupported) => Ok(()), + Ok(()) | Err(uucore::selinux::SeLinuxError::OperationNotSupported) => Ok(()), Err(e) => Err(CpError::Error( translate!("cp-error-selinux-error", "error" => e), )), @@ -1745,8 +1804,18 @@ pub(crate) fn set_selinux_context(path: &Path, context: Option<&String>) -> Copy /// user-writable if needed and restoring its original permissions afterward. This avoids "Operation /// not permitted" errors on read-only files. Returns an error if permission or metadata operations fail, /// or if xattr copying fails. -#[cfg(all(unix, not(target_os = "android")))] +/// +/// Uses file descriptor-based operations to avoid TOCTOU races during xattr copying. +#[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "macos", + target_os = "netbsd" +))] fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyResult<()> { + use std::fs::File; + use uucore::fsxattr::copy_xattrs; let metadata = fs::symlink_metadata(dest)?; // Check if the destination file is currently read-only for the user. @@ -1766,9 +1835,21 @@ fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyRe // When -Z is used, skip copying security.selinux xattr so that // the default context can be set instead of preserving from source copy_xattrs_skip_selinux(source, dest) + } else if metadata.is_file() && fs::symlink_metadata(source)?.is_file() { + // Use file descriptor-based operations for regular files to avoid TOCTOU races. + // Directories cannot be opened with write mode for xattr operations + // Symlinks (especially dangling ones) cannot be opened via File::open + // The source must be regular too: opening a FIFO here blocks until a + // writer appears, and a device would have side effects on open. + let source_file = File::open(source)?; + let dest_file = OpenOptions::new().write(true).open(dest)?; + copy_xattrs_fd(&source_file, &dest_file) } else { copy_xattrs(source, dest) }; + // Every attribute has been tried; report the first one that failed. + let copy_xattrs_result = copy_xattrs_result + .and_then(|failed| failed.into_iter().next().map_or(Ok(()), |(_, e)| Err(e))); // Restore read-only if we changed it. if was_readonly { @@ -1812,6 +1893,11 @@ pub(crate) fn copy_attributes( attributes.mode }; + // A created directory only defaults to copying the source mode; unlike an + // explicit preserve (-p/-a), GNU applies the umask to it. + let apply_umask_to_mode = + dest_is_freshly_created_dir && !matches!(attributes.mode, Preserve::Yes { .. }); + // Track whether `chown` to the source's uid succeeded. If it did not // (typical case: non-root user copying a root-owned setuid file), the // mode preservation below must strip setuid/setgid so the destination @@ -1876,6 +1962,12 @@ pub(crate) fn copy_attributes( let mode = perms.mode() & !0o6000; perms.set_mode(mode); } + if apply_umask_to_mode { + // The umask never covers setuid/setgid, so clear them + // explicitly: a non-preserving copy must not carry the + // source's set-user/group-ID bits into the new directory. + perms.set_mode(perms.mode() & !0o6000 & !uucore::mode::get_umask()); + } perms }; #[cfg(not(unix))] @@ -1883,18 +1975,19 @@ pub(crate) fn copy_attributes( fs::set_permissions(dest, source_perms) .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; - // FIXME: Implement this for windows as well - #[cfg(feature = "feat_acl")] - exacl::getfacl(source, None) - .and_then(|acl| exacl::setfacl(&[dest], &acl, None)) - .map_err(|err| CpError::Error(err.to_string()))?; // GNU `cp -p` preserves POSIX ACLs as part of mode. On Linux the // ACLs are stored as `system.posix_acl_*` xattrs; copy just those // so we keep ACL parity with GNU without preserving user xattrs // (which are intentionally excluded from the default -p set per // issue #9704). Best-effort: ignore failures on filesystems that // do not support ACL xattrs. - #[cfg(all(unix, not(target_os = "android")))] + #[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "macos", + target_os = "netbsd" + ))] copy_acls(source, dest); } @@ -1902,9 +1995,24 @@ pub(crate) fn copy_attributes( })?; handle_preserve(attributes.timestamps, || -> CopyResult<()> { - let atime = FileTime::from_last_access_time(&source_metadata); - let mtime = FileTime::from_last_modification_time(&source_metadata); - if dest.is_symlink() { + let (atime, mtime) = source_times(&source_metadata, context)?; + // `set_file_times` opens the destination (O_RDONLY) before calling + // futimens; opening a FIFO or device with no peer blocks forever, and a + // socket cannot be opened at all. For symlinks and these special files + // use the path-based, no-follow variant, which sets the times via + // utimensat without opening. + #[cfg(unix)] + let no_open = { + let ft = source_metadata.file_type(); + dest.is_symlink() + || ft.is_fifo() + || ft.is_socket() + || ft.is_char_device() + || ft.is_block_device() + }; + #[cfg(not(unix))] + let no_open = dest.is_symlink(); + if no_open { filetime::set_symlink_file_times(dest, atime, mtime)?; } else { filetime::set_file_times(dest, atime, mtime)?; @@ -1916,28 +2024,49 @@ pub(crate) fn copy_attributes( #[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) { - if let Some(context) = context { - if let Err(e) = context.set_for_path(dest, false, false) { - return Err(CpError::Error( - translate!("cp-error-selinux-set-context", "path" => dest.quote(), "error" => e), - )); - } - } - } else { - return Err(CpError::Error( + let context = selinux::SecurityContext::of_path(source, false, false).map_err(|_| { + CpError::SelinuxContext( translate!("cp-error-selinux-get-context", "path" => source.quote()), - )); + ) + })?; + if let Some(context) = context { + context.set_for_path(dest, false, false).map_err(|e| { + // Keep the errno: the ENOTSUP of a mount with a fixed context is + // what -a and --preserve=all have to stay quiet about. + let source = match e { + selinux::errors::Error::IO { source, .. } + | selinux::errors::Error::IO1Name { source, .. } + | selinux::errors::Error::IO1Path { source, .. } + | selinux::errors::Error::IO1Process { source, .. } => source, + e => io::Error::other(e), + }; + CpError::SelinuxContextIoErr( + source, + translate!("cp-error-selinux-set-context", "path" => dest.quote()), + ) + })?; } Ok(()) })?; handle_preserve(attributes.xattr, || -> CopyResult<()> { - #[cfg(all(unix, not(target_os = "android")))] + #[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "macos", + target_os = "netbsd" + ))] { copy_extended_attrs(source, dest, skip_selinux_xattr)?; } - #[cfg(not(all(unix, not(target_os = "android"))))] + #[cfg(not(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "macos", + target_os = "netbsd" + )))] #[allow(unused_variables)] { // The documentation for GNU cp states: @@ -1961,18 +2090,8 @@ pub(crate) fn copy_attributes( fn symlink_file( source: &Path, dest: &Path, - #[cfg(not(target_os = "wasi"))] symlinked_files: &mut HashSet, - #[cfg(target_os = "wasi")] _symlinked_files: &mut HashSet, + symlinked_files: &mut HashSet, ) -> CopyResult<()> { - #[cfg(target_os = "wasi")] - { - Err(CpError::IoErrContext( - io::Error::new(io::ErrorKind::Unsupported, "symlinks not supported"), - translate!("cp-error-cannot-create-symlink", - "dest" => get_filename(dest).unwrap_or("?").quote(), - "source" => get_filename(source).unwrap_or("?").quote()), - )) - } #[cfg(not(any(windows, target_os = "wasi")))] { std::os::unix::fs::symlink(source, dest).map_err(|e| { @@ -1995,13 +2114,21 @@ fn symlink_file( ) })?; } - #[cfg(not(target_os = "wasi"))] + #[cfg(target_os = "wasi")] { - if let Ok(file_info) = FileInformation::from_path(dest, false) { - symlinked_files.insert(file_info); - } - Ok(()) + platform::create_symlink(source, dest).map_err(|e| { + CpError::IoErrContext( + e, + translate!("cp-error-cannot-create-symlink", + "dest" => get_filename(dest).unwrap_or("?").quote(), + "source" => get_filename(source).unwrap_or("?").quote()), + ) + })?; + } + if let Ok(file_info) = FileInformation::from_path(dest, false) { + symlinked_files.insert(file_info); } + Ok(()) } fn context_for(src: &Path, dest: &Path) -> String { @@ -2015,6 +2142,11 @@ fn backup_dest(dest: &Path, backup_path: &Path, is_dest_symlink: bool) -> CopyRe if is_dest_symlink { fs::rename(dest, backup_path)?; } else { + match fs::remove_file(backup_path) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } fs::copy(dest, backup_path)?; } Ok(()) @@ -2109,7 +2241,7 @@ fn handle_existing_dest( let mut is_dest_removed = false; let backup_path = backup_control::get_backup_path(options.backup, dest, &options.backup_suffix); if let Some(backup_path) = backup_path { - if paths_refer_to_same_file(source, &backup_path, true) { + if backup_would_destroy_source(source, dest, &options.backup_suffix, options.backup, true) { return Err(translate!("cp-error-backing-up-destroy-source", "dest" => dest.quote(), "source" => source.quote()) .into()); } @@ -2191,12 +2323,12 @@ fn delete_dest_if_needed_and_allowed( fn delete_path(path: &Path, options: &Options) -> CopyResult<()> { // Windows requires clearing readonly attribute before deletion when using --force #[cfg(windows)] - if options.force() { - if let Ok(mut perms) = fs::metadata(path).map(|m| m.permissions()) { - #[allow(clippy::permissions_set_readonly_false)] - perms.set_readonly(false); - let _ = fs::set_permissions(path, perms); - } + if options.force() + && let Ok(mut perms) = fs::metadata(path).map(|m| m.permissions()) + { + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + let _ = fs::set_permissions(path, perms); } match fs::remove_file(path) { @@ -2319,6 +2451,7 @@ fn handle_copy_mode( ) -> CopyResult { match options.copy_mode { CopyMode::Link => { + let mut force = false; if dest.exists() { let backup_path = backup_control::get_backup_path(options.backup, dest, &options.backup_suffix); @@ -2326,16 +2459,19 @@ fn handle_copy_mode( backup_dest(dest, &backup_path, dest.is_symlink())?; fs::remove_file(dest)?; } - if options.overwrite == OverwriteMode::Clobber(ClobberMode::Force) { - fs::remove_file(dest)?; - } + force = options.overwrite == OverwriteMode::Clobber(ClobberMode::Force); } - if options.dereference(source_in_command_line) && source.is_symlink() { - let resolved = - canonicalize(source, MissingHandling::Missing, ResolveMode::Physical).unwrap(); - fs::hard_link(resolved, dest) + let src = if options.dereference(source_in_command_line) && source.is_symlink() { + canonicalize(source, MissingHandling::Missing, ResolveMode::Physical).unwrap() + } else { + source.to_path_buf() + }; + // Replace atomically rather than unlinking first: the gap would + // let another user claim `dest` under a name the caller trusts. + if force { + replace_link(&src, dest, false) } else { - fs::hard_link(source, dest) + fs::hard_link(&src, dest) } .map_err(|e| { CpError::IoErrContext( @@ -2357,10 +2493,13 @@ fn handle_copy_mode( )?; } CopyMode::SymLink => { + // Atomic replace, for the same reason as CopyMode::Link above. if dest.exists() && options.overwrite == OverwriteMode::Clobber(ClobberMode::Force) { - fs::remove_file(dest)?; + replace_link(source, dest, true)?; + symlinked_files.insert(FileInformation::from_path(dest, false)?); + } else { + symlink_file(source, dest, symlinked_files)?; } - symlink_file(source, dest, symlinked_files)?; } CopyMode::Update => { if dest.exists() { @@ -2426,6 +2565,26 @@ fn handle_copy_mode( } } CopyMode::AttrOnly => { + // The destination must have the source's type. Creating a regular + // file for a FIFO or a device both gets the type wrong and, for a + // FIFO, makes the later xattr copy open it and block forever with + // no writer. + #[cfg(unix)] + { + let ft = source_metadata.file_type(); + if ft.is_fifo() { + return copy_fifo(dest, options.overwrite, options.debug) + .map(|()| PerformedAction::Copied); + } + if ft.is_socket() { + return copy_socket(dest, options.overwrite, options.debug) + .map(|()| PerformedAction::Copied); + } + if ft.is_char_device() || ft.is_block_device() { + return copy_node(dest, source_metadata, options.overwrite, options.debug) + .map(|()| PerformedAction::Copied); + } + } OpenOptions::new() .write(true) .truncate(false) @@ -2603,6 +2762,23 @@ fn copy_file( } } + // `--attributes-only` skips `handle_existing_dest` above, so its + // same-file check never runs; GNU cp refuses self-copies in this + // mode too. + if initial_dest_metadata.is_some() + && options.attributes_only + && !matches!( + options.overwrite, + OverwriteMode::Clobber(ClobberMode::RemoveDestination) + ) + && is_forbidden_to_copy_to_same_file(source, dest, options, source_in_command_line) + { + return Err(translate!("cp-error-same-file", + "source" => source.quote(), + "dest" => dest.quote()) + .into()); + } + if options.attributes_only && source_is_symlink && !matches!( @@ -2680,7 +2856,15 @@ fn copy_file( } // TODO: implement something similar to gnu's lchown - if !dest_is_symlink { + // + // Check the destination as it is now, not as it was before the copy: + // copying a symlink without dereferencing recreates it as a symlink here, + // and chmod() would follow it and change the mode of the link target, + // which can live outside the copied tree. Conversely, --remove-destination + // replaces a symlink with a regular file that still needs its mode set. + // With --link, dest shares the source's inode, so chmod would change the + // mode of the source file as well. + if options.copy_mode != CopyMode::Link && !dest.is_symlink() { // Here, to match GNU semantics, we quietly ignore an error // if a user does not have the correct ownership to modify // the permissions of a file. @@ -2720,9 +2904,28 @@ fn copy_file( ) }; - // GNU cp truncates the destination when a required attribute cannot be preserved - copy_attributes_result.inspect_err(|_| { - fs::File::create(dest).map(|f| f.set_len(0)).ok(); + // Empty the destination when the SELinux security context cannot be + // preserved, but keep the copied data when preserving other attributes + // (e.g. xattrs) fails. + copy_attributes_result.inspect_err(|err| { + if matches!( + err, + CpError::SelinuxContext(_) | CpError::SelinuxContextIoErr(..) + ) && fs::File::create(dest).is_err() + { + // The permissions applied above may lack the write bit (e.g. a + // read-only source), making the truncating open fail. Restore + // owner write long enough to truncate, then put the intended + // permissions back. + #[cfg(unix)] + if let Ok(metadata) = fs::symlink_metadata(dest) { + let mode = metadata.permissions().mode(); + if fs::set_permissions(dest, Permissions::from_mode(mode | 0o200)).is_ok() { + fs::File::create(dest).ok(); + fs::set_permissions(dest, Permissions::from_mode(mode)).ok(); + } + } + } })?; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] @@ -2768,8 +2971,8 @@ fn handle_no_preserve_mode(options: &Options, org_mode: u32) -> u32 { }; #[cfg(not(any( + target_vendor = "apple", target_os = "android", - target_os = "macos", target_os = "freebsd", target_os = "redox", )))] @@ -2784,8 +2987,8 @@ fn handle_no_preserve_mode(options: &Options, org_mode: u32) -> u32 { } #[cfg(any( + target_vendor = "apple", target_os = "android", - target_os = "macos", target_os = "freebsd", target_os = "redox", ))] @@ -3080,4 +3283,26 @@ mod tests { } ); } + + #[test] + fn test_preserve_ord() { + assert!(Preserve::No { explicit: true } > Preserve::No { explicit: false }); + assert!(Preserve::Yes { required: false } > Preserve::No { explicit: true }); + assert!(Preserve::Yes { required: true } > Preserve::Yes { required: false }); + } + + #[test] + fn test_attributes_union_retains_explicit_no() { + let explicit_no_mode = Attributes { + mode: Preserve::No { explicit: true }, + ..Attributes::NONE + }; + let timestamps_yes = Attributes { + timestamps: Preserve::Yes { required: true }, + ..Attributes::NONE + }; + let unioned = explicit_no_mode.union(×tamps_yes); + assert_eq!(unioned.mode, Preserve::No { explicit: true }); + assert_eq!(unioned.timestamps, Preserve::Yes { required: true }); + } } diff --git a/src/uu/cp/src/platform/linux.rs b/src/uu/cp/src/platform/linux.rs index 2585cd2d52f..d14b067e915 100644 --- a/src/uu/cp/src/platform/linux.rs +++ b/src/uu/cp/src/platform/linux.rs @@ -2,10 +2,12 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore ficlone reflink ftruncate pwrite fiemap lseek nofollow + +// spell-checker:ignore reflink ftruncate fiemap lseek nofollow use rustix::fs::{SeekFrom, ftruncate, ioctl_ficlone, seek}; -use std::io::Read; +use std::fs::File; +use std::io::{self, Read}; use std::os::unix::fs::FileExt; use std::os::unix::fs::FileTypeExt; use std::os::unix::fs::MetadataExt; @@ -21,41 +23,34 @@ use crate::{ is_stream, }; -// Replacement for `std::fs::copy` that uses the safe-copy primitives but -// only applies `O_NOFOLLOW` to the *source* open. The destination is -// followed if it is a pre-existing symlink, matching GNU cp -d/-P which -// only forbid dereferencing on the source side. -fn fs_copy(source: P, dest: Q, source_nofollow: bool, context: &str) -> CopyResult<()> -where - P: AsRef, - Q: AsRef, -{ - let mut src = open_source(source, source_nofollow) - .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; - let mut dst = create_dest_restrictive(&dest, false).map_err(|e| { +// Create the destination. It is followed when it is a pre-existing symlink, +// matching GNU cp -d/-P which only forbid dereferencing on the source side. +fn create_dest(dest: &Path) -> CopyResult { + create_dest_restrictive(dest, false).map_err(|e| { CpError::IoErrContext( e, - translate!("cp-error-cannot-create-regular-file", "path" => dest.as_ref().quote()), + translate!("cp-error-cannot-create-regular-file", "path" => dest.quote()), ) - })?; - buf_copy::copy_fast(&mut src, &mut dst) - .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; - Ok(()) + }) } /// The fallback behavior for [`clone`] on failed system call. +/// +/// Every fallback reuses the descriptors already opened for the clone +/// attempt; re-opening the dest by path can fail with EACCES when the +/// umask stripped the write bits from its freshly created mode. #[derive(Clone, Copy)] enum CloneFallback { /// Raise an error. Error, - /// Use [`std::fs::copy`]. + /// Copy the bytes with [`buf_copy::copy_fast`]. FSCopy, - /// Use [`sparse_copy`] + /// Use [`sparse_copy_fd`] SparseCopy, - /// Use [`sparse_copy_without_hole`] + /// Use [`sparse_copy_without_hole_fd`] SparseCopyWithoutHole, } @@ -64,75 +59,89 @@ enum CloneFallback { enum CopyMethod { /// Do a sparse copy SparseCopy, - /// Use [`std::fs::copy`]. + /// Copy the bytes with [`buf_copy::copy_fast`]. FSCopy, /// Default (can either be [`CopyMethod::SparseCopy`] or [`CopyMethod::FSCopy`]) Default, - /// Use [`sparse_copy_without_hole`] + /// Use [`sparse_copy_without_hole_fd`] SparseCopyWithoutHole, } /// Use the Linux `ioctl_ficlone` API to do a copy-on-write clone. /// /// `fallback` controls what to do if the system call fails. -fn clone

( - source: P, - dest: P, +fn clone( + src_file: &mut File, + dest: &Path, fallback: CloneFallback, - nofollow: bool, context: &str, -) -> CopyResult<()> -where - P: AsRef, -{ - let src_file = - open_source(&source, nofollow).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; - let dst_file = create_dest_restrictive(&dest, false).map_err(|e| { - CpError::IoErrContext( - e, - translate!("cp-error-cannot-create-regular-file", "path" => dest.as_ref().quote()), - ) - })?; - if ioctl_ficlone(dst_file, src_file).is_err() { +) -> CopyResult<()> { + // Only needed to decide whether a failed --reflink=always clone should + // clean up the dest, so skip the lstat for the other fallbacks. + let dest_existed = matches!(fallback, CloneFallback::Error) && dest.symlink_metadata().is_ok(); + let mut dst_file = create_dest(dest)?; + if let Err(err) = ioctl_ficlone(&dst_file, &*src_file) { + // Reuse the already-open descriptors: the dest was just created with + // a restrictive mode that the umask may have stripped of write bits, + // so re-opening it by path can fail with EACCES (LP: #2164777). return match fallback { - CloneFallback::Error => Err(CpError::IoErrContext( - std::io::Error::last_os_error(), - context.to_owned(), - )), - CloneFallback::FSCopy => fs_copy(source, dest, nofollow, context), - CloneFallback::SparseCopy => sparse_copy(source, dest, nofollow, context), + CloneFallback::Error => { + // GNU cp removes a dest it created itself, but keeps a + // pre-existing (now truncated) one. Only unlink while the + // path still names the inode we created: it may have been + // replaced since, and removing blindly would drop an + // unrelated file. + if !dest_existed && path_still_refers_to(dest, &dst_file) { + let _ = std::fs::remove_file(dest); + } + Err(CpError::IoErrContext(err.into(), context.to_owned())) + } + CloneFallback::FSCopy => buf_copy::copy_fast(src_file, &mut dst_file) + .map_err(|e| CpError::IoErrContext(e, context.to_owned())), + CloneFallback::SparseCopy => sparse_copy_fd(src_file, &dst_file, context), CloneFallback::SparseCopyWithoutHole => { - sparse_copy_without_hole(source, dest, nofollow, context) + sparse_copy_without_hole_fd(src_file, &dst_file, context) } }; } Ok(()) } +/// Whether `path` still resolves to the inode behind `file`. +fn path_still_refers_to(path: &Path, file: &File) -> bool { + let (Ok(current), Ok(opened)) = (path.symlink_metadata(), file.metadata()) else { + return false; + }; + current.dev() == opened.dev() && current.ino() == opened.ino() +} + /// Checks whether a file contains any non null bytes i.e. any byte != 0x0 /// This function returns a tuple of (bool, u64, u64) signifying a tuple of (whether a file has /// data, its size, no of blocks it has allocated in disk) -fn check_for_data(source: &Path, nofollow: bool) -> Result<(bool, u64, u64), std::io::Error> { - let mut src_file = open_source(source, nofollow)?; +fn check_for_data(src_file: &mut File) -> io::Result<(bool, u64, u64)> { let metadata = src_file.metadata()?; let size = metadata.size(); let blocks = metadata.blocks(); // checks edge case of virtual files in /proc which have a size of zero but contains data - if size == 0 { + let (has_data, blocks) = if size == 0 { let mut buf: Vec = vec![0; metadata.blksize() as usize]; // Directly use metadata.blksize() - let _ = src_file.read(&mut buf)?; - return Ok((buf.iter().any(|&x| x != 0x0), size, 0)); - } - let has_data = seek(src_file, SeekFrom::Data(0)).is_ok(); + let read = src_file.read(&mut buf)?; + (buf[..read].iter().any(|&x| x != 0x0), 0) + } else { + (seek(&*src_file, SeekFrom::Data(0)).is_ok(), blocks) + }; + + // The probe moved the descriptor; the copy that follows reads it + // sequentially, so hand it back positioned at the start. + seek(&*src_file, SeekFrom::Start(0))?; Ok((has_data, size, blocks)) } /// Checks whether a file is sparse i.e. it contains holes, uses the crude heuristic blocks < size / 512 /// Reference:`` -fn check_sparse_detection(source: &Path, nofollow: bool) -> Result { - let src_file = open_source(source, nofollow)?; +fn check_sparse_detection(src_file: &File) -> io::Result { let metadata = src_file.metadata()?; let size = metadata.size(); let blocks = metadata.blocks(); @@ -140,47 +149,39 @@ fn check_sparse_detection(source: &Path, nofollow: bool) -> Result(source: P, dest: P, nofollow: bool, context: &str) -> CopyResult<()> -where - P: AsRef, -{ - let src_file = - open_source(&source, nofollow).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; - let dst_file = create_dest_restrictive(&dest, false).map_err(|e| { - CpError::IoErrContext( - e, - translate!("cp-error-cannot-create-regular-file", "path" => dest.as_ref().quote()), - ) - })?; +fn sparse_copy_without_hole_fd(src_file: &File, dst_file: &File, context: &str) -> CopyResult<()> { + let ctx_err = |e: io::Error| CpError::IoErrContext(e, context.to_owned()); - let ctx_err = |e: std::io::Error| CpError::IoErrContext(e, context.to_owned()); - - let size = src_file.metadata().map_err(&ctx_err)?.size(); - ftruncate(&dst_file, size).map_err(|e| CpError::IoErrContext(e.into(), context.to_owned()))?; + let size = src_file.metadata().map_err(ctx_err)?.size(); + ftruncate(dst_file, size).map_err(|e| CpError::IoErrContext(e.into(), context.to_owned()))?; let mut current_offset = 0; // Maximize the data read at once to 16 MiB to avoid memory hogging with large files // 16 MiB chunks should saturate an SSD - let step = std::cmp::min(size, 16 * 1024 * 1024) as usize; + // At least 1 byte, so that a source that was empty at fstat time but + // gained data before the SEEK_DATA loop cannot make `step_by` panic. + let step = size.clamp(1, 16 * 1024 * 1024) as usize; let mut buf: Vec = vec![0x0; step]; - while let Ok(data) = seek(&src_file, SeekFrom::Data(current_offset)) { + while let Ok(data) = seek(src_file, SeekFrom::Data(current_offset)) { current_offset = data; - let Ok(hole) = seek(&src_file, SeekFrom::Hole(current_offset)) else { + let Ok(hole) = seek(src_file, SeekFrom::Hole(current_offset)) else { break; }; let len = hole - current_offset; // Read and write data in chunks of `step` while reusing the same buffer for i in (0..len).step_by(step) { - // Ensure we don't read past the end of the file or the start of the next hole - let read_len = std::cmp::min((len - i) as usize, step); + // Ensure we don't read past the end of the file or the start of + // the next hole. Take the min in u64: casting `len - i` first + // would truncate extents of 4 GiB and more on 32-bit targets. + let read_len = std::cmp::min(len - i, step as u64) as usize; let buf = &mut buf[..read_len]; src_file .read_exact_at(buf, current_offset + i) - .map_err(&ctx_err)?; + .map_err(ctx_err)?; dst_file .write_all_at(buf, current_offset + i) - .map_err(&ctx_err)?; + .map_err(ctx_err)?; } current_offset = hole; } @@ -188,46 +189,38 @@ where } /// Perform a sparse copy from one file to another. /// Creates a holes for large sequences of zeros in `non_sparse_files`, used for `--sparse=always` -fn sparse_copy

(source: P, dest: P, nofollow: bool, context: &str) -> CopyResult<()> -where - P: AsRef, -{ - let mut src_file = - open_source(&source, nofollow).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; - let dst_file = create_dest_restrictive(&dest, false).map_err(|e| { - CpError::IoErrContext( - e, - translate!("cp-error-cannot-create-regular-file", "path" => dest.as_ref().quote()), - ) - })?; - - let ctx_err = |e: std::io::Error| CpError::IoErrContext(e, context.to_owned()); +fn sparse_copy_fd(src_file: &mut File, dst_file: &File, context: &str) -> CopyResult<()> { + let ctx_err = |e: io::Error| CpError::IoErrContext(e, context.to_owned()); - let size: usize = src_file - .metadata() - .map_err(&ctx_err)? - .size() - .try_into() - .unwrap(); - ftruncate(&dst_file, size.try_into().unwrap()) - .map_err(|e| CpError::IoErrContext(e.into(), context.to_owned()))?; + // Keep the size as u64: on 32-bit targets a usize conversion would + // panic for sources of 4 GiB and more. + let size = src_file.metadata().map_err(ctx_err)?.size(); + ftruncate(dst_file, size).map_err(|e| CpError::IoErrContext(e.into(), context.to_owned()))?; - let blksize = dst_file.metadata().map_err(&ctx_err)?.blksize(); - let mut buf: Vec = vec![0; blksize.try_into().unwrap()]; - let mut current_offset: usize = 0; + let blksize = dst_file.metadata().map_err(ctx_err)?.blksize(); + let mut buf: Vec = vec![0; blksize as usize]; + let mut current_offset: u64 = 0; // TODO Perhaps we can employ the "fiemap ioctl" API to get the // file extent mappings: // https://www.kernel.org/doc/html/latest/filesystems/fiemap.html while current_offset < size { - let this_read = src_file.read(&mut buf).map_err(&ctx_err)?; + let this_read = src_file.read(&mut buf).map_err(ctx_err)?; + if this_read == 0 { + // EOF before the size seen at fstat time (source truncated + // concurrently): shrink the dest to the bytes actually copied + // instead of leaving a zero-filled tail up to the stale size. + ftruncate(dst_file, current_offset) + .map_err(|e| CpError::IoErrContext(e.into(), context.to_owned()))?; + break; + } let buf = &buf[..this_read]; if buf.iter().any(|&x| x != 0) { dst_file - .write_all_at(buf, current_offset.try_into().unwrap()) - .map_err(&ctx_err)?; + .write_all_at(buf, current_offset) + .map_err(ctx_err)?; } - current_offset += this_read; + current_offset += this_read as u64; } Ok(()) } @@ -274,22 +267,28 @@ where ) })?; - let ctx_err = |e: std::io::Error| CpError::IoErrContext(e, context.to_owned()); + let ctx_err = |e: io::Error| CpError::IoErrContext(e, context.to_owned()); - let dest_is_stream = is_stream(&dst_file.metadata().map_err(&ctx_err)?); + let dest_is_stream = is_stream(&dst_file.metadata().map_err(ctx_err)?); if !dest_is_stream { // `copy_stream` doesn't clear the dest file, if dest is not a stream, we should clear it manually. - dst_file.set_len(0).map_err(&ctx_err)?; + dst_file.set_len(0).map_err(ctx_err)?; } buf_copy::copy_fast(&mut src_file, &mut dst_file) - .map_err(|e| std::io::Error::other(format!("{e}"))) - .map_err(&ctx_err)?; + .map_err(|e| io::Error::other(format!("{e}"))) + .map_err(ctx_err)?; Ok(()) } /// Copies `source` to `dest` using copy-on-write if possible. +/// +/// The source is opened once and the descriptor is threaded through both the +/// sparseness probe and the copy itself. Probing and copying used to open the +/// path separately, costing three opens per copy and letting the strategy be +/// decided from one file while the bytes came from whatever the path named +/// later (#13185). pub(crate) fn copy_on_write( source: &Path, dest: &Path, @@ -313,16 +312,20 @@ pub(crate) fn copy_on_write( copy_debug.offload = OffloadReflinkDebug::Avoided; copy_stream(source, dest, nofollow, context) } else { + let mut src_file = open_source(source, nofollow) + .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; let mut copy_method = CopyMethod::Default; - let result = handle_reflink_never_sparse_always(source, dest, nofollow); + let result = handle_reflink_never_sparse_always(&mut src_file, dest); if let Ok((debug, method)) = result { copy_debug = debug; copy_method = method; } + let mut dst_file = create_dest(dest)?; match copy_method { - CopyMethod::FSCopy => fs_copy(source, dest, nofollow, context), - _ => sparse_copy(source, dest, nofollow, context), + CopyMethod::FSCopy => buf_copy::copy_fast(&mut src_file, &mut dst_file) + .map_err(|e| CpError::IoErrContext(e, context.to_owned())), + _ => sparse_copy_fd(&mut src_file, &dst_file, context), } } } @@ -333,11 +336,15 @@ pub(crate) fn copy_on_write( copy_debug.offload = OffloadReflinkDebug::Avoided; copy_stream(source, dest, nofollow, context) } else { - let result = handle_reflink_never_sparse_never(source, nofollow); + let mut src_file = open_source(source, nofollow) + .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; + let result = handle_reflink_never_sparse_never(&mut src_file); if let Ok(debug) = result { copy_debug = debug; } - fs_copy(source, dest, nofollow, context) + let mut dst_file = create_dest(dest)?; + buf_copy::copy_fast(&mut src_file, &mut dst_file) + .map_err(|e| CpError::IoErrContext(e, context.to_owned())) } } (ReflinkMode::Never, SparseMode::Auto) => { @@ -347,18 +354,22 @@ pub(crate) fn copy_on_write( copy_debug.offload = OffloadReflinkDebug::Avoided; copy_stream(source, dest, nofollow, context) } else { + let mut src_file = open_source(source, nofollow) + .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; let mut copy_method = CopyMethod::Default; - let result = handle_reflink_never_sparse_auto(source, dest, nofollow); + let result = handle_reflink_never_sparse_auto(&mut src_file, dest); if let Ok((debug, method)) = result { copy_debug = debug; copy_method = method; } + let mut dst_file = create_dest(dest)?; match copy_method { CopyMethod::SparseCopyWithoutHole => { - sparse_copy_without_hole(source, dest, nofollow, context) + sparse_copy_without_hole_fd(&src_file, &dst_file, context) } - _ => fs_copy(source, dest, nofollow, context), + _ => buf_copy::copy_fast(&mut src_file, &mut dst_file) + .map_err(|e| CpError::IoErrContext(e, context.to_owned())), } } } @@ -369,8 +380,10 @@ pub(crate) fn copy_on_write( copy_debug.offload = OffloadReflinkDebug::Avoided; copy_stream(source, dest, nofollow, context) } else { + let mut src_file = open_source(source, nofollow) + .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; let mut copy_method = CopyMethod::Default; - let result = handle_reflink_auto_sparse_always(source, dest, nofollow); + let result = handle_reflink_auto_sparse_always(&mut src_file, dest); if let Ok((debug, method)) = result { copy_debug = debug; copy_method = method; @@ -378,9 +391,9 @@ pub(crate) fn copy_on_write( match copy_method { CopyMethod::FSCopy => { - clone(source, dest, CloneFallback::FSCopy, nofollow, context) + clone(&mut src_file, dest, CloneFallback::FSCopy, context) } - _ => clone(source, dest, CloneFallback::SparseCopy, nofollow, context), + _ => clone(&mut src_file, dest, CloneFallback::SparseCopy, context), } } } @@ -391,12 +404,14 @@ pub(crate) fn copy_on_write( copy_debug.offload = OffloadReflinkDebug::Avoided; copy_stream(source, dest, nofollow, context) } else { - let result = handle_reflink_auto_sparse_never(source, nofollow); + let mut src_file = open_source(source, nofollow) + .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; + let result = handle_reflink_auto_sparse_never(&mut src_file); if let Ok(debug) = result { copy_debug = debug; } - clone(source, dest, CloneFallback::FSCopy, nofollow, context) + clone(&mut src_file, dest, CloneFallback::FSCopy, context) } } (ReflinkMode::Auto, SparseMode::Auto) => { @@ -404,8 +419,10 @@ pub(crate) fn copy_on_write( copy_debug.offload = OffloadReflinkDebug::Unsupported; copy_stream(source, dest, nofollow, context) } else { + let mut src_file = open_source(source, nofollow) + .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; let mut copy_method = CopyMethod::Default; - let result = handle_reflink_auto_sparse_auto(source, dest, nofollow); + let result = handle_reflink_auto_sparse_auto(&mut src_file, dest); if let Ok((debug, method)) = result { copy_debug = debug; copy_method = method; @@ -413,13 +430,12 @@ pub(crate) fn copy_on_write( match copy_method { CopyMethod::SparseCopyWithoutHole => clone( - source, + &mut src_file, dest, CloneFallback::SparseCopyWithoutHole, - nofollow, context, ), - _ => clone(source, dest, CloneFallback::FSCopy, nofollow, context), + _ => clone(&mut src_file, dest, CloneFallback::FSCopy, context), } } } @@ -428,7 +444,9 @@ pub(crate) fn copy_on_write( copy_debug.sparse_detection = SparseDebug::No; copy_debug.reflink = OffloadReflinkDebug::Yes; - clone(source, dest, CloneFallback::Error, nofollow, context) + let mut src_file = open_source(source, nofollow) + .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; + clone(&mut src_file, dest, CloneFallback::Error, context) } (ReflinkMode::Always, _) => { return Err(translate!("cp-error-reflink-always-sparse-auto").into()); @@ -441,18 +459,17 @@ pub(crate) fn copy_on_write( /// Handles debug results when flags are "--reflink=auto" and "--sparse=always" and specifies what /// type of copy should be used fn handle_reflink_auto_sparse_always( - source: &Path, + src_file: &mut File, dest: &Path, - nofollow: bool, -) -> Result<(CopyDebug, CopyMethod), std::io::Error> { +) -> io::Result<(CopyDebug, CopyMethod)> { let mut copy_debug = CopyDebug { offload: OffloadReflinkDebug::Unknown, reflink: OffloadReflinkDebug::Unsupported, sparse_detection: SparseDebug::Zeros, }; let mut copy_method = CopyMethod::Default; - let (data_flag, size, blocks) = check_for_data(source, nofollow)?; - let sparse_flag = check_sparse_detection(source, nofollow)?; + let (data_flag, size, blocks) = check_for_data(src_file)?; + let sparse_flag = check_sparse_detection(src_file)?; if data_flag || size < 512 { copy_debug.offload = OffloadReflinkDebug::Avoided; @@ -465,12 +482,8 @@ fn handle_reflink_auto_sparse_always( copy_debug.sparse_detection = SparseDebug::SeekHoleZeros; } (false, true, 0) => copy_method = CopyMethod::FSCopy, - - (true, false, 0) => copy_debug.sparse_detection = SparseDebug::SeekHole, (true, true, _) => copy_debug.sparse_detection = SparseDebug::SeekHoleZeros, - (true, false, _) => copy_debug.sparse_detection = SparseDebug::SeekHole, - (_, _, _) => (), } if check_dest_is_fifo(dest) { @@ -481,17 +494,14 @@ fn handle_reflink_auto_sparse_always( /// Handles debug results when flags are "--reflink=auto" and "--sparse=auto" and specifies what /// type of copy should be used -fn handle_reflink_never_sparse_never( - source: &Path, - nofollow: bool, -) -> Result { +fn handle_reflink_never_sparse_never(src_file: &mut File) -> io::Result { let mut copy_debug = CopyDebug { offload: OffloadReflinkDebug::Unknown, reflink: OffloadReflinkDebug::No, sparse_detection: SparseDebug::No, }; - let (data_flag, size, _blocks) = check_for_data(source, nofollow)?; - let sparse_flag = check_sparse_detection(source, nofollow)?; + let (data_flag, size, _blocks) = check_for_data(src_file)?; + let sparse_flag = check_sparse_detection(src_file)?; if sparse_flag { copy_debug.sparse_detection = SparseDebug::SeekHole; @@ -504,19 +514,16 @@ fn handle_reflink_never_sparse_never( } /// Handles debug results when flags are "--reflink=auto" and "--sparse=never", files will be copied -/// through cloning them with fallback switching to [`std::fs::copy`] -fn handle_reflink_auto_sparse_never( - source: &Path, - nofollow: bool, -) -> Result { +/// through cloning them with fallback switching to [`buf_copy::copy_fast`] +fn handle_reflink_auto_sparse_never(src_file: &mut File) -> io::Result { let mut copy_debug = CopyDebug { offload: OffloadReflinkDebug::Unknown, reflink: OffloadReflinkDebug::No, sparse_detection: SparseDebug::No, }; - let (data_flag, size, _blocks) = check_for_data(source, nofollow)?; - let sparse_flag = check_sparse_detection(source, nofollow)?; + let (data_flag, size, _blocks) = check_for_data(src_file)?; + let sparse_flag = check_sparse_detection(src_file)?; if sparse_flag { copy_debug.sparse_detection = SparseDebug::SeekHole; @@ -531,10 +538,9 @@ fn handle_reflink_auto_sparse_never( /// Handles debug results when flags are "--reflink=auto" and "--sparse=auto" and specifies what /// type of copy should be used fn handle_reflink_auto_sparse_auto( - source: &Path, + src_file: &mut File, dest: &Path, - nofollow: bool, -) -> Result<(CopyDebug, CopyMethod), std::io::Error> { +) -> io::Result<(CopyDebug, CopyMethod)> { let mut copy_debug = CopyDebug { offload: OffloadReflinkDebug::Unknown, reflink: OffloadReflinkDebug::Unsupported, @@ -542,8 +548,8 @@ fn handle_reflink_auto_sparse_auto( }; let mut copy_method = CopyMethod::Default; - let (data_flag, size, blocks) = check_for_data(source, nofollow)?; - let sparse_flag = check_sparse_detection(source, nofollow)?; + let (data_flag, size, blocks) = check_for_data(src_file)?; + let sparse_flag = check_sparse_detection(src_file)?; if (data_flag && size != 0) || (size > 0 && size < 512) { copy_debug.offload = OffloadReflinkDebug::Yes; @@ -575,18 +581,17 @@ fn handle_reflink_auto_sparse_auto( /// Handles debug results when flags are "--reflink=never" and "--sparse=auto" and specifies what /// type of copy should be used fn handle_reflink_never_sparse_auto( - source: &Path, + src_file: &mut File, dest: &Path, - nofollow: bool, -) -> Result<(CopyDebug, CopyMethod), std::io::Error> { +) -> io::Result<(CopyDebug, CopyMethod)> { let mut copy_debug = CopyDebug { offload: OffloadReflinkDebug::Unknown, reflink: OffloadReflinkDebug::No, sparse_detection: SparseDebug::No, }; - let (data_flag, size, blocks) = check_for_data(source, nofollow)?; - let sparse_flag = check_sparse_detection(source, nofollow)?; + let (data_flag, size, blocks) = check_for_data(src_file)?; + let sparse_flag = check_sparse_detection(src_file)?; let mut copy_method = CopyMethod::Default; if data_flag || size < 512 { @@ -612,10 +617,9 @@ fn handle_reflink_never_sparse_auto( /// Handles debug results when flags are "--reflink=never" and "--sparse=always" and specifies what /// type of copy should be used fn handle_reflink_never_sparse_always( - source: &Path, + src_file: &mut File, dest: &Path, - nofollow: bool, -) -> Result<(CopyDebug, CopyMethod), std::io::Error> { +) -> io::Result<(CopyDebug, CopyMethod)> { let mut copy_debug = CopyDebug { offload: OffloadReflinkDebug::Unknown, reflink: OffloadReflinkDebug::No, @@ -623,8 +627,8 @@ fn handle_reflink_never_sparse_always( }; let mut copy_method = CopyMethod::SparseCopy; - let (data_flag, size, blocks) = check_for_data(source, nofollow)?; - let sparse_flag = check_sparse_detection(source, nofollow)?; + let (data_flag, size, blocks) = check_for_data(src_file)?; + let sparse_flag = check_sparse_detection(src_file)?; if data_flag || size < 512 { copy_debug.offload = OffloadReflinkDebug::Avoided; diff --git a/src/uu/cp/src/platform/macos.rs b/src/uu/cp/src/platform/macos.rs index ea7f6e56e51..b338c2bffbc 100644 --- a/src/uu/cp/src/platform/macos.rs +++ b/src/uu/cp/src/platform/macos.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore reflink + use std::ffi::CString; use std::fs::{self, File, OpenOptions}; use std::os::unix::ffi::OsStrExt; @@ -32,9 +34,7 @@ pub(crate) fn copy_on_write( nofollow: bool, ) -> CopyResult { if sparse_mode != SparseMode::Auto { - return Err(translate!("cp-error-sparse-not-supported") - .to_string() - .into()); + return Err(translate!("cp-error-sparse-not-supported").into()); } let mut copy_debug = CopyDebug { offload: OffloadReflinkDebug::Unknown, @@ -54,16 +54,20 @@ pub(crate) fn copy_on_write( let raw_pfn = unsafe { libc::dlsym(libc::RTLD_NEXT, clonefile.as_ptr()) }; let mut error = 0; - if !raw_pfn.is_null() { + // `--reflink=never` must not clone: clonefile(2) COW-shares the source's blocks and copies + // its metadata (including mtime), so skip it here and fall through to a normal byte copy + // below, matching GNU/BSD `cp` (and the Linux path's ReflinkMode::Never handling). + let attempt_clone = !raw_pfn.is_null() && reflink_mode != ReflinkMode::Never; + if attempt_clone { // Call clonefile(2). // Safety: Casting a C function pointer to a rust function value is one of the few // blessed uses of `transmute()`. unsafe { let pfn: extern "C" fn( - src: *const libc::c_char, - dst: *const libc::c_char, + src: *const core::ffi::c_char, + dst: *const core::ffi::c_char, flags: u32, - ) -> libc::c_int = std::mem::transmute(raw_pfn); + ) -> core::ffi::c_int = std::mem::transmute(raw_pfn); error = pfn(src.as_ptr(), dst.as_ptr(), 0); if std::io::Error::last_os_error().kind() == std::io::ErrorKind::AlreadyExists // Only remove the `dest` if the `source` and `dest` are not the same @@ -95,7 +99,7 @@ pub(crate) fn copy_on_write( } } - if raw_pfn.is_null() || error != 0 { + if !attempt_clone || error != 0 { // clonefile(2) is either not supported or it errored out (possibly because the FS does not // support COW). if reflink_mode == ReflinkMode::Always { diff --git a/src/uu/cp/src/platform/mod.rs b/src/uu/cp/src/platform/mod.rs index e9dfdb875d4..ecc59dbfb1c 100644 --- a/src/uu/cp/src/platform/mod.rs +++ b/src/uu/cp/src/platform/mod.rs @@ -5,18 +5,18 @@ #[cfg(all( unix, - not(any(target_os = "macos", target_os = "linux", target_os = "android")) + not(any(target_vendor = "apple", target_os = "linux", target_os = "android")) ))] mod other_unix; #[cfg(all( unix, - not(any(target_os = "macos", target_os = "linux", target_os = "android")) + not(any(target_vendor = "apple", target_os = "linux", target_os = "android")) ))] pub(crate) use self::other_unix::copy_on_write; -#[cfg(target_os = "macos")] +#[cfg(target_vendor = "apple")] mod macos; -#[cfg(target_os = "macos")] +#[cfg(target_vendor = "apple")] pub(crate) use self::macos::copy_on_write; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -24,12 +24,17 @@ mod linux; #[cfg(any(target_os = "linux", target_os = "android"))] pub(crate) use self::linux::copy_on_write; -#[cfg(target_os = "windows")] +#[cfg(windows)] mod windows; -#[cfg(target_os = "windows")] +#[cfg(windows)] pub(crate) use self::windows::copy_on_write; -#[cfg(not(any(unix, target_os = "windows")))] +#[cfg(not(any(unix, windows)))] mod other; -#[cfg(not(any(unix, target_os = "windows")))] +#[cfg(not(any(unix, windows)))] pub(crate) use self::other::copy_on_write; + +#[cfg(target_os = "wasi")] +mod wasi; +#[cfg(target_os = "wasi")] +pub(crate) use self::wasi::create_symlink; diff --git a/src/uu/cp/src/platform/other.rs b/src/uu/cp/src/platform/other.rs index 143c35e185b..84e4b02e8b9 100644 --- a/src/uu/cp/src/platform/other.rs +++ b/src/uu/cp/src/platform/other.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore reflink + use std::fs; use std::path::Path; use uucore::translate; @@ -19,7 +21,7 @@ pub(crate) fn copy_on_write( sparse_mode: SparseMode, context: &str, ) -> CopyResult { - if reflink_mode != ReflinkMode::Never { + if reflink_mode == ReflinkMode::Always { return Err(translate!("cp-error-reflink-not-supported") .to_string() .into()); diff --git a/src/uu/cp/src/platform/other_unix.rs b/src/uu/cp/src/platform/other_unix.rs index 18615c4d196..754fa872e41 100644 --- a/src/uu/cp/src/platform/other_unix.rs +++ b/src/uu/cp/src/platform/other_unix.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore reflink + use std::path::Path; use uucore::buf_copy; diff --git a/src/uu/cp/src/platform/wasi.rs b/src/uu/cp/src/platform/wasi.rs new file mode 100644 index 00000000000..d0cfe4088a9 --- /dev/null +++ b/src/uu/cp/src/platform/wasi.rs @@ -0,0 +1,11 @@ +// 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; +use std::path::Path; + +pub(crate) fn create_symlink(source: &Path, dest: &Path) -> io::Result<()> { + rustix::fs::symlink(source, dest).map_err(io::Error::from) +} diff --git a/src/uu/cp/src/platform/windows.rs b/src/uu/cp/src/platform/windows.rs index cae0d4443f4..018c318745a 100644 --- a/src/uu/cp/src/platform/windows.rs +++ b/src/uu/cp/src/platform/windows.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore reflink misalign deleters + use std::fs::{File, OpenOptions}; use std::io::{Read, Seek, SeekFrom}; use std::os::windows::fs::{FileExt, MetadataExt, OpenOptionsExt}; @@ -166,11 +168,7 @@ fn sparse_copy(src_file: &mut File, dest: &Path) -> Result<(), SparseCopyError> // Detect holes at the destination volume's sparse allocation granularity. let mut buf = vec![0u8; sparse_block_size(dest)]; let mut offset: u64 = 0; - loop { - let read = read_full(src_file, &mut buf)?; - if read == 0 { - break; - } + while let read @ 1.. = read_full(src_file, &mut buf)? { let chunk = &buf[..read]; // Only write blocks that contain data; unwritten ranges remain holes. if chunk.iter().any(|&b| b != 0) { diff --git a/src/uu/csplit/Cargo.toml b/src/uu/csplit/Cargo.toml index 5105951fbe3..af9ee641124 100644 --- a/src/uu/csplit/Cargo.toml +++ b/src/uu/csplit/Cargo.toml @@ -11,19 +11,16 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/csplit.rs" doctest = false [dependencies] clap = { workspace = true } -thiserror = { workspace = true } +fluent = { workspace = true } regex = { workspace = true } +thiserror = { workspace = true } uucore = { workspace = true, features = ["entries", "fs", "format"] } -fluent = { workspace = true } [[bin]] name = "csplit" @@ -32,7 +29,13 @@ path = "src/main.rs" [dev-dependencies] divan = { workspace = true } tempfile = { workspace = true } -uucore = { workspace = true, features = ["benchmark"] } +uucore = { workspace = true, features = ["benchmark", "diagnostics"] } + +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true [[bench]] name = "csplit_bench" diff --git a/src/uu/csplit/locales/en-US.ftl b/src/uu/csplit/locales/en-US.ftl index ba0ce032e49..ec36a1df3ef 100644 --- a/src/uu/csplit/locales/en-US.ftl +++ b/src/uu/csplit/locales/en-US.ftl @@ -27,3 +27,8 @@ csplit-warning-line-number-same-as-previous = line number '{ $line_number }' is csplit-stream-not-utf8 = stream did not contain valid UTF-8 csplit-read-error = read error csplit-write-split-not-created = trying to write to a split that was not created + +csplit-diag-help-pattern = a pattern is a line number N, /REGEXP/[OFFSET] or %REGEXP%[OFFSET], each optionally followed by { "{" }N{ "}" } or { "{" }*{ "}" } +csplit-diag-help-line-number = line numbers start at 1 and must not decrease from one pattern to the next +csplit-diag-help-digits = --digits takes a decimal number of digits, as in -n 3 +csplit-diag-help-suffix-format = a suffix format is one printf conversion for an unsigned integer, as in %03u diff --git a/src/uu/csplit/locales/fr-FR.ftl b/src/uu/csplit/locales/fr-FR.ftl index 0c2e217ee8c..9f39f226d04 100644 --- a/src/uu/csplit/locales/fr-FR.ftl +++ b/src/uu/csplit/locales/fr-FR.ftl @@ -27,3 +27,8 @@ csplit-warning-line-number-same-as-previous = le numéro de ligne '{ $line_numbe csplit-stream-not-utf8 = le flux ne contenait pas d'UTF-8 valide csplit-read-error = erreur de lecture csplit-write-split-not-created = tentative d'écriture dans une division qui n'a pas été créée + +csplit-diag-help-pattern = un motif est un numéro de ligne N, /EXPR/[DÉCALAGE] ou %EXPR%[DÉCALAGE], chacun pouvant être suivi de { "{" }N{ "}" } ou { "{" }*{ "}" } +csplit-diag-help-line-number = les numéros de ligne commencent à 1 et ne doivent pas décroître d'un motif au suivant +csplit-diag-help-digits = --digits attend un nombre décimal de chiffres, comme -n 3 +csplit-diag-help-suffix-format = un format de suffixe est une seule conversion printf pour un entier non signé, comme %03u diff --git a/src/uu/csplit/src/csplit.rs b/src/uu/csplit/src/csplit.rs index b73b32176e6..ff709e9ff1b 100644 --- a/src/uu/csplit/src/csplit.rs +++ b/src/uu/csplit/src/csplit.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore rustdoc + #![allow(rustdoc::private_intra_doc_links)] use std::borrow::Borrow; @@ -21,6 +23,7 @@ use uucore::error::{FromIo, UResult}; use uucore::format_usage; mod csplit_error; +mod diagnostics; mod patterns; mod split_name; @@ -122,9 +125,15 @@ where let mut input_iter = InputSplitter::new(enumerated_input_lines); let mut split_writer = SplitWriter::new(options); let patterns_vec: Vec = patterns::get_patterns(patterns)?; - let all_up_to_line = patterns_vec - .iter() - .all(|p| matches!(p, patterns::Pattern::UpToLine(_, _))); + // A `{*}` regex only stops once it no longer matches, and by then it has + // consumed the rest of the input. + let repeats_forever = patterns_vec.iter().any(|p| { + matches!( + p, + patterns::Pattern::UpToMatch(_, _, patterns::ExecutePattern::Always) + | patterns::Pattern::SkipToMatch(_, _, patterns::ExecutePattern::Always) + ) + }); let ret = do_csplit(&mut split_writer, patterns_vec, &mut input_iter); // consume the rest, unless there was an error @@ -138,9 +147,9 @@ where split_writer.writeln(&line?)?; } 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 + } else if !repeats_forever { + // GNU semantics: even if no remaining input, create a final + // (possibly empty) split split_writer.new_writer()?; split_writer.finish_split() } else { @@ -149,6 +158,12 @@ where } else { ret }; + // GNU still reports the bytes written to the split that was in progress + // when the failure happened. + if ret.is_err() && !split_writer.split_finished { + let _ = split_writer.finish_split(); + } + // delete files on error by default if ret.is_err() && !options.keep_files { split_writer.delete_all_splits()?; @@ -237,6 +252,8 @@ struct SplitWriter<'a> { size: usize, /// flag to indicate that no content should be written to a split dev_null: bool, + /// whether the current split has already been accounted for + split_finished: bool, } impl Drop for SplitWriter<'_> { @@ -261,6 +278,7 @@ impl SplitWriter<'_> { current_writer: None, size: 0, dev_null: false, + split_finished: true, } } @@ -278,6 +296,7 @@ impl SplitWriter<'_> { self.counter += 1; self.size = 0; self.dev_null = false; + self.split_finished = false; Ok(()) } @@ -313,6 +332,7 @@ impl SplitWriter<'_> { /// /// Returns an error if flushing the writer fails. fn finish_split(&mut self) -> Result<(), CsplitError> { + self.split_finished = true; 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 { @@ -325,7 +345,7 @@ impl SplitWriter<'_> { if self.options.elide_empty_files && self.size == 0 { self.counter -= 1; } else if !self.options.quiet { - println!("{}", self.size); + writeln!(io::stdout(), "{}", self.size).map_err(CsplitError::IoError)?; } } Ok(()) @@ -480,7 +500,9 @@ impl SplitWriter<'_> { // but do not rewind it either since no match should be done within. // The consequence is that the buffer may already be full with lines from a previous // split, which is taken care of when calling `shrink_buffer_to_size`. - let offset_usize = -offset as usize; + let offset_usize = offset.unsigned_abs() as usize; + // lines of the current split before the match; a negative offset may not go past them + let mut lines_in_split = input_iter.buffer_len(); input_iter.set_size_of_buffer(offset_usize); while let Some((ln, line)) = input_iter.next() { let line = line?; @@ -488,6 +510,10 @@ impl SplitWriter<'_> { .strip_suffix("\r\n") .unwrap_or_else(|| line.strip_suffix('\n').unwrap_or(&line)); if regex.is_match(l) { + if offset_usize > lines_in_split { + self.finish_split()?; + return Err(CsplitError::LineOutOfRange(pattern_as_str.to_string())); + } for line in input_iter.shrink_buffer_to_size() { self.writeln(&line)?; } @@ -506,11 +532,9 @@ impl SplitWriter<'_> { } self.finish_split()?; - if input_iter.buffer_len() < offset_usize { - return Err(CsplitError::LineOutOfRange(pattern_as_str.to_string())); - } return Ok(()); } + lines_in_split += 1; if let Some(line) = input_iter.add_line_to_buffer(ln, line) { self.writeln(&line)?; } @@ -624,6 +648,10 @@ where #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let args: Vec = args.collect(); + // Kept for the caret in pattern diagnostics, which needs the operands as + // typed. + let diag_args = uucore::diagnostics::capture(&args); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; // get the file to split @@ -635,15 +663,31 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .unwrap() .map(Borrow::borrow) .collect(); - let options = CsplitOptions::new(&matches)?; + let operands = diagnostics::Operands { + patterns: &patterns, + suffix_format: matches + .get_one::(options::SUFFIX_FORMAT) + .map(String::as_str), + digits: matches + .get_one::(options::DIGITS) + .map(String::as_str), + }; + let report = |error| { + uucore::diagnostics::error_after_report(diag_args.as_deref(), error, |args, error| { + diagnostics::render(args, &operands, error) + }) + }; + + let options = CsplitOptions::new(&matches).map_err(report)?; if file_name == "-" { let stdin = io::stdin(); - Ok(csplit(&options, &patterns, stdin.lock())?) + csplit(&options, &patterns, stdin.lock()).map_err(report)?; } else { let file = File::open(file_name) .map_err_context(|| format!("cannot open {} for reading", file_name.quote()))?; - Ok(csplit(&options, &patterns, BufReader::new(file))?) + csplit(&options, &patterns, BufReader::new(file)).map_err(report)?; } + Ok(()) } pub fn uu_app() -> Command { @@ -659,6 +703,7 @@ pub fn uu_app() -> Command { .short('b') .long(options::SUFFIX_FORMAT) .value_name("FORMAT") + .allow_hyphen_values(true) .help(translate!("csplit-help-suffix-format")), ) .arg( diff --git a/src/uu/csplit/src/csplit_error.rs b/src/uu/csplit/src/csplit_error.rs index 0d7225b3368..61120c846ac 100644 --- a/src/uu/csplit/src/csplit_error.rs +++ b/src/uu/csplit/src/csplit_error.rs @@ -4,13 +4,30 @@ // file that was distributed with this source code. use std::io; -use thiserror::Error; +use std::ops::Range; use uucore::display::Quotable; use uucore::error::{UError, strip_errno}; use uucore::translate; +/// Where inside a pattern operand the caret belongs, and what to write under +/// it. +/// +/// `csplit: '/a(b/': invalid pattern` says nothing about which part of the +/// pattern the parser choked on. The parser works that out anyway, so it is +/// kept here for [`crate::diagnostics`] rather than thrown away; it never +/// changes the one-line message. +#[derive(Debug)] +pub struct PatternProblem { + /// Byte range inside the operand to point at. + pub span: Range, + /// What the regex engine said, when the operand was a well-formed + /// `/REGEXP/` or `%REGEXP%` whose regex did not compile. Not localized: + /// it is quoted from the engine, which has no translations. + pub label: Option, +} + /// Errors thrown by the csplit command -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum CsplitError { #[error("{}", strip_errno(_0))] IoError(#[from] io::Error), @@ -27,7 +44,7 @@ pub enum CsplitError { #[error("{}", translate!("csplit-error-line-number-smaller-than-previous", "current" => _0, "previous" => _1))] LineNumberSmallerThanPrevious(usize, usize), #[error("{}", translate!("csplit-error-invalid-pattern", "pattern" => _0.quote()))] - InvalidPattern(String), + InvalidPattern(String, Option), #[error("{}", translate!("csplit-error-invalid-number", "number" => _0.quote()))] InvalidNumber(String), #[error("{}", translate!("csplit-error-suffix-format-incorrect"))] diff --git a/src/uu/csplit/src/diagnostics.rs b/src/uu/csplit/src/diagnostics.rs new file mode 100644 index 00000000000..61f0417f9b5 --- /dev/null +++ b/src/uu/csplit/src/diagnostics.rs @@ -0,0 +1,236 @@ +// 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. + +//! Maps a [`CsplitError`] onto the operand it came from, so that +//! [`uucore::diagnostics`] can render it with a caret. + +use std::ffi::OsString; + +use uucore::diagnostics::{Snapshot, ValueOptions}; +use uucore::translate; + +use crate::csplit_error::CsplitError; + +/// The operands a caret may point at, as they were typed. +/// +/// The errors name a pattern or a format but not where it was written, so the +/// operands are carried alongside them to the point where the report is drawn. +pub struct Operands<'a> { + /// The pattern operands, in the order they appear on the line. + pub patterns: &'a [&'a str], + /// The value of `-b`/`--suffix-format`, when it was given. + pub suffix_format: Option<&'a str>, + /// The value of `-n`/`--digits`, when it was given. + pub digits: Option<&'a str>, +} + +/// The options whose value can be a separate argument, which an operand must +/// not be counted as. +const VALUE_OPTIONS: ValueOptions = ValueOptions { + shorts: &['b', 'f', 'n'], + longs: &["suffix-format", "prefix", "digits"], +}; + +/// Render `err` against `args` — the whole argument list, program name +/// included — where `operands` are the operands as typed. +/// +/// Returns `false` when the error cannot be tied to one of them, in which case +/// the caller should fall back to the plain one-line message. +pub fn render(args: &[OsString], operands: &Operands, err: &CsplitError) -> bool { + let snapshot = Snapshot::with_program(args); + let index_of_pattern = |pattern: &str| index_of_pattern(args, &snapshot, operands, pattern); + let message = err.to_string(); + + match err { + CsplitError::InvalidPattern(pattern, problem) => { + let Some(index) = index_of_pattern(pattern) else { + return false; + }; + // Without a problem there is no offset to trust, so the operand is + // underlined whole. + let Some(problem) = problem else { + return snapshot.render( + index, + &message, + None, + Some(&translate!("csplit-diag-help-pattern")), + ); + }; + snapshot.render_inside_at( + index, + pattern, + problem.span.clone(), + &message, + problem.label.as_deref(), + Some(&translate!("csplit-diag-help-pattern")), + ) + } + // Neither error says which operand it is about, so the pattern is + // found by the line number it carries. + CsplitError::LineNumberIsZero => render_line_number(args, &snapshot, operands, 0, &message), + CsplitError::LineNumberSmallerThanPrevious(current, _) => { + render_line_number(args, &snapshot, operands, *current, &message) + } + CsplitError::InvalidNumber(_) => { + let Some(digits) = operands.digits else { + return false; + }; + snapshot.render_option_value( + digits, + Some('n'), + Some("digits"), + 0..digits.len(), + &message, + None, + Some(&translate!("csplit-diag-help-digits")), + ) + } + // Both messages describe the format as a whole rather than a character + // of it, so the whole value is underlined. + CsplitError::SuffixFormatIncorrect | CsplitError::SuffixFormatTooManyPercents => { + let Some(format) = operands.suffix_format else { + return false; + }; + snapshot.render_option_value( + format, + Some('b'), + Some("suffix-format"), + 0..format.len(), + &message, + None, + Some(&translate!("csplit-diag-help-suffix-format")), + ) + } + // Raised while reading the input rather than while parsing the line, + // and named by the pattern as csplit rewrites it rather than as it was + // typed, so there is nothing to point a caret at. + _ => false, + } +} + +/// Render `message` against the pattern operand holding the line number +/// `line_number`. +fn render_line_number( + args: &[OsString], + snapshot: &Snapshot, + operands: &Operands, + line_number: usize, + message: &str, +) -> bool { + let Some(pattern) = operands + .patterns + .iter() + .find(|p| p.parse::() == Ok(line_number)) + else { + return false; + }; + let Some(index) = index_of_pattern(args, snapshot, operands, pattern) else { + return false; + }; + snapshot.render( + index, + message, + None, + Some(&translate!("csplit-diag-help-line-number")), + ) +} + +/// Where in `args` the pattern operand `pattern` sits. +/// +/// The patterns are the operands following FILE. The argument arrived at is +/// checked against the pattern as typed, so a walk thrown off by a spelling +/// [`VALUE_OPTIONS`] does not know draws nothing rather than the wrong thing. +fn index_of_pattern( + args: &[OsString], + snapshot: &Snapshot, + operands: &Operands, + pattern: &str, +) -> Option { + let nth = operands.patterns.iter().position(|p| *p == pattern)?; + // FILE is the first operand; the patterns follow it. + let index = snapshot.index_of_operand(nth + 1, &VALUE_OPTIONS)?; + (args[index].as_encoded_bytes() == pattern.as_bytes()).then_some(index) +} + +#[cfg(test)] +mod tests { + use super::{Operands, VALUE_OPTIONS, index_of_pattern}; + use std::ffi::OsString; + use uucore::diagnostics::Snapshot; + + /// The index the caret would be drawn at for `pattern`, given a command + /// line and the patterns clap picked out of it. + fn index(args: &[&str], patterns: &[&str], pattern: &str) -> Option { + let args: Vec = args.iter().map(OsString::from).collect(); + let operands = Operands { + patterns, + suffix_format: None, + digits: None, + }; + index_of_pattern(&args, &Snapshot::with_program(&args), &operands, pattern) + } + + #[test] + fn a_separate_option_value_is_not_a_pattern() { + // Without the option table, the `3` would be taken for the first + // pattern and the caret drawn on it. + assert_eq!( + index(&["csplit", "-n", "3", "file", "5"], &["5"], "5"), + Some(4) + ); + assert_eq!(index(&["csplit", "-n3", "file", "5"], &["5"], "5"), Some(3)); + assert_eq!( + index(&["csplit", "--digits", "3", "file", "5"], &["5"], "5"), + Some(4) + ); + assert_eq!( + index(&["csplit", "--digits=3", "file", "5"], &["5"], "5"), + Some(3) + ); + // An abbreviated long name still takes its value. + assert_eq!( + index(&["csplit", "--dig", "3", "file", "5"], &["5"], "5"), + Some(4) + ); + } + + #[test] + fn a_flag_takes_nothing_and_a_cluster_ends_in_its_value() { + assert_eq!(index(&["csplit", "-k", "file", "5"], &["5"], "5"), Some(3)); + assert_eq!( + index(&["csplit", "-kn", "3", "file", "5"], &["5"], "5"), + Some(4) + ); + assert_eq!( + index(&["csplit", "-kn3", "file", "5"], &["5"], "5"), + Some(3) + ); + } + + #[test] + fn the_patterns_are_counted_in_order_after_the_file() { + let args = ["csplit", "notes.txt", "3", "1"]; + assert_eq!(index(&args, &["3", "1"], "3"), Some(2)); + assert_eq!(index(&args, &["3", "1"], "1"), Some(3)); + } + + #[test] + fn a_walk_landing_elsewhere_draws_nothing() { + // The patterns clap reports do not line up with the line: rather than + // blame whatever sits there, nothing is drawn. + assert_eq!(index(&["csplit", "notes.txt"], &["5"], "5"), None); + } + + #[test] + fn the_option_table_names_every_value_taking_option() { + for option in VALUE_OPTIONS.longs { + assert!(VALUE_OPTIONS.takes_next(&format!("--{option}"))); + } + for short in VALUE_OPTIONS.shorts { + assert!(VALUE_OPTIONS.takes_next(&format!("-{short}"))); + } + assert!(!VALUE_OPTIONS.takes_next("-k")); + } +} diff --git a/src/uu/csplit/src/patterns.rs b/src/uu/csplit/src/patterns.rs index e13d3c1f226..2574b25b87b 100644 --- a/src/uu/csplit/src/patterns.rs +++ b/src/uu/csplit/src/patterns.rs @@ -2,9 +2,12 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (regex) SKIPTO UPTO ; (vars) ntimes -use crate::csplit_error::CsplitError; +use std::ops::Range; + +use crate::csplit_error::{CsplitError, PatternProblem}; use regex::Regex; use uucore::show_warning; use uucore::translate; @@ -103,8 +106,9 @@ pub fn get_patterns(args: &[&str]) -> Result, CsplitError> { fn extract_patterns(args: &[&str]) -> Result, CsplitError> { let mut patterns = Vec::with_capacity(args.len()); + // An empty regex between the delimiters is allowed and matches every line. let to_match_reg = - Regex::new(r"^(/(?P.+)/|%(?P.+)%)(?P[\+-]?[0-9]+)?$").unwrap(); + Regex::new(r"^(/(?P.*)/|%(?P.*)%)(?P[\+-]?[0-9]+)?$").unwrap(); let execute_ntimes_reg = Regex::new(r"^\{(?P[0-9]+)|\*\}$").unwrap(); let mut iter = args.iter().copied().peekable(); @@ -137,22 +141,80 @@ fn extract_patterns(args: &[&str]) -> Result, CsplitError> { }; if let Some(up_to_match) = captures.name("UPTO") { let pattern = Regex::new(up_to_match.as_str()) - .map_err(|_| CsplitError::InvalidPattern(arg.to_owned()))?; + .map_err(|e| invalid_regex(arg, up_to_match.range(), &e))?; patterns.push(Pattern::UpToMatch(pattern, offset, execute_ntimes)); } else if let Some(skip_to_match) = captures.name("SKIPTO") { let pattern = Regex::new(skip_to_match.as_str()) - .map_err(|_| CsplitError::InvalidPattern(arg.to_owned()))?; + .map_err(|e| invalid_regex(arg, skip_to_match.range(), &e))?; patterns.push(Pattern::SkipToMatch(pattern, offset, execute_ntimes)); } } else if let Ok(line_number) = arg.parse::() { patterns.push(Pattern::UpToLine(line_number, execute_ntimes)); } else { - return Err(CsplitError::InvalidPattern(arg.to_owned())); + // Nothing about the operand parsed, so the whole of it is at fault. + return Err(CsplitError::InvalidPattern( + arg.to_owned(), + Some(PatternProblem { + span: 0..arg.len(), + label: None, + }), + )); } } Ok(patterns) } +/// The error for a `/REGEXP/` or `%REGEXP%` operand whose regex did not +/// compile, told where inside `arg` the caret belongs. +/// +/// # Arguments +/// +/// * `arg` - The pattern operand as typed. +/// * `body` - Byte range of the regex inside `arg`, delimiters excluded. +/// * `error` - What the regex engine returned. +fn invalid_regex(arg: &str, body: Range, error: ®ex::Error) -> CsplitError { + let text = error.to_string(); + let span = caret_span(&text, &arg[body.clone()]) + .map_or(body.clone(), |s| body.start + s.start..body.start + s.end); + CsplitError::InvalidPattern( + arg.to_owned(), + Some(PatternProblem { + span, + label: text + .lines() + .find_map(|line| line.strip_prefix("error: ")) + .map(str::to_owned), + }), + ) +} + +/// The range of `regex` that the engine's own caret line marks. +/// +/// A regex error is rendered as the pattern echoed under a four-space indent +/// with a row of carets beneath it, which is exactly the span wanted here. It +/// is read back rather than trusted: the echoed line has to be the regex as +/// passed, or `None` is returned and the caller falls back to the whole of it. +/// +/// # Arguments +/// +/// * `text` - The engine's message. +/// * `regex` - The regex it was given. +fn caret_span(text: &str, regex: &str) -> Option> { + const INDENT: &str = " "; + let echoed = format!("{INDENT}{regex}"); + let mut lines = text.lines(); + while let Some(line) = lines.next() { + if line != echoed { + continue; + } + let carets = lines.next()?; + let start = carets.find('^')?; + let width = carets[start..].bytes().take_while(|&b| b == b'^').count(); + return start.checked_sub(INDENT.len()).map(|s| s..s + width); + } + None +} + /// Asserts the line numbers are in increasing order, starting at 1. fn validate_line_numbers(patterns: &[Pattern]) -> Result<(), CsplitError> { patterns diff --git a/src/uu/csplit/src/split_name.rs b/src/uu/csplit/src/split_name.rs index 33c606b4888..d2c400dad20 100644 --- a/src/uu/csplit/src/split_name.rs +++ b/src/uu/csplit/src/split_name.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (regex) diuox use uucore::format::{Format, FormatError, num_format::UnsignedInt}; diff --git a/src/uu/cut/Cargo.toml b/src/uu/cut/Cargo.toml index 66165a48e83..970cf1e744d 100644 --- a/src/uu/cut/Cargo.toml +++ b/src/uu/cut/Cargo.toml @@ -11,24 +11,27 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/cut.rs" doctest = false [dependencies] -clap = { workspace = true } -uucore = { workspace = true, features = ["ranges"] } -memchr = { workspace = true } bstr = { workspace = true } +clap = { workspace = true } fluent = { workspace = true } +memchr = { workspace = true } +uucore = { workspace = true, features = ["i18n-charmap", "parser", "ranges"] } [dev-dependencies] divan = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true + [[bin]] name = "cut" path = "src/main.rs" diff --git a/src/uu/cut/benches/cut_bench.rs b/src/uu/cut/benches/cut_bench.rs index c0288a8546e..649b8089119 100644 --- a/src/uu/cut/benches/cut_bench.rs +++ b/src/uu/cut/benches/cut_bench.rs @@ -5,7 +5,7 @@ use divan::{Bencher, black_box}; use uu_cut::uumain; -use uucore::benchmark::{run_util_function, setup_test_file, text_data}; +use uucore::benchmark::{get_bench_args, setup_test_file, text_data}; /// Benchmark cutting specific byte ranges #[divan::bench] @@ -13,12 +13,9 @@ fn cut_bytes(bencher: Bencher) { let data = text_data::generate_by_lines(100_000, 80); let file_path = setup_test_file(&data); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-b", "1-20", file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-b", &"1-20", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark cutting specific character ranges @@ -27,12 +24,30 @@ fn cut_characters(bencher: Bencher) { let data = text_data::generate_mixed_data(100_000); let file_path = setup_test_file(&data); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-c", "5-30", file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-c", &"5-30", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); +} + +/// Benchmark cutting a range that both starts and ends inside long multi-byte +/// lines. `cut_characters` uses short lines and a range running past their end, +/// so it measures the fixed per-line cost; here the character walk dominates. +#[divan::bench] +fn cut_characters_long_lines(bencher: Bencher) { + let mut data = Vec::new(); + for i in 0..40_000 { + // Roughly 100 characters a line, a third of them multi-byte, so that + // neither end of the range lands on a byte offset equal to its + // character position. + let line = format!("{}école-piñata-{i:05}-Straße-", "aéiöu".repeat(16)); + data.extend_from_slice(line.as_bytes()); + data.push(b'\n'); + } + let file_path = setup_test_file(&data); + + bencher + .with_inputs(|| get_bench_args(&[&"-c", &"20-70", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark cutting fields with tab delimiter @@ -45,12 +60,9 @@ fn cut_fields_tab(bencher: Bencher) { } let file_path = setup_test_file(&data); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-f", "2,4", file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-f", &"2,4", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark cutting fields with custom delimiter @@ -63,12 +75,9 @@ fn cut_fields_custom_delim(bencher: Bencher) { } let file_path = setup_test_file(&data); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-d", ",", "-f", "1,3,5", file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-d", &",", &"-f", &"1,3,5", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark cutting fields with newline delimiter @@ -81,12 +90,9 @@ fn cut_fields_newline_delim(bencher: Bencher) { } let file_path = setup_test_file(&data); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-d", "\n", "-f", "1,3,5", file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-d", &"\n", &"-f", &"1,3,5", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/cut/locales/en-US.ftl b/src/uu/cut/locales/en-US.ftl index d320fc86d11..6e63feb2638 100644 --- a/src/uu/cut/locales/en-US.ftl +++ b/src/uu/cut/locales/en-US.ftl @@ -1,114 +1,54 @@ -cut-about = Prints specified byte or field columns from each line of stdin or the input files +cut-about = Extract sections from each line of files. cut-usage = cut OPTION... [FILE]... -cut-after-help = Each call must specify a mode (what to use for columns), - a sequence (which columns to print), and provide a data source +cut-after-help = If no FILE is specified, reads from stdin. Use '-' as a FILE argument to include stdin. - ### Specifying a mode + LIST is a comma-separated list of numbers or ranges: - Use --bytes (-b) or --characters (-c) to specify byte mode + N only column N + N- columns N through the end of the line + N-M columns N through M + -M columns 1 through M - Use --fields (-f) to specify field mode, where each line is broken into - fields identified by a delimiter character. For example for a typical CSV - you could use this in combination with setting comma as the delimiter + Examples: - ### Specifying a sequence - - A sequence is a group of 1 or more numbers or inclusive ranges separated - by a commas. - - cut -f 2,5-7 some_file.txt - - will display the 2nd, 5th, 6th, and 7th field for each source line - - Ranges can extend to the end of the row by excluding the second number - - cut -f 3- some_file.txt - - will display the 3rd field and all fields after for each source line - - The first number of a range can be excluded, and this is effectively the - same as using 1 as the first number: it causes the range to begin at the - first column. Ranges can also display a single column - - cut -f 1,3-5 some_file.txt - - will display the 1st, 3rd, 4th, and 5th field for each source line - - The --complement option, when used, inverts the effect of the sequence - - cut --complement -f 4-6 some_file.txt - - will display the every field but the 4th, 5th, and 6th - - ### Specifying a data source - - If no sourcefile arguments are specified, stdin is used as the source of - lines to print - - If sourcefile arguments are specified, stdin is ignored and all files are - read in consecutively if a sourcefile is not successfully read, a warning - will print to stderr, and the eventual status code will be 1, but cut - will continue to read through proceeding sourcefiles - - To print columns from both STDIN and a file argument, use - (dash) as a - sourcefile argument to represent stdin. - - ### Field Mode options - - The fields in each line are identified by a delimiter (separator) - - #### Set the delimiter - - Set the delimiter which separates fields in the file using the - --delimiter (-d) option. Setting the delimiter is optional. - If not set, a default delimiter of Tab will be used. - - If the -w option is provided, fields will be separated by any number - of whitespace characters (Space and Tab). The output delimiter will - be a Tab unless explicitly specified. Only one of -d or -w option can be specified. - This is an extension adopted from FreeBSD. - - #### Optionally Filter based on delimiter - - If the --only-delimited (-s) flag is provided, only lines which - contain the delimiter will be printed - - #### Replace the delimiter - - If the --output-delimiter option is provided, the argument used for - it will replace the delimiter character in each line printed. This is - useful for transforming tabular data - e.g. to convert a CSV to a - TSV (tab-separated file) - - ### Line endings - - When the --zero-terminated (-z) option is used, cut sees \\0 (null) as the - 'line ending' character (both for the purposes of reading lines and - separating printed lines) instead of \\n (newline). This is useful for - tabular data where some of the cells may contain newlines - - echo 'ab\\0cd' | cut -z -c 1 - - will result in 'a\\0c\\0' + cut -f 2,5-7 file.txt extract fields 2, 5, 6, and 7 + cut -f 3- file.txt extract field 3 to the end of the line + cut --complement -f 4-6 file.txt extract all fields except 4, 5, and 6 + cut -d',' -f1 file.csv extract first field from a CSV # Help messages -cut-help-bytes = filter byte columns from the input source -cut-help-characters = alias for character mode -cut-help-delimiter = specify the delimiter character that separates fields in the input source. Defaults to Tab. -cut-help-whitespace-delimited = Use any number of whitespace (Space, Tab) to separate fields in the input source (FreeBSD extension). -cut-help-fields = filter field columns from the input source -cut-help-complement = invert the filter - instead of displaying only the filtered columns, display all but those columns -cut-help-only-delimited = in field mode, only print lines which contain the delimiter -cut-help-zero-terminated = instead of filtering columns based on line, filter columns based on \\0 (NULL character) -cut-help-output-delimiter = in field mode, replace the delimiter in output lines with this option's argument +cut-help-bytes = extract bytes listed in LIST +cut-help-characters = extract characters listed in LIST +cut-help-delimiter = use DELIM as field separator (default: Tab) +cut-help-whitespace-delimited = use any whitespace (Space/Tab) as delimiter; ignore leading and trailing blanks with 'trimmed' +cut-help-fields = extract fields listed in LIST +cut-help-fields-merged = like -f, but merge adjacent delimiters; the delimiter defaults to whitespace and the output delimiter to a space +cut-help-complement = invert selection: print all columns except the specified ones +cut-help-only-delimited = suppress lines that do not contain the delimiter +cut-help-zero-terminated = use NULL (\0) instead of newline as line terminator +cut-help-output-delimiter = replace input delimiter with NEW_DELIM in output +cut-help-no-partial = with -b, do not output partial multi-byte characters # Error messages cut-error-is-directory = Is a directory cut-error-write-error = write error -cut-error-delimiter-and-whitespace-conflict = invalid input: Only one of --delimiter (-d) or -w option can be specified +cut-error-delimiter-and-whitespace-conflict = -d and -w are mutually exclusive cut-error-delimiter-must-be-single-character = the delimiter must be a single character -cut-error-multiple-mode-args = invalid usage: expects no more than one of --fields (-f), --chars (-c) or --bytes (-b) -cut-error-missing-mode-arg = invalid usage: expects one of --fields (-f), --chars (-c) or --bytes (-b) -cut-error-delimiter-only-with-fields = invalid input: The '--delimiter' ('-d') option can only be used when printing a sequence of fields -cut-error-whitespace-only-with-fields = invalid input: The '-w' option can only be used when printing a sequence of fields -cut-error-only-delimited-only-with-fields = invalid input: The '--only-delimited' ('-s') option can only be used when printing a sequence of fields +cut-error-multiple-mode-args = only one list may be specified +cut-error-missing-mode-arg = you must specify a list of bytes, characters, or fields +cut-error-delimiter-only-with-fields = an input delimiter makes sense{ "\u000A\u0009" }only when operating on fields +cut-error-only-delimited-only-with-fields = suppressing non-delimited lines makes sense{ "\u000A\u0009" }only when operating on fields +cut-error-field-numbered-from-1 = fields are numbered from 1 +cut-error-position-numbered-from-1 = byte/character positions are numbered from 1 +cut-error-invalid-field-range = invalid field range +cut-error-invalid-position-range = invalid byte or character range +cut-error-invalid-decreasing-range = invalid decreasing range +cut-error-invalid-range-no-endpoint = invalid range with no endpoint: { $range } +cut-error-invalid-field-value = invalid field value { $value } +cut-error-invalid-position-value = invalid byte/character position { $value } +cut-error-field-number-too-large = field number { $value } is too large +cut-error-position-too-large = byte/character offset { $value } is too large + +# Diagnostic labels: what the caret points at in a list of ranges +cut-diag-label-zero-bound = counting starts at 1 +cut-diag-help-list-syntax = a list is N, N-M, N- or -M, separated by commas, as in -f1,4-6,9- diff --git a/src/uu/cut/locales/fr-FR.ftl b/src/uu/cut/locales/fr-FR.ftl index a95773099d6..8428bb9c0fc 100644 --- a/src/uu/cut/locales/fr-FR.ftl +++ b/src/uu/cut/locales/fr-FR.ftl @@ -3,7 +3,7 @@ cut-usage = cut OPTION... [FICHIER]... cut-after-help = Chaque appel doit spécifier un mode (quoi utiliser pour les colonnes), une séquence (quelles colonnes afficher), et fournir une source de données - ### Spécifier un mode + Spécifier un mode : Utilisez --bytes (-b) ou --characters (-c) pour spécifier le mode octet @@ -11,7 +11,7 @@ cut-after-help = Chaque appel doit spécifier un mode (quoi utiliser pour les co champs identifiés par un caractère délimiteur. Par exemple pour un CSV typique vous pourriez utiliser ceci en combinaison avec la définition de la virgule comme délimiteur - ### Spécifier une séquence + Spécifier une séquence : Une séquence est un groupe de 1 ou plusieurs nombres ou plages inclusives séparés par des virgules. @@ -40,7 +40,7 @@ cut-after-help = Chaque appel doit spécifier un mode (quoi utiliser pour les co affichera tous les champs sauf les 4ème, 5ème, et 6ème - ### Spécifier une source de données + Spécifier une source de données : Si aucun argument de fichier source n'est spécifié, stdin est utilisé comme source de lignes à afficher @@ -53,11 +53,11 @@ cut-after-help = Chaque appel doit spécifier un mode (quoi utiliser pour les co Pour afficher les colonnes depuis STDIN et un argument de fichier, utilisez - (tiret) comme argument de fichier source pour représenter stdin. - ### Options du Mode Champ + Options du mode champ : Les champs dans chaque ligne sont identifiés par un délimiteur (séparateur) - #### Définir le délimiteur + Définir le délimiteur : Définissez le délimiteur qui sépare les champs dans le fichier en utilisant l'option --delimiter (-d). Définir le délimiteur est optionnel. @@ -68,19 +68,19 @@ cut-after-help = Chaque appel doit spécifier un mode (quoi utiliser pour les co un Tab sauf si explicitement spécifié. Seulement une des options -d ou -w peut être spécifiée. Ceci est une extension adoptée de FreeBSD. - #### Filtrage optionnel basé sur le délimiteur + Filtrage optionnel basé sur le délimiteur : Si l'option --only-delimited (-s) est fournie, seules les lignes qui contiennent le délimiteur seront affichées - #### Remplacer le délimiteur + Remplacer le délimiteur : Si l'option --output-delimiter est fournie, l'argument utilisé pour elle remplacera le caractère délimiteur dans chaque ligne affichée. Ceci est utile pour transformer les données tabulaires - par ex. pour convertir un CSV en TSV (fichier séparé par tabulations) - ### Fins de ligne + Fins de ligne : Quand l'option --zero-terminated (-z) est utilisée, cut voit \\0 (null) comme le caractère de 'fin de ligne' (à la fois pour lire les lignes et @@ -97,18 +97,33 @@ cut-help-characters = alias pour le mode caractère cut-help-delimiter = spécifier le caractère délimiteur qui sépare les champs dans la source d'entrée. Par défaut Tab. cut-help-whitespace-delimited = Utiliser tout nombre d'espaces (Espace, Tab) pour séparer les champs dans la source d'entrée (extension FreeBSD). cut-help-fields = filtrer les colonnes de champs depuis la source d'entrée +cut-help-fields-merged = comme -f, mais fusionne les délimiteurs adjacents ; le délimiteur par défaut est l'espacement et le délimiteur de sortie un espace cut-help-complement = inverser le filtre - au lieu d'afficher seulement les colonnes filtrées, afficher toutes sauf ces colonnes cut-help-only-delimited = en mode champ, afficher seulement les lignes qui contiennent le délimiteur cut-help-zero-terminated = au lieu de filtrer les colonnes basées sur la ligne, filtrer les colonnes basées sur \\0 (caractère NULL) cut-help-output-delimiter = en mode champ, remplacer le délimiteur dans les lignes de sortie avec l'argument de cette option +cut-help-no-partial = avec -b, ne pas afficher les caractères multi-octets partiels # Messages d'erreur cut-error-is-directory = Est un répertoire cut-error-write-error = erreur d'écriture -cut-error-delimiter-and-whitespace-conflict = entrée invalide : Seulement une des options --delimiter (-d) ou -w peut être spécifiée +cut-error-delimiter-and-whitespace-conflict = -d et -w sont mutuellement exclusifs cut-error-delimiter-must-be-single-character = le délimiteur doit être un caractère unique -cut-error-multiple-mode-args = usage invalide : attend au plus une des options --fields (-f), --chars (-c) ou --bytes (-b) -cut-error-missing-mode-arg = usage invalide : attend une des options --fields (-f), --chars (-c) ou --bytes (-b) -cut-error-delimiter-only-with-fields = entrée invalide : L'option '--delimiter' ('-d') n'est utilisable que si on affiche une séquence de champs -cut-error-whitespace-only-with-fields = entrée invalide : L'option '-w' n'est utilisable que si on affiche une séquence de champs -cut-error-only-delimited-only-with-fields = entrée invalide : L'option '--only-delimited' ('-s') n'est utilisable que si on affiche une séquence de champs +cut-error-multiple-mode-args = une seule liste peut être spécifiée +cut-error-missing-mode-arg = vous devez spécifier une liste d'octets, de caractères ou de champs +cut-error-delimiter-only-with-fields = un délimiteur d'entrée n'a de sens{ "\u000A\u0009" }que lors d'opérations sur des champs +cut-error-only-delimited-only-with-fields = supprimer les lignes sans délimiteur n'a de sens{ "\u000A\u0009" }que lors d'opérations sur des champs +cut-error-field-numbered-from-1 = les champs sont numérotés à partir de 1 +cut-error-position-numbered-from-1 = les positions d'octet/caractère sont numérotées à partir de 1 +cut-error-invalid-field-range = plage de champs invalide +cut-error-invalid-position-range = plage d'octets ou de caractères invalide +cut-error-invalid-decreasing-range = plage décroissante invalide +cut-error-invalid-range-no-endpoint = plage invalide sans borne : { $range } +cut-error-invalid-field-value = valeur de champ invalide { $value } +cut-error-invalid-position-value = position d'octet/caractère invalide { $value } +cut-error-field-number-too-large = le numéro de champ { $value } est trop grand +cut-error-position-too-large = le décalage d'octet/caractère { $value } est trop grand + +# Étiquettes de diagnostic : ce que le caret désigne dans une liste d'intervalles +cut-diag-label-zero-bound = le décompte commence à 1 +cut-diag-help-list-syntax = une liste s'écrit N, N-M, N- ou -M, séparés par des virgules, comme -f1,4-6,9- diff --git a/src/uu/cut/src/cut.rs b/src/uu/cut/src/cut.rs index c87c7e8f7fe..b3033cbd466 100644 --- a/src/uu/cut/src/cut.rs +++ b/src/uu/cut/src/cut.rs @@ -3,22 +3,25 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) delim sourcefiles undelimited +// spell-checker:ignore (ToDO) delim foxjumping sourcefiles undelimited xacfoxjumping use bstr::io::BufReadExt; -use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser}; +use clap::builder::{PossibleValue, ValueParser}; +use clap::{Arg, ArgAction, ArgMatches, Command}; use std::ffi::OsString; use std::fs::File; use std::io::{BufRead, BufReader, BufWriter, IsTerminal, Read, Write, stdin, stdout}; use std::path::Path; use uucore::display::Quotable; -use uucore::error::{FromIo, UResult, USimpleError, set_exit_code}; +use uucore::error::{FromIo, UResult, USimpleError, UUsageError, set_exit_code, strip_errno}; +use uucore::i18n::charmap::{Encoding, locale_encoding, mb_char_len}; use uucore::line_ending::LineEnding; use uucore::os_str_as_bytes; +use uucore::parser::shortcut_value_parser::ShortcutValueParser; use self::searcher::Searcher; -use matcher::{ExactMatcher, Matcher, WhitespaceMatcher}; -use uucore::ranges::Range; +use matcher::{ExactMatcher, Matcher, MbExactMatcher, WhitespaceMatcher}; +use uucore::ranges::{Range, RangeError, RangeErrorKind}; use uucore::translate; use uucore::{format_usage, show_error, show_if_err}; @@ -29,6 +32,8 @@ struct Options<'a> { out_delimiter: Option<&'a [u8]>, line_ending: LineEnding, field_opts: Option>, + /// `-n`: with `-b`, do not split multi-byte characters across the selection. + suppress_split: bool, } enum Delimiter<'a> { @@ -39,6 +44,8 @@ enum Delimiter<'a> { struct FieldOptions<'a> { delimiter: Delimiter<'a>, only_delimited: bool, + /// `--whitespace-delimited=trimmed`: strip leading/trailing blanks per line. + whitespace_trimmed: bool, } enum Mode<'a> { @@ -59,12 +66,201 @@ impl<'a> From<&'a OsString> for Delimiter<'a> { } } -fn list_to_ranges(list: &str, complement: bool) -> Result, String> { - if complement { - Range::from_list(list).map(|r| uucore::ranges::complement(&r)) +/// GNU's range-list diagnostics, worded for field mode (`-f`/`-F`) or for +/// byte/character mode (`-b`/`-c`). +struct RangeErrors { + numbered_from_1: &'static str, + invalid_range: &'static str, + invalid_value: &'static str, + too_large: &'static str, +} + +const FIELD_ERRORS: RangeErrors = RangeErrors { + numbered_from_1: "cut-error-field-numbered-from-1", + invalid_range: "cut-error-invalid-field-range", + invalid_value: "cut-error-invalid-field-value", + too_large: "cut-error-field-number-too-large", +}; + +const POSITION_ERRORS: RangeErrors = RangeErrors { + numbered_from_1: "cut-error-position-numbered-from-1", + invalid_range: "cut-error-invalid-position-range", + invalid_value: "cut-error-invalid-position-value", + too_large: "cut-error-position-too-large", +}; + +/// Fill in a message that names the offending text. Quoting it also keeps the +/// number away from Fluent's numeric formatting. +fn about(key: &str, text: &str) -> String { + translate!(key, "value" => text.quote()) +} + +/// Split the leading run of ASCII digits off `s`, returning it along with the +/// rest of `s`. The run is empty when `s` does not start with a digit. +fn split_digits(s: &str) -> (&str, &str) { + s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len())) +} + +/// One item of a range list that does not parse: GNU's wording for it, plus +/// which label a caret should carry. The span is added by the caller, which is +/// the only place that knows where in the list the item sat. +struct ItemError { + message: String, + kind: RangeErrorKind, +} + +/// Parse one endpoint of a range, `default` standing in for an omitted one. +/// GNU rejects `usize::MAX` itself, so the highest value taken is `MAX - 1`. +fn parse_endpoint(digits: &str, default: usize, errors: &RangeErrors) -> Result { + if digits.is_empty() { + return Ok(default); + } + match digits.parse::() { + Ok(n @ ..usize::MAX) => Ok(n), + _ => Err(ItemError { + message: about(errors.too_large, digits), + kind: RangeErrorKind::TooLarge, + }), + } +} + +/// Parse a single `N`, `N-`, `-N` or `N-M` item into a range. +fn parse_range(item: &str, errors: &RangeErrors) -> Result { + let (low_digits, rest) = split_digits(item); + // GNU reports everything it could not consume, not just the first byte. + if low_digits.is_empty() && !rest.starts_with('-') { + return Err(ItemError { + message: about(errors.invalid_value, item), + kind: RangeErrorKind::NotANumber, + }); + } + let Some(high_part) = rest.strip_prefix('-') else { + // No dash at all: a lone number, or trailing junk after it. + if !rest.is_empty() { + return Err(ItemError { + message: about(errors.invalid_value, rest), + kind: RangeErrorKind::NotANumber, + }); + } + let n = parse_endpoint(low_digits, 0, errors)?; + return Ok(Range { low: n, high: n }); + }; + + let (high_digits, tail) = split_digits(high_part); + if !tail.is_empty() { + // A second dash makes the item a malformed range, anything else a bad + // value. + return Err(if tail.starts_with('-') { + ItemError { + message: translate!(errors.invalid_range), + kind: RangeErrorKind::NoEndpoint, + } + } else { + ItemError { + message: about(errors.invalid_value, tail), + kind: RangeErrorKind::NotANumber, + } + }); + } + if low_digits.is_empty() && high_digits.is_empty() { + return Err(ItemError { + message: translate!("cut-error-invalid-range-no-endpoint", "range" => item), + kind: RangeErrorKind::NoEndpoint, + }); + } + + let low = parse_endpoint(low_digits, 1, errors)?; + let high = parse_endpoint(high_digits, usize::MAX - 1, errors)?; + if low > high { + return Err(ItemError { + message: translate!("cut-error-invalid-decreasing-range"), + kind: RangeErrorKind::Inverted, + }); + } + Ok(Range { low, high }) +} + +/// Parse a range list, reporting GNU's exact diagnostics. +/// +/// GNU distinguishes its messages by mode and by the kind of problem, which the +/// shared [`Range::from_list`] parser does not, so the list is parsed here and +/// the resulting ranges are merged directly. +fn parse_range_list(list: &str, is_field: bool) -> Result, RangeError> { + let errors = if is_field { + &FIELD_ERRORS } else { - Range::from_list(list) + &POSITION_ERRORS + }; + let mut ranges = Vec::new(); + // Where the current item starts inside `list`; the separators are one byte + // each, so stepping past an item is its length plus one. + let mut at = 0; + + for item in list.split([',', ' ']) { + let span = at..at + item.len(); + at = span.end + 1; + // A stray separator leaves an empty item, which GNU reads as position + // zero -- the same complaint as an explicit `0`. + let zero_bound = || RangeError { + message: translate!(errors.numbered_from_1), + span: span.clone(), + kind: RangeErrorKind::ZeroBound, + }; + if item.is_empty() { + return Err(zero_bound()); + } + let range = parse_range(item, errors).map_err(|e| RangeError { + message: e.message, + span: span.clone(), + kind: e.kind, + })?; + if range.low == 0 { + return Err(zero_bound()); + } + ranges.push(range); } + + Ok(Range::merge(ranges)) +} + +fn list_to_ranges(list: &str, complement: bool, is_field: bool) -> Result, RangeError> { + let ranges = parse_range_list(list, is_field)?; + Ok(if complement { + uucore::ranges::complement(&ranges) + } else { + ranges + }) +} + +/// Write the parts of `line` selected by `ranges`, treating every byte as a +/// character. +/// +/// Always inlined: it is the body of the per-line loop, and a call per line +/// costs more than the work it does on short lines. +#[inline(always)] +fn write_line_bytes( + line: &[u8], + out: &mut W, + ranges: &[Range], + out_delim: &[u8], + explicit_delim: bool, +) -> std::io::Result<()> { + let mut print_delim = false; + for &Range { low, high } in ranges { + if low > line.len() { + break; + } + if print_delim { + out.write_all(out_delim)?; + } else if explicit_delim { + print_delim = true; + } + // change `low` from 1-indexed value to 0-index value + let low = low - 1; + let high = high.min(line.len()); + out.write_all(&line[low..high])?; + } + Ok(()) } fn cut_bytes( @@ -76,114 +272,306 @@ fn cut_bytes( let newline_char = opts.line_ending.into(); let mut buf_in = BufReader::new(reader); let out_delim = opts.out_delimiter.unwrap_or(b"\t"); + let explicit_delim = opts.out_delimiter.is_some(); + + buf_in + .for_byte_record(newline_char, |line| { + write_line_bytes(line, out, ranges, out_delim, explicit_delim)?; + out.write_all(&[newline_char])?; + Ok(true) + }) + .map_err(|e| USimpleError::new(1, strip_errno(&e))) +} - let result = buf_in.for_byte_record(newline_char, |line| { +/// Offset of the first byte above `0x7F` in `bytes`, or `bytes.len()` if there +/// is none. +/// +/// Whole words are scanned at a time while at least one is left, then the tail +/// byte by byte. Callers pass exactly the bytes they may consume, so the word +/// loop never has to discount bytes reaching past the end. +#[inline(always)] +fn ascii_run(bytes: &[u8]) -> usize { + const HIGH_BITS: u64 = 0x8080_8080_8080_8080; + + let mut idx = 0; + while let Some(chunk) = bytes[idx..].first_chunk::<8>() { + let high = u64::from_le_bytes(*chunk) & HIGH_BITS; + if high != 0 { + return idx + high.trailing_zeros() as usize / 8; + } + idx += 8; + } + while idx < bytes.len() && bytes[idx] < 0x80 { + idx += 1; + } + idx +} + +/// Everything the character-mode line loop needs besides the line itself. It is +/// all fixed for the whole run, so it is built once and passed by reference: +/// handing these over one argument per line costs more than the work done on a +/// short line. +struct CharCut<'a> { + ranges: &'a [Range], + out_delim: &'a [u8], + explicit_delim: bool, + /// `-c`: a position is the index of the character. Otherwise (`-b -n`) it + /// is the offset of the character's last byte, which is what GNU selects on. + by_char: bool, + encoding: Encoding, +} + +impl CharCut<'_> { + /// Walk `line` from byte offset `idx` (at position `pos`) as long as the + /// position of the last consumed character stays within `limit`, and return + /// the byte offset and position reached. + #[inline(always)] + fn advance(&self, line: &[u8], mut idx: usize, mut pos: usize, limit: usize) -> (usize, usize) { + while pos < limit && idx < line.len() { + // ASCII bytes are single-byte characters in every encoding handled + // here, so offset and position move together and a whole run of + // them can be taken at once, without going through the decoder. + let room = (limit - pos).min(line.len() - idx); + let run = ascii_run(&line[idx..idx + room]); + idx += run; + pos += run; + if run == room { + break; + } + let len = self.encoding.char_len(&line[idx..]); // in `1..=line.len() - idx` + let step = if self.by_char { 1 } else { len }; + if pos + step > limit { + break; + } + idx += len; + pos += step; + } + (idx, pos) + } + + /// Write the parts of `line` selected by the ranges, keeping multi-byte + /// characters whole. A character belongs to a range when its position is in + /// `low..=high`. + /// + /// Always inlined, like its byte counterpart: it is the body of the + /// per-line loop, and the call costs more than the work done on a short + /// line. + #[inline(always)] + fn write_line(&self, line: &[u8], out: &mut W) -> std::io::Result<()> { let mut print_delim = false; - for &Range { low, high } in ranges { + // Byte offset of the next character to look at, and the position + // already consumed. The ranges are sorted and disjoint, so one pass + // over the line is enough and each range maps to a contiguous slice. + let (mut idx, mut pos) = (0, 0); + // End of the previous range, and end of the last range before the run + // of adjacent ranges the current one belongs to (1-based, `0` for + // none), plus whether a delimiter is owed to the next character. + let (mut prev_high, mut before_run_high, mut delim_pending) = (0, 0, false); + for &Range { low, high } in self.ranges { + // A character position is never below its own byte offset, so a + // range starting past the last byte selects nothing, and so do the + // ones after it. if low > line.len() { break; } + // Skip the characters located before the range. + (idx, pos) = self.advance(line, idx, pos, low - 1); + if idx == line.len() { + break; + } + // Adjacent ranges select a contiguous stretch of the line, so they + // form one run as far as characters are concerned. + if prev_high + 1 != low { + before_run_high = prev_high; + } + // A range boundary only separates characters when it falls between + // two of them; the delimiter is owed to whichever character prints + // next, possibly from a later range. Positions are byte offsets + // here, so all this only concerns `-b -n`: a character position + // can never land inside a character. + if self.by_char || idx >= prev_high { + delim_pending = true; + } + // The selected bytes of a character must reach its end without a + // hole, so one an earlier run reached into is dropped even though + // its last byte falls here, taking the owed delimiter with it. + if !self.by_char && idx < before_run_high { + delim_pending = false; + let len = self.encoding.char_len(&line[idx..]); + idx += len; + pos += len; + if idx >= line.len() { + break; + } + } + prev_high = high; + let start = idx; + if high >= line.len() { + // The range reaches past the end of the line, so it covers + // every character left and none of them needs to be decoded. + // The ranges after it start further away still. + if print_delim && delim_pending { + out.write_all(self.out_delim)?; + } + return out.write_all(&line[start..]); + } + (idx, pos) = self.advance(line, idx, pos, high); + // A range covering only part of a multi-byte character selects + // nothing, and leaves the delimiter it owes to the next one. + if idx == start { + continue; + } if print_delim { - out.write_all(out_delim)?; - } else if opts.out_delimiter.is_some() { + if delim_pending { + out.write_all(self.out_delim)?; + } + } else if self.explicit_delim { print_delim = true; } - // change `low` from 1-indexed value to 0-index value - let low = low - 1; - let high = high.min(line.len()); - out.write_all(&line[low..high])?; + delim_pending = false; + out.write_all(&line[start..idx])?; } - out.write_all(&[newline_char])?; - Ok(true) - }); + Ok(()) + } +} - if let Err(e) = result { - return Err(USimpleError::new(1, e.to_string())); +/// Cut `-c` (whole characters) or `-b -n` (bytes, keeping whole characters). +/// +/// In a single-byte locale, or for `-b` without `-n`, this falls back to the +/// plain byte path. Otherwise each character is emitted whole when its 1-based +/// position falls in a range: the character index for `-c`, or the offset of +/// its last byte for `-b -n` (matching GNU). +fn cut_chars( + reader: R, + out: &mut W, + ranges: &[Range], + opts: &Options, + by_char: bool, +) -> UResult<()> { + let encoding = locale_encoding(); + if encoding == Encoding::SingleByte || !(by_char || opts.suppress_split) { + return cut_bytes(reader, out, ranges, opts); } - Ok(()) + let newline_char = opts.line_ending.into(); + let mut buf_in = BufReader::new(reader); + let cut = CharCut { + ranges, + out_delim: opts.out_delimiter.unwrap_or(b"\t"), + explicit_delim: opts.out_delimiter.is_some(), + by_char, + encoding, + }; + + buf_in + .for_byte_record(newline_char, |line| { + cut.write_line(line, out)?; + out.write_all(&[newline_char])?; + Ok(true) + }) + .map_err(|e| USimpleError::new(1, strip_errno(&e))) } -/// Output delimiter is explicitly specified -fn cut_fields_explicit_out_delim( - reader: R, +/// Write the fields of `line` selected by `ranges`, separated by `out_delim`. +/// +/// `line` may or may not end with `newline_char`; either way the output is +/// terminated, unless the line has no delimiter and `only_delimited` drops it. +fn write_fields_line( + line: &[u8], out: &mut W, matcher: &M, ranges: &[Range], only_delimited: bool, newline_char: u8, out_delim: &[u8], -) -> UResult<()> { - let mut buf_in = BufReader::new(reader); - - let result = buf_in.for_byte_record_with_terminator(newline_char, |line| { - let mut fields_pos = 1; - let mut low_idx = 0; - let mut delim_search = Searcher::new(matcher, line).peekable(); - let mut print_delim = false; - - if delim_search.peek().is_none() { - if !only_delimited { - // Always write the entire line, even if it doesn't end with `newline_char` - out.write_all(line)?; - if line.is_empty() || line[line.len() - 1] != newline_char { - out.write_all(&[newline_char])?; - } +) -> std::io::Result<()> { + let mut fields_pos = 1; + let mut low_idx = 0; + let mut delim_search = Searcher::new(matcher, line).peekable(); + let mut print_delim = false; + + if delim_search.peek().is_none() { + if !only_delimited { + // Always write the entire line, even if it doesn't end with `newline_char` + out.write_all(line)?; + if line.is_empty() || line[line.len() - 1] != newline_char { + out.write_all(&[newline_char])?; } + } + + return Ok(()); + } - return Ok(true); + for &Range { low, high } in ranges { + if low - fields_pos > 0 { + // current field is not in the range, so jump to the field corresponding to the + // beginning of the range if any + low_idx = match delim_search.nth(low - fields_pos - 1) { + Some((_, last)) => last, + None => break, + }; } - for &Range { low, high } in ranges { - if low - fields_pos > 0 { - // current field is not in the range, so jump to the field corresponding to the - // beginning of the range if any - low_idx = match delim_search.nth(low - fields_pos - 1) { - Some((_, last)) => last, - None => break, - }; + // at this point, current field is the first in the range + for _ in 0..=high - low { + // skip printing delimiter if this is the first matching field for this line + if print_delim { + out.write_all(out_delim)?; + } else { + print_delim = true; } - // at this point, current field is the first in the range - for _ in 0..=high - low { - // skip printing delimiter if this is the first matching field for this line - if print_delim { - out.write_all(out_delim)?; - } else { - print_delim = true; - } - - if let Some((first, last)) = delim_search.next() { - // print the current field up to the next field delim - let segment = &line[low_idx..first]; + if let Some((first, last)) = delim_search.next() { + // print the current field up to the next field delim + let segment = &line[low_idx..first]; - out.write_all(segment)?; + out.write_all(segment)?; - low_idx = last; - fields_pos = high + 1; - } else { - // this is the last field in the line, so print the rest - let segment = &line[low_idx..]; + low_idx = last; + fields_pos = high + 1; + } else { + // this is the last field in the line, so print the rest + let segment = &line[low_idx..]; - out.write_all(segment)?; + out.write_all(segment)?; - if line[line.len() - 1] == newline_char { - return Ok(true); - } - break; + if line[line.len() - 1] == newline_char { + return Ok(()); } + break; } } + } - out.write_all(&[newline_char])?; - Ok(true) - }); + out.write_all(&[newline_char]) +} - if let Err(e) = result { - return Err(USimpleError::new(1, e.to_string())); - } +/// Output delimiter is explicitly specified +fn cut_fields_explicit_out_delim( + reader: R, + out: &mut W, + matcher: &M, + ranges: &[Range], + only_delimited: bool, + newline_char: u8, + out_delim: &[u8], +) -> UResult<()> { + let mut buf_in = BufReader::new(reader); - Ok(()) + buf_in + .for_byte_record_with_terminator(newline_char, |line| { + write_fields_line( + line, + out, + matcher, + ranges, + only_delimited, + newline_char, + out_delim, + )?; + Ok(true) + }) + .map_err(|e| USimpleError::new(1, e.to_string())) } /// Output delimiter is the same as input delimiter @@ -197,61 +585,57 @@ fn cut_fields_implicit_out_delim( ) -> UResult<()> { let mut buf_in = BufReader::new(reader); - let result = buf_in.for_byte_record_with_terminator(newline_char, |line| { - let mut fields_pos = 1; - let mut low_idx = 0; - let mut delim_search = Searcher::new(matcher, line).peekable(); - let mut print_delim = false; - - if delim_search.peek().is_none() { - if !only_delimited { - // Always write the entire line, even if it doesn't end with `newline_char` - out.write_all(line)?; - if line.is_empty() || line[line.len() - 1] != newline_char { - out.write_all(&[newline_char])?; + buf_in + .for_byte_record_with_terminator(newline_char, |line| { + let mut fields_pos = 1; + let mut low_idx = 0; + let mut delim_search = Searcher::new(matcher, line).peekable(); + let mut print_delim = false; + + if delim_search.peek().is_none() { + if !only_delimited { + // Always write the entire line, even if it doesn't end with `newline_char` + out.write_all(line)?; + if line.is_empty() || line[line.len() - 1] != newline_char { + out.write_all(&[newline_char])?; + } } - } - return Ok(true); - } + return Ok(true); + } - for &Range { low, high } in ranges { - if low - fields_pos > 0 { - if let Some((first, last)) = delim_search.nth(low - fields_pos - 1) { - low_idx = if print_delim { first } else { last } - } else { - break; + for &Range { low, high } in ranges { + if low - fields_pos > 0 { + if let Some((first, last)) = delim_search.nth(low - fields_pos - 1) { + low_idx = if print_delim { first } else { last } + } else { + break; + } } - } - if let Some((first, _)) = delim_search.nth(high - low) { - let segment = &line[low_idx..first]; + if let Some((first, _)) = delim_search.nth(high - low) { + let segment = &line[low_idx..first]; - out.write_all(segment)?; + out.write_all(segment)?; - print_delim = true; - low_idx = first; - fields_pos = high + 1; - } else { - let segment = &line[low_idx..line.len()]; + print_delim = true; + low_idx = first; + fields_pos = high + 1; + } else { + let segment = &line[low_idx..line.len()]; - out.write_all(segment)?; + out.write_all(segment)?; - if line[line.len() - 1] == newline_char { - return Ok(true); + if line[line.len() - 1] == newline_char { + return Ok(true); + } + break; } - break; } - } - out.write_all(&[newline_char])?; - Ok(true) - }); - - if let Err(e) = result { - return Err(USimpleError::new(1, e.to_string())); - } - - Ok(()) + out.write_all(&[newline_char])?; + Ok(true) + }) + .map_err(|e| USimpleError::new(1, e.to_string())) } /// Streams and filters fields where the record terminator and @@ -376,13 +760,85 @@ fn cut_fields_newline_char_delim( current_field_idx += 1; } - if has_data && !suppressed { + // With -s, a record that produced no selected field must not emit a line. + if has_data && !suppressed && (first_field_printed || !only_delimited) { out.write_all(&[newline_char])?; } Ok(()) } +/// `--whitespace-delimited=trimmed`: strip leading/trailing blanks from each +/// line, then split on runs of blanks. With no internal blank the line is +/// undelimited (suppressed under `-s`). +fn cut_fields_whitespace_trimmed( + reader: R, + out: &mut W, + ranges: &[Range], + only_delimited: bool, + newline_char: u8, + out_delim: &[u8], +) -> UResult<()> { + let is_blank = |&b: &u8| b == b' ' || b == b'\t'; + let matcher = WhitespaceMatcher {}; + let mut buf_in = BufReader::new(reader); + + // Trimming is the only difference from the plain whitespace path: once the + // outer blanks are gone, the remaining blank runs are ordinary delimiters. + buf_in + .for_byte_record(newline_char, |line| { + let start = line.iter().position(|b| !is_blank(b)).unwrap_or(line.len()); + let end = line + .iter() + .rposition(|b| !is_blank(b)) + .map_or(start, |p| p + 1); + + write_fields_line( + &line[start..end], + out, + &matcher, + ranges, + only_delimited, + newline_char, + out_delim, + )?; + Ok(true) + }) + .map_err(|e| USimpleError::new(1, e.to_string())) +} + +/// Run field cutting with the given matcher, choosing the explicit- or +/// implicit-output-delimiter routine. +fn cut_fields_with_matcher( + reader: R, + out: &mut W, + matcher: &M, + ranges: &[Range], + only_delimited: bool, + newline_char: u8, + out_delimiter: Option<&[u8]>, +) -> UResult<()> { + match out_delimiter { + Some(out_delim) => cut_fields_explicit_out_delim( + reader, + out, + matcher, + ranges, + only_delimited, + newline_char, + out_delim, + ), + None => cut_fields_implicit_out_delim( + reader, + out, + matcher, + ranges, + only_delimited, + newline_char, + ), + } +} + fn cut_fields( reader: R, out: &mut W, @@ -403,40 +859,51 @@ fn cut_fields( field_opts.only_delimited, ) } - Delimiter::Slice(delim) => { - let matcher = ExactMatcher::new(delim); - match opts.out_delimiter { - Some(out_delim) => cut_fields_explicit_out_delim( + // An ASCII single-byte delimiter can never occur inside a multi-byte + // character, so the fast byte-wise matcher is correct in every locale. + // Otherwise match the delimiter as a whole character. + Delimiter::Slice(delim) if delim.len() == 1 && delim[0] <= 0x7F => cut_fields_with_matcher( + reader, + out, + &ExactMatcher::new(delim), + ranges, + field_opts.only_delimited, + newline_char, + opts.out_delimiter, + ), + Delimiter::Slice(delim) => cut_fields_with_matcher( + reader, + out, + &MbExactMatcher::new(delim), + ranges, + field_opts.only_delimited, + newline_char, + opts.out_delimiter, + ), + Delimiter::Whitespace => { + let out_delim = opts.out_delimiter.unwrap_or(b"\t"); + if field_opts.whitespace_trimmed { + cut_fields_whitespace_trimmed( reader, out, - &matcher, ranges, field_opts.only_delimited, newline_char, out_delim, - ), - None => cut_fields_implicit_out_delim( + ) + } else { + let matcher = WhitespaceMatcher {}; + cut_fields_explicit_out_delim( reader, out, &matcher, ranges, field_opts.only_delimited, newline_char, - ), + out_delim, + ) } } - Delimiter::Whitespace => { - let matcher = WhitespaceMatcher {}; - cut_fields_explicit_out_delim( - reader, - out, - &matcher, - ranges, - field_opts.only_delimited, - newline_char, - opts.out_delimiter.unwrap_or(b"\t"), - ) - } } } @@ -458,8 +925,8 @@ where } show_if_err!(match mode { - Mode::Bytes(ranges, opts) => cut_bytes(stdin(), &mut out, ranges, opts), - Mode::Characters(ranges, opts) => cut_bytes(stdin(), &mut out, ranges, opts), + Mode::Bytes(ranges, opts) => cut_chars(stdin(), &mut out, ranges, opts, false), + Mode::Characters(ranges, opts) => cut_chars(stdin(), &mut out, ranges, opts, true), Mode::Fields(ranges, opts) => cut_fields(stdin(), &mut out, ranges, opts), }); @@ -482,8 +949,11 @@ where .map_err_context(|| filename.maybe_quote().to_string()) .and_then(|file| { match &mode { - Mode::Bytes(ranges, opts) | Mode::Characters(ranges, opts) => { - cut_bytes(file, &mut out, ranges, opts) + Mode::Bytes(ranges, opts) => { + cut_chars(file, &mut out, ranges, opts, false) + } + Mode::Characters(ranges, opts) => { + cut_chars(file, &mut out, ranges, opts, true) } Mode::Fields(ranges, opts) => cut_fields(file, &mut out, ranges, opts), } @@ -501,11 +971,11 @@ where /// Get delimiter and output delimiter from `-d`/`--delimiter` and `--output-delimiter` options respectively /// Allow either delimiter to have a value that is neither UTF-8 nor ASCII to align with GNU behavior fn get_delimiters(matches: &ArgMatches) -> UResult<(Delimiter<'_>, Option<&[u8]>)> { - let whitespace_delimited = matches.get_flag(options::WHITESPACE_DELIMITED); + let whitespace_delimited = matches.contains_id(options::WHITESPACE_DELIMITED); let delim_opt = matches.get_one::(options::DELIMITER); let delim = match delim_opt { Some(_) if whitespace_delimited => { - return Err(USimpleError::new( + return Err(UUsageError::new( 1, translate!("cut-error-delimiter-and-whitespace-conflict"), )); @@ -514,13 +984,15 @@ fn get_delimiters(matches: &ArgMatches) -> UResult<(Delimiter<'_>, Option<&[u8]> if os_string.is_empty() { Delimiter::Slice(b"\0") } else { - // For delimiter `-d` option value - allow both UTF-8 (possibly multi-byte) characters - // and Non UTF-8 (and not ASCII) single byte "characters", like `b"\xAD"` to align with GNU behavior + // The delimiter must be a single character in the *current locale's* + // encoding: a multi-byte UTF-8 character in a UTF-8 locale, a 2-byte + // GB18030 character, or any single byte like `b"\xAD"`. In a C/POSIX + // (single-byte) locale even a valid UTF-8 multibyte sequence counts + // as several characters, matching GNU. let bytes = os_str_as_bytes(os_string)?; - if os_string.to_str().is_some_and(|s| s.chars().count() > 1) - || os_string.to_str().is_none() && bytes.len() > 1 - { - return Err(USimpleError::new( + let is_single_char = mb_char_len(bytes) == bytes.len(); + if !is_single_char { + return Err(UUsageError::new( 1, translate!("cut-error-delimiter-must-be-single-character"), )); @@ -553,18 +1025,24 @@ mod options { pub const CHARACTERS: &str = "characters"; pub const DELIMITER: &str = "delimiter"; pub const FIELDS: &str = "fields"; + // -F: field mode that merges whitespace (or -d) runs, default output is a space. + pub const FIELDS_MERGED: &str = "fields-merged"; pub const ZERO_TERMINATED: &str = "zero-terminated"; pub const ONLY_DELIMITED: &str = "only-delimited"; pub const OUTPUT_DELIMITER: &str = "output-delimiter"; pub const WHITESPACE_DELIMITED: &str = "whitespace-delimited"; pub const COMPLEMENT: &str = "complement"; pub const FILE: &str = "file"; - // ignored option - pub const NOTHING: &str = "nothing"; + pub const NO_PARTIAL: &str = "no-partial"; } #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let args: Vec = args.collect(); + // Kept for the caret in list diagnostics, which needs the arguments as + // they were typed, before the rewrite below edits one of them. + let diag_args = uucore::diagnostics::capture(&args); + // GNU `cut` supports `-d=` to set the delimiter to `=`. // Clap parsing is limited in this situation, see: // https://github.com/uutils/coreutils/issues/2424#issuecomment-863825242 @@ -583,94 +1061,91 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let (delimiter, out_delimiter) = get_delimiters(&matches)?; let line_ending = LineEnding::from_zero_flag(matches.get_flag(options::ZERO_TERMINATED)); - - // Only one, and only one of cutting mode arguments, i.e. `-b`, `-c`, `-f`, - // is expected. The number of those arguments is used for parsing a cutting - // mode and handling the error cases. - let mode_args_count = [ - matches.indices_of(options::BYTES), - matches.indices_of(options::CHARACTERS), - matches.indices_of(options::FIELDS), - ] - .into_iter() - .map(|indices| indices.unwrap_or_default().count()) - .sum(); - - let mode_parse = match ( - mode_args_count, - matches.get_one::(options::BYTES), - matches.get_one::(options::CHARACTERS), - matches.get_one::(options::FIELDS), - ) { - (1, Some(byte_ranges), None, None) => { - list_to_ranges(byte_ranges, complement).map(|ranges| { - Mode::Bytes( - ranges, - Options { - out_delimiter, - line_ending, - field_opts: None, - }, + let suppress_split = matches.get_flag(options::NO_PARTIAL); + // `--whitespace-delimited[=trimmed]` (`-w`): the optional value selects trimming. + let whitespace_trimmed = matches + .get_one::(options::WHITESPACE_DELIMITED) + .is_some(); + + let mode_arg = get_mode_arg(&matches)?; + let list = matches + .get_one::(mode_arg) + .expect("should be ensured by get_mode_arg"); + let is_field = matches!(mode_arg, options::FIELDS | options::FIELDS_MERGED); + let ranges = list_to_ranges(list, complement, is_field).map_err(|e| { + // The list is the value of the option that selected the mode, so the + // caret can be put under the one range that is at fault. + let (short, long) = mode_arg_names(mode_arg); + uucore::diagnostics::error_after_report( + diag_args.as_deref(), + UUsageError::new(1, e.message.clone()), + |args, _| { + e.render_option_value( + args, + list, + Some(short), + long, + &translate!("cut-diag-label-zero-bound"), + &translate!("cut-diag-help-list-syntax"), ) - }) - } - - (1, None, Some(char_ranges), None) => { - list_to_ranges(char_ranges, complement).map(|ranges| { - Mode::Characters( - ranges, - Options { - out_delimiter, - line_ending, - field_opts: None, - }, - ) - }) - } - - (1, None, None, Some(field_ranges)) => { - list_to_ranges(field_ranges, complement).map(|ranges| { - Mode::Fields( - ranges, - Options { - out_delimiter, - line_ending, - field_opts: Some(FieldOptions { - delimiter, - only_delimited, - }), + }, + ) + })?; + + let mode = match mode_arg { + options::BYTES => Mode::Bytes( + ranges, + Options { + out_delimiter, + line_ending, + field_opts: None, + suppress_split, + }, + ), + options::CHARACTERS => Mode::Characters( + ranges, + Options { + out_delimiter, + line_ending, + field_opts: None, + suppress_split, + }, + ), + options::FIELDS => Mode::Fields( + ranges, + Options { + out_delimiter, + line_ending, + field_opts: Some(FieldOptions { + delimiter, + only_delimited, + whitespace_trimmed, + }), + suppress_split, + }, + ), + // `-F`: like `-f`, but consecutive delimiters merge, the delimiter + // defaults to whitespace, and the output delimiter defaults to a space. + options::FIELDS_MERGED => Mode::Fields( + ranges, + Options { + out_delimiter: out_delimiter.or(Some(b" ")), + line_ending, + field_opts: Some(FieldOptions { + delimiter: if matches.contains_id(options::DELIMITER) { + delimiter + } else { + Delimiter::Whitespace }, - ) - }) - } - - (2.., _, _, _) => Err(translate!("cut-error-multiple-mode-args")), - _ => Err(translate!("cut-error-missing-mode-arg")), - }; - - let mode_parse = match mode_parse { - Err(_) => mode_parse, - Ok(mode) => match mode { - Mode::Bytes(_, _) | Mode::Characters(_, _) - if matches.contains_id(options::DELIMITER) => - { - Err(translate!("cut-error-delimiter-only-with-fields")) - } - Mode::Bytes(_, _) | Mode::Characters(_, _) - if matches.get_flag(options::WHITESPACE_DELIMITED) => - { - Err(translate!("cut-error-whitespace-only-with-fields")) - } - Mode::Bytes(_, _) | Mode::Characters(_, _) - if matches.get_flag(options::ONLY_DELIMITED) => - { - Err(translate!("cut-error-only-delimited-only-with-fields")) - } - _ => Ok(mode), - }, + only_delimited, + whitespace_trimmed, + }), + suppress_split, + }, + ), + _ => unreachable!(), }; - let mode = mode_parse.map_err(|e| USimpleError::new(1, e))?; #[allow(clippy::unwrap_used, reason = "clap provides '-' by default")] let files = matches.get_many::(options::FILE).unwrap(); @@ -679,6 +1154,80 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Ok(()) } +// Exactly one of the cutting mode arguments `-b`, `-c`, `-f` or `-F` must be +// given. +// +// Returns `options::BYTES`, `options::CHARACTERS`, `options::FIELDS`, or +// `options::FIELDS_MERGED`. +fn get_mode_arg(matches: &ArgMatches) -> UResult<&str> { + let mode_args_and_counts: Vec<_> = [ + options::BYTES, + options::CHARACTERS, + options::FIELDS, + options::FIELDS_MERGED, + ] + .into_iter() + .filter_map(|arg| { + let count = matches.indices_of(arg)?.count(); + (count > 0).then_some((arg, count)) + }) + .collect(); + + let mode_arg = match mode_args_and_counts.as_slice() { + [(arg, 1)] => *arg, + [] => { + return Err(UUsageError::new( + 1, + translate!("cut-error-missing-mode-arg"), + )); + } + _ => { + return Err(UUsageError::new( + 1, + translate!("cut-error-multiple-mode-args"), + )); + } + }; + + if matches!(mode_arg, options::BYTES | options::CHARACTERS) { + let checks = [ + ( + matches.contains_id(options::DELIMITER), + "cut-error-delimiter-only-with-fields", + ), + ( + // GNU words `-w` as a delimiter too, so it shares the message. + matches.contains_id(options::WHITESPACE_DELIMITED), + "cut-error-delimiter-only-with-fields", + ), + ( + matches.get_flag(options::ONLY_DELIMITED), + "cut-error-only-delimited-only-with-fields", + ), + ]; + + for (is_triggered, msg_key) in checks { + if is_triggered { + return Err(UUsageError::new(1, translate!(msg_key))); + } + } + } + + Ok(mode_arg) +} + +/// The names of a mode option, since the caret has to recognize the value +/// however it was written: `-f2-`, `-f 2-` or `--fields=2-`. +fn mode_arg_names(mode_arg: &str) -> (char, Option<&str>) { + match mode_arg { + options::BYTES => ('b', Some(options::BYTES)), + options::CHARACTERS => ('c', Some(options::CHARACTERS)), + options::FIELDS => ('f', Some(options::FIELDS)), + options::FIELDS_MERGED => ('F', None), + _ => unreachable!("unknown cut mode option"), + } +} + pub fn uu_app() -> Command { Command::new("cut") .version(uucore::crate_version!()) @@ -724,9 +1273,13 @@ pub fn uu_app() -> Command { .arg( Arg::new(options::WHITESPACE_DELIMITED) .short('w') + .long(options::WHITESPACE_DELIMITED) .help(translate!("cut-help-whitespace-delimited")) - .value_name("WHITESPACE") - .action(ArgAction::SetTrue), + .value_name("trimmed") + .value_parser(ShortcutValueParser::new([PossibleValue::new("trimmed")])) + .num_args(0..=1) + .require_equals(true) + .action(ArgAction::Set), ) .arg( Arg::new(options::FIELDS) @@ -737,6 +1290,14 @@ pub fn uu_app() -> Command { .value_name("LIST") .action(ArgAction::Append), ) + .arg( + Arg::new(options::FIELDS_MERGED) + .short('F') + .help(translate!("cut-help-fields-merged")) + .allow_hyphen_values(true) + .value_name("LIST") + .action(ArgAction::Append), + ) .arg( Arg::new(options::COMPLEMENT) .long(options::COMPLEMENT) @@ -774,9 +1335,73 @@ pub fn uu_app() -> Command { .value_parser(clap::value_parser!(OsString)), ) .arg( - Arg::new(options::NOTHING) + Arg::new(options::NO_PARTIAL) .short('n') - .help("(ignored)") + .long(options::NO_PARTIAL) + .help(translate!("cut-help-no-partial")) .action(ArgAction::SetTrue), ) } + +#[cfg(test)] +mod tests { + use super::{CharCut, Encoding}; + + fn utf8_cut(by_char: bool) -> CharCut<'static> { + CharCut { + ranges: &[], + out_delim: b"\t", + explicit_delim: false, + by_char, + encoding: Encoding::Utf8, + } + } + + // "quick" + 2-byte char + "brown" + 3-byte char + "foxjumping" + const LINE: &[u8] = b"quick\xc3\xa9brown\xe2\x82\xacfoxjumping"; + + #[test] + fn advance_counts_characters_for_c() { + let cut = utf8_cut(true); + // Inside the leading ASCII run, and stopping right on its last byte. + assert_eq!(cut.advance(LINE, 0, 0, 3), (3, 3)); + assert_eq!(cut.advance(LINE, 0, 0, 5), (5, 5)); + // Taking the 2-byte character moves two bytes but one position. + assert_eq!(cut.advance(LINE, 0, 0, 6), (7, 6)); + // Through "brown": 12 bytes for 11 characters, the 2-byte one included. + assert_eq!(cut.advance(LINE, 0, 0, 11), (12, 11)); + // A limit past the end stops at the end: 25 bytes, 22 characters. + assert_eq!(cut.advance(LINE, 0, 0, 99), (LINE.len(), 22)); + // Resuming mid-line, and a limit that is already reached. + assert_eq!(cut.advance(LINE, 7, 6, 9), (10, 9)); + assert_eq!(cut.advance(LINE, 7, 6, 6), (7, 6)); + } + + #[test] + fn advance_counts_bytes_for_b_n() { + let cut = utf8_cut(false); + // Positions are byte offsets here, so the ASCII head is unchanged. + assert_eq!(cut.advance(LINE, 0, 0, 5), (5, 5)); + // The 2-byte character only fits once the limit covers both bytes. + assert_eq!(cut.advance(LINE, 0, 0, 6), (5, 5)); + assert_eq!(cut.advance(LINE, 0, 0, 7), (7, 7)); + // Likewise the 3-byte one: 12 and 13 are short, 14 takes it. + assert_eq!(cut.advance(LINE, 0, 0, 13), (12, 12)); + assert_eq!(cut.advance(LINE, 0, 0, 15), (15, 15)); + } + + #[test] + fn advance_crosses_word_boundaries() { + let cut = utf8_cut(true); + // "foxjumping" is long enough for the word-at-a-time path, both + // landing exactly on a word boundary and past one. + let tail = &LINE[15..]; + assert_eq!(cut.advance(tail, 0, 0, 8), (8, 8)); + assert_eq!(cut.advance(tail, 0, 0, 10), (10, 10)); + // A high byte found by the word scan still yields to the decoder. + let wide = b"abcdefghij\xc3\xa9kl"; + assert_eq!(cut.advance(wide, 0, 0, 11), (12, 11)); + // An empty line has nothing to walk. + assert_eq!(cut.advance(b"", 0, 0, 4), (0, 0)); + } +} diff --git a/src/uu/cut/src/matcher.rs b/src/uu/cut/src/matcher.rs index c1be9fb5ee7..036daa6c027 100644 --- a/src/uu/cut/src/matcher.rs +++ b/src/uu/cut/src/matcher.rs @@ -3,7 +3,10 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore clen + use memchr::{memchr, memchr2}; +use uucore::i18n::charmap::{Encoding, locale_encoding}; // Find the next matching byte sequence positions // Return (first, last) where haystack[first..last] corresponds to the matched pattern @@ -39,22 +42,71 @@ impl Matcher for ExactMatcher<'_> { } } -// Matches for any number of SPACE or TAB +// Matches the delimiter as a whole character, never inside a multi-byte +// character. Used for delimiters that could be a continuation byte (e.g. a +// lone `0xa9`) or a multi-byte character; ASCII delimiters use `ExactMatcher`. +pub struct MbExactMatcher<'a> { + needle: &'a [u8], +} + +impl<'a> MbExactMatcher<'a> { + pub fn new(needle: &'a [u8]) -> Self { + assert!(!needle.is_empty()); + Self { needle } + } +} + +impl Matcher for MbExactMatcher<'_> { + fn next_match(&self, haystack: &[u8]) -> Option<(usize, usize)> { + // Resolved once per line: character boundaries have to be walked from + // the start, so there is no way to skip ahead with `memchr` here. + let encoding = locale_encoding(); + let mut pos = 0; + while pos < haystack.len() { + let clen = encoding.char_len(&haystack[pos..]); + if clen == self.needle.len() && &haystack[pos..pos + clen] == self.needle { + return Some((pos, pos + clen)); + } + pos += clen; + } + None + } +} + +// Matches any number of whitespace characters. ASCII space and tab are always +// recognized; in a UTF-8 locale Unicode space separators are too. pub struct WhitespaceMatcher {} impl Matcher for WhitespaceMatcher { fn next_match(&self, haystack: &[u8]) -> Option<(usize, usize)> { - let match_idx = memchr2(b' ', b'\t', haystack)?; - let mut skip = match_idx + 1; - - while skip < haystack.len() { - match haystack[skip] { - b' ' | b'\t' => skip += 1, - _ => break, + // In a single-byte locale SPACE and TAB are the only blanks, so the run + // can be found with a SIMD scan instead of decoding every character. + let encoding = locale_encoding(); + if encoding == Encoding::SingleByte { + let start = memchr2(b' ', b'\t', haystack)?; + let mut end = start + 1; + while end < haystack.len() && matches!(haystack[end], b' ' | b'\t') { + end += 1; } + return Some((start, end)); } - Some((match_idx, skip)) + let mut pos = 0; + while pos < haystack.len() { + if let Some(blank_len) = encoding.blank_len(&haystack[pos..]) { + let start = pos; + pos += blank_len; + while pos < haystack.len() { + match encoding.blank_len(&haystack[pos..]) { + Some(len) => pos += len, + None => break, + } + } + return Some((start, pos)); + } + pos += encoding.char_len(&haystack[pos..]); + } + None } } diff --git a/src/uu/date/Cargo.toml b/src/uu/date/Cargo.toml index 2180ae8f5c9..4984fca9aef 100644 --- a/src/uu/date/Cargo.toml +++ b/src/uu/date/Cargo.toml @@ -1,4 +1,5 @@ -# spell-checker:ignore datetime tzdb zoneinfo +# spell-checker:ignore datetime tzdb zoneinfo ohos OHOS tzdata + [package] name = "uu_date" description = "date ~ (uutils) display or set the current time" @@ -12,40 +13,38 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/date.rs" doctest = false -[features] -default = ["i18n-datetime"] -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", "tzdb-concatenated", ] } +jiff-icu = { workspace = true, optional = true } parse_datetime = { workspace = true } +thiserror = { workspace = true } uucore = { workspace = true, features = ["parser", "i18n-datetime"] } [target.'cfg(unix)'.dependencies] libc = { workspace = true } rustix = { workspace = true, features = ["time"] } +[target.'cfg(target_env = "ohos")'.dependencies] +# OHOS has no /usr/share/zoneinfo or /etc/localtime: embed IANA tzdata so +# ohos_system_zone() can resolve the TimeService timezone ID directly (pass-through, +# no environment injection). +jiff-tzdb = { workspace = true } + +[target.'cfg(target_os = "wasi")'.dependencies] +libc = { workspace = true } + [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = [ "Win32_Foundation", @@ -61,6 +60,22 @@ divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[features] +default = ["i18n-datetime"] +i18n-datetime = [ + "uucore/i18n-datetime", + "dep:icu_calendar", + "dep:icu_locale", + "dep:jiff-icu", +] + +[lints] +workspace = true + [[bench]] name = "date_bench" harness = false + +[[bench]] +name = "date_locale_bench" +harness = false diff --git a/src/uu/date/benches/date_bench.rs b/src/uu/date/benches/date_bench.rs index 1c1d05aaea2..a74a3a4c822 100644 --- a/src/uu/date/benches/date_bench.rs +++ b/src/uu/date/benches/date_bench.rs @@ -7,7 +7,7 @@ use divan::{Bencher, black_box}; use std::io::Write; use tempfile::NamedTempFile; use uu_date::uumain; -use uucore::benchmark::run_util_function; +use uucore::benchmark::get_bench_args; /// Helper to create a temporary file containing N lines of date strings. fn setup_date_file(lines: usize, date_format: &str) -> NamedTempFile { @@ -23,11 +23,11 @@ fn setup_date_file(lines: usize, date_format: &str) -> NamedTempFile { 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(); + let path = file.path(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-f", path])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-f", &path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmarks processing a file containing dates with Timezone abbreviations. @@ -36,11 +36,11 @@ 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(); + let path = file.path(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-f", path])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-f", &path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmarks formatting speed using a custom output format. @@ -48,30 +48,27 @@ fn file_tz_abbreviations(bencher: Bencher) { 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(); + let path = file.path(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-f", path, "+%A %d %B %Y"])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-f", &path, &"+%A %d %B %Y"]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// 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, &[])); - }); + bencher + .with_inputs(|| get_bench_args(&[]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// 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"], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"--date=last friday 12:00 + 2 days"]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/date/benches/date_locale_bench.rs b/src/uu/date/benches/date_locale_bench.rs new file mode 100644 index 00000000000..e388af361a1 --- /dev/null +++ b/src/uu/date/benches/date_locale_bench.rs @@ -0,0 +1,24 @@ +// 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 uu_date::uumain; +use uucore::benchmark::{get_bench_args, setup_test_file}; + +#[divan::bench] +fn file_localized_names(bencher: Bencher) { + let path = setup_test_file("2023-05-10 12:00:00\n".repeat(1_000).as_bytes()); + + bencher + .with_inputs(|| get_bench_args(&[&"-f", &path, &"+%A %a %B %b"]).into_iter()) + .bench_values(|args| black_box(uumain(args))); +} + +fn main() { + unsafe { + std::env::set_var("LC_ALL", "fr_FR.UTF-8"); + } + divan::main(); +} diff --git a/src/uu/date/locales/en-US.ftl b/src/uu/date/locales/en-US.ftl index ea864904285..33e16eef679 100644 --- a/src/uu/date/locales/en-US.ftl +++ b/src/uu/date/locales/en-US.ftl @@ -93,7 +93,6 @@ date-help-rfc-3339 = output date/time in RFC 3339 format. date-help-debug = annotate the parsed date, and warn about questionable usage to stderr date-help-reference = display the last modification time of FILE date-help-set = set time described by STRING -date-help-set-macos = set time described by STRING (not available on mac yet) date-help-set-redox = set time described by STRING (not available on redox yet) date-help-universal = print or set Coordinated Universal Time (UTC) @@ -101,8 +100,8 @@ date-error-invalid-date = invalid date '{$date}' date-error-invalid-format = invalid format '{$format}' ({$error}) date-error-expected-file-got-directory = expected file, got directory {$path} date-error-date-overflow = date overflow '{$date}' -date-error-setting-date-not-supported-macos = setting the date is not supported by macOS date-error-setting-date-not-supported-redox = setting the date is not supported by Redox +date-error-setting-date-not-supported-wasi = setting the date is not supported by WASI 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 9a67704af1e..2398691565c 100644 --- a/src/uu/date/locales/fr-FR.ftl +++ b/src/uu/date/locales/fr-FR.ftl @@ -88,7 +88,6 @@ date-help-rfc-3339 = afficher la date/heure au format RFC 3339. date-help-debug = annoter la date analysée et avertir des usages douteux sur stderr date-help-reference = afficher l'heure de dernière modification du FICHIER date-help-set = définir l'heure décrite par CHAÎNE -date-help-set-macos = définir l'heure décrite par CHAÎNE (pas encore disponible sur mac) date-help-set-redox = définir l'heure décrite par CHAÎNE (pas encore disponible sur redox) date-help-universal = afficher ou définir le Temps Universel Coordonné (UTC) @@ -96,8 +95,8 @@ date-error-invalid-date = date invalide '{$date}' date-error-invalid-format = format invalide '{$format}' ({$error}) date-error-expected-file-got-directory = fichier attendu, répertoire obtenu {$path} date-error-date-overflow = débordement de date '{$date}' -date-error-setting-date-not-supported-macos = la définition de la date n'est pas prise en charge par macOS date-error-setting-date-not-supported-redox = la définition de la date n'est pas prise en charge par Redox +date-error-setting-date-not-supported-wasi = la définition de la date n'est pas prise en charge par WASI 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 53266875e33..e8bfe69d915 100644 --- a/src/uu/date/src/date.rs +++ b/src/uu/date/src/date.rs @@ -4,6 +4,7 @@ // file that was distributed with this source code. // spell-checker:ignore strtime ; (format) DATEFILE MMDDhhmm ; (vars) datetime datetimes getres AWST ACST AEST foobarbaz unparseable +// spell-checker:ignore ohos OHOS tzdata tzdb tzif zoneinfo euctw mod format_modifiers; mod locale; @@ -12,24 +13,57 @@ use clap::{Arg, ArgAction, Command}; use jiff::fmt::strtime::{self, BrokenDownTime, Config, PosixCustom}; use jiff::tz::{Offset, TimeZone, TimeZoneDatabase}; use jiff::{Timestamp, Zoned}; +use parse_datetime::{ExtendedDateTime, ParsedDateTime}; use std::borrow::Cow; use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io::{BufRead, BufReader, BufWriter, Read, Write, stderr}; use std::path::PathBuf; use std::sync::OnceLock; use uucore::display::Quotable; use uucore::error::FromIo; -use uucore::error::{UResult, USimpleError}; +use uucore::error::{UError, UResult, strip_errno}; #[cfg(feature = "i18n-datetime")] use uucore::i18n::datetime::{localize_format_string, should_use_icu_locale}; use uucore::translate; -use uucore::{format_usage, show}; +use uucore::translate_text; +use uucore::{format_usage, show, show_if_err}; #[cfg(windows)] use windows_sys::Win32::{Foundation::SYSTEMTIME, System::SystemInformation::SetSystemTime}; use uucore::parser::shortcut_value_parser::ShortcutValueParser; +/// OHOS helper: pass through the system time zone ID returned by +/// TimeService (OH_TimeService_GetTimeZone, e.g. "Asia/Shanghai") and +/// resolve it against the embedded IANA tzdata (jiff-tzdb) so that +/// historical DST rules and transitions are preserved. jiff's +/// `try_system()` is useless on OHOS because both `/etc/localtime` and +/// the zoneinfo dirs are absent. +#[cfg(target_env = "ohos")] +fn ohos_system_zone() -> jiff::tz::TimeZone { + use core::ffi::{CStr, c_char}; + + #[link(name = "time_service_ndk")] + unsafe extern "C" { + fn OH_TimeService_GetTimeZone(tz: *mut c_char, len: u32) -> i32; + } + let mut buf = [0u8; 64]; + let rc = unsafe { OH_TimeService_GetTimeZone(buf.as_mut_ptr() as *mut c_char, 64) }; + if rc != 0 { + return jiff::tz::TimeZone::UTC; + } + let id = unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) } + .to_string_lossy() + .into_owned(); + if let Some((name, tzif)) = jiff_tzdb::get(&id) { + if let Ok(tz) = jiff::tz::TimeZone::tzif(name, tzif) { + return tz; + } + } + jiff::tz::TimeZone::UTC +} + // Options const DATE: &str = "date"; const HOURS: &str = "hours"; @@ -52,12 +86,37 @@ const OPT_REFERENCE: &str = "reference"; const OPT_UNIVERSAL: &str = "universal"; const OPT_UNIVERSAL_2: &str = "utc"; +#[derive(Debug, thiserror::Error)] +enum DateError { + #[error("{}", translate!("date-error-write", "error" => strip_errno(.0)))] + Write(std::io::Error), + #[error("{}", translate!("date-error-extra-operand", "operand" => .operand))] + ExtraOperand { operand: String }, + #[error("{}", translate_text!("date-error-invalid-date", "date" => .date))] + InvalidDate { date: String }, + #[error("{}", translate_text!("date-error-format-missing-plus", "arg" => .arg))] + FormatMissingPlus { arg: String }, + #[error("{}", translate!("date-error-expected-file-got-directory", "path" => .path))] + ExpectedFileGotDirectory { path: String }, + #[error("{}", translate!("date-error-cannot-set-date", "path" => .path, "error" => .error))] + CannotSetDate { path: String, error: String }, + #[error("{}", translate!("date-error-invalid-format", "format" => .format, "error" => .error))] + InvalidFormat { format: String, error: String }, + #[cfg(target_os = "redox")] + #[error("{}", translate!("date-error-setting-date-not-supported-redox"))] + SettingDateNotSupportedRedox, + #[cfg(target_os = "wasi")] + #[error("{}", translate!("date-error-setting-date-not-supported-wasi"))] + SettingDateNotSupportedWasi, +} + +impl UError for DateError {} + /// Settings for this program, parsed from the command line struct Settings { utc: bool, format: Format, date_source: DateSource, - set_to: Option, debug: bool, } @@ -85,7 +144,7 @@ enum Format { Rfc5322, Rfc3339(Rfc3339Format), Resolution, - Custom(String), + Custom(Vec), Default, } @@ -187,6 +246,15 @@ fn escape_invalid_bytes(bytes: &[u8]) -> String { String::from_utf8_lossy(&escaped).into_owned() } +/// Renders a command-line operand for an error message the way GNU does: +/// valid UTF-8 is kept as-is, anything else is octal-escaped. +fn operand_for_error(operand: &OsStr) -> String { + operand.to_str().map_or_else( + || escape_invalid_bytes(operand.as_encoded_bytes()), + ToOwned::to_owned, + ) +} + /// Strip parenthesized comments from a date string. /// /// GNU date removes balanced parentheses and their content, treating them as comments. @@ -285,20 +353,18 @@ fn parse_military_timezone_with_offset(s: &str) -> Option<(i32, DayDelta)> { pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - let date_source = if let Some(date_os) = matches.get_one::(OPT_DATE) { + let date_source = if let Some(date_os) = matches.get_one::(OPT_DATE) { // Convert OsString to String, handling invalid UTF-8 with GNU-compatible error - let date = date_os.to_str().ok_or_else(|| { - let bytes = date_os.as_encoded_bytes(); - let escaped_str = escape_invalid_bytes(bytes); - USimpleError::new(1, format!("invalid date '{escaped_str}'")) + let date = date_os.to_str().ok_or_else(|| DateError::InvalidDate { + date: operand_for_error(date_os), })?; DateSource::Human(date.into()) - } else if let Some(file) = matches.get_one::(OPT_FILE) { - match file.as_ref() { - "-" => DateSource::Stdin, + } else if let Some(file) = matches.get_one::(OPT_FILE) { + match file.as_encoded_bytes() { + b"-" => DateSource::Stdin, _ => DateSource::File(file.into()), } - } else if let Some(file) = matches.get_one::(OPT_REFERENCE) { + } else if let Some(file) = matches.get_one::(OPT_REFERENCE) { DateSource::FileMtime(file.into()) } else if matches.get_flag(OPT_RESOLUTION) { DateSource::Resolution @@ -307,35 +373,30 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }; // Check for extra operands (multiple positional arguments) - if let Some(formats) = matches.get_many::(OPT_FORMAT) { - let format_args: Vec<&String> = formats.collect(); + if let Some(formats) = matches.get_many::(OPT_FORMAT) { + let format_args: Vec<&OsString> = formats.collect(); if format_args.len() > 1 { - return Err(USimpleError::new( - 1, - translate!("date-error-extra-operand", "operand" => format_args[1]), - )); + return Err(Box::new(DateError::ExtraOperand { + operand: operand_for_error(format_args[1]), + })); } } - let format = if let Some(fmt) = matches.get_one::(OPT_FORMAT) { - if !fmt.starts_with('+') { + let format = if let Some(fmt) = matches.get_one::(OPT_FORMAT) { + let bytes = uucore::os_str_as_bytes_lossy(fmt); + let Some(rest) = bytes.strip_prefix(b"+") else { + let arg = operand_for_error(fmt); // if an optional Format String was found but the user has not provided an input date // GNU prints an invalid date Error if !matches!(date_source, DateSource::Human(_)) { - return Err(USimpleError::new( - 1, - translate!("date-error-invalid-date", "date" => fmt), - )); + return Err(Box::new(DateError::InvalidDate { date: arg })); } // If the user did provide an input date with the --date flag and the Format String is // not starting with '+' GNU prints the missing '+' error message - return Err(USimpleError::new( - 1, - translate!("date-error-format-missing-plus", "arg" => fmt), - )); - } - let fmt = fmt[1..].to_string(); - Format::Custom(fmt) + + return Err(Box::new(DateError::FormatMissingPlus { arg })); + }; + Format::Custom(rest.to_vec()) } else if let Some(fmt) = matches .get_many::(OPT_ISO_8601) .map(|mut iter| iter.next().unwrap_or(&DATE.to_string()).as_str().into()) @@ -361,41 +422,44 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let now = if utc { Timestamp::now().to_zoned(TimeZone::UTC) } else { - Zoned::now() - }; - - let set_to = match matches - .get_one::(OPT_SET) - .map(|s| parse_date(s, &now, DebugOptions::new(debug_mode, true))) - { - None => None, - Some(Err((input, _err))) => { - return Err(USimpleError::new( - 1, - translate!("date-error-invalid-date", "date" => input), - )); + #[cfg(target_env = "ohos")] + { + Timestamp::now().to_zoned(ohos_system_zone()) + } + #[cfg(not(target_env = "ohos"))] + { + Zoned::now() } - Some(Ok(date)) => Some(date), }; let settings = Settings { utc, format, date_source, - set_to, debug: debug_mode, }; - if let Some(date) = settings.set_to { - return set_system_datetime(convert_for_set(date, settings.utc)); + if let Some(input) = matches.get_one::(OPT_SET) { + return set_and_echo(input, &now, &settings); } + let allow_extended = matches!(settings.format, Format::Default); + let output_time_zone = now.time_zone().clone(); + // 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(); + let parse = |input: &str, warn_midnight| { + parse_date( + input, + &now, + DebugOptions::new(settings.debug, warn_midnight), + allow_extended, + ) + }; // GNU compatibility (Empty string): // An empty string (or whitespace-only) should be treated as midnight today. @@ -427,8 +491,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { && time_digits.len() <= 4 && time_digits.chars().all(|c| c.is_ascii_digit()); - let date = if is_empty_or_whitespace || is_military_j { - // Treat empty string or 'J' as midnight today (00:00:00) in local time + let date = if is_empty_or_whitespace || input == "-" || is_military_j { + // Treat empty string, single hyphen, or 'J' as midnight today in local time let date_part = strtime::format("%F", &now).unwrap_or_else(|_| String::from("1970-01-01")); let offset = if settings.utc { @@ -447,7 +511,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { "date: warning: using midnight as starting time: 00:00:00" ); } - parse_date(composed, &now, DebugOptions::new(settings.debug, false)) + parse(&composed, false) } 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 @@ -467,7 +531,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { DayDelta::Previous => format_date_with_epoch_fallback(now.yesterday()), }; let composed = format!("{date_part} {total_hours:02}:00:00 +00:00"); - parse_date(composed, &now, DebugOptions::new(settings.debug, false)) + parse(&composed, false) } else if is_pure_digits { // Derive HH and MM from the digits let (hh_opt, mm_opt) = if time_digits.len() <= 2 { @@ -493,13 +557,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } else { format!("{date_part} {hh:02}:{mm:02} {offset}") }; - parse_date(composed, &now, DebugOptions::new(settings.debug, false)) + parse(&composed, false) } else { // Fallback on parse failure of digits - parse_date(input, &now, DebugOptions::new(settings.debug, true)) + parse(input, true) } } else { - parse_date(input, &now, DebugOptions::new(settings.debug, true)) + parse(input, true) }; let iter = std::iter::once(date); @@ -509,90 +573,82 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { std::io::stdin(), &now, DebugOptions::new(settings.debug, true), + allow_extended, ), DateSource::File(ref path) => { if path.is_dir() { - return Err(USimpleError::new( - 2, - translate!("date-error-expected-file-got-directory", "path" => path.quote()), - )); + return Err(Box::new(DateError::ExpectedFileGotDirectory { + path: path.quote().to_string(), + })); } let file = File::open(path).map_err_context(|| path.as_os_str().maybe_quote().to_string())?; - parse_dates_from_reader(file, &now, DebugOptions::new(settings.debug, true)) + parse_dates_from_reader( + file, + &now, + DebugOptions::new(settings.debug, true), + allow_extended, + ) } DateSource::FileMtime(ref path) => { let metadata = std::fs::metadata(path) .map_err_context(|| path.as_os_str().maybe_quote().to_string())?; let mtime = metadata.modified()?; - let ts = Timestamp::try_from(mtime).map_err(|e| { - USimpleError::new( - 1, - translate!("date-error-cannot-set-date", "path" => path.quote(), "error" => e), - ) + let ts = Timestamp::try_from(mtime).map_err(|e| DateError::CannotSetDate { + path: path.quote().to_string(), + error: e.to_string(), })?; + #[cfg(target_env = "ohos")] + let date = ts.to_zoned(ohos_system_zone()); + #[cfg(not(target_env = "ohos"))] let date = ts.to_zoned(TimeZone::try_system().unwrap_or(TimeZone::UTC)); - let iter = std::iter::once(Ok(date)); + let iter = std::iter::once(Ok(ParsedDateTime::InRange(date))); Box::new(iter) } DateSource::Resolution => { let resolution = get_clock_resolution(); + #[cfg(target_env = "ohos")] + let date = resolution.to_zoned(ohos_system_zone()); + #[cfg(not(target_env = "ohos"))] let date = resolution.to_zoned(TimeZone::system()); - let iter = std::iter::once(Ok(date)); + let iter = std::iter::once(Ok(ParsedDateTime::InRange(date))); Box::new(iter) } DateSource::Now => { - let iter = std::iter::once(Ok(now)); + let iter = std::iter::once(Ok(ParsedDateTime::InRange(now))); Box::new(iter) } }; - let format_string = make_format_string(&settings); + let output = OutputContext::new(&settings); 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 { - Ok(date) => { - let date = if settings.utc { - date.with_time_zone(TimeZone::UTC) - } else { - date - }; - let skip_localization = - matches!(settings.format, Format::Rfc5322 | Format::Rfc3339(_)); - match format_date_with_locale_aware_months( + Ok(ParsedDateTime::InRange(date)) => { + write_formatted_date(&mut stdout, date, &output, &settings)?; + } + Ok(ParsedDateTime::Extended(date)) => { + let formatted = format_extended_default( &date, - format_string, - &config, - skip_localization, - ) { - 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), - )); - } - } + &output.chunks, + &output.config, + &output_time_zone, + ); + write_formatted_bytes(&mut stdout, formatted, &output)?; } Err((input, _err)) => { let _ = stdout.flush(); - show!(USimpleError::new( - 1, - translate!("date-error-invalid-date", "date" => input) - )); + + show!(DateError::InvalidDate { + date: input.clone() + }); } } } - stdout - .flush() - .map_err(|e| USimpleError::new(1, translate!("date-error-write", "error" => e)))?; + stdout.flush().map_err(DateError::Write)?; Ok(()) } @@ -610,7 +666,7 @@ pub fn uu_app() -> Command { .value_name("STRING") .allow_hyphen_values(true) .overrides_with(OPT_DATE) - .value_parser(clap::value_parser!(std::ffi::OsString)) + .value_parser(clap::value_parser!(OsString)) .help(translate!("date-help-date")), ) .arg( @@ -619,7 +675,9 @@ pub fn uu_app() -> Command { .long(OPT_FILE) .value_name("DATEFILE") .value_hint(clap::ValueHint::FilePath) + .value_parser(clap::value_parser!(OsString)) .conflicts_with(OPT_DATE) + .overrides_with(OPT_FILE) .help(translate!("date-help-file")), ) .arg( @@ -671,7 +729,9 @@ pub fn uu_app() -> Command { .long(OPT_REFERENCE) .value_name("FILE") .value_hint(clap::ValueHint::AnyPath) + .value_parser(clap::value_parser!(OsString)) .conflicts_with_all([OPT_DATE, OPT_FILE, OPT_RESOLUTION]) + .overrides_with(OPT_REFERENCE) .help(translate!("date-help-reference")), ) .arg( @@ -681,14 +741,10 @@ pub fn uu_app() -> Command { .value_name("STRING") .allow_hyphen_values(true) .help({ - #[cfg(not(any(target_os = "macos", target_os = "redox")))] + #[cfg(not(target_os = "redox"))] { translate!("date-help-set") } - #[cfg(target_os = "macos")] - { - translate!("date-help-set-macos") - } #[cfg(target_os = "redox")] { translate!("date-help-set-redox") @@ -705,7 +761,11 @@ pub fn uu_app() -> Command { .help(translate!("date-help-universal")) .action(ArgAction::SetTrue), ) - .arg(Arg::new(OPT_FORMAT).num_args(0..)) + .arg( + Arg::new(OPT_FORMAT) + .num_args(0..) + .value_parser(clap::value_parser!(OsString)), + ) } /// Replace bare `%s` conversion specifiers in `fmt` with the Unix epoch second @@ -719,7 +779,7 @@ fn substitute_epoch_seconds(fmt: &str, date: &Zoned) -> String { return fmt.to_string(); } - let seconds = parse_datetime::ParsedDateTime::InRange(date.clone()) + let seconds = ParsedDateTime::InRange(date.clone()) .unix_epoch_second() .to_string(); @@ -746,6 +806,283 @@ fn substitute_epoch_seconds(fmt: &str, date: &Zoned) -> String { out } +/// Remove the `O` strftime modifier from `fmt`. +/// +/// In the C locale `%O` requests alternative numeric symbols that do not +/// exist, so GNU `date` treats it as a no-op: `%Om` renders exactly like +/// `%m` (issue #11656). jiff does not know the modifier and would emit it +/// literally, so strip it before jiff sees the format string. +/// +/// The `O` is only dropped when it actually modifies something, that is, +/// when a specifier letter follows it. A dangling `%O` (at the end of the +/// string, or followed by a non-letter) stays literal, as does any `O` +/// that merely follows the `%%` escape. +fn strip_o_modifier(fmt: &str) -> String { + if !fmt.contains("%O") { + return fmt.to_string(); + } + + let mut out = String::with_capacity(fmt.len()); + let mut chars = fmt.chars().peekable(); + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + match chars.peek() { + Some('O') => { + let mut lookahead = chars.clone(); + lookahead.next(); + if lookahead.peek().is_some_and(char::is_ascii_alphabetic) { + chars.next(); + } + out.push('%'); + } + // Keep `%%` intact: an `O` after a literal percent is plain text. + Some('%') => { + chars.next(); + out.push_str("%%"); + } + _ => out.push('%'), + } + } + out +} + +/// A piece of a format string. +/// +/// A format string is not necessarily valid UTF-8: a locale using a legacy +/// charset (`zh_TW.euctw`, `cs_CZ`, ...) describes its date format with bytes +/// of that charset, and the command line may contain arbitrary bytes too. Such +/// bytes are never part of a conversion specifier, so they are split off and +/// copied to the output untouched while the rest goes through the formatter. +enum FormatChunk<'a> { + Specifiers(&'a str), + RawBytes(&'a [u8]), +} + +/// Split a format string into UTF-8 pieces and the raw bytes between them. +fn split_format(format: &[u8]) -> Vec> { + let mut chunks = Vec::new(); + let mut rest = format; + + while !rest.is_empty() { + let error = match std::str::from_utf8(rest) { + Ok(valid) => { + chunks.push(FormatChunk::Specifiers(valid)); + break; + } + Err(error) => error, + }; + + // A trailing unpaired '%' would lose its specifier byte, so hand it to + // the raw run instead; GNU prints it literally. + let mut valid_len = error.valid_up_to(); + let trailing_percents = rest[..valid_len] + .iter() + .rev() + .take_while(|&&b| b == b'%') + .count(); + if trailing_percents % 2 == 1 { + valid_len -= 1; + } + + let valid = std::str::from_utf8(&rest[..valid_len]).unwrap_or_default(); + if !valid.is_empty() { + chunks.push(FormatChunk::Specifiers(valid)); + } + + let invalid_end = error + .error_len() + .map_or(rest.len(), |len| error.valid_up_to() + len); + chunks.push(FormatChunk::RawBytes(&rest[valid_len..invalid_end])); + rest = &rest[invalid_end..]; + } + + chunks +} + +/// Format each UTF-8 chunk with `format_chunk` and pass the other bytes through. +fn format_chunks( + chunks: &[FormatChunk], + mut format_chunk: impl FnMut(&str) -> Result, +) -> Result, String> { + let mut output = Vec::new(); + for chunk in chunks { + match chunk { + FormatChunk::Specifiers(fmt) => output.extend_from_slice(format_chunk(fmt)?.as_bytes()), + FormatChunk::RawBytes(bytes) => output.extend_from_slice(bytes), + } + } + Ok(output) +} + +/// The format string, its chunks and the formatter config, shared by the +/// display loop, the `-s` echo and the tests. +struct OutputContext<'a> { + format_string: &'a [u8], + chunks: Vec>, + config: Config, +} + +impl<'a> OutputContext<'a> { + fn new(settings: &'a Settings) -> Self { + let format_string = make_format_string(settings); + Self { + format_string, + chunks: split_format(format_string), + config: Config::new().custom(PosixCustom::new()).lenient(true), + } + } +} + +/// Write one in-range date, newline-terminated, with the normal display +/// formatting. +fn write_formatted_date( + writer: &mut W, + date: Zoned, + output: &OutputContext, + settings: &Settings, +) -> UResult<()> { + let skip_localization = matches!(settings.format, Format::Rfc5322 | Format::Rfc3339(_)); + let date = if settings.utc { + date.with_time_zone(TimeZone::UTC) + } else { + date + }; + write_formatted_bytes( + writer, + format_chunks(&output.chunks, |fmt| { + format_date_with_locale_aware_months(&date, fmt, &output.config, skip_localization) + }), + output, + ) +} + +/// Write formatted bytes, newline-terminated, mapping a formatting failure to +/// `InvalidFormat` after flushing what made it out. +fn write_formatted_bytes( + writer: &mut W, + formatted: Result, String>, + output: &OutputContext, +) -> UResult<()> { + match formatted { + Ok(bytes) => { + writer.write_all(&bytes).map_err(DateError::Write)?; + writer.write_all(b"\n").map_err(DateError::Write)?; + Ok(()) + } + Err(e) => { + let _ = writer.flush(); + Err(Box::new(DateError::InvalidFormat { + format: String::from_utf8_lossy(output.format_string).into_owned(), + error: e, + })) + } + } +} + +/// Set the clock, then echo the resulting date like GNU does (issue #14677). +/// Report a set failure first, so it isn't lost when the echo write fails. +fn set_and_echo(input: &str, now: &Zoned, settings: &Settings) -> UResult<()> { + match parse_date(input, now, DebugOptions::new(settings.debug, true), false) { + Ok(ParsedDateTime::InRange(date)) => { + show_if_err!(set_system_datetime(convert_for_set( + date.clone(), + settings.utc + ))); + + let output = OutputContext::new(settings); + let mut stdout = BufWriter::new(std::io::stdout().lock()); + write_formatted_date(&mut stdout, date, &output, settings)?; + stdout.flush().map_err(DateError::Write)?; + Ok(()) + } + Ok(ParsedDateTime::Extended(_)) | Err(_) => Err(Box::new(DateError::InvalidDate { + date: input.to_owned(), + })), + } +} + +fn format_extended_default( + date: &ExtendedDateTime, + chunks: &[FormatChunk], + config: &Config, + output_time_zone: &TimeZone, +) -> Result, String> { + // Apply the output timezone to an equivalent in-range instant, then let the + // existing formatter handle every field except the real extended year. + let utc = ExtendedDateTime::from_unix_seconds(date.unix_seconds(), date.nanosecond, 0) + .map_err(str::to_string)?; + let surrogate = jiff::civil::DateTime::new( + surrogate_year(utc.year), + utc.month as i8, + utc.day as i8, + utc.hour as i8, + utc.minute as i8, + utc.second as i8, + utc.nanosecond as i32, + ) + .map_err(|e| e.to_string())? + .to_zoned(TimeZone::UTC) + .map_err(|e| e.to_string())? + .with_time_zone(output_time_zone.clone()); + let output = ExtendedDateTime::from_unix_seconds( + date.unix_seconds(), + date.nanosecond, + surrogate.offset().seconds(), + ) + .map_err(str::to_string)?; + let mut year_replaced = false; + let formatted = format_chunks(chunks, |fmt| { + let (fmt, replaced) = substitute_extended_year(fmt, output.year); + year_replaced |= replaced; + format_date_with_locale_aware_months(&surrogate, &fmt, config, false) + })?; + + if year_replaced { + Ok(formatted) + } else { + Err("default date format does not contain %Y".to_string()) + } +} + +fn surrogate_year(year: u32) -> i16 { + // The Gregorian calendar and recurring timezone rules repeat every 400 years. + const BASE: i64 = 9_599; + (BASE + (i64::from(year) - BASE).rem_euclid(400)) as i16 +} + +/// Expand `%Y` to an out-of-range year, reporting whether it occurred at all. +fn substitute_extended_year(format_string: &str, year: u32) -> (String, bool) { + let mut output = String::with_capacity(format_string.len() + 8); + let mut chars = format_string.chars().peekable(); + let year = year.to_string(); + let mut replaced = false; + + while let Some(ch) = chars.next() { + if ch != '%' { + output.push(ch); + continue; + } + + match chars.next() { + Some('%') => output.push_str("%%"), + Some('Y') => { + output.push_str(&year); + replaced = true; + } + Some(specifier) => { + output.push('%'); + output.push(specifier); + } + None => output.push('%'), + } + } + + (output, replaced) +} + fn format_date_with_locale_aware_months( date: &Zoned, format_string: &str, @@ -769,7 +1106,7 @@ fn format_date_with_locale_aware_months( // negative infinity (e.g. `@-1.5` → `-2`, not `-1`). Every other field jiff // produces already agrees with GNU, so only `%s` needs correcting; rewrite it // to the floored epoch second before jiff sees the format string. - let fmt_owned = substitute_epoch_seconds(fmt, date); + let fmt_owned = strip_o_modifier(&substitute_epoch_seconds(fmt, date)); let fmt = fmt_owned.as_str(); // Check if format string has GNU modifiers (width/flags) and format if present @@ -784,22 +1121,25 @@ fn format_date_with_locale_aware_months( } /// Return the appropriate format string for the given settings. -fn make_format_string(settings: &Settings) -> &str { +/// +/// Bytes rather than `str`: a user-supplied format, like the format of a +/// locale using a legacy charset, may not be valid UTF-8. +fn make_format_string(settings: &Settings) -> &[u8] { match settings.format { Format::Iso8601(ref fmt) => match *fmt { - Iso8601Format::Date => "%F", - Iso8601Format::Hours => "%FT%H%:z", - Iso8601Format::Minutes => "%FT%H:%M%:z", - Iso8601Format::Seconds => "%FT%T%:z", - Iso8601Format::Ns => "%FT%T,%N%:z", + Iso8601Format::Date => b"%F", + Iso8601Format::Hours => b"%FT%H%:z", + Iso8601Format::Minutes => b"%FT%H:%M%:z", + Iso8601Format::Seconds => b"%FT%T%:z", + Iso8601Format::Ns => b"%FT%T,%N%:z", }, - Format::Rfc5322 => "%a, %d %h %Y %T %z", + Format::Rfc5322 => b"%a, %d %h %Y %T %z", Format::Rfc3339(ref fmt) => match *fmt { - Rfc3339Format::Date => "%F", - Rfc3339Format::Seconds => "%F %T%:z", - Rfc3339Format::Ns => "%F %T.%N%:z", + Rfc3339Format::Date => b"%F", + Rfc3339Format::Seconds => b"%F %T%:z", + Rfc3339Format::Ns => b"%F %T.%N%:z", }, - Format::Resolution => "%s.%N", + Format::Resolution => b"%s.%N", Format::Custom(ref fmt) => fmt, Format::Default => locale::get_locale_default_format(), } @@ -810,7 +1150,7 @@ fn make_format_string(settings: &Settings) -> &str { /// (e.g., EDT always means UTC-4, even in winter when New York observes EST). /// Offset is in seconds to support half-hour zones like IST (UTC+5:30). /// All other timezones (JST, CET, etc.) are dynamically resolved from IANA database. -/* spell-checker: disable */ +// spell-checker:disable static FIXED_OFFSET_ABBREVIATIONS: &[(&str, i32)] = &[ ("UTC", 0), ("GMT", 0), @@ -838,7 +1178,7 @@ static FIXED_OFFSET_ABBREVIATIONS: &[(&str, i32)] = &[ // Asian timezones ("KST", 32400), // UTC+9 Korean Standard Time ]; -/* spell-checker: enable */ +// spell-checker:enable /// Lazy-loaded timezone abbreviation lookup map built from IANA database. static TZ_ABBREV_CACHE: OnceLock> = OnceLock::new(); @@ -900,8 +1240,12 @@ fn resolve_tz_abbreviation(word: &str) -> Option { /// (e.g. "10:30 EST"). /// /// If a trailing abbreviation is found and the rest of the string is a parsable -/// date, returns `Some(Zoned)`. Returns `None` if no abbreviation is detected or -/// if parsing fails, indicating that standard parsing should be attempted. +/// date that could still legally take a timezone, returns `Some(Zoned)`. +/// +/// Returns `None` when no abbreviation is detected, when parsing fails, or when +/// the remainder already carries zone information or cannot take a zone at all +/// (GNU `date` rejects those). In every `None` case the caller should fall back +/// to standard parsing, which reports the error. fn try_parse_with_abbreviation>(date_str: S, now: &Zoned) -> Option { let s = date_str.as_ref(); @@ -911,21 +1255,32 @@ fn try_parse_with_abbreviation>(date_str: S, now: &Zoned) -> Optio let date_part = s.trim_end_matches(last_word).trim(); - // Reject inputs that specify a timezone twice, e.g. "EST EST" or "EST PST": - // GNU `date` considers these invalid. If what remains after stripping the - // trailing abbreviation is itself a bare timezone abbreviation, don't rescue - // it here; let the standard parser reject the whole string. - if date_part - .split_whitespace() - .last() - .is_some_and(|w| resolve_tz_abbreviation(w).is_some()) - { + // GNU rejects "@0 EST": a timestamp cannot take a timezone. + if date_part.starts_with('@') { return None; } // Parse in the target timezone so "10:30 EDT" means 10:30 in EDT. let parsed = parse_datetime::parse_datetime_at_date(now.clone(), date_part).ok()?; - let zoned = parsed.into_zoned()?.datetime().to_zoned(tz).ok()?; + let zoned = parsed.into_zoned()?; + + // `parse_datetime` returns the zone the input named, or `now`'s when it named + // none, so a mismatch means `date_part` carries one of its own. + if zoned.time_zone() != now.time_zone() { + return None; + } + + // That check cannot see a zone whose offset equals `now`'s ("12:00 UTC EST" + // under `-u`). Gated so the common case stays at one parse: `-f` runs this + // once per line. + let names_zone = date_part.contains('+') + || date_part.contains(|c: char| c.is_ascii_alphabetic()) + || date_part.split_whitespace().any(|w| w.starts_with('-')); + if names_zone { + parse_datetime::parse_datetime_at_date(now.clone(), format!("{date_part} EST")).ok()?; + } + + let zoned = zoned.datetime().to_zoned(tz).ok()?; // The trailing abbreviation only describes the *input* timezone. For display, // re-zone to the system timezone (i.e. `now`'s zone, which is UTC under `-u`). @@ -934,8 +1289,6 @@ fn try_parse_with_abbreviation>(date_str: S, now: &Zoned) -> Optio Some(zoned.with_time_zone(now.time_zone().clone())) } -/// Parse a `String` into a `DateTime`. -/// If it fails, return a tuple of the `String` along with its `ParseError`. /// Helper function to parse dates from a line-based reader (stdin or file) /// /// Takes any `Read` source, reads it line by line, and parses each line as a date. @@ -944,27 +1297,39 @@ fn parse_dates_from_reader( reader: R, now: &Zoned, dbg_opts: DebugOptions, -) -> Box> + '_> { - let lines = BufReader::new(reader).lines(); - Box::new( - lines - .map_while(Result::ok) - .map(move |s| parse_date(s, now, dbg_opts)), - ) + allow_extended: bool, +) -> Box< + dyn Iterator> + '_, +> { + let lines = BufReader::new(reader).split(b'\n'); + Box::new(lines.map_while(Result::ok).map(move |mut bytes| { + // GNU handles each line as a C string, so a NUL byte ends it + if let Some(nul) = bytes.iter().position(|&b| b == 0) { + bytes.truncate(nul); + } + // Strip a trailing '\r' (CRLF input; GNU's lexer ignores it too) + if bytes.last() == Some(&b'\r') { + bytes.pop(); + } + match String::from_utf8(bytes) { + Ok(s) => parse_date(s, now, dbg_opts, allow_extended), + // Report lines with invalid UTF-8 (with non-printable bytes + // octal-escaped like GNU) instead of silently stopping the input + Err(e) => Err(( + escape_invalid_bytes(e.as_bytes()), + parse_datetime::ParseDateTimeError::InvalidInput, + )), + } + })) } -/// -/// **Update for parse_datetime 0.13:** -/// - parse_datetime 0.11: returned `chrono::DateTime` → required conversion to `jiff::Zoned` -/// - parse_datetime 0.13: returns `jiff::Zoned` directly → no conversion needed -/// -/// This change was necessary to fix issue #8754 (parsing large second values like -/// "12345.123456789 seconds ago" which failed in 0.11 but works in 0.13). -fn parse_date + Clone>( +/// Parse a string into either an in-range [`Zoned`] value or an extended date. +fn parse_date>( s: S, now: &Zoned, dbg_opts: DebugOptions, -) -> Result { + allow_extended: bool, +) -> Result { let input_str = s.as_ref(); if dbg_opts.debug { @@ -988,21 +1353,13 @@ fn parse_date + Clone>( let tz_display = zoned.time_zone().iana_name().unwrap_or("system default"); let _ = writeln!(err, "date: input timezone: {tz_display}"); } - return Ok(zoned); + return Ok(ParsedDateTime::InRange(zoned)); } match parse_datetime::parse_datetime_at_date(now.clone(), input_str) { // Convert to system timezone for display // (parse_datetime returns a value in the input's timezone) - Ok(parsed) => { - // Out-of-range years (the `Extended` variant) can't be represented - // as a `Zoned`; treat them as an unparseable input. - let Some(date) = parsed.into_zoned() else { - return Err(( - input_str.into(), - parse_datetime::ParseDateTimeError::InvalidInput, - )); - }; + Ok(ParsedDateTime::InRange(date)) => { let result = date.timestamp().to_zoned(now.time_zone().clone()); if dbg_opts.debug { // Show final parsed date and time @@ -1035,18 +1392,48 @@ fn parse_date + Clone>( } } } - Ok(result) + Ok(ParsedDateTime::InRange(result)) } + Ok(ParsedDateTime::Extended(date)) if allow_extended => Ok(ParsedDateTime::Extended(date)), + Ok(ParsedDateTime::Extended(_)) => Err(( + input_str.into(), + parse_datetime::ParseDateTimeError::InvalidInput, + )), Err(e) => Err((input_str.into(), e)), } } -#[cfg(not(any(unix, windows)))] +#[cfg(target_os = "wasi")] +/// Returns the resolution of the system's realtime clock. +/// +/// `rustix::time::clock_getres` excludes WASI, so `libc::clock_getres` is +/// used directly as a workaround. +fn get_clock_resolution() -> Timestamp { + let mut timespec = std::mem::MaybeUninit::::uninit(); + + // SAFETY: `clock_getres` writes a complete `timespec` into the provided + // pointer when it returns 0. We only read `timespec` after checking for + // success, so it is always fully initialized before use. + let timespec = unsafe { + let ret = libc::clock_getres(libc::CLOCK_REALTIME, timespec.as_mut_ptr()); + assert_eq!( + ret, + 0, + "clock_getres(CLOCK_REALTIME) failed: {}", + std::io::Error::last_os_error() + ); + timespec.assume_init() + }; + + #[allow(clippy::unnecessary_cast, reason = "needed for 32 bit target")] + Timestamp::constant(timespec.tv_sec as _, timespec.tv_nsec as _) +} + +#[cfg(not(any(unix, windows, target_os = "wasi")))] fn get_clock_resolution() -> Timestamp { unimplemented!("getting clock resolution not implemented (unsupported target)"); } -#[cfg(all(unix, not(target_os = "redox")))] /// Returns the resolution of the system’s realtime clock. /// /// # Panics @@ -1054,6 +1441,7 @@ fn get_clock_resolution() -> Timestamp { /// Panics if `clock_getres` fails. On a POSIX-compliant system this should not occur, /// as `CLOCK_REALTIME` is required to be supported. /// Failure would indicate a non-conforming or otherwise broken implementation. +#[cfg(all(unix, not(target_os = "redox")))] fn get_clock_resolution() -> Timestamp { use rustix::time::{ClockId, clock_getres}; @@ -1063,7 +1451,7 @@ fn get_clock_resolution() -> Timestamp { Timestamp::constant(timespec.tv_sec as _, timespec.tv_nsec as _) } -#[cfg(all(unix, target_os = "redox"))] +#[cfg(target_os = "redox")] fn get_clock_resolution() -> Timestamp { // Redox OS does not support the posix clock_getres function, however // internally it uses a resolution of 1ns to represent timestamps. @@ -1080,7 +1468,7 @@ fn get_clock_resolution() -> Timestamp { Timestamp::constant(0, 100) } -#[cfg(not(any(unix, windows)))] +#[cfg(not(any(unix, windows, target_os = "wasi")))] fn set_system_datetime(_date: Zoned) -> UResult<()> { unimplemented!("setting date not implemented (unsupported target)"); } @@ -1094,28 +1482,23 @@ fn convert_for_set(date: Zoned, utc: bool) -> Zoned { } } -#[cfg(target_os = "macos")] +#[cfg(target_os = "wasi")] +/// The WASI sandbox has no syscall for setting the wall clock. fn set_system_datetime(_date: Zoned) -> UResult<()> { - Err(USimpleError::new( - 1, - translate!("date-error-setting-date-not-supported-macos"), - )) + Err(Box::new(DateError::SettingDateNotSupportedWasi)) } #[cfg(target_os = "redox")] fn set_system_datetime(_date: Zoned) -> UResult<()> { - Err(USimpleError::new( - 1, - translate!("date-error-setting-date-not-supported-redox"), - )) + Err(Box::new(DateError::SettingDateNotSupportedRedox)) } -#[cfg(all(unix, not(target_os = "macos"), not(target_os = "redox")))] /// System call to set date (unix). /// See here for more: /// `` /// `` /// `` +#[cfg(all(unix, not(target_os = "redox")))] fn set_system_datetime(date: Zoned) -> UResult<()> { use rustix::time::{ClockId, Timespec, clock_settime}; @@ -1130,11 +1513,11 @@ fn set_system_datetime(date: Zoned) -> UResult<()> { .map_err_context(|| translate!("date-error-cannot-set-date")) } -#[cfg(windows)] /// System call to set date (Windows). /// See here for more: /// * /// * +#[cfg(windows)] fn set_system_datetime(date: Zoned) -> UResult<()> { let system_time = SYSTEMTIME { wYear: date.year() as u16, @@ -1197,8 +1580,14 @@ mod tests { #[test] fn test_abbreviation_resolves_relative_date_against_now() { let now = "2025-03-15T20:00:00+00:00[UTC]".parse::().unwrap(); - let result = - parse_date("yesterday 10:00 GMT", &now, DebugOptions::new(false, false)).unwrap(); + let result = parse_date( + "yesterday 10:00 GMT", + &now, + DebugOptions::new(false, false), + false, + ) + .unwrap() + .expect_in_range(); assert_eq!(result.date(), jiff::civil::date(2025, 3, 14)); } @@ -1210,8 +1599,10 @@ mod tests { "Sat 20 Mar 2021 14:53:01 AWST", &now, DebugOptions::new(false, false), + false, ) - .unwrap(); + .unwrap() + .expect_in_range(); let utc = convert_for_set(date, true); assert_eq!((utc.hour(), utc.minute(), utc.second()), (6, 53, 1)); // AWST(+08:00) -> -8h } @@ -1249,4 +1640,72 @@ mod tests { 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 } + + /// Run one date through the shared writer used by the display loop and + /// the `-s` echo, returning what it produced. + fn write_date_with_settings(date: Zoned, settings: Settings) -> String { + let output = OutputContext::new(&settings); + let mut out = Vec::new(); + write_formatted_date(&mut out, date, &output, &settings).unwrap(); + String::from_utf8(out).unwrap() + } + + #[test] + fn test_write_formatted_date_custom_format() { + // Specifiers that no locale rewrites, so the test is locale-independent. + let date: Zoned = "2020-03-12T05:30:00+00:00[UTC]".parse().unwrap(); + let settings = Settings { + utc: false, + format: Format::Custom(b"%F %T %Z".to_vec()), + date_source: DateSource::Now, + debug: false, + }; + assert_eq!( + write_date_with_settings(date, settings), + "2020-03-12 05:30:00 UTC\n" + ); + } + + #[test] + fn test_write_formatted_date_rfc5322_not_localized() { + let date: Zoned = "2020-03-12T05:30:00+00:00[UTC]".parse().unwrap(); + let settings = Settings { + utc: false, + format: Format::Rfc5322, + date_source: DateSource::Now, + debug: false, + }; + assert_eq!( + write_date_with_settings(date, settings), + "Thu, 12 Mar 2020 05:30:00 +0000\n" + ); + } + + #[test] + fn test_write_formatted_date_utc_rezones_for_display() { + // Parsed at +08:00; `-u` echoes the same instant in UTC. + let date: Zoned = "2020-03-12T13:30:00+08:00[+08:00]".parse().unwrap(); + let settings = Settings { + utc: true, + format: Format::Custom(b"%F %T %:z".to_vec()), + date_source: DateSource::Now, + debug: false, + }; + assert_eq!( + write_date_with_settings(date, settings), + "2020-03-12 05:30:00 +00:00\n" + ); + } + + #[test] + fn test_get_clock_resolution() { + let res = get_clock_resolution(); + + // Every supported platform reports a resolution of at least 1ns. + let nanos = res.as_second() as i128 * 1_000_000_000 + i128::from(res.subsec_nanosecond()); + assert!( + (1..=1_000_000_000).contains(&nanos), + "unexpected clock resolution: {nanos}ns" + ); + } } diff --git a/src/uu/date/src/format_modifiers.rs b/src/uu/date/src/format_modifiers.rs index f88aa8d73d5..f334b30b08d 100644 --- a/src/uu/date/src/format_modifiers.rs +++ b/src/uu/date/src/format_modifiers.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore strtime Yhello //! GNU date format modifier support @@ -19,7 +20,8 @@ //! - `_`: Pad with spaces instead of zeros //! - `0`: Pad with zeros (default for numeric fields) //! - `^`: Convert to uppercase -//! - `#`: Use opposite case (uppercase becomes lowercase and vice versa) +//! - `#`: Swap the case of names; lowercase %p and %Z, even with `^`; +//! no effect on %c and %r (%P is always lowercase) //! - `+`: Force display of sign (+ for positive, - for negative) //! //! ### Width @@ -105,22 +107,20 @@ struct ParsedSpec<'a> { /// valid specifier follows. fn parse_format_spec(s: &str) -> Option> { let bytes = s.as_bytes(); - if bytes.first() != Some(&b'%') { - return None; - } + bytes.first().filter(|b| *b == &b'%')?; let mut pos = 1; // Flags: any of [_0^#+-], zero or more. let flags_start = pos; - while pos < bytes.len() && matches!(bytes[pos], b'_' | b'0' | b'^' | b'#' | b'+' | b'-') { + while bytes.get(pos).is_some_and(|c| b"_0^#+-".contains(c)) { pos += 1; } let flags = &s[flags_start..pos]; // Width: zero or more ASCII digits. let width_start = pos; - while pos < bytes.len() && bytes[pos].is_ascii_digit() { + while bytes.get(pos).is_some_and(u8::is_ascii_digit) { pos += 1; } let width = if pos > width_start { @@ -131,12 +131,10 @@ fn parse_format_spec(s: &str) -> Option> { // Specifier: up to three `:` followed by a single ASCII letter. let spec_start = pos; - while pos < bytes.len() && bytes[pos] == b':' && pos - spec_start < 3 { + while bytes.get(pos) == Some(&b':') && pos - spec_start < 3 { pos += 1; } - if pos >= bytes.len() || !bytes[pos].is_ascii_alphabetic() { - return None; - } + bytes.get(pos).filter(|c| c.is_ascii_alphabetic())?; pos += 1; let spec = &s[spec_start..pos]; @@ -282,6 +280,7 @@ fn is_space_padded_specifier(specifier: &str) -> bool { /// Returns the default width for a specifier. /// This is used when a flag like `_` is used without an explicit width. fn get_default_width(specifier: &str) -> usize { + #[allow(clippy::match_same_arms)] // needs comment at each arms match specifier.chars().last() { // Day of month: 2 digits (01-31) Some('d') | Some('e') => 2, @@ -330,10 +329,13 @@ fn strip_default_padding(value: &str) -> String { if stripped.is_empty() { return "0".to_string(); } - if let Some(first_char) = stripped.chars().next() { - if first_char.is_ascii_digit() { - return stripped.to_string(); - } + if stripped + .chars() + .next() + .as_ref() + .is_some_and(char::is_ascii_digit) + { + return stripped.to_string(); } } if value.starts_with(' ') { @@ -391,10 +393,8 @@ fn apply_modifiers(value: &str, parsed: &ParsedSpec<'_>) -> Result { uppercase = true; - swap_case = false; // ^ overrides # } - '#' if !uppercase => { - // Only apply # if ^ hasn't been set + '#' => { swap_case = true; } '+' => { @@ -406,10 +406,14 @@ fn apply_modifiers(value: &str, parsed: &ParsedSpec<'_>) -> Result) -> Result 4 digits) - if force_sign && !result.starts_with('+') && !result.starts_with('-') { - if result.chars().next().is_some_and(|c| c.is_ascii_digit()) { - let default_w = get_default_width(specifier); - // Add sign only if explicit width provided OR result exceeds default width - if width.is_some() || (default_w > 0 && result.len() > default_w) { - result.insert(0, '+'); - } + if force_sign + && result + .chars() + .next() + .is_some_and(|c| !matches!(c, '+' | '-') && c.is_ascii_digit()) + { + let default_w = get_default_width(specifier); + // Add sign only if explicit width provided OR result exceeds default width + if width.is_some() || (default_w > 0 && result.len() > default_w) { + result.insert(0, '+'); } } @@ -491,10 +498,11 @@ fn apply_modifiers(value: &str, parsed: &ParsedSpec<'_>) -> Result { + $( + #[cfg(not(all(target_os = "linux", not(target_env = "musl"))))] + $item + )* + }; ($($item:item)*) => { $( - #[cfg(all(unix, not(target_os = "android"), not(target_os = "cygwin"), not(target_os = "redox")))] + #[cfg(all(target_os = "linux", not(target_env = "musl")))] $item )* - } + }; } cfg_langinfo! { - use std::ffi::CStr; + use core::ffi::CStr; use std::sync::OnceLock; #[cfg(test)] @@ -27,15 +41,12 @@ cfg_langinfo! { /// glibc's `_DATE_FMT` has been stable for the last 12 years /// being added upstream to libc TODO: update to libc - #[cfg(any(target_os = "linux", target_os = "cygwin"))] const DATE_FMT: libc::nl_item = 0x2006c; - #[cfg(not(any(target_os = "linux", target_os = "cygwin")))] - const DATE_FMT: libc::nl_item = libc::D_T_FMT; } cfg_langinfo! { /// Cached locale date/time format string - static DEFAULT_FORMAT_CACHE: OnceLock<&'static str> = OnceLock::new(); + static DEFAULT_FORMAT_CACHE: OnceLock<&'static [u8]> = OnceLock::new(); /// Mutex to serialize setlocale() calls during tests. /// @@ -47,24 +58,23 @@ cfg_langinfo! { /// Returns the default date format string for the current locale. /// - /// The format respects locale preferences for time display (12-hour vs 24-hour), - /// component ordering, and numeric formatting conventions. Ensures timezone - /// information is included in the output. - pub fn get_locale_default_format() -> &'static str { + /// This is the locale's `date_fmt`/`d_t_fmt` used verbatim, so the output + /// matches what `date +"$(locale date_fmt)"` produces. It is returned as + /// bytes because legacy charsets (e.g. `zh_TW.euctw`) are not UTF-8. + pub fn get_locale_default_format() -> &'static [u8] { DEFAULT_FORMAT_CACHE.get_or_init(|| { // Try to get locale format string if let Some(format) = get_locale_format_string() { - let format_with_tz = ensure_timezone_in_format(&format); - return Box::leak(format_with_tz.into_boxed_str()); + return Box::leak(format.into_boxed_slice()); } - // Fallback: use 24-hour format as safe default - "%a %b %e %X %Z %Y" + // Fallback: use the POSIX locale format + POSIX_DEFAULT_FORMAT }) } /// Retrieves the date/time format string from the system locale - fn get_locale_format_string() -> Option { + fn get_locale_format_string() -> Option> { // In tests, acquire mutex to prevent race conditions with setlocale() // which is process-global and not thread-safe #[cfg(test)] @@ -80,67 +90,60 @@ cfg_langinfo! { return None; } - let format = CStr::from_ptr(d_t_fmt_ptr).to_str().ok()?; - if format.is_empty() { - return None; - } - - Some(format.to_string()) - } - } - - /// Ensures the format string includes timezone (%Z) - fn ensure_timezone_in_format(format: &str) -> String { - if format.contains("%Z") { - return format.to_string(); - } - - // Try to insert %Z before year specifier (%Y or %y) - if let Some(pos) = format.find("%Y").or_else(|| format.find("%y")) { - let mut result = String::with_capacity(format.len() + 3); - result.push_str(&format[..pos]); - result.push_str("%Z "); - result.push_str(&format[pos..]); - result - } else { - // No year found, append %Z at the end - format.to_string() + " %Z" + let format = CStr::from_ptr(d_t_fmt_ptr).to_bytes(); + (!format.is_empty()).then(|| format.to_vec()) } } } -/// On platforms without nl_langinfo support, use 24-hour format by default -#[cfg(any( - not(unix), - target_os = "android", - target_os = "cygwin", - target_os = "redox" -))] -pub fn get_locale_default_format() -> &'static str { - "%a %b %e %X %Z %Y" +cfg_langinfo! { else + /// On platforms without `_DATE_FMT`, fall back to the POSIX format + pub fn get_locale_default_format() -> &'static [u8] { + POSIX_DEFAULT_FORMAT + } } #[cfg(test)] mod tests { + use super::POSIX_DEFAULT_FORMAT; + + /// `date` with no format has to print the timezone, so the fallback used + /// wherever the locale offers no date format must carry %Z. + #[test] + fn test_posix_default_format_has_timezone() { + assert!(POSIX_DEFAULT_FORMAT.windows(2).any(|pair| pair == b"%Z")); + } + + cfg_langinfo! { else + /// Platforms without glibc's `_DATE_FMT` get the POSIX format, not + /// `D_T_FMT`, which would drop the timezone. + #[test] + fn test_default_format_without_date_fmt() { + assert_eq!(super::get_locale_default_format(), POSIX_DEFAULT_FORMAT); + } + } + cfg_langinfo! { use super::*; - /// Helper function to expand a format string with a known test date + /// Expands a format string with a fixed test date (Monday, January 15, + /// 2024, 14:30:45 UTC), so format strings can be validated by their + /// output rather than by looking for literal format codes. /// - /// Uses a fixed test date: Monday, January 15, 2024, 14:30:45 UTC - /// This allows us to validate format strings by checking their expanded output - /// rather than looking for literal format codes. - fn expand_format_with_test_date(format: &str) -> String { + /// Returns `None` when the format cannot be expanded: a legacy-charset + /// locale (e.g. `zh_CN.GB18030`) hands us bytes that are not UTF-8, and + /// callers must skip rather than assert on an empty expansion. + fn expand_format_with_test_date(format: &[u8]) -> Option { use jiff::civil::date; use jiff::fmt::strtime; + let format = std::str::from_utf8(format).ok()?; + // Create test timestamp: Monday, January 15, 2024, 14:30:45 UTC - let Ok(test_date) = date(2024, 1, 15).at(14, 30, 45, 0).in_tz("UTC") else { - return String::new(); - }; + let test_date = date(2024, 1, 15).at(14, 30, 45, 0).in_tz("UTC").ok()?; // Expand the format string with the test date - strtime::format(format, &test_date).unwrap_or_default() + strtime::format(format, &test_date).ok() } #[test] @@ -153,7 +156,9 @@ mod tests { fn test_default_format_contains_valid_codes() { let format = get_locale_default_format(); - let expanded = expand_format_with_test_date(format); + let Some(expanded) = expand_format_with_test_date(format) else { + return; + }; // Verify expanded output contains expected components // Test date: Monday, January 15, 2024, 14:30:45 @@ -171,12 +176,6 @@ mod tests { expanded.contains("2024") || expanded.contains("24"), "Expanded format should contain year, got: {expanded}" ); - - // Keep literal %Z check - this is enforced by ensure_timezone_in_format() - assert!( - format.contains("%Z"), - "Format string must contain %Z timezone (enforced by ensure_timezone_in_format)" - ); } #[test] @@ -187,7 +186,9 @@ mod tests { // The format should not be empty assert!(!format.is_empty(), "Locale format should not be empty"); - let expanded = expand_format_with_test_date(format); + let Some(expanded) = expand_format_with_test_date(format) else { + return; + }; // Verify expanded output contains date components // Test date: Monday, January 15, 2024 @@ -296,13 +297,16 @@ mod tests { } #[test] - fn test_timezone_included_in_format() { - // The implementation should ensure %Z is present + fn test_format_is_locale_verbatim() { + // The locale format must be used as-is: no timezone specifier is + // injected, otherwise `date` and `date +"$(locale date_fmt)"` + // would disagree for locales whose format has no %Z. let format = get_locale_default_format(); - assert!( - format.contains("%Z") || format.contains("%z"), - "Format should contain timezone indicator: {format}" - ); + let Some(locale_format) = get_locale_format_string() else { + assert_eq!(format, POSIX_DEFAULT_FORMAT); + return; + }; + assert_eq!(format, locale_format); } } } diff --git a/src/uu/dd/Cargo.toml b/src/uu/dd/Cargo.toml index f5232d99290..9e879dd2ecf 100644 --- a/src/uu/dd/Cargo.toml +++ b/src/uu/dd/Cargo.toml @@ -11,18 +11,16 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/dd.rs" doctest = false [dependencies] clap = { workspace = true } -gcd = { workspace = true } +fluent = { workspace = true } libc = { workspace = true } -rustix = { workspace = true } +num-integer = { workspace = true } +thiserror = { workspace = true } uucore = { workspace = true, features = [ "format", "parser-size", @@ -30,8 +28,9 @@ uucore = { workspace = true, features = [ "fs", "signals", ] } -thiserror = { workspace = true } -fluent = { workspace = true } + +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))'.dependencies] +rustix = { workspace = true, features = ["fs"] } [[bin]] name = "dd" @@ -42,6 +41,9 @@ divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bench]] name = "dd_bench" harness = false diff --git a/src/uu/dd/locales/en-US.ftl b/src/uu/dd/locales/en-US.ftl index 3b72e4a8f6c..844dbc690f6 100644 --- a/src/uu/dd/locales/en-US.ftl +++ b/src/uu/dd/locales/en-US.ftl @@ -1,118 +1,79 @@ dd-about = Copy, and optionally convert, a file system resource dd-usage = dd [OPERAND]... dd OPTION -dd-after-help = ### Operands - - - bs=BYTES : read and write up to BYTES bytes at a time (default: 512); - overwrites ibs and obs. - - cbs=BYTES : the 'conversion block size' in bytes. Applies to the - conv=block, and conv=unblock operations. - - conv=CONVS : a comma-separated list of conversion options or (for legacy - reasons) file flags. - - count=N : stop reading input after N ibs-sized read operations rather - than proceeding until EOF. See iflag=count_bytes if stopping after N bytes - is preferred - - ibs=N : the size of buffer used for reads (default: 512) - - if=FILE : the file used for input. When not specified, stdin is used instead - - iflag=FLAGS : a comma-separated list of input flags which specify how the - input source is treated. FLAGS may be any of the input-flags or general-flags - specified below. - - skip=N (or iseek=N) : skip N ibs-sized records into input before beginning - copy/convert operations. See iflag=seek_bytes if seeking N bytes is preferred. - - obs=N : the size of buffer used for writes (default: 512) - - of=FILE : the file used for output. When not specified, stdout is used - instead - - oflag=FLAGS : comma separated list of output flags which specify how the - output source is treated. FLAGS may be any of the output flags or general - flags specified below - - seek=N (or oseek=N) : seeks N obs-sized records into output before - beginning copy/convert operations. See oflag=seek_bytes if seeking N bytes is - preferred - - status=LEVEL : controls whether volume and performance stats are written to - stderr. - - When unspecified, dd will print stats upon completion. An example is below. - - ```plain - 6+0 records in - 16+0 records out - 8192 bytes (8.2 kB, 8.0 KiB) copied, 0.00057009 s, - 14.4 MB/s - - The first two lines are the 'volume' stats and the final line is the - 'performance' stats. - The volume stats indicate the number of complete and partial ibs-sized reads, - or obs-sized writes that took place during the copy. The format of the volume - stats is +. If records have been truncated (see - conv=block), the volume stats will contain the number of truncated records. - - Possible LEVEL values are: - - progress : Print periodic performance stats as the copy proceeds. - - noxfer : Print final volume stats, but not performance stats. - - none : Do not print any stats. - - Printing performance stats is also triggered by the INFO signal (where supported), - or the USR1 signal. Setting the POSIXLY_CORRECT environment variable to any value - (including an empty value) will cause the USR1 signal to be ignored. - - ### Conversion Options - - - ascii : convert from EBCDIC to ASCII. This is the inverse of the ebcdic - option. Implies conv=unblock. - - ebcdic : convert from ASCII to EBCDIC. This is the inverse of the ascii - option. Implies conv=block. - - ibm : convert from ASCII to EBCDIC, applying the conventions for [, ] - and ~ specified in POSIX. Implies conv=block. - - - ucase : convert from lower-case to upper-case. - - lcase : converts from upper-case to lower-case. - - - block : for each newline less than the size indicated by cbs=BYTES, remove - the newline and pad with spaces up to cbs. Lines longer than cbs are truncated. - - unblock : for each block of input of the size indicated by cbs=BYTES, remove - right-trailing spaces and replace with a newline character. - - - sparse : attempts to seek the output when an obs-sized block consists of - only zeros. - - swab : swaps each adjacent pair of bytes. If an odd number of bytes is - present, the final byte is omitted. - - sync : pad each ibs-sided block with zeros. If block or unblock is - specified, pad with spaces instead. - - excl : the output file must be created. Fail if the output file is already - present. - - nocreat : the output file will not be created. Fail if the output file in - not already present. - - notrunc : the output file will not be truncated. If this option is not - present, output will be truncated when opened. - - noerror : all read errors will be ignored. If this option is not present, - dd will only ignore Error::Interrupted. - - fdatasync : data will be written before finishing. - - fsync : data and metadata will be written before finishing. - - ### Input flags - - - count_bytes : a value to count=N will be interpreted as bytes. - - skip_bytes : a value to skip=N will be interpreted as bytes. - - fullblock : wait for ibs bytes from each read. zero-length reads are still - considered EOF. - - ### Output flags - - - append : open file in append mode. Consider setting conv=notrunc as well. - - seek_bytes : a value to seek=N will be interpreted as bytes. - - ### General Flags - - - direct : use direct I/O for data. - - directory : fail unless the given input (if used as an iflag) or - output (if used as an oflag) is a directory. - - dsync : use synchronized I/O for data. - - sync : use synchronized I/O for data and metadata. - - nonblock : use non-blocking I/O. - - noatime : do not update access time. - - nocache : request that OS drop cache. - - noctty : do not assign a controlling tty. - - nofollow : do not follow system links. +dd-after-help = Operands: + + - bs=BYTES : read and write up to BYTES bytes at a time (default: 512); overwrites ibs and obs. + - cbs=BYTES : the 'conversion block size' in bytes. Applies to the conv=block, and conv=unblock operations. + - conv=CONVS : a comma-separated list of conversion options or (for legacy reasons) file flags. + - count=N : stop reading input after N ibs-sized read operations rather than proceeding until EOF. See iflag=count_bytes if stopping after N bytes is preferred + - ibs=N : the size of buffer used for reads (default: 512) + - if=FILE : the file used for input. When not specified, stdin is used instead + - iflag=FLAGS : a comma-separated list of input flags which specify how the input source is treated. FLAGS may be any of the input-flags or general-flags specified below. + - skip=N (or iseek=N) : skip N ibs-sized records into input before beginning copy/convert operations. See iflag=seek_bytes if seeking N bytes is preferred. + - obs=N : the size of buffer used for writes (default: 512) + - of=FILE : the file used for output. When not specified, stdout is used instead + - oflag=FLAGS : comma separated list of output flags which specify how the output source is treated. FLAGS may be any of the output flags or general flags specified below + - seek=N (or oseek=N) : seeks N obs-sized records into output before beginning copy/convert operations. See oflag=seek_bytes if seeking N bytes is preferred + - status=LEVEL : controls whether volume and performance stats are written to stderr. + + When unspecified, dd will print stats upon completion. An example is below. + + 6+0 records in + 16+0 records out + 8192 bytes (8.2 kB, 8.0 KiB) copied, 0.00057009 s, + 14.4 MB/s + + The first two lines are the 'volume' stats and the final line is the 'performance' stats. The volume stats indicate the number of complete and partial ibs-sized reads, or obs-sized writes that took place during the copy. The format of the volume stats is +. If records have been truncated (see conv=block), the volume stats will contain the number of truncated records. + + Possible LEVEL values are: + - progress : Print periodic performance stats as the copy proceeds. + - noxfer : Print final volume stats, but not performance stats. + - none : Do not print any stats. + + Printing performance stats is also triggered by the INFO signal (where supported), or the USR1 signal. Setting the POSIXLY_CORRECT environment variable to any value (including an empty value) will cause the USR1 signal to be ignored. + + Conversion options: + + - ascii : convert from EBCDIC to ASCII. This is the inverse of the ebcdic option. Implies conv=unblock. + - ebcdic : convert from ASCII to EBCDIC. This is the inverse of the ascii option. Implies conv=block. + - ibm : convert from ASCII to EBCDIC, applying the conventions for [, ] and ~ specified in POSIX. Implies conv=block. + - ucase : convert from lower-case to upper-case. + - lcase : convert from upper-case to lower-case. + - block : for each newline less than the size indicated by cbs=BYTES, remove the newline and pad with spaces up to cbs. Lines longer than cbs are truncated. + - unblock : for each block of input of the size indicated by cbs=BYTES, remove right-trailing spaces and replace with a newline character. + - sparse : attempts to seek the output when an obs-sized block consists of only zeros. + - swab : swaps each adjacent pair of bytes. If an odd number of bytes is present, the final byte is omitted. + - sync : pad each ibs-sided block with zeros. If block or unblock is specified, pad with spaces instead. + - excl : the output file must be created. Fail if the output file is already present. + - nocreat : the output file will not be created. Fail if the output file is not already present. + - notrunc : the output file will not be truncated. If this option is not present, output will be truncated when opened. + - noerror : all read errors will be ignored. If this option is not present, dd will only ignore Error::Interrupted. + - fdatasync : data will be written before finishing. + - fsync : data and metadata will be written before finishing. + + Input flags: + + - count_bytes : a value to count=N will be interpreted as bytes. + - skip_bytes : a value to skip=N will be interpreted as bytes. + - fullblock : wait for ibs bytes from each read. zero-length reads are still considered EOF. + + Output flags: + + - append : open file in append mode. Consider setting conv=notrunc as well. + - seek_bytes : a value to seek=N will be interpreted as bytes. + + General flags: + + - direct : use direct I/O for data. + - directory : fail unless the given input (if used as an iflag) or output (if used as an oflag) is a directory. + - dsync : use synchronized I/O for data. + - sync : use synchronized I/O for data and metadata. + - nonblock : use non-blocking I/O. + - noatime : do not update access time. + - nocache : request that OS drop cache. + - noctty : do not assign a controlling tty. + - nofollow : do not follow system links. # Common strings dd-standard-input = 'standard input' @@ -130,21 +91,21 @@ dd-error-not-directory = setting flags for '{ $file }': Not a directory dd-error-failed-discard-cache = failed to discard cache for: { $file } # Parse errors -dd-error-unrecognized-operand = Unrecognized operand '{ $operand }' -dd-error-multiple-format-table = Only one of conv=ascii conv=ebcdic or conv=ibm may be specified -dd-error-multiple-case = Only one of conv=lcase or conv=ucase may be specified -dd-error-multiple-block = Only one of conv=block or conv=unblock may be specified -dd-error-multiple-excl = Only one ov conv=excl or conv=nocreat may be specified -dd-error-invalid-flag = invalid input flag: ‘{ $flag }’ - Try '{ $cmd } --help' for more information. -dd-error-conv-flag-no-match = Unrecognized conv=CONV -> { $flag } +dd-error-unrecognized-operand = unrecognized operand '{ $operand }' +dd-error-multiple-format-table = cannot combine any two of {"{"}ascii,ebcdic,ibm{"}"} +dd-error-multiple-case = cannot combine lcase and ucase +dd-error-multiple-block = cannot combine block and unblock +dd-error-multiple-excl = cannot combine excl and nocreat +dd-error-invalid-flag = invalid input flag: '{ $flag }' +dd-error-invalid-output-flag = invalid output flag: '{ $flag }' +dd-error-conv-flag-no-match = invalid conversion: '{ $flag }' dd-error-multiplier-parse-failure = invalid number: '{ $input }' dd-error-multiplier-overflow = Multiplier string would overflow on current system -> { $input } dd-error-block-without-cbs = conv=block or conv=unblock specified without cbs=N -dd-error-status-not-recognized = status=LEVEL not recognized -> { $level } +dd-error-status-not-recognized = invalid status level: '{ $level }' dd-error-unimplemented = feature not implemented on this system -> { $feature } dd-error-bs-out-of-range = { $param }=N cannot fit into memory -dd-error-invalid-number = invalid number: ‘{ $input }’ +dd-error-invalid-number = invalid number: '{ $input }' # Progress messages dd-progress-records-in = { $complete }+{ $partial } records in @@ -161,3 +122,14 @@ dd-progress-bytes-copied-si-iec = { $bytes } bytes ({ $si }, { $iec }) copied, { # Warnings dd-warning-zero-multiplier = { $zero } is a zero multiplier; use { $alternative } if that is intended dd-warning-signal-handler = Internal dd Warning: Unable to register signal handler + +# Diagnostics +dd-diag-help-operand = an operand is KEY=VALUE, as in if=file bs=4k count=10 +dd-diag-label-conv = not a known conversion +dd-diag-label-iflag = not a known input flag +dd-diag-label-oflag = not a known output flag +dd-diag-help-conv = conv= is one of ascii, ebcdic, ibm, lcase, ucase, block, unblock, swab, sync, noerror, sparse, excl, nocreat, notrunc, fdatasync or fsync +dd-diag-help-iflag = iflag= is one of direct, directory, dsync, sync, nocache, nonblock, noatime, noctty, nofollow, fullblock, count_bytes or skip_bytes +dd-diag-help-oflag = oflag= is one of direct, directory, dsync, sync, nocache, nonblock, noatime, noctty, nofollow, append or seek_bytes +dd-diag-help-status = status= is one of none, noxfer or progress +dd-diag-help-number = a number may be followed by a multiplier: c, w, b, then K, M, G and so on for 1024, kB, MB, GB for 1000 diff --git a/src/uu/dd/locales/fr-FR.ftl b/src/uu/dd/locales/fr-FR.ftl index 494a284a814..10c96bc766d 100644 --- a/src/uu/dd/locales/fr-FR.ftl +++ b/src/uu/dd/locales/fr-FR.ftl @@ -1,118 +1,79 @@ dd-about = Copier, et optionnellement convertir, une ressource du système de fichiers dd-usage = dd [OPÉRANDE]... dd OPTION -dd-after-help = ### Opérandes - - - bs=OCTETS : lire et écrire jusqu'à OCTETS octets à la fois (par défaut : 512) ; - remplace ibs et obs. - - cbs=OCTETS : la 'taille de bloc de conversion' en octets. S'applique aux - opérations conv=block et conv=unblock. - - conv=CONVS : une liste séparée par des virgules d'options de conversion ou (pour des - raisons historiques) d'indicateurs de fichier. - - count=N : arrêter la lecture de l'entrée après N opérations de lecture de taille ibs - plutôt que de continuer jusqu'à EOF. Voir iflag=count_bytes si l'arrêt après N octets - est préféré - - ibs=N : la taille du tampon utilisé pour les lectures (par défaut : 512) - - if=FICHIER : le fichier utilisé pour l'entrée. Quand non spécifié, stdin est utilisé à la place - - iflag=INDICATEURS : une liste séparée par des virgules d'indicateurs d'entrée qui spécifient comment - la source d'entrée est traitée. INDICATEURS peut être n'importe lequel des indicateurs d'entrée ou - indicateurs généraux spécifiés ci-dessous. - - skip=N (ou iseek=N) : ignorer N enregistrements de taille ibs dans l'entrée avant de commencer - les opérations de copie/conversion. Voir iflag=seek_bytes si la recherche de N octets est préférée. - - obs=N : la taille du tampon utilisé pour les écritures (par défaut : 512) - - of=FICHIER : le fichier utilisé pour la sortie. Quand non spécifié, stdout est utilisé - à la place - - oflag=INDICATEURS : liste séparée par des virgules d'indicateurs de sortie qui spécifient comment la - source de sortie est traitée. INDICATEURS peut être n'importe lequel des indicateurs de sortie ou - indicateurs généraux spécifiés ci-dessous - - seek=N (ou oseek=N) : recherche N enregistrements de taille obs dans la sortie avant de - commencer les opérations de copie/conversion. Voir oflag=seek_bytes si la recherche de N octets est - préférée - - status=NIVEAU : contrôle si les statistiques de volume et de performance sont écrites sur - stderr. +dd-after-help = Opérandes : + + - bs=OCTETS : lire et écrire jusqu'à OCTETS octets à la fois (par défaut : 512) ; remplace ibs et obs. + - cbs=OCTETS : la 'taille de bloc de conversion' en octets. S'applique aux opérations conv=block et conv=unblock. + - conv=CONVS : une liste séparée par des virgules d'options de conversion ou (pour des raisons historiques) d'indicateurs de fichier. + - count=N : arrêter la lecture de l'entrée après N opérations de lecture de taille ibs plutôt que de continuer jusqu'à EOF. Voir iflag=count_bytes si l'arrêt après N octets est préféré + - ibs=N : la taille du tampon utilisé pour les lectures (par défaut : 512) + - if=FICHIER : le fichier utilisé pour l'entrée. Quand non spécifié, stdin est utilisé à la place + - iflag=INDICATEURS : une liste séparée par des virgules d'indicateurs d'entrée qui spécifient comment la source d'entrée est traitée. INDICATEURS peut être n'importe lequel des indicateurs d'entrée ou indicateurs généraux spécifiés ci-dessous. + - skip=N (ou iseek=N) : ignorer N enregistrements de taille ibs dans l'entrée avant de commencer les opérations de copie/conversion. Voir iflag=seek_bytes si la recherche de N octets est préférée. + - obs=N : la taille du tampon utilisé pour les écritures (par défaut : 512) + - of=FICHIER : le fichier utilisé pour la sortie. Quand non spécifié, stdout est utilisé à la place + - oflag=INDICATEURS : liste séparée par des virgules d'indicateurs de sortie qui spécifient comment la source de sortie est traitée. INDICATEURS peut être n'importe lequel des indicateurs de sortie ou indicateurs généraux spécifiés ci-dessous + - seek=N (ou oseek=N) : recherche N enregistrements de taille obs dans la sortie avant de commencer les opérations de copie/conversion. Voir oflag=seek_bytes si la recherche de N octets est préférée + - status=NIVEAU : contrôle si les statistiques de volume et de performance sont écrites sur stderr. Quand non spécifié, dd affichera les statistiques à la fin. Un exemple est ci-dessous. - ```plain 6+0 enregistrements en entrée 16+0 enregistrements en sortie 8192 octets (8.2 kB, 8.0 KiB) copiés, 0.00057009 s, 14.4 MB/s - Les deux premières lignes sont les statistiques de 'volume' et la dernière ligne est les - statistiques de 'performance'. - Les statistiques de volume indiquent le nombre de lectures complètes et partielles de taille ibs, - ou d'écritures de taille obs qui ont eu lieu pendant la copie. Le format des statistiques de - volume est +. Si des enregistrements ont été tronqués (voir - conv=block), les statistiques de volume contiendront le nombre d'enregistrements tronqués. + Les deux premières lignes sont les statistiques de 'volume' et la dernière ligne est les statistiques de 'performance'. Les statistiques de volume indiquent le nombre de lectures complètes et partielles de taille ibs, ou d'écritures de taille obs qui ont eu lieu pendant la copie. Le format des statistiques de volume est +. Si des enregistrements ont été tronqués (voir conv=block), les statistiques de volume contiendront le nombre d'enregistrements tronqués. Les valeurs possibles de NIVEAU sont : - - progress : Afficher les statistiques de performance périodiques pendant la copie. - - noxfer : Afficher les statistiques de volume finales, mais pas les statistiques de performance. - - none : N'afficher aucune statistique. - - L'affichage des statistiques de performance est aussi déclenché par le signal INFO (quand pris en charge), - ou le signal USR1. Définir la variable d'environnement POSIXLY_CORRECT à n'importe quelle valeur - (y compris une valeur vide) fera ignorer le signal USR1. - - ### Options de conversion - - - ascii : convertir d'EBCDIC vers ASCII. C'est l'inverse de l'option ebcdic. - Implique conv=unblock. - - ebcdic : convertir d'ASCII vers EBCDIC. C'est l'inverse de l'option ascii. - Implique conv=block. - - ibm : convertir d'ASCII vers EBCDIC, en appliquant les conventions pour [, ] - et ~ spécifiées dans POSIX. Implique conv=block. - - - ucase : convertir de minuscules vers majuscules. - - lcase : convertir de majuscules vers minuscules. - - - block : pour chaque nouvelle ligne inférieure à la taille indiquée par cbs=OCTETS, supprimer - la nouvelle ligne et remplir avec des espaces jusqu'à cbs. Les lignes plus longues que cbs sont tronquées. - - unblock : pour chaque bloc d'entrée de la taille indiquée par cbs=OCTETS, supprimer - les espaces de fin à droite et remplacer par un caractère de nouvelle ligne. - - - sparse : tente de rechercher la sortie quand un bloc de taille obs ne contient que - des zéros. - - swab : échange chaque paire d'octets adjacents. Si un nombre impair d'octets est - présent, l'octet final est omis. - - sync : remplit chaque bloc de taille ibs avec des zéros. Si block ou unblock est - spécifié, remplit avec des espaces à la place. - - excl : le fichier de sortie doit être créé. Échoue si le fichier de sortie est déjà - présent. - - nocreat : le fichier de sortie ne sera pas créé. Échoue si le fichier de sortie n'est - pas déjà présent. - - notrunc : le fichier de sortie ne sera pas tronqué. Si cette option n'est pas - présente, la sortie sera tronquée à l'ouverture. - - noerror : toutes les erreurs de lecture seront ignorées. Si cette option n'est pas présente, - dd n'ignorera que Error::Interrupted. - - fdatasync : les données seront écrites avant la fin. - - fsync : les données et les métadonnées seront écrites avant la fin. - - ### Indicateurs d'entrée - - - count_bytes : une valeur pour count=N sera interprétée comme des octets. - - skip_bytes : une valeur pour skip=N sera interprétée comme des octets. - - fullblock : attendre ibs octets de chaque lecture. les lectures de longueur zéro sont toujours - considérées comme EOF. - - ### Indicateurs de sortie - - - append : ouvrir le fichier en mode ajout. Considérez définir conv=notrunc aussi. - - seek_bytes : une valeur pour seek=N sera interprétée comme des octets. - - ### Indicateurs généraux - - - direct : utiliser les E/S directes pour les données. - - directory : échouer sauf si l'entrée donnée (si utilisée comme iflag) ou - la sortie (si utilisée comme oflag) est un répertoire. - - dsync : utiliser les E/S synchronisées pour les données. - - sync : utiliser les E/S synchronisées pour les données et les métadonnées. - - nonblock : utiliser les E/S non-bloquantes. - - noatime : ne pas mettre à jour l'heure d'accès. - - nocache : demander au système d'exploitation de supprimer le cache. - - noctty : ne pas assigner un tty de contrôle. - - nofollow : ne pas suivre les liens système. + - progress : Afficher les statistiques de performance périodiques pendant la copie. + - noxfer : Afficher les statistiques de volume finales, mais pas les statistiques de performance. + - none : N'afficher aucune statistique. + + L'affichage des statistiques de performance est aussi déclenché par le signal INFO (quand pris en charge), ou le signal USR1. Définir la variable d'environnement POSIXLY_CORRECT à n'importe quelle valeur (y compris une valeur vide) fera ignorer le signal USR1. + + Options de conversion : + + - ascii : convertir d'EBCDIC vers ASCII. C'est l'inverse de l'option ebcdic. Implique conv=unblock. + - ebcdic : convertir d'ASCII vers EBCDIC. C'est l'inverse de l'option ascii. Implique conv=block. + - ibm : convertir d'ASCII vers EBCDIC, en appliquant les conventions pour [, ] et ~ spécifiées dans POSIX. Implique conv=block. + - ucase : convertir de minuscules vers majuscules. + - lcase : convertir de majuscules vers minuscules. + - block : pour chaque nouvelle ligne inférieure à la taille indiquée par cbs=OCTETS, supprimer la nouvelle ligne et remplir avec des espaces jusqu'à cbs. Les lignes plus longues que cbs sont tronquées. + - unblock : pour chaque bloc d'entrée de la taille indiquée par cbs=OCTETS, supprimer les espaces de fin à droite et remplacer par un caractère de nouvelle ligne. + - sparse : tente de rechercher la sortie quand un bloc de taille obs ne contient que des zéros. + - swab : échange chaque paire d'octets adjacents. Si un nombre impair d'octets est présent, l'octet final est omis. + - sync : remplit chaque bloc de taille ibs avec des zéros. Si block ou unblock est spécifié, remplit avec des espaces à la place. + - excl : le fichier de sortie doit être créé. Échoue si le fichier de sortie est déjà présent. + - nocreat : le fichier de sortie ne sera pas créé. Échoue si le fichier de sortie n'est pas déjà présent. + - notrunc : le fichier de sortie ne sera pas tronqué. Si cette option n'est pas présente, la sortie sera tronquée à l'ouverture. + - noerror : toutes les erreurs de lecture seront ignorées. Si cette option n'est pas présente, dd n'ignorera que Error::Interrupted. + - fdatasync : les données seront écrites avant la fin. + - fsync : les données et les métadonnées seront écrites avant la fin. + + Indicateurs d'entrée : + + - count_bytes : une valeur pour count=N sera interprétée comme des octets. + - skip_bytes : une valeur pour skip=N sera interprétée comme des octets. + - fullblock : attendre ibs octets de chaque lecture. les lectures de longueur zéro sont toujours considérées comme EOF. + + Indicateurs de sortie : + + - append : ouvrir le fichier en mode ajout. Considérez définir conv=notrunc aussi. + - seek_bytes : une valeur pour seek=N sera interprétée comme des octets. + + Indicateurs généraux : + + - direct : utiliser les E/S directes pour les données. + - directory : échouer sauf si l'entrée donnée (si utilisée comme iflag) ou la sortie (si utilisée comme oflag) est un répertoire. + - dsync : utiliser les E/S synchronisées pour les données. + - sync : utiliser les E/S synchronisées pour les données et les métadonnées. + - nonblock : utiliser les E/S non-bloquantes. + - noatime : ne pas mettre à jour l'heure d'accès. + - nocache : demander au système d'exploitation de supprimer le cache. + - noctty : ne pas assigner un tty de contrôle. + - nofollow : ne pas suivre les liens système. # Common strings dd-standard-input = 'entrée standard' @@ -130,21 +91,21 @@ dd-error-not-directory = définir les indicateurs pour '{ $file }' : N'est pas u dd-error-failed-discard-cache = échec de la suppression du cache pour : { $file } # Parse errors -dd-error-unrecognized-operand = Opérande non reconnue '{ $operand }' -dd-error-multiple-format-table = Seul un seul de conv=ascii conv=ebcdic ou conv=ibm peut être spécifié -dd-error-multiple-case = Seul un seul de conv=lcase ou conv=ucase peut être spécifié -dd-error-multiple-block = Seul un seul de conv=block ou conv=unblock peut être spécifié -dd-error-multiple-excl = Seul un seul de conv=excl ou conv=nocreat peut être spécifié +dd-error-unrecognized-operand = opérande non reconnue '{ $operand }' +dd-error-multiple-format-table = impossible de combiner deux options parmi {"{"}ascii,ebcdic,ibm{"}"} +dd-error-multiple-case = impossible de combiner lcase et ucase +dd-error-multiple-block = impossible de combiner block et unblock +dd-error-multiple-excl = impossible de combiner excl et nocreat dd-error-invalid-flag = indicateur d'entrée invalide : '{ $flag }' - Essayez '{ $cmd } --help' pour plus d'informations. -dd-error-conv-flag-no-match = conv=CONV non reconnu -> { $flag } -dd-error-multiplier-parse-failure = nombre invalide : ‘{ $input }‘ +dd-error-invalid-output-flag = indicateur de sortie invalide : '{ $flag }' +dd-error-conv-flag-no-match = conversion invalide : '{ $flag }' +dd-error-multiplier-parse-failure = nombre invalide : '{ $input }' dd-error-multiplier-overflow = La chaîne de multiplicateur déborderait sur le système actuel -> { $input } dd-error-block-without-cbs = conv=block ou conv=unblock spécifié sans cbs=N -dd-error-status-not-recognized = status=NIVEAU non reconnu -> { $level } +dd-error-status-not-recognized = niveau d'état invalide : '{ $level }' dd-error-unimplemented = fonctionnalité non implémentée sur ce système -> { $feature } dd-error-bs-out-of-range = { $param }=N ne peut pas tenir en mémoire -dd-error-invalid-number = nombre invalide : ‘{ $input }‘ +dd-error-invalid-number = nombre invalide : '{ $input }' # Progress messages dd-progress-records-in = { $complete }+{ $partial } enregistrements en entrée @@ -161,3 +122,14 @@ dd-progress-bytes-copied-si-iec = { $bytes } octets ({ $si }, { $iec }) copiés, # Warnings dd-warning-zero-multiplier = { $zero } est un multiplicateur zéro ; utilisez { $alternative } si c'est voulu dd-warning-signal-handler = Avertissement dd interne : Impossible d'enregistrer le gestionnaire de signal + +# Diagnostics +dd-diag-help-operand = un opérande s'écrit CLÉ=VALEUR, comme dans if=fichier bs=4k count=10 +dd-diag-label-conv = conversion inconnue +dd-diag-label-iflag = indicateur d'entrée inconnu +dd-diag-label-oflag = indicateur de sortie inconnu +dd-diag-help-conv = conv= vaut ascii, ebcdic, ibm, lcase, ucase, block, unblock, swab, sync, noerror, sparse, excl, nocreat, notrunc, fdatasync ou fsync +dd-diag-help-iflag = iflag= vaut direct, directory, dsync, sync, nocache, nonblock, noatime, noctty, nofollow, fullblock, count_bytes ou skip_bytes +dd-diag-help-oflag = oflag= vaut direct, directory, dsync, sync, nocache, nonblock, noatime, noctty, nofollow, append ou seek_bytes +dd-diag-help-status = status= vaut none, noxfer ou progress +dd-diag-help-number = un nombre peut être suivi d'un multiplicateur : c, w, b, puis K, M, G et ainsi de suite pour 1024, kB, MB, GB pour 1000 diff --git a/src/uu/dd/src/blocks.rs b/src/uu/dd/src/blocks.rs index a3eace951c2..7137c89251b 100644 --- a/src/uu/dd/src/blocks.rs +++ b/src/uu/dd/src/blocks.rs @@ -3,47 +3,18 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore datastructures rstat rposition cflags ctable +// spell-checker:ignore datastructures rstat rposition cflags ctable efgh use crate::conversion_tables::ConversionTable; use crate::datastructures::ConversionMode; use crate::progress::ReadStat; +use std::io; const NEWLINE: u8 = b'\n'; const SPACE: u8 = b' '; -/// Split a slice into chunks, padding or truncating as necessary. -/// -/// The slice `buf` is split on newlines, then each block is resized -/// to `cbs` bytes, padding with spaces if necessary. This function -/// expects the input bytes to be ASCII-encoded. -/// -/// If `sync` is true and there has been at least one partial record -/// read from the input (as indicated in `rstat`), then leave an -/// all-spaces block at the end. Otherwise, remove the last block if -/// it is all spaces. -fn block(buf: &[u8], cbs: usize, sync: bool, rstat: &mut ReadStat) -> Vec> { - let mut blocks = buf.split(|&e| e == NEWLINE).map(<[u8]>::to_vec).fold( - Vec::new(), - |mut blocks, mut split| { - if split.len() > cbs { - rstat.records_truncated += 1; - } - split.resize(cbs, SPACE); - blocks.push(split); - - blocks - }, - ); - - // If `sync` is true and there has been at least one partial - // record read from the input, then leave the all-spaces block at - // the end. Otherwise, remove it. - let _ = blocks - .pop_if(|last| (!sync || rstat.reads_partial == 0) && last.iter().all(|&e| e == SPACE)); - - blocks -} +/// `cbs` may be huge, so `conv=block` writes the padding in pieces of this size. +const PAD_CHUNK: usize = 64 * 1024; /// Trims padding from each cbs-length partition of buf /// as specified by conv=unblock and cbs=N @@ -60,75 +31,205 @@ fn unblock(buf: &[u8], cbs: usize) -> Vec { }) } -/// Apply the specified conversion, blocking, and/or unblocking in the right order. -/// -/// The `mode` specifies the combination of conversion, blocking, and -/// unblocking to apply and the order in which to apply it. This -/// function is responsible only for applying the operations. -/// -/// `buf` is the buffer of input bytes to transform. This function -/// mutates this input and also returns a new buffer of bytes -/// representing the result of the transformation. +fn apply_conversion(buf: &[u8], ct: &ConversionTable) -> Vec { + buf.iter().map(|&b| ct[b as usize]).collect() +} + +/// Applies the conversion, blocking, and/or unblocking of a [`ConversionMode`]. /// -/// `rstat` maintains a running total of the number of partial and -/// complete blocks read before calling this function. In certain -/// settings of `mode`, this function will update the number of -/// records truncated; that's why `rstat` is borrowed mutably. -pub(crate) fn conv_block_unblock_helper( - buf: Vec, - mode: &ConversionMode, - rstat: &mut ReadStat, -) -> Vec { - fn apply_conversion(buf: Vec, ct: &ConversionTable) -> impl Iterator + '_ { - buf.into_iter().map(|b| ct[b as usize]) +/// The current `conv=block` record is kept across calls, since a record +/// may span several reads. +pub(crate) struct Converter { + mode: ConversionMode, + /// The length of the current record, at most `cbs`. + record_len: usize, + /// Whether the current record was counted as truncated. + truncated: bool, + /// The blocked output not yet written. + out: Vec, +} + +impl Converter { + pub(crate) fn new(mode: ConversionMode) -> Self { + Self { + mode, + record_len: 0, + truncated: false, + out: Vec::new(), + } } - match mode { - ConversionMode::ConvertOnly(ct) => apply_conversion(buf, ct).collect(), - ConversionMode::BlockThenConvert(ct, cbs, sync) => { - let blocks = block(&buf, *cbs, *sync, rstat); - blocks - .into_iter() - .flat_map(|block| apply_conversion(block, ct)) - .collect() + /// Transform `buf` and pass the result to `write`, possibly in several pieces. + /// + /// Blocking updates the number of records truncated in `rstat`. + pub(crate) fn convert( + &mut self, + buf: &[u8], + rstat: &mut ReadStat, + write: &mut impl FnMut(&[u8]) -> io::Result<()>, + ) -> io::Result<()> { + match self.mode { + ConversionMode::ConvertOnly(ct) => write(&apply_conversion(buf, ct)), + ConversionMode::BlockOnly(cbs) => self.block(buf, cbs, None, rstat, write), + ConversionMode::BlockThenConvert(ct, cbs) => { + self.block(buf, cbs, Some(ct), rstat, write) + } + ConversionMode::ConvertThenBlock(ct, cbs) => { + self.block(&apply_conversion(buf, ct), cbs, None, rstat, write) + } + ConversionMode::UnblockOnly(cbs) => write(&unblock(buf, cbs)), + ConversionMode::UnblockThenConvert(ct, cbs) => { + write(&apply_conversion(&unblock(buf, cbs), ct)) + } + ConversionMode::ConvertThenUnblock(ct, cbs) => { + write(&unblock(&apply_conversion(buf, ct), cbs)) + } } - ConversionMode::ConvertThenBlock(ct, cbs, sync) => { - let buf: Vec<_> = apply_conversion(buf, ct).collect(); - block(&buf, *cbs, *sync, rstat) - .into_iter() - .flatten() - .collect() + } + + /// Pad the last record if the input did not end with a newline. + pub(crate) fn finish( + &mut self, + write: &mut impl FnMut(&[u8]) -> io::Result<()>, + ) -> io::Result<()> { + let (cbs, ct) = match self.mode { + ConversionMode::BlockOnly(cbs) | ConversionMode::ConvertThenBlock(_, cbs) => { + (cbs, None) + } + ConversionMode::BlockThenConvert(ct, cbs) => (cbs, Some(ct)), + _ => return Ok(()), + }; + if self.record_len > 0 { + self.end_record(cbs, ct, write)?; } - ConversionMode::BlockOnly(cbs, sync) => block(&buf, *cbs, *sync, rstat) - .into_iter() - .flatten() - .collect(), - ConversionMode::UnblockThenConvert(ct, cbs) => { - let buf = unblock(&buf, *cbs); - apply_conversion(buf, ct).collect() + self.flush(write) + } + + /// Split `buf` on newlines, padding or truncating each record to `cbs` bytes. + /// + /// `ct` is applied to the output, padding included. + fn block( + &mut self, + mut buf: &[u8], + cbs: usize, + ct: Option<&ConversionTable>, + rstat: &mut ReadStat, + write: &mut impl FnMut(&[u8]) -> io::Result<()>, + ) -> io::Result<()> { + loop { + let newline = buf.iter().position(|&e| e == NEWLINE); + let record = &buf[..newline.unwrap_or(buf.len())]; + let n = record.len().min(cbs - self.record_len); + match ct { + Some(ct) => self.out.extend(record[..n].iter().map(|&b| ct[b as usize])), + None => self.out.extend_from_slice(&record[..n]), + } + self.record_len += n; + if n < record.len() && !self.truncated { + rstat.records_truncated += 1; + self.truncated = true; + } + let Some(i) = newline else { break }; + self.end_record(cbs, ct, write)?; + buf = &buf[i + 1..]; + } + self.flush(write) + } + + /// Pad the current record to `cbs` bytes and start a new one. + fn end_record( + &mut self, + cbs: usize, + ct: Option<&ConversionTable>, + write: &mut impl FnMut(&[u8]) -> io::Result<()>, + ) -> io::Result<()> { + let pad = ct.map_or(SPACE, |ct| ct[SPACE as usize]); + let mut remaining = cbs - self.record_len; + while remaining > 0 { + let n = remaining.min(PAD_CHUNK); + self.out.resize(self.out.len() + n, pad); + remaining -= n; + if self.out.len() >= PAD_CHUNK { + self.flush(write)?; + } } - ConversionMode::ConvertThenUnblock(ct, cbs) => { - let buf: Vec<_> = apply_conversion(buf, ct).collect(); - unblock(&buf, *cbs) + self.record_len = 0; + self.truncated = false; + Ok(()) + } + + /// Write the blocked output. + fn flush(&mut self, write: &mut impl FnMut(&[u8]) -> io::Result<()>) -> io::Result<()> { + if !self.out.is_empty() { + write(&self.out)?; + self.out.clear(); } - ConversionMode::UnblockOnly(cbs) => unblock(&buf, *cbs), + Ok(()) } } #[cfg(test)] mod tests { - use crate::blocks::{block, unblock}; + use crate::blocks::{Converter, PAD_CHUNK, unblock}; + use crate::datastructures::ConversionMode; use crate::progress::ReadStat; + use std::io; const NEWLINE: u8 = b'\n'; const SPACE: u8 = b' '; + /// Block each slice of `reads` in turn and split the output into `cbs`-byte records. + fn block_reads(reads: &[&[u8]], cbs: usize, rstat: &mut ReadStat) -> Vec> { + let mode = ConversionMode::BlockOnly(cbs); + let mut converter = Converter::new(mode); + let mut out = Vec::new(); + let mut write = |data: &[u8]| -> io::Result<()> { + out.extend_from_slice(data); + Ok(()) + }; + for buf in reads { + converter.convert(buf, rstat, &mut write).unwrap(); + } + converter.finish(&mut write).unwrap(); + out.chunks(cbs).map(<[u8]>::to_vec).collect() + } + + fn block(buf: &[u8], cbs: usize, rstat: &mut ReadStat) -> Vec> { + block_reads(&[buf], cbs, rstat) + } + + #[test] + fn block_test_record_across_reads() { + let mut rs = ReadStat::default(); + let res = block_reads(&[b"ab", b"cd\nef", b"ghi\n"], 4, &mut rs); + + assert_eq!(res, vec![b"abcd".to_vec(), b"efgh".to_vec()]); + assert_eq!(rs.records_truncated, 1); + } + + #[test] + fn block_test_huge_cbs_pads_in_pieces() { + let mode = ConversionMode::BlockOnly(usize::MAX); + let mut converter = Converter::new(mode); + let mut written = 0; + let res = converter.convert(b"x\n", &mut ReadStat::default(), &mut |data| { + assert!(data.len() <= 2 * PAD_CHUNK); + written += data.len(); + if written > 4 * PAD_CHUNK { + return Err(io::ErrorKind::WriteZero.into()); + } + Ok(()) + }); + + assert!(res.is_err()); + } + #[test] fn block_test_no_nl() { let mut rs = ReadStat::default(); let buf = [0u8, 1u8, 2u8, 3u8]; - let res = block(&buf, 4, false, &mut rs); + let res = block(&buf, 4, &mut rs); assert_eq!(res, vec![vec![0u8, 1u8, 2u8, 3u8]]); } @@ -137,7 +238,7 @@ mod tests { fn block_test_no_nl_short_record() { let mut rs = ReadStat::default(); let buf = [0u8, 1u8, 2u8, 3u8]; - let res = block(&buf, 8, false, &mut rs); + let res = block(&buf, 8, &mut rs); assert_eq!( res, @@ -149,7 +250,7 @@ mod tests { fn block_test_no_nl_trunc() { let mut rs = ReadStat::default(); let buf = [0u8, 1u8, 2u8, 3u8, 4u8]; - let res = block(&buf, 4, false, &mut rs); + let res = block(&buf, 4, &mut rs); // Commented section(s) should be truncated and appear for reference only. assert_eq!(res, vec![vec![0u8, 1u8, 2u8, 3u8 /*, 4u8*/]]); @@ -162,7 +263,7 @@ mod tests { let buf = [ 0u8, 1u8, 2u8, 3u8, 4u8, NEWLINE, 0u8, 1u8, 2u8, 3u8, 4u8, NEWLINE, 5u8, 6u8, 7u8, 8u8, ]; - let res = block(&buf, 4, false, &mut rs); + let res = block(&buf, 4, &mut rs); assert_eq!( res, @@ -182,7 +283,7 @@ mod tests { fn block_test_surrounded_nl() { let mut rs = ReadStat::default(); let buf = [0u8, 1u8, 2u8, 3u8, NEWLINE, 4u8, 5u8, 6u8, 7u8, 8u8]; - let res = block(&buf, 8, false, &mut rs); + let res = block(&buf, 8, &mut rs); assert_eq!( res, @@ -199,7 +300,7 @@ mod tests { let buf = [ 0u8, 1u8, 2u8, 3u8, NEWLINE, 4u8, NEWLINE, 5u8, 6u8, 7u8, 8u8, 9u8, ]; - let res = block(&buf, 8, false, &mut rs); + let res = block(&buf, 8, &mut rs); assert_eq!( res, @@ -217,7 +318,7 @@ mod tests { let buf = [ 0u8, 1u8, 2u8, 3u8, NEWLINE, 4u8, 5u8, 6u8, 7u8, NEWLINE, 8u8, 9u8, ]; - let res = block(&buf, 8, false, &mut rs); + let res = block(&buf, 8, &mut rs); assert_eq!( res, @@ -233,7 +334,7 @@ mod tests { fn block_test_end_nl_diff_cbs_block() { let mut rs = ReadStat::default(); let buf = [0u8, 1u8, 2u8, 3u8, NEWLINE]; - let res = block(&buf, 4, false, &mut rs); + let res = block(&buf, 4, &mut rs); assert_eq!(res, vec![vec![0u8, 1u8, 2u8, 3u8]]); } @@ -242,7 +343,7 @@ mod tests { fn block_test_end_nl_same_cbs_block() { let mut rs = ReadStat::default(); let buf = [0u8, 1u8, 2u8, NEWLINE]; - let res = block(&buf, 4, false, &mut rs); + let res = block(&buf, 4, &mut rs); assert_eq!(res, vec![vec![0u8, 1u8, 2u8, SPACE]]); } @@ -251,7 +352,7 @@ mod tests { fn block_test_double_end_nl() { let mut rs = ReadStat::default(); let buf = [0u8, 1u8, 2u8, NEWLINE, NEWLINE]; - let res = block(&buf, 4, false, &mut rs); + let res = block(&buf, 4, &mut rs); assert_eq!( res, @@ -263,7 +364,7 @@ mod tests { fn block_test_start_nl() { let mut rs = ReadStat::default(); let buf = [NEWLINE, 0u8, 1u8, 2u8, 3u8]; - let res = block(&buf, 4, false, &mut rs); + let res = block(&buf, 4, &mut rs); assert_eq!( res, @@ -275,7 +376,7 @@ mod tests { fn block_test_double_surrounded_nl_no_trunc() { let mut rs = ReadStat::default(); let buf = [0u8, 1u8, 2u8, 3u8, NEWLINE, NEWLINE, 4u8, 5u8, 6u8, 7u8]; - let res = block(&buf, 8, false, &mut rs); + let res = block(&buf, 8, &mut rs); assert_eq!( res, @@ -293,7 +394,7 @@ mod tests { let buf = [ 0u8, 1u8, 2u8, 3u8, NEWLINE, NEWLINE, 4u8, 5u8, 6u8, 7u8, 8u8, ]; - let res = block(&buf, 4, false, &mut rs); + let res = block(&buf, 4, &mut rs); assert_eq!( res, diff --git a/src/uu/dd/src/bufferedoutput.rs b/src/uu/dd/src/bufferedoutput.rs index 7f4f92b8528..9cd917f87d4 100644 --- a/src/uu/dd/src/bufferedoutput.rs +++ b/src/uu/dd/src/bufferedoutput.rs @@ -2,12 +2,14 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// + // spell-checker:ignore wstat towrite cdefg bufferedoutput + //! Buffer partial output blocks until they are completed. //! //! Use the [`BufferedOutput`] struct to create a buffered form of the //! [`Output`] writer. + use crate::{Output, WriteStat}; /// Buffer partial output blocks until they are completed. @@ -67,6 +69,8 @@ impl<'a> BufferedOutput<'a> { /// buffered until enough bytes have been provided to complete a /// block. The returned [`WriteStat`] object will include the /// number of blocks written during execution of this function. + // See `BlockWriter::write_blocks`. + #[inline(always)] pub(crate) fn write_blocks(&mut self, buf: &[u8]) -> std::io::Result { // Split the incoming buffer into two parts: the bytes to write // and the bytes to buffer for next time. @@ -74,6 +78,14 @@ impl<'a> BufferedOutput<'a> { // If `buf` does not include enough bytes to form a full block, // just buffer the whole thing and write zero blocks. let n = self.buf.len() + buf.len(); + if n < self.inner.settings.obs { + // Not even one complete block can be formed: buffer everything. + // Falling through would pass the pending bytes to the inner + // writer and emit them as a premature partial write (reachable + // once short reads end a fill early, see issue #13458). + self.buf.extend_from_slice(buf); + return Ok(WriteStat::default()); + } let rem = n % self.inner.settings.obs; let i = buf.len().saturating_sub(rem); let (to_write, to_buffer) = buf.split_at(i); @@ -144,6 +156,28 @@ mod tests { assert_eq!(output.buf, b"ab"); } + #[test] + fn test_buffered_output_write_blocks_lone_partial_accumulates() { + let settings = Settings { + obs: 6, + ..Default::default() + }; + let inner = Output { + dst: Dest::Sink, + settings: &settings, + }; + let mut output = BufferedOutput::new(inner).unwrap(); + // Two writes that together still do not fill one block must both + // be buffered; the second call must not flush the pending partial + // block prematurely. + output.write_blocks(b"ab").unwrap(); + let wstat = output.write_blocks(b"cd").unwrap(); + assert_eq!(wstat.writes_complete, 0); + assert_eq!(wstat.writes_partial, 0); + assert_eq!(wstat.bytes_total, 0); + assert_eq!(output.buf, b"abcd"); + } + #[test] fn test_buffered_output_write_blocks_complete() { let settings = Settings { diff --git a/src/uu/dd/src/conversion_tables.rs b/src/uu/dd/src/conversion_tables.rs index 8ad52926486..10b8fee215b 100644 --- a/src/uu/dd/src/conversion_tables.rs +++ b/src/uu/dd/src/conversion_tables.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore tolower toupper CTYPE // Note: Conversion tables are just lookup tables. @@ -25,7 +26,7 @@ pub fn build_lcase_table() -> ConversionTable { let mut table = [0u8; 256]; for (i, item) in table.iter_mut().enumerate() { // SAFETY: tolower is called with a valid byte value and returns a valid byte - *item = unsafe { libc::tolower(i as libc::c_int) } as u8; + *item = unsafe { libc::tolower(i as core::ffi::c_int) } as u8; } table } @@ -45,7 +46,7 @@ pub fn build_ucase_table() -> ConversionTable { let mut table = [0u8; 256]; for (i, item) in table.iter_mut().enumerate() { // SAFETY: toupper is called with a valid byte value and returns a valid byte - *item = unsafe { libc::toupper(i as libc::c_int) } as u8; + *item = unsafe { libc::toupper(i as core::ffi::c_int) } as u8; } table } @@ -200,39 +201,39 @@ pub const EBCDIC_TO_ASCII: ConversionTable = [ ]; pub const EBCDIC_TO_ASCII_UCASE_TO_LCASE: ConversionTable = [ - 0x00, 0x01, 0x02, 0x03, 0x37, 0x2d, 0x2e, 0x2f, 0x16, 0x05, 0x25, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, - 0x10, 0x11, 0x12, 0x13, 0x3c, 0x3d, 0x32, 0x26, 0x18, 0x19, 0x3f, 0x27, 0x1c, 0x1d, 0x1e, 0x1f, - 0x40, 0x5a, 0x7f, 0x7b, 0x5b, 0x6c, 0x50, 0x7d, 0x4d, 0x5d, 0x5c, 0x4e, 0x6b, 0x60, 0x4b, 0x61, - 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0x7a, 0x5e, 0x4c, 0x7e, 0x6e, 0x6f, - 0x7c, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, - 0x97, 0x98, 0x99, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xad, 0xe0, 0xbd, 0x5f, 0x6d, - 0x79, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, - 0x97, 0x98, 0x99, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xc0, 0x4f, 0xd0, 0xa1, 0x07, - 0x20, 0x21, 0x22, 0x23, 0x24, 0x15, 0x06, 0x17, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x09, 0x0a, 0x1b, - 0x30, 0x31, 0x1a, 0x33, 0x34, 0x35, 0x36, 0x08, 0x38, 0x39, 0x3a, 0x3b, 0x04, 0x14, 0x3e, 0xe1, - 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, - 0x58, 0x59, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, - 0x76, 0x77, 0x78, 0x80, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, 0x90, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, - 0x9f, 0xa0, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, 0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, - 0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf, 0xda, 0xdb, - 0xdc, 0xdd, 0xde, 0xdf, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff, + 0x00, 0x01, 0x02, 0x03, 0x9c, 0x09, 0x86, 0x7f, 0x97, 0x8d, 0x8e, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x9d, 0x85, 0x08, 0x87, 0x18, 0x19, 0x92, 0x8f, 0x1c, 0x1d, 0x1e, 0x1f, + 0x80, 0x81, 0x82, 0x83, 0x84, 0x0a, 0x17, 0x1b, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x05, 0x06, 0x07, + 0x90, 0x91, 0x16, 0x93, 0x94, 0x95, 0x96, 0x04, 0x98, 0x99, 0x9a, 0x9b, 0x14, 0x15, 0x9e, 0x1a, + 0x20, 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xd5, 0x2e, 0x3c, 0x28, 0x2b, 0x7c, + 0x26, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, 0xb0, 0xb1, 0x21, 0x24, 0x2a, 0x29, 0x3b, 0x7e, + 0x2d, 0x2f, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xcb, 0x2c, 0x25, 0x5f, 0x3e, 0x3f, + 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, 0xc0, 0xc1, 0xc2, 0x60, 0x3a, 0x23, 0x40, 0x27, 0x3d, 0x22, + 0xc3, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, + 0xca, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0x5e, 0xcc, 0xcd, 0xce, 0xcf, 0xd0, + 0xd1, 0xe5, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0xd2, 0xd3, 0xd4, 0x5b, 0xd6, 0xd7, + 0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf, 0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0x5d, 0xe6, 0xe7, + 0x7b, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, + 0x7d, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0xee, 0xef, 0xf0, 0xf1, 0xf2, 0xf3, + 0x5c, 0x9f, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff, ]; pub const EBCDIC_TO_ASCII_LCASE_TO_UCASE: ConversionTable = [ - 0x00, 0x01, 0x02, 0x03, 0x37, 0x2d, 0x2e, 0x2f, 0x16, 0x05, 0x25, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, - 0x10, 0x11, 0x12, 0x13, 0x3c, 0x3d, 0x32, 0x26, 0x18, 0x19, 0x3f, 0x27, 0x1c, 0x1d, 0x1e, 0x1f, - 0x40, 0x5a, 0x7f, 0x7b, 0x5b, 0x6c, 0x50, 0x7d, 0x4d, 0x5d, 0x5c, 0x4e, 0x6b, 0x60, 0x4b, 0x61, - 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0x7a, 0x5e, 0x4c, 0x7e, 0x6e, 0x6f, - 0x7c, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, - 0xd7, 0xd8, 0xd9, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xad, 0xe0, 0xbd, 0x5f, 0x6d, - 0x79, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, - 0xd7, 0xd8, 0xd9, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xc0, 0x4f, 0xd0, 0xa1, 0x07, - 0x20, 0x21, 0x22, 0x23, 0x24, 0x15, 0x06, 0x17, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x09, 0x0a, 0x1b, - 0x30, 0x31, 0x1a, 0x33, 0x34, 0x35, 0x36, 0x08, 0x38, 0x39, 0x3a, 0x3b, 0x04, 0x14, 0x3e, 0xe1, - 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, - 0x58, 0x59, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, - 0x76, 0x77, 0x78, 0x80, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, 0x90, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, - 0x9f, 0xa0, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, 0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, - 0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf, 0xda, 0xdb, - 0xdc, 0xdd, 0xde, 0xdf, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff, + 0x00, 0x01, 0x02, 0x03, 0x9c, 0x09, 0x86, 0x7f, 0x97, 0x8d, 0x8e, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x9d, 0x85, 0x08, 0x87, 0x18, 0x19, 0x92, 0x8f, 0x1c, 0x1d, 0x1e, 0x1f, + 0x80, 0x81, 0x82, 0x83, 0x84, 0x0a, 0x17, 0x1b, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x05, 0x06, 0x07, + 0x90, 0x91, 0x16, 0x93, 0x94, 0x95, 0x96, 0x04, 0x98, 0x99, 0x9a, 0x9b, 0x14, 0x15, 0x9e, 0x1a, + 0x20, 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xd5, 0x2e, 0x3c, 0x28, 0x2b, 0x7c, + 0x26, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, 0xb0, 0xb1, 0x21, 0x24, 0x2a, 0x29, 0x3b, 0x7e, + 0x2d, 0x2f, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xcb, 0x2c, 0x25, 0x5f, 0x3e, 0x3f, + 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, 0xc0, 0xc1, 0xc2, 0x60, 0x3a, 0x23, 0x40, 0x27, 0x3d, 0x22, + 0xc3, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, + 0xca, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x5e, 0xcc, 0xcd, 0xce, 0xcf, 0xd0, + 0xd1, 0xe5, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0xd2, 0xd3, 0xd4, 0x5b, 0xd6, 0xd7, + 0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf, 0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0x5d, 0xe6, 0xe7, + 0x7b, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, + 0x7d, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0xee, 0xef, 0xf0, 0xf1, 0xf2, 0xf3, + 0x5c, 0x9f, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff, ]; diff --git a/src/uu/dd/src/datastructures.rs b/src/uu/dd/src/datastructures.rs index 340a2e57e0c..9b8751bf969 100644 --- a/src/uu/dd/src/datastructures.rs +++ b/src/uu/dd/src/datastructures.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore ctable, outfile, iseek, oseek use crate::conversion_tables::ConversionTable; @@ -14,13 +15,13 @@ type Cbs = usize; /// combination of conversion, blocking, or unblocking, applied in a /// certain order. The variants of this enumeration give the different /// ways of combining those three operations. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone, Copy)] pub(crate) enum ConversionMode { ConvertOnly(&'static ConversionTable), - BlockOnly(Cbs, bool), + BlockOnly(Cbs), UnblockOnly(Cbs), - BlockThenConvert(&'static ConversionTable, Cbs, bool), - ConvertThenBlock(&'static ConversionTable, Cbs, bool), + BlockThenConvert(&'static ConversionTable, Cbs), + ConvertThenBlock(&'static ConversionTable, Cbs), UnblockThenConvert(&'static ConversionTable, Cbs), ConvertThenUnblock(&'static ConversionTable, Cbs), } diff --git a/src/uu/dd/src/dd.rs b/src/uu/dd/src/dd.rs index 0599963e6e3..b9e759bf901 100644 --- a/src/uu/dd/src/dd.rs +++ b/src/uu/dd/src/dd.rs @@ -3,18 +3,19 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// 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 oconv canonicalized FADV DONTNEED ESPIPE SPIPE bufferedoutput, SETFL +// 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, virtio, wlen, wstat, zram, oconv canonicalized FADV DONTNEED ESPIPE SPIPE bufferedoutput, SETFL mod blocks; mod bufferedoutput; mod conversion_tables; mod datastructures; +mod diagnostics; mod numbers; mod parseargs; mod progress; use crate::bufferedoutput::BufferedOutput; -use blocks::conv_block_unblock_helper; +use blocks::Converter; use datastructures::{ConversionMode, IConvFlags, IFlags, OConvFlags, OFlags, options}; use parseargs::Parser; use progress::ProgUpdateType; @@ -47,7 +48,7 @@ use std::thread; use std::time::{Duration, Instant}; use clap::{Arg, Command}; -use gcd::Gcd; +use num_integer::Integer; use uucore::display::Quotable; use uucore::error::{FromIo, UResult}; #[cfg(unix)] @@ -56,8 +57,6 @@ use uucore::error::{USimpleError, set_exit_code}; use uucore::show_if_err; use uucore::{format_usage, show_error}; -const BUF_INIT_BYTE: u8 = 0xDD; - /// Final settings after parsing #[derive(Default)] struct Settings { @@ -89,7 +88,6 @@ struct Settings { /// /// When all instances are dropped the background thread will exit on the next interval. pub struct Alarm { - interval: Duration, trigger: Arc, } @@ -110,7 +108,7 @@ impl Alarm { } }); - Self { interval, trigger } + Self { trigger } } /// Manually trigger the alarm as a signal event @@ -127,11 +125,6 @@ impl Alarm { pub fn get_trigger(&self) -> u8 { self.trigger.swap(ALARM_TRIGGER_NONE, Relaxed) } - - // Getter function for the configured interval duration - pub fn get_interval(&self) -> Duration { - self.interval - } } /// A number in blocks or bytes @@ -161,38 +154,57 @@ impl Num { fn to_bytes(self, block_size: u64) -> u64 { match self { - Self::Blocks(n) => n * block_size, + Self::Blocks(n) => n.saturating_mul(block_size), Self::Bytes(n) => n, } } } -/// Read and discard `n` bytes from `reader` using a buffer of size `buf_size`. +/// A 4 KiB-aligned heap buffer used as `dd`'s read scratch. /// -/// 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 { - // todo: consider splice()ing to /dev/null on Linux - let mut buf = Vec::new(); - buf.try_reserve(buf_size.min(n as usize))?; // try_with_capacity is unstable - let mut total = 0u64; - let mut remaining = n; - while remaining > 0 { - let to_read = cmp::min(remaining, buf_size as u64); - buf.clear(); - match reader.by_ref().take(to_read).read_to_end(&mut buf) { - 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 => {} - Err(e) => return Err(e), - } +/// `O_DIRECT` fails reads with `EINVAL` when the user buffer does not meet +/// the device's DMA alignment (typically 512 bytes; 4 KiB covers every +/// mainline Linux block driver). Over-allocates [`alloc_copy_buffer`] +/// storage by one alignment unit and slices at the first aligned byte, +/// keeping the pages zeroed and not faulted in. +struct AlignedBuf { + storage: Vec, + /// Distance from the start of `storage` to the first aligned byte; + /// `offset + len <= storage.len()` since `storage` is over-allocated + /// by `ALIGNMENT`. + offset: usize, + /// Logical buffer length in bytes. + len: usize, +} + +impl AlignedBuf { + const ALIGNMENT: usize = 4096; + + fn new(size: usize) -> io::Result { + let total = size + .checked_add(Self::ALIGNMENT) + .ok_or_else(|| io::Error::from(io::ErrorKind::OutOfMemory))?; + let storage = alloc_copy_buffer(total)?; + let misalignment = storage.as_ptr().addr() % Self::ALIGNMENT; + let offset = if misalignment == 0 { + 0 + } else { + Self::ALIGNMENT - misalignment + }; + Ok(Self { + storage, + offset, + len: size, + }) } - Ok(total) + fn as_mut_bytes(&mut self) -> &mut [u8] { + &mut self.storage[self.offset..self.offset + self.len] + } + + fn as_bytes(&self) -> &[u8] { + &self.storage[self.offset..self.offset + self.len] + } } /// Data sources. @@ -235,7 +247,7 @@ impl Source { match self { #[cfg(not(unix))] Self::Stdin(stdin) => { - let m = read_and_discard(stdin, n, ibs)?; + let m = uucore::io::read_and_discard(stdin, n, ibs)?; if m < n { show_error!( "{}", @@ -274,7 +286,7 @@ impl Source { // 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)?; + let m = uucore::io::read_and_discard(f, n, ibs)?; if m < n { show_error!( "{}", @@ -295,7 +307,7 @@ impl Source { } Self::File(f) => f.seek(SeekFrom::Current(n.try_into().unwrap())), #[cfg(unix)] - Self::Fifo(f) => read_and_discard(f, n, ibs), + Self::Fifo(f) => uucore::io::read_and_discard(f, n, ibs), } } @@ -354,20 +366,14 @@ impl<'a> Input<'a> { #[cfg(windows)] let mut src = { let f = File::from(io::stdin().as_handle().try_clone_to_owned()?); - let is_file = if let Ok(metadata) = f.metadata() { - // this hack is needed as there is no other way on windows - // to differentiate between the case where `seek` works - // on a file handle or not. i.e. when the handle is no real - // file but a pipe, `seek` is still successful, but following - // `read`s are not affected by the seek. - metadata.creation_time() != 0 - } else { - false - }; - if is_file { - Source::File(f) - } else { - Source::Stdin(io::stdin()) + // this hack is needed as there is no other way on windows + // to differentiate between the case where `seek` works + // on a file handle or not. i.e. when the handle is no real + // file but a pipe, `seek` is still successful, but following + // `read`s are not affected by the seek. + match f.metadata() { + Ok(metadata) if metadata.creation_time() != 0 => Source::File(f), + _ => Source::Stdin(io::stdin()), } }; #[cfg(all(not(unix), not(windows)))] @@ -430,7 +436,7 @@ impl<'a> Input<'a> { } #[cfg(any(target_os = "linux", target_os = "android"))] -fn make_linux_iflags(iflags: &IFlags) -> Option { +fn make_linux_iflags(iflags: &IFlags) -> Option { let mut flag = 0; if iflags.direct { @@ -515,8 +521,10 @@ impl Input<'_> { /// Fills a given buffer. /// Reads in increments of 'self.ibs'. - /// The start of each ibs-sized read follows the previous one. - fn fill_consecutive(&mut self, buf: &mut Vec) -> io::Result { + /// The start of each ibs-sized read follows the previous one; a short + /// read ends the fill, so the bytes received so far form one partial + /// record for the copy loop (as in GNU dd). + fn fill_consecutive(&mut self, buf: &mut [u8]) -> io::Result { let mut reads_complete = 0; let mut reads_partial = 0; let mut bytes_total = 0; @@ -530,11 +538,17 @@ impl Input<'_> { rlen if rlen > 0 => { bytes_total += rlen; reads_partial += 1; + // A short read must end this fill: the next read would + // start at the following ibs-aligned chunk, leaving a + // gap of stale bytes inside `buf` that the caller's + // `bytes_total` slice would keep in the output while + // dropping the same number of real trailing bytes + // (issue #13458). + break; } _ => break, } } - buf.truncate(bytes_total); Ok(ReadStat { reads_complete, reads_partial, @@ -547,7 +561,8 @@ impl Input<'_> { /// Fills a given buffer. /// Reads in increments of 'self.ibs'. /// The start of each ibs-sized read is aligned to multiples of ibs; remaining space is filled with the 'pad' byte. - fn fill_blocks(&mut self, buf: &mut Vec, pad: u8) -> io::Result { + /// Returns the read statistics and the total filled length (reads + padding). + fn fill_blocks(&mut self, buf: &mut [u8], pad: u8) -> io::Result<(ReadStat, usize)> { let mut reads_complete = 0; let mut reads_partial = 0; let mut base_idx = 0; @@ -562,8 +577,7 @@ impl Input<'_> { rlen if rlen < target_len => { bytes_total += rlen; reads_partial += 1; - let padding = vec![pad; target_len - rlen]; - buf.splice(base_idx + rlen..next_blk, padding); + buf[base_idx + rlen..next_blk].fill(pad); } rlen => { bytes_total += rlen; @@ -574,13 +588,15 @@ impl Input<'_> { base_idx += self.settings.ibs; } - buf.truncate(base_idx); - Ok(ReadStat { - reads_complete, - reads_partial, - records_truncated: 0, - bytes_total: bytes_total.try_into().unwrap(), - }) + Ok(( + ReadStat { + reads_complete, + reads_partial, + records_truncated: 0, + bytes_total: bytes_total.try_into().unwrap(), + }, + base_idx, + )) } } @@ -667,7 +683,7 @@ impl Dest { #[cfg(unix)] Self::Fifo(f) => { // Seeking in a named pipe means *reading* from the pipe. - read_and_discard(f, n, obs) + uucore::io::read_and_discard(f, n, obs) } #[cfg(unix)] Self::Sink => Ok(0), @@ -676,22 +692,20 @@ impl Dest { /// Truncate the underlying file to the current stream position, if possible. fn truncate(&mut self) -> io::Result<()> { - #[allow(clippy::match_wildcard_for_single_variants)] - match self { - Self::File(f, _) => { - let pos = f.stream_position()?; - // `set_len()` can fail with EINVAL on special outputs such as - // `/dev/null`; GNU `dd` ignores that. But on a regular file a - // truncate failure (e.g. ENOSPC, read-only fs) means silent data - // loss, so the error must surface there. - match f.set_len(pos) { - Ok(()) => Ok(()), - Err(e) if f.metadata().is_ok_and(|m| m.file_type().is_file()) => Err(e), - Err(_) => Ok(()), - } - } - _ => Ok(()), + let Self::File(f, _) = self else { + return Ok(()); + }; + let pos = f.stream_position()?; + // `set_len()` can fail with EINVAL on special outputs such as + // `/dev/null`; GNU ignores that. But on a regular file a + // truncate failure (e.g. ENOSPC, read-only fs) means silent data + // loss. + if let Err(e) = f.set_len(pos) + && f.metadata().is_ok_and(|m| m.file_type().is_file()) + { + return Err(e); } + Ok(()) } /// Discard the system file cache for the given portion of the destination. @@ -735,21 +749,16 @@ fn handle_o_direct_write(f: &mut File, buf: &[u8], original_error: io::Error) -> let flags_without_direct = oflags & !OFlags::DIRECT; // Remove O_DIRECT flag - if fcntl_setfl(&*f, flags_without_direct).is_err() { - return Err(original_error); - } + fcntl_setfl(&*f, flags_without_direct).map_err(|_| original_error)?; // Retry the write without O_DIRECT let write_result = f.write(buf); // Restore O_DIRECT flag (GNU doesn't restore it, but we'll be safer) // Log any restoration errors without failing the operation - if let Err(os_err) = fcntl_setfl(&*f, oflags) { + if let Err(e) = fcntl_setfl(&*f, oflags).map_err(io::Error::from) { // Just log the error, don't fail the whole operation - show_error!( - "Failed to restore O_DIRECT flag: {}", - io::Error::from(os_err) - ); + show_error!("Failed to restore O_DIRECT flag: {e}"); } write_result @@ -1081,6 +1090,9 @@ impl BlockWriter<'_> { } } + // Also called by the converter's writer, which would otherwise stop it + // from being inlined in the copy loop. + #[inline] fn write_blocks(&mut self, buf: &[u8]) -> io::Result { match self { Self::Unbuffered(o) => o.write_blocks(buf), @@ -1207,10 +1219,17 @@ fn dd_copy(mut i: Input, o: Output) -> io::Result<()> { BlockWriter::Unbuffered(o) }; - // Create a common empty buffer with a capacity of the block size. - // This is the max size needed. - let mut buf = Vec::new(); - buf.try_reserve(bsize)?; // try_with_capacity is unstable https://github.com/rust-lang/rust/issues/91913 + // Aligned read scratch sized to the block size (the max size needed). + // 4 KiB alignment satisfies block devices that enforce a strict + // `dma_alignment` for `iflag=direct` reads — see `AlignedBuf`. + let mut buf = AlignedBuf::new(bsize)?; + // Applies the `conv=` flags not handled by `read_helper`. + let mut converter = i.settings.iconv.mode.map(Converter::new); + // The records truncated by `converter`, copied into `rstat` for progress + // reports: plain copies are faster when nothing borrows `rstat`. + let mut conv_rstat = ReadStat::default(); + // What `converter` wrote before a write error, added to `wstat` after the loop. + let mut conv_wstat = WriteStat::default(); // The main read/write loop. // @@ -1218,14 +1237,20 @@ fn dd_copy(mut i: Input, o: Output) -> io::Result<()> { // blocks to this output. Read/write statistics are updated on // each iteration and cumulative statistics are reported to // the progress reporting thread. + // A failure ends the loop, so the statistics gathered so far still get reported. + let mut copy_error = None; while below_count_limit(i.settings.count, &rstat) { // Read a block from the input then write the block to the output. // // As an optimization, make an educated guess about the // best buffer size for reading based on the number of // blocks already read and the number of blocks remaining. - 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)?; + let loop_bsize = calc_loop_bsize(i.settings.count, &rstat, i.settings.ibs, bsize); + let Ok((rstat_update, data)) = + read_helper(&mut i, &mut buf, loop_bsize).map_err(|e| copy_error = Some(e)) + else { + break; + }; if rstat_update.is_empty() { if input_nocache { i.discard_cache(read_offset, 0); @@ -1235,7 +1260,13 @@ fn dd_copy(mut i: Input, o: Output) -> io::Result<()> { } break; } - let wstat_update = o.write_blocks(&buf)?; + let written = match &mut converter { + None => o.write_blocks(data), + Some(c) => write_converted(c, data, &mut conv_rstat, &mut conv_wstat, &mut o), + }; + let Ok(wstat_update) = written.map_err(|e| copy_error = Some(e)) else { + break; + }; // Discard the system file cache for the read portion of // the input file. @@ -1273,23 +1304,73 @@ fn dd_copy(mut i: Input, o: Output) -> io::Result<()> { if check_and_reset_sigusr1() { alarm.manual_trigger(); } - match alarm.get_trigger() { - ALARM_TRIGGER_NONE => {} - t @ (ALARM_TRIGGER_TIMER | ALARM_TRIGGER_SIGNAL) => { - let tp = match t { - ALARM_TRIGGER_TIMER => ProgUpdateType::Periodic, - _ => ProgUpdateType::Signal, - }; - let prog_update = ProgUpdate::new(rstat, wstat, start.elapsed(), tp); - prog_tx.send(prog_update).unwrap_or(()); - } - _ => {} - } + let tp = match alarm.get_trigger() { + ALARM_TRIGGER_TIMER => ProgUpdateType::Periodic, + ALARM_TRIGGER_SIGNAL => ProgUpdateType::Signal, + _ => continue, + }; + rstat.records_truncated = conv_rstat.records_truncated; + let prog_update = ProgUpdate::new(rstat, wstat, start.elapsed(), tp); + prog_tx.send(prog_update).unwrap_or(()); + } + + wstat += conv_wstat; + // The input may end in the middle of a `conv=block` record. + if copy_error.is_none() + && let Some(c) = &mut converter + && let Err(e) = c.finish(&mut write_to(&mut o, &mut wstat)) + { + copy_error = Some(e); + } + + rstat.records_truncated = conv_rstat.records_truncated; + if let Some(e) = copy_error { + // Flushing and syncing are pointless now, but the caller still wants the statistics. + let prog_update = ProgUpdate::new(rstat, wstat, start.elapsed(), ProgUpdateType::Final); + prog_tx.send(prog_update).unwrap_or(()); + output_thread + .join() + .expect("Failed to join with the output thread."); + return Err(e); } finalize(o, rstat, wstat, start, &prog_tx, output_thread, truncate) } +/// Write `data` through `converter`, which may count truncated records in `rstat`. +/// +/// On error, the pieces already written are counted in `failed_wstat`. +/// Kept out of the copy loop, which is faster for plain copies. +#[inline(never)] +fn write_converted( + converter: &mut Converter, + data: &[u8], + rstat: &mut ReadStat, + failed_wstat: &mut WriteStat, + o: &mut BlockWriter, +) -> io::Result { + let mut wstat = WriteStat::default(); + let result = converter.convert(data, rstat, &mut write_to(o, &mut wstat)); + match result { + Ok(()) => Ok(wstat), + Err(e) => { + *failed_wstat = wstat; + Err(e) + } + } +} + +/// The writer given to the converter: write each piece to `o` and count it in `wstat`. +fn write_to<'a>( + o: &'a mut BlockWriter, + wstat: &'a mut WriteStat, +) -> impl FnMut(&[u8]) -> io::Result<()> + 'a { + |buf| { + *wstat += o.write_blocks(buf)?; + Ok(()) + } +} + /// Flush output, print final stats, and join with the progress thread. fn finalize( mut output: BlockWriter, @@ -1326,7 +1407,7 @@ fn finalize( #[cfg(any(target_os = "linux", target_os = "android"))] #[allow(clippy::cognitive_complexity)] -fn make_linux_oflags(oflags: &OFlags) -> Option { +fn make_linux_oflags(oflags: &OFlags) -> Option { let mut flag = 0; // oflag=FLAG @@ -1361,46 +1442,61 @@ fn make_linux_oflags(oflags: &OFlags) -> Option { if flag == 0 { None } else { Some(flag) } } -/// Read from an input (that is, a source of bytes) into the given buffer. +/// The copy buffer, `bsize` bytes long and ready to be read into. +/// +/// `Read::read` fills an initialized slice, and zeroed pages are the only +/// initialization that costs nothing: `vec![0; n]` allocates through +/// `alloc_zeroed`, so the pages arrive from the kernel already zero and stay +/// untouched until something is read into them. Writing a fill byte over +/// reserved capacity instead faults in the whole of `bs=` before the first +/// read, which is what made a large `bs=` cost its full size in time and in +/// resident memory even with nothing to copy. +/// +/// The reservation still happens first, because `vec![0; n]` aborts when the +/// allocation fails and `dd` reports that as an error instead. +fn alloc_copy_buffer(bsize: usize) -> io::Result> { + let mut probe: Vec = Vec::new(); + // try_with_capacity is unstable https://github.com/rust-lang/rust/issues/91913 + probe.try_reserve(bsize)?; + drop(probe); + Ok(vec![0u8; bsize]) +} + +/// Read one block worth of data, applying `conv=sync` and `conv=swab`. /// -/// This function also performs any conversions as specified by -/// `conv=swab` or `conv=block` command-line arguments. This function -/// mutates the `buf` argument in-place. The returned [`ReadStat`] -/// indicates how many blocks were read. -fn read_helper(i: &mut Input, buf: &mut Vec, bsize: usize) -> io::Result { - // Local Helper Fns ------------------------------------------------- +/// `read_buf` is the page-aligned scratch read into directly. The other +/// `conv=` transformations are applied by [`Converter`]. +fn read_helper<'a>( + i: &mut Input, + read_buf: &'a mut AlignedBuf, + bsize: usize, +) -> io::Result<(ReadStat, &'a [u8])> { fn perform_swab(buf: &mut [u8]) { for base in (1..buf.len()).step_by(2) { buf.swap(base, base - 1); } } - // ------------------------------------------------------------------ - // Read - // Resize the buffer to the bsize. Any garbage data in the buffer is overwritten or truncated, so there is no need to fill with BUF_INIT_BYTE first. - // resizing buf cause serious performance drop https://github.com/uutils/coreutils/issues/11544 - buf.resize(bsize, BUF_INIT_BYTE); - let mut rstat = match i.settings.iconv.sync { - Some(ch) => i.fill_blocks(buf, ch)?, - _ => i.fill_consecutive(buf)?, + let (rstat, data_len) = { + let scratch = &mut read_buf.as_mut_bytes()[..bsize]; + let (rstat, data_len) = if let Some(ch) = i.settings.iconv.sync { + i.fill_blocks(scratch, ch)? + } else { + let s = i.fill_consecutive(scratch)?; + let n = s.bytes_total as usize; + (s, n) + }; + if !rstat.is_empty() && i.settings.iconv.swab { + perform_swab(&mut scratch[..data_len]); + } + (rstat, data_len) }; - // Return early if no data - if rstat.reads_complete == 0 && rstat.reads_partial == 0 { - return Ok(rstat); - } - // Perform any conv=x[,x...] options - if i.settings.iconv.swab { - perform_swab(buf); + if rstat.is_empty() { + return Ok((rstat, &[])); } - match i.settings.iconv.mode { - Some(ref mode) => { - *buf = conv_block_unblock_helper(buf.clone(), mode, &mut rstat); - Ok(rstat) - } - None => Ok(rstat), - } + Ok((rstat, &read_buf.as_bytes()[..data_len])) } // Calculate a 'good' internal buffer size. @@ -1410,7 +1506,7 @@ fn read_helper(i: &mut Input, buf: &mut Vec, bsize: usize) -> io::Result usize { - let gcd = Gcd::gcd(ibs, obs); + let gcd = ibs.gcd(&obs); // calculate the lcm from gcd; saturate so an oversized product fails at // allocation instead of panicking here (ibs / gcd).saturating_mul(obs) @@ -1418,13 +1514,7 @@ fn calc_bsize(ibs: usize, obs: usize) -> usize { /// Calculate the buffer size appropriate for this loop iteration, respecting /// a `count=N` if present. -fn calc_loop_bsize( - count: Option, - rstat: &ReadStat, - wstat: &WriteStat, - ibs: usize, - ideal_bsize: usize, -) -> usize { +fn calc_loop_bsize(count: Option, rstat: &ReadStat, ibs: usize, ideal_bsize: usize) -> usize { match count { Some(Num::Blocks(rmax)) => { let rsofar = rstat.reads_complete + rstat.reads_partial; @@ -1432,9 +1522,9 @@ fn calc_loop_bsize( cmp::min(ideal_bsize as u64, rremain * ibs as u64) as usize } Some(Num::Bytes(bmax)) => { - let bmax: u128 = bmax.into(); - let bremain: u128 = bmax - wstat.bytes_total; - cmp::min(ideal_bsize as u128, bremain) as usize + // `iflag=count_bytes` limits input, so use bytes read. + let bremain = bmax.saturating_sub(rstat.bytes_total); + cmp::min(ideal_bsize as u64, bremain) as usize } None => ideal_bsize, } @@ -1513,15 +1603,21 @@ fn is_fifo(filename: &str) -> bool { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + // The command line is kept for the caret in operand diagnostics. + let (matches, diag_args) = uucore::clap_localization::handle_clap_result_with_diagnostics( + uu_app(), + args.collect(), + 1, + )?; - let settings: Settings = Parser::new().parse( + let settings: Settings = Parser::new().parse_with_diagnostics( matches .get_many::(options::OPERANDS) .unwrap_or_default(), + diag_args.as_deref(), )?; - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] if uucore::signals::stderr_was_closed() && settings.status != Some(StatusLevel::None) { return Err(USimpleError::new(1, "write error")); } @@ -1672,4 +1768,43 @@ mod tests { Output::new_file(Path::new(settings.outfile.as_ref().unwrap()), &settings).is_err() ); } + + #[test] + fn test_calc_loop_bsize_count_bytes() { + use crate::progress::ReadStat; + use crate::{Num, calc_loop_bsize}; + + for (bytes_read, expected) in [(512, 488), (1000, 0), (1001, 0)] { + let rstat = ReadStat { + bytes_total: bytes_read, + ..ReadStat::default() + }; + assert_eq!( + calc_loop_bsize(Some(Num::Bytes(1000)), &rstat, 512, 512), + expected + ); + } + } + + #[test] + fn test_aligned_buf_is_aligned_and_sized() { + use crate::AlignedBuf; + + // Sizes around and away from the 4096 chunk boundary. + for size in [1, 511, 4096, 4097, 65536, (1 << 20) + 13] { + let mut buf = AlignedBuf::new(size).unwrap(); + assert_eq!(buf.as_bytes().len(), size); + assert_eq!(buf.as_mut_bytes().len(), size); + // The alignment `O_DIRECT` reads rely on. + assert_eq!(buf.as_bytes().as_ptr().addr() % 4096, 0); + } + } + + #[test] + fn test_aligned_buf_zero_size() { + use crate::AlignedBuf; + + let buf = AlignedBuf::new(0).unwrap(); + assert!(buf.as_bytes().is_empty()); + } } diff --git a/src/uu/dd/src/diagnostics.rs b/src/uu/dd/src/diagnostics.rs new file mode 100644 index 00000000000..e38ab3226bb --- /dev/null +++ b/src/uu/dd/src/diagnostics.rs @@ -0,0 +1,111 @@ +// 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 parseargs + +//! Maps a [`ParseError`] onto the part of the operand it came from, so that +//! [`uucore::diagnostics`] can render it with a caret. +//! +//! Every dd operand is a `KEY=VALUE` pair, so an error is about the key, the +//! value, or one flag inside a comma-separated value; the caret says which. + +use std::ffi::{OsStr, OsString}; +use std::ops::Range; + +use uucore::diagnostics::{Snapshot, list_items}; +use uucore::error::UError; +use uucore::translate; + +use crate::parseargs::ParseError; + +/// The error to raise for an operand dd rejected. +/// +/// Draws the caret when the arguments as typed were kept, and quiets `error` +/// when it did: the report has already said everything the one-line message +/// would, and the exit code is all that is left to carry. +/// +/// # Arguments +/// +/// * `diag_args` - The arguments as typed, program name included, or `None` +/// when they were not kept. +/// * `operand` - The `KEY=VALUE` operand at fault, as typed. +/// * `error` - What the parser made of it. +#[cold] +#[inline(never)] +pub fn operand_error( + diag_args: Option<&[OsString]>, + operand: &str, + error: ParseError, +) -> Box { + uucore::diagnostics::error_after_report(diag_args, error, |args, error| { + render(args, operand, error) + }) +} + +/// Render `error` against `args`, with a caret under the part of `operand` +/// that is at fault. +/// +/// # Returns +/// +/// `false` when the error is not about a part of the operand, or when the +/// operand cannot be found among the arguments. +fn render(args: &[OsString], operand: &str, error: &ParseError) -> bool { + let key_end = operand.find('=').unwrap_or(operand.len()); + // The value starts past the `=`, or ends the operand when there is none. + let value_start = operand.len().min(key_end + 1); + let value = || value_start..operand.len(); + // A flag inside a comma-separated value, at its place in the list rather + // than wherever its text first turns up. + let flag = |flag: &str| { + list_items(&operand[value_start..], &[',']) + .find(|&(part, _)| part == flag) + .map(|(_, span)| value_start + span.start..value_start + span.end) + }; + + // The label says what is wrong with the span, the help what would have + // been right; a flag list names the flags rather than the commas, which + // are not what the parser tripped on. + let (span, label, help): (Range, Option<&str>, &str) = match error { + ParseError::UnrecognizedOperand(_) => (0..key_end, None, "dd-diag-help-operand"), + ParseError::FlagNoMatch(name) => ( + flag(name).unwrap_or_else(value), + Some("dd-diag-label-iflag"), + "dd-diag-help-iflag", + ), + ParseError::OutputFlagNoMatch(name) => ( + flag(name).unwrap_or_else(value), + Some("dd-diag-label-oflag"), + "dd-diag-help-oflag", + ), + ParseError::ConvFlagNoMatch(name) => ( + flag(name).unwrap_or_else(value), + Some("dd-diag-label-conv"), + "dd-diag-help-conv", + ), + ParseError::StatusLevelNotRecognized(_) => (value(), None, "dd-diag-help-status"), + ParseError::MultiplierStringParseFailure(_) + | ParseError::MultiplierStringOverflow(_) + | ParseError::InvalidNumber(_) + | ParseError::InvalidNumberWithErrMsg(_, _) + | ParseError::BsOutOfRange(_) => (value(), None, "dd-diag-help-number"), + // The rest is about how operands combine rather than about one of + // them, so there is nothing to point a caret at. + _ => return false, + }; + + let snapshot = Snapshot::with_program(args); + let Some(index) = snapshot.index_of(OsStr::new(operand)) else { + return false; + }; + let label = label.map(|key| translate!(key)); + snapshot.render_inside_at( + index, + operand, + span, + &error.to_string(), + label.as_deref(), + Some(&translate!(help)), + ) +} diff --git a/src/uu/dd/src/parseargs.rs b/src/uu/dd/src/parseargs.rs index cd20771c185..1749e2d32bd 100644 --- a/src/uu/dd/src/parseargs.rs +++ b/src/uu/dd/src/parseargs.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore ctty, ctable, iseek, oseek, iconvflags, oconvflags parseargs outfile oconv #[cfg(test)] @@ -9,7 +10,7 @@ mod unit_tests; use super::{ConversionMode, IConvFlags, IFlags, Num, OConvFlags, OFlags, Settings, StatusLevel}; use crate::conversion_tables::ConversionTable; -use thiserror::Error; +use std::ffi::OsString; use uucore::display::Quotable; use uucore::error::UError; use uucore::parser::parse_size::{ParseSizeError, Parser as SizeParser}; @@ -17,9 +18,9 @@ use uucore::show_warning; use uucore::translate; /// Parser Errors describe errors with parser input -#[derive(Debug, PartialEq, Eq, Error)] +#[derive(Debug, PartialEq, Eq, thiserror::Error)] pub enum ParseError { - #[error("{}", translate!("dd-error-unrecognized-operand", "operand" => .0.clone()))] + #[error("{}", translate!("dd-error-unrecognized-operand", "operand" => .0))] UnrecognizedOperand(String), #[error("{}", translate!("dd-error-multiple-format-table"))] MultipleFmtTable, @@ -29,25 +30,27 @@ pub enum ParseError { MultipleBlockUnblock, #[error("{}", translate!("dd-error-multiple-excl"))] MultipleExclNoCreate, - #[error("{}", translate!("dd-error-invalid-flag", "flag" => .0.clone(), "cmd" => uucore::execution_phrase()))] + #[error("{}", translate!("dd-error-invalid-flag", "flag" => .0))] FlagNoMatch(String), - #[error("{}", translate!("dd-error-conv-flag-no-match", "flag" => .0.clone()))] + #[error("{}", translate!("dd-error-invalid-output-flag", "flag" => .0))] + OutputFlagNoMatch(String), + #[error("{}", translate!("dd-error-conv-flag-no-match", "flag" => .0))] ConvFlagNoMatch(String), - #[error("{}", translate!("dd-error-multiplier-parse-failure", "input" => .0.clone()))] + #[error("{}", translate!("dd-error-multiplier-parse-failure", "input" => .0))] MultiplierStringParseFailure(String), - #[error("{}", translate!("dd-error-multiplier-overflow", "input" => .0.clone()))] + #[error("{}", translate!("dd-error-multiplier-overflow", "input" => .0))] MultiplierStringOverflow(String), #[error("{}", translate!("dd-error-block-without-cbs"))] BlockUnblockWithoutCBS, - #[error("{}", translate!("dd-error-status-not-recognized", "level" => .0.clone()))] + #[error("{}", translate!("dd-error-status-not-recognized", "level" => .0))] StatusLevelNotRecognized(String), - #[error("{}", translate!("dd-error-unimplemented", "feature" => .0.clone()))] + #[error("{}", translate!("dd-error-unimplemented", "feature" => .0))] Unimplemented(String), - #[error("{}", translate!("dd-error-bs-out-of-range", "param" => .0.clone()))] + #[error("{}", translate!("dd-error-bs-out-of-range", "param" => .0))] BsOutOfRange(String), - #[error("{}", translate!("dd-error-invalid-number", "input" => .0.clone()))] + #[error("{}", translate!("dd-error-invalid-number", "input" => .0))] InvalidNumber(String), - #[error("invalid number: ‘{0}’: {1}")] + #[error("invalid number: '{0}': {1}")] InvalidNumberWithErrMsg(String, String), } @@ -129,19 +132,42 @@ impl Parser { Self::default() } - pub(crate) fn parse( + /// Parse the operands, pointing a caret at the one that is at fault. + /// + /// # Arguments + /// + /// * `operands` - The operands as typed. + /// * `diag_args` - The whole argument list, program name included, or + /// `None` when it was not kept. + pub(crate) fn parse_with_diagnostics( self, operands: impl IntoIterator>, - ) -> Result { - self.read(operands)?.validate() + diag_args: Option<&[OsString]>, + ) -> Result> { + match self.read(operands) { + Err((operand, error)) => Err(crate::diagnostics::operand_error( + diag_args, &operand, error, + )), + // A validation error is about how the operands combine rather than + // about any one of them, so it keeps its plain message. + Ok(parser) => parser.validate().map_err(Into::into), + } } + /// Read the operands into the parser state. + /// + /// # Returns + /// + /// The operand at fault along with the error, so that a caller with the + /// command line at hand can point a caret inside it. pub(crate) fn read( mut self, operands: impl IntoIterator>, - ) -> Result { + ) -> Result { for operand in operands { - self.parse_operand(operand.as_ref())?; + let operand = operand.as_ref(); + self.parse_operand(operand) + .map_err(|error| (operand.to_string(), error))?; } Ok(self) @@ -199,7 +225,7 @@ impl Parser { }; let iconv = IConvFlags { - mode: conversion_mode(conversion_table, block, non_ascii, conv.sync), + mode: conversion_mode(conversion_table, block, non_ascii), swab: conv.swab, sync: if conv.sync { if block.is_some() { @@ -352,7 +378,6 @@ impl Parser { for f in val.split(',') { match f { // Common flags - "cio" => return Err(ParseError::Unimplemented(f.to_string())), "direct" => linux_only!(f, i.direct = true), "directory" => linux_only!(f, i.directory = true), "dsync" => linux_only!(f, i.dsync = true), @@ -362,9 +387,9 @@ impl Parser { "noatime" => linux_only!(f, i.noatime = true), "noctty" => linux_only!(f, i.noctty = true), "nofollow" => linux_only!(f, i.nofollow = true), - "nolinks" => return Err(ParseError::Unimplemented(f.to_string())), - "binary" => return Err(ParseError::Unimplemented(f.to_string())), - "text" => return Err(ParseError::Unimplemented(f.to_string())), + "cio" | "nolinks" | "binary" | "text" => { + return Err(ParseError::Unimplemented(f.to_string())); + } // Input-only flags "fullblock" => i.fullblock = true, @@ -384,7 +409,6 @@ impl Parser { for f in val.split(',') { match f { // Common flags - "cio" => return Err(ParseError::Unimplemented(val.to_string())), "direct" => linux_only!(f, o.direct = true), "directory" => linux_only!(f, o.directory = true), "dsync" => linux_only!(f, o.dsync = true), @@ -394,16 +418,16 @@ impl Parser { "noatime" => linux_only!(f, o.noatime = true), "noctty" => linux_only!(f, o.noctty = true), "nofollow" => linux_only!(f, o.nofollow = true), - "nolinks" => return Err(ParseError::Unimplemented(f.to_string())), - "binary" => return Err(ParseError::Unimplemented(f.to_string())), - "text" => return Err(ParseError::Unimplemented(f.to_string())), + "cio" | "nolinks" | "binary" | "text" => { + return Err(ParseError::Unimplemented(f.to_string())); + } // Output-only flags "append" => o.append = true, "seek_bytes" => o.seek_bytes = true, // GNU silently ignores iflags given as oflag. "fullblock" | "count_bytes" | "skip_bytes" => {} - _ => return Err(ParseError::FlagNoMatch(f.to_string())), + _ => return Err(ParseError::OutputFlagNoMatch(f.to_string())), } } Ok(()) @@ -449,6 +473,20 @@ impl UError for ParseError { fn code(&self) -> i32 { 1 } + + /// The messages that end on a hint about the syntax they rejected, as + /// GNU does. The hint is left to this, rather than written into the + /// message, so that it survives a caret report replacing the message. + fn usage(&self) -> bool { + matches!( + self, + Self::UnrecognizedOperand(_) + | Self::FlagNoMatch(_) + | Self::OutputFlagNoMatch(_) + | Self::ConvFlagNoMatch(_) + | Self::StatusLevelNotRecognized(_) + ) + } } fn show_zero_multiplier_warning() { @@ -476,8 +514,6 @@ fn parse_bytes_only(s: &str, i: usize) -> Result { /// 512. You can also use standard block size suffixes like `'k'` for /// 1024. /// -/// If the number would be too large, return [`u64::MAX`] instead. -/// /// # Errors /// /// If a number cannot be parsed or if the multiplication would cause @@ -501,7 +537,12 @@ fn parse_bytes_no_x(full: &str, s: &str) -> Result { let (num, multiplier) = match (s.find('c'), s.rfind('w'), s.rfind('b')) { (None, None, None) => match parser.parse_u64(s) { Ok(n) => (n, 1), - Err(ParseSizeError::SizeTooBig(_)) => (u64::MAX, 1), + Err(ParseSizeError::SizeTooBig(_)) => { + return Err(ParseError::InvalidNumberWithErrMsg( + full.to_string(), + "Value too large for defined data type".to_string(), + )); + } Err(_) => return Err(ParseError::InvalidNumber(full.to_string())), }, (Some(i), None, None) => (parse_bytes_only(s, i)?, 1), @@ -572,8 +613,8 @@ fn get_ctable( (Conversion::Ascii, Case::Lower) => &EBCDIC_TO_ASCII_UCASE_TO_LCASE, (Conversion::Ebcdic, Case::Upper) => &ASCII_TO_EBCDIC_LCASE_TO_UCASE, (Conversion::Ebcdic, Case::Lower) => &ASCII_TO_EBCDIC_UCASE_TO_LCASE, - (Conversion::Ibm, Case::Upper) => &ASCII_TO_IBM_UCASE_TO_LCASE, - (Conversion::Ibm, Case::Lower) => &ASCII_TO_IBM_LCASE_TO_UCASE, + (Conversion::Ibm, Case::Upper) => &ASCII_TO_IBM_LCASE_TO_UCASE, + (Conversion::Ibm, Case::Lower) => &ASCII_TO_IBM_UCASE_TO_LCASE, }, }) } @@ -590,15 +631,14 @@ fn conversion_mode( ctable: Option<&'static ConversionTable>, block: Option, is_ascii: bool, - is_sync: bool, ) -> Option { match (ctable, block) { (Some(ct), None) => Some(ConversionMode::ConvertOnly(ct)), (Some(ct), Some(Block::Block(cbs))) => { if is_ascii { - Some(ConversionMode::ConvertThenBlock(ct, cbs, is_sync)) + Some(ConversionMode::ConvertThenBlock(ct, cbs)) } else { - Some(ConversionMode::BlockThenConvert(ct, cbs, is_sync)) + Some(ConversionMode::BlockThenConvert(ct, cbs)) } } (Some(ct), Some(Block::Unblock(cbs))) => { @@ -608,7 +648,7 @@ fn conversion_mode( Some(ConversionMode::UnblockThenConvert(ct, cbs)) } } - (None, Some(Block::Block(cbs))) => Some(ConversionMode::BlockOnly(cbs, is_sync)), + (None, Some(Block::Block(cbs))) => Some(ConversionMode::BlockOnly(cbs)), (None, Some(Block::Unblock(cbs))) => Some(ConversionMode::UnblockOnly(cbs)), (None, None) => None, } @@ -634,7 +674,7 @@ mod tests { assert_eq!(parse_bytes_with_opt_multiplier("123w").unwrap(), 123 * 2); assert_eq!(parse_bytes_with_opt_multiplier("123b").unwrap(), 123 * 512); assert_eq!(parse_bytes_with_opt_multiplier("123k").unwrap(), 123 * 1024); - assert_eq!(parse_bytes_with_opt_multiplier(BIG).unwrap(), u64::MAX); + assert!(parse_bytes_with_opt_multiplier(BIG).is_err()); } #[test] diff --git a/src/uu/dd/src/parseargs/unit_tests.rs b/src/uu/dd/src/parseargs/unit_tests.rs index cde0ef0cc1f..e41dbe48d33 100644 --- a/src/uu/dd/src/parseargs/unit_tests.rs +++ b/src/uu/dd/src/parseargs/unit_tests.rs @@ -2,6 +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, 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, oconv use super::*; @@ -12,6 +13,19 @@ use crate::conversion_tables::{ }; use crate::parseargs::Parser; +impl Parser { + /// Parse the operands, keeping only the error itself. + /// + /// The utility goes through `parse_with_diagnostics`, which also knows + /// which operand failed; this is the plain form the tests compare against. + pub(crate) fn parse( + self, + operands: impl IntoIterator>, + ) -> Result { + self.read(operands).map_err(|(_, error)| error)?.validate() + } +} + #[cfg(not(any(target_os = "linux", target_os = "android")))] #[allow(clippy::useless_vec)] #[test] diff --git a/src/uu/dd/src/progress.rs b/src/uu/dd/src/progress.rs index ce38684acab..ebc11eb3010 100644 --- a/src/uu/dd/src/progress.rs +++ b/src/uu/dd/src/progress.rs @@ -2,13 +2,16 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore btotal sigval + //! Read and write progress tracking for dd. //! //! The [`ProgUpdate`] struct represents summary statistics for the //! read and write progress of a running `dd` process. The //! [`gen_prog_updater`] function can be used to implement a progress //! updater that runs in its own thread. + use std::io::Write; #[cfg(target_os = "linux")] use std::sync::atomic::{AtomicBool, Ordering}; @@ -454,7 +457,7 @@ pub(crate) fn check_and_reset_sigusr1() -> bool { } #[cfg(target_os = "linux")] -extern "C" fn sigusr1_handler(_: std::os::raw::c_int) { +extern "C" fn sigusr1_handler(_: core::ffi::c_int) { SIGUSR1_RECEIVED.store(true, Ordering::Relaxed); } diff --git a/src/uu/df/Cargo.toml b/src/uu/df/Cargo.toml index eaa9571f9f2..92243649c82 100644 --- a/src/uu/df/Cargo.toml +++ b/src/uu/df/Cargo.toml @@ -11,19 +11,16 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/df.rs" doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["libc", "fsext", "parser-size", "fs"] } -unicode-width = { workspace = true } -thiserror = { workspace = true } fluent = { workspace = true } +thiserror = { workspace = true } +unicode-width = { workspace = true } +uucore = { workspace = true, features = ["libc", "fsext", "parser-size", "fs"] } [dev-dependencies] divan = { workspace = true } @@ -33,8 +30,11 @@ uucore = { workspace = true, features = ["benchmark"] } [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["fs"] } -[target.'cfg(unix)'.dev-dependencies] -rustix = { workspace = true, features = ["stdio"] } +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true [[bin]] name = "df" diff --git a/src/uu/df/benches/df_bench.rs b/src/uu/df/benches/df_bench.rs index f678f7f7b9a..1200ef729f2 100644 --- a/src/uu/df/benches/df_bench.rs +++ b/src/uu/df/benches/df_bench.rs @@ -9,7 +9,7 @@ use std::fs; use std::path::PathBuf; use tempfile::TempDir; use uu_df::uumain; -use uucore::benchmark::run_util_function; +use uucore::benchmark::get_bench_args; fn create_deep_directory(base_dir: &std::path::Path, depth: usize) -> PathBuf { let mut current = base_dir.to_path_buf(); @@ -30,43 +30,12 @@ fn df_deep_directory(bencher: Bencher) { 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(); -} - -#[cfg(unix)] -#[divan::bench] -fn df_with_path(bencher: Bencher) { - use rustix::stdio::dup2_stdout; - - let temp_dir = TempDir::new().unwrap(); - let temp_path_str = temp_dir.path().to_str().unwrap(); - let stdout_bak = rustix::io::dup(rustix::stdio::stdout()).unwrap(); - let devnull = fs::OpenOptions::new() - .write(true) - .open("/dev/null") - .unwrap(); - - bencher.bench_local(|| { - dup2_stdout(&devnull).unwrap(); - black_box(run_util_function(uumain, &[temp_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[]).into_iter()) + .bench_values(|args| black_box(uumain(args))); - dup2_stdout(&stdout_bak).unwrap(); -} - -#[cfg(not(unix))] -#[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])); - }); + env::set_current_dir(original_dir).unwrap(); } fn main() { diff --git a/src/uu/df/locales/en-US.ftl b/src/uu/df/locales/en-US.ftl index 9e4fc52c4d2..3c5d7433713 100644 --- a/src/uu/df/locales/en-US.ftl +++ b/src/uu/df/locales/en-US.ftl @@ -18,6 +18,7 @@ df-help-human-readable = print sizes in human readable format (e.g., 1K 234M 2G) df-help-si = likewise, but use powers of 1000 not 1024 df-help-inodes = list inode information instead of block usage df-help-kilo = like --block-size=1K +df-help-mega = like --block-size=1M df-help-local = limit listing to local file systems df-help-no-sync = do not invoke sync before getting usage info (default) df-help-output = use output format defined by FIELD_LIST, or print all fields if FIELD_LIST is omitted. diff --git a/src/uu/df/locales/fr-FR.ftl b/src/uu/df/locales/fr-FR.ftl index 69cdfa08b28..830ea42f21d 100644 --- a/src/uu/df/locales/fr-FR.ftl +++ b/src/uu/df/locales/fr-FR.ftl @@ -18,6 +18,7 @@ df-help-human-readable = afficher les tailles dans un format lisible par l'homme df-help-si = pareillement, mais utiliser les puissances de 1000 pas 1024 df-help-inodes = lister les informations d'inode au lieu de l'utilisation des blocs df-help-kilo = comme --block-size=1K +df-help-mega = comme --block-size=1M df-help-local = limiter l'affichage aux systèmes de fichiers locaux df-help-no-sync = ne pas invoquer sync avant d'obtenir les informations d'utilisation (par défaut) df-help-output = utiliser le format de sortie défini par LISTE_CHAMPS, ou afficher tous les champs si LISTE_CHAMPS est omise. diff --git a/src/uu/df/src/blocks.rs b/src/uu/df/src/blocks.rs index 708709c6464..edaf4a3cd54 100644 --- a/src/uu/df/src/blocks.rs +++ b/src/uu/df/src/blocks.rs @@ -2,8 +2,10 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + //! Types for representing and displaying block sizes. -use crate::{OPT_BLOCKSIZE, OPT_PORTABILITY}; + +use crate::{OPT_BLOCKSIZE, OPT_KILO, OPT_MEGA, OPT_PORTABILITY}; use clap::ArgMatches; use std::fmt; @@ -170,6 +172,10 @@ pub(crate) fn read_block_size(matches: &ArgMatches) -> Result 14, - Self::Used => 5, - Self::Size => 5, + Self::Used | Self::Size => 5, // the shortest headers have a length of 4 chars so we use that as the minimum width _ => 4, } diff --git a/src/uu/df/src/df.rs b/src/uu/df/src/df.rs index 32204f40b7a..022b61d1196 100644 --- a/src/uu/df/src/df.rs +++ b/src/uu/df/src/df.rs @@ -2,15 +2,19 @@ // // 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 sysfs + +// spell-checker:ignore itotal iused iavail ipcent pcent tmpfs squashfs lofs sysfs chroots + mod blocks; mod columns; mod filesystem; +mod platform; mod table; use blocks::HumanReadable; use clap::builder::ValueParser; use table::HeaderMode; +use uucore::diagnostics::OptionValue; use uucore::display::Quotable; use uucore::error::{UError, UResult, USimpleError, get_exit_code}; use uucore::fsext::{MountInfo, read_fs_list}; @@ -21,24 +25,29 @@ use uucore::{format_usage, show, show_warning}; use clap::{Arg, ArgAction, ArgMatches, Command, parser::ValueSource}; use std::ffi::OsString; -use std::io::stdout; +use std::io::{BufWriter, Write, stdout}; use std::path::Path; -use thiserror::Error; use crate::blocks::{BlockSize, read_block_size}; use crate::columns::{Column, ColumnError}; -use crate::filesystem::Filesystem; +pub use crate::filesystem::Filesystem; use crate::filesystem::FsError; use crate::table::Table; static OPT_HELP: &str = "help"; static OPT_ALL: &str = "all"; static OPT_BLOCKSIZE: &str = "blocksize"; +/// The long name of [`OPT_BLOCKSIZE`], which its clap id does not spell. +/// +/// The caret report looks the option up on the command line by this name, so +/// the `Arg` and the report have to agree on it. +static OPT_BLOCKSIZE_LONG: &str = "block-size"; static OPT_TOTAL: &str = "total"; static OPT_HUMAN_READABLE_BINARY: &str = "human-readable-binary"; static OPT_HUMAN_READABLE_DECIMAL: &str = "human-readable-decimal"; static OPT_INODES: &str = "inodes"; static OPT_KILO: &str = "kilo"; +static OPT_MEGA: &str = "mega"; static OPT_LOCAL: &str = "local"; static OPT_NO_SYNC: &str = "no-sync"; static OPT_OUTPUT: &str = "output"; @@ -58,7 +67,7 @@ static OUTPUT_FIELD_LIST: [&str; 12] = [ /// Most of these parameters control which rows and which columns are /// displayed. The `block_size` determines the units to use when /// displaying numbers of bytes or inodes. -struct Options { +pub struct Options { show_local_fs: bool, show_all_fs: bool, human_readable: Option, @@ -112,25 +121,30 @@ impl Default for Options { } impl Options { + /// Convert command-line arguments into [`Options`]. + pub fn from_matches(matches: &ArgMatches, diag_args: Option<&[OsString]>) -> UResult { + Self::from(matches, diag_args) + } + /// Whether -a, -l, -t, or -x options require the mount table. fn requires_mount_table(&self) -> bool { self.show_all_fs || self.show_local_fs || self.include.is_some() || self.exclude.is_some() } } -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum OptionsError { // TODO This needs to vary based on whether `--block-size` // or `-B` were provided. - #[error("{}", translate!("df-error-block-size-too-large", "size" => .0.clone()))] + #[error("{}", translate!("df-error-block-size-too-large", "size" => .0))] BlockSizeTooLarge(String), // TODO This needs to vary based on whether `--block-size` // or `-B` were provided., - #[error("{}", translate!("df-error-invalid-block-size", "size" => .0.clone()))] + #[error("{}", translate!("df-error-invalid-block-size", "size" => .0))] InvalidBlockSize(String), // TODO This needs to vary based on whether `--block-size` // or `-B` were provided. - #[error("{}", translate!("df-error-invalid-suffix", "size" => .0.clone()))] + #[error("{}", translate!("df-error-invalid-suffix", "size" => .0))] InvalidSuffix(String), /// An error getting the columns to display in the output table. @@ -147,9 +161,48 @@ enum OptionsError { FilesystemTypeBothSelectedAndExcluded(Vec), } +/// The error for a `--block-size` that could not be parsed, with a caret under +/// the part of it that is at fault. +/// +/// # Arguments +/// +/// * `error` - What the size parser rejected the value with. +/// * `matches` - The parsed command line, for the value as it was typed. +/// * `diag_args` - The arguments as typed, or `None` when they were not kept. +#[cold] +#[inline(never)] +fn block_size_error( + error: &ParseSizeError, + matches: &ArgMatches, + diag_args: Option<&[OsString]>, +) -> Box { + // Only `-B`/`--block-size` reaches the parser with a value to point at: + // `read_block_size` parses a size only under `contains_id(OPT_BLOCKSIZE)`, + // and the `DF_BLOCK_SIZE` fallbacks go through `found()`, which drops an + // invalid value silently. So the value the caret points at is always there. + let size = matches + .get_one::(OPT_BLOCKSIZE) + .expect("a block size error can only come from --block-size"); + let options_error = match error { + ParseSizeError::InvalidSuffix(s) => OptionsError::InvalidSuffix(s.clone()), + ParseSizeError::SizeTooBig(_) => OptionsError::BlockSizeTooLarge(size.clone()), + ParseSizeError::ParseFailure(s) | ParseSizeError::PhysicalMem(s) => { + OptionsError::InvalidBlockSize(s.clone()) + } + }; + let message = options_error.to_string(); + error.size_value_error( + diag_args, + &OptionValue::new(size, 'B', OPT_BLOCKSIZE_LONG), + 0, + &message, + DfError::OptionsError(options_error), + ) +} + impl Options { /// Convert command-line arguments into [`Options`]. - fn from(matches: &ArgMatches) -> Result { + fn from(matches: &ArgMatches, diag_args: Option<&[OsString]>) -> UResult { let include: Option> = matches .get_many::(OPT_TYPE) .map(|v| v.map(|s| s.to_string_lossy().to_string()).collect()); @@ -157,24 +210,21 @@ impl Options { .get_many::(OPT_EXCLUDE_TYPE) .map(|v| v.map(|s| s.to_string_lossy().to_string()).collect()); - if let (Some(include), Some(exclude)) = (&include, &exclude) { - if let Some(types) = Self::get_intersected_types(include, exclude) { - return Err(OptionsError::FilesystemTypeBothSelectedAndExcluded(types)); - } + if let (Some(include), Some(exclude)) = (&include, &exclude) + && let Some(types) = Self::get_intersected_types(include, exclude) + { + return Err(DfError::OptionsError( + OptionsError::FilesystemTypeBothSelectedAndExcluded(types), + ) + .into()); } Ok(Self { show_local_fs: matches.get_flag(OPT_LOCAL), show_all_fs: matches.get_flag(OPT_ALL), sync: matches.get_flag(OPT_SYNC), - block_size: read_block_size(matches).map_err(|e| match e { - ParseSizeError::InvalidSuffix(s) => OptionsError::InvalidSuffix(s), - ParseSizeError::SizeTooBig(_) => OptionsError::BlockSizeTooLarge( - matches.get_one::(OPT_BLOCKSIZE).unwrap().to_owned(), - ), - ParseSizeError::ParseFailure(s) => OptionsError::InvalidBlockSize(s), - ParseSizeError::PhysicalMem(s) => OptionsError::InvalidBlockSize(s), - })?, + block_size: read_block_size(matches) + .map_err(|error| block_size_error(&error, matches, diag_args))?, header_mode: { if matches.get_flag(OPT_HUMAN_READABLE_BINARY) || matches.get_flag(OPT_HUMAN_READABLE_DECIMAL) @@ -202,7 +252,8 @@ impl Options { include, exclude, show_total: matches.get_flag(OPT_TOTAL), - columns: Column::from_matches(matches).map_err(OptionsError::ColumnError)?, + columns: Column::from_matches(matches) + .map_err(|e| DfError::OptionsError(OptionsError::ColumnError(e)))?, }) } @@ -240,25 +291,26 @@ fn is_included(mi: &MountInfo, opt: &Options) -> bool { /// The "lt" in the function name is in analogy to the /// [`std::cmp::PartialOrd::lt`]. fn mount_info_lt(m1: &MountInfo, m2: &MountInfo) -> bool { - // let "real" devices with '/' in the name win. + // A source naming an actual device node outranks one that does not, + // so a pseudo-filesystem name never displaces a path under /dev. if m1.dev_name.starts_with('/') && !m2.dev_name.starts_with('/') { return false; } let m1_nearer_root = m1.mount_dir.len() < m2.mount_dir.len(); - // With bind mounts, prefer items nearer the root of the source - let m2_below_root = !m1.mount_root.is_empty() - && !m2.mount_root.is_empty() - && m1.mount_root.len() > m2.mount_root.len(); - // let points towards the root of the device win. + // A bind mount whose source subtree is shorter exposes more of the + // device, which makes it the more representative entry of the two. + let m2_below_root = !m2.mount_root.is_empty() && m1.mount_root.len() > m2.mount_root.len(); + // Otherwise the shallower mount point is the better description of + // the device, unless the deeper one covers more of the source. if m1_nearer_root && !m2_below_root { return false; } - // let an entry over-mounted on a new device win, but only when - // matching an existing mnt point, to avoid problematic - // replacement when given inaccurate mount lists, seen with some - // chroot environments for example. + // What is left is one entry mounted over another. Treat the covering + // entry as the live one, but only where both share a mount point: a + // mount table that reports stale or duplicated rows (chroots are a + // common source) would otherwise evict an entry it never covered. !(m1.dev_name != m2.dev_name && m1.mount_dir == m2.mount_dir) } @@ -292,14 +344,15 @@ fn get_all_filesystems(opt: &Options) -> UResult> { // 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( + if dev_path.is_absolute() + && dev_path.is_symlink() + && let Ok(canonicalized_symlink) = uucore::fs::canonicalize( dev_path, uucore::fs::MissingHandling::Existing, uucore::fs::ResolveMode::Logical, - ) { - mi.dev_name = canonicalized_symlink.to_string_lossy().to_string(); - } + ) + { + mi.dev_name = canonicalized_symlink.to_string_lossy().to_string(); } mounts.push(mi); @@ -309,10 +362,7 @@ fn get_all_filesystems(opt: &Options) -> UResult> { // Convert each `MountInfo` into a `Filesystem`, which contains // both the mount information and usage information. - #[cfg(not(windows))] - let maybe_mount = |m| Filesystem::from_mount(&mounts, m, None).ok(); - #[cfg(windows)] - let maybe_mount = |m| Filesystem::from_mount(m, None).ok(); + let maybe_mount = |m| platform::filesystem_from_mount(&mounts, m, None).ok(); Ok(mounts .iter() @@ -329,7 +379,6 @@ where // The list of all mounted filesystems. let mounts_result = read_fs_list(); - #[allow(unused_variables)] let (mounts, use_fallback) = match mounts_result { Ok(m) => (m, false), Err(e) => { @@ -349,14 +398,7 @@ where // Convert each path into a `Filesystem`, which contains // both the mount information and usage information. for path in paths { - #[cfg(unix)] - let fs_result = if use_fallback { - Filesystem::from_path_direct(path) - } else { - Filesystem::from_path(&mounts, path) - }; - #[cfg(not(unix))] - let fs_result = Filesystem::from_path(&mounts, path); + let fs_result = platform::filesystem_for_path(&mounts, use_fallback, path); match fs_result { Ok(fs) => { @@ -396,7 +438,7 @@ where Ok(result) } -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum DfError { /// A problem while parsing command-line options. #[error("{}", .0)] @@ -409,36 +451,25 @@ impl UError for DfError { } } -#[uucore::main] -pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - - #[cfg(windows)] - { - if matches.get_flag(OPT_INODES) { - println!( - "{}", - translate!("df-error-inodes-not-supported-windows", "program" => "df") - ); - return Ok(()); - } - } - - let opt = Options::from(&matches).map_err(DfError::OptionsError)?; - +/// Gather the filesystems that `df` would report on, without formatting anything. +/// +/// `paths` is `None` to report on every mounted filesystem, as `df` does when +/// called without operands, or `Some` to report on the filesystems containing +/// the given paths. +pub fn filesystems(paths: Option<&[&Path]>, opt: &Options) -> UResult> { // Run a sync call before any operation if so instructed. if opt.sync { - #[cfg(not(any(windows, target_os = "redox")))] - rustix::fs::sync(); + platform::sync(); } - // Get the list of filesystems to display in the output table. - let filesystems: Vec = match matches.get_many::(OPT_PATHS) { + let unreadable_mount_table = |e: Box| { + let context = translate!("df-error-cannot-read-table-of-mounted-filesystems"); + USimpleError::new(e.code(), format!("{context}: {e}")) + }; + + match paths { None => { - let filesystems = get_all_filesystems(&opt).map_err(|e| { - let context = translate!("df-error-cannot-read-table-of-mounted-filesystems"); - USimpleError::new(e.code(), format!("{context}: {e}")) - })?; + let filesystems = get_all_filesystems(opt).map_err(unreadable_mount_table)?; if filesystems.is_empty() { return Err(USimpleError::new( @@ -447,30 +478,64 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { )); } - filesystems + Ok(filesystems) } - Some(paths) => { - let paths: Vec<_> = paths.collect(); - let filesystems = get_named_filesystems(&paths, &opt).map_err(|e| { - let context = translate!("df-error-cannot-read-table-of-mounted-filesystems"); - USimpleError::new(e.code(), format!("{context}: {e}")) - })?; + Some(paths) => get_named_filesystems(paths, opt).map_err(unreadable_mount_table), + } +} - // This can happen if paths are given as command-line arguments - // but none of the paths exist. - if filesystems.is_empty() { - return Ok(()); - } +/// Write `filesystems` to `writer` as the standard `df` table. +pub fn write_table(writer: &mut W, filesystems: Vec, opt: &Options) -> UResult<()> +where + W: Write, +{ + Table::new(opt, filesystems).write_to(writer)?; + Ok(()) +} - filesystems - } - }; +/// Display filesystem usage information as a table on stdout. +/// +/// `paths` has the same meaning as in [`filesystems`]. +pub fn df(paths: Option<&[&Path]>, opt: &Options) -> UResult<()> { + let filesystems = filesystems(paths, opt)?; + + // Every path given on the command line was rejected; `filesystems` has + // already emitted a diagnostic for each, so there is no table to print. + if filesystems.is_empty() { + return Ok(()); + } - Table::new(&opt, filesystems).write_to(&mut stdout())?; + let mut writer = BufWriter::new(stdout().lock()); + write_table(&mut writer, filesystems, opt)?; + + // `BufWriter` swallows errors per drop, so flush explicitly. + writer.flush()?; Ok(()) } +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + // The arguments are kept for the caret in SIZE diagnostics, which echoes + // the command line. + let (matches, diag_args) = uucore::clap_localization::handle_clap_result_with_diagnostics( + uu_app(), + args.collect(), + 1, + )?; + + if let Some(result) = platform::maybe_unsupported_options(&matches) { + return result; + } + + let opt = Options::from_matches(&matches, diag_args.as_deref())?; + let paths: Option> = matches + .get_many::(OPT_PATHS) + .map(|paths| paths.map(Path::new).collect()); + + df(paths.as_deref(), &opt) +} + pub fn uu_app() -> Command { Command::new("df") .version(uucore::crate_version!()) @@ -497,9 +562,9 @@ pub fn uu_app() -> Command { .arg( Arg::new(OPT_BLOCKSIZE) .short('B') - .long("block-size") + .long(OPT_BLOCKSIZE_LONG) .value_name("SIZE") - .overrides_with_all([OPT_KILO, OPT_BLOCKSIZE]) + .overrides_with_all([OPT_KILO, OPT_MEGA, OPT_BLOCKSIZE]) .help(translate!("df-help-block-size")), ) .arg( @@ -537,7 +602,14 @@ pub fn uu_app() -> Command { Arg::new(OPT_KILO) .short('k') .help(translate!("df-help-kilo")) - .overrides_with_all([OPT_BLOCKSIZE, OPT_KILO]) + .overrides_with_all([OPT_BLOCKSIZE, OPT_KILO, OPT_MEGA]) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(OPT_MEGA) + .short('m') + .help(translate!("df-help-mega")) + .overrides_with_all([OPT_BLOCKSIZE, OPT_KILO, OPT_MEGA]) .action(ArgAction::SetTrue), ) .arg( @@ -622,6 +694,80 @@ pub fn uu_app() -> Command { #[cfg(test)] mod tests { + // spell-checker:ignore apfs + mod write_table { + + use crate::blocks::BlockSize; + use crate::{Filesystem, Options, write_table}; + use std::ffi::OsString; + use uucore::fsext::{FsUsage, MountInfo}; + + /// `Options::default()` picks up the block size from the environment + /// (`POSIXLY_CORRECT` halves it), so pin it for reproducible output. + fn options() -> Options { + Options { + block_size: BlockSize::Bytes(1024), + ..Options::default() + } + } + + fn filesystem() -> Filesystem { + Filesystem { + file: Some(OsString::from("/tmp/file")), + mount_info: MountInfo { + dev_id: String::from("1"), + dev_name: String::from("/dev/disk1"), + fs_type: String::from("apfs"), + mount_dir: OsString::from("/"), + mount_option: String::new(), + mount_root: OsString::new(), + remote: false, + dummy: false, + }, + usage: FsUsage { + blocksize: 1024, + blocks: 10, + bfree: 4, + bavail: 3, + bavail_top_bit_set: false, + files: 20, + ffree: 5, + }, + } + } + + /// Rendering must be possible without touching stdout, so that crate + /// users can capture the table themselves. + #[test] + fn test_write_table_to_arbitrary_writer() { + let mut buffer: Vec = Vec::new(); + write_table(&mut buffer, vec![filesystem()], &options()).unwrap(); + + // Header text is localized and the bundle is not loaded in unit + // tests, so only the data row is asserted on here. + let table = String::from_utf8(buffer).unwrap(); + let row = table.lines().nth(1).unwrap(); + + assert_eq!(table.lines().count(), 2); + assert!(row.starts_with("/dev/disk1")); + assert!(row.ends_with(" /")); + // 10 blocks of 1K, of which 6 are used and 3 available. + assert_eq!( + row.split_whitespace().collect::>(), + ["/dev/disk1", "10", "6", "3", "67%", "/"] + ); + } + + #[test] + fn test_write_table_empty() { + let mut buffer: Vec = Vec::new(); + write_table(&mut buffer, vec![], &options()).unwrap(); + + let table = String::from_utf8(buffer).unwrap(); + assert_eq!(table.lines().count(), 1); + } + } + mod mount_info_lt { use crate::mount_info_lt; diff --git a/src/uu/df/src/filesystem.rs b/src/uu/df/src/filesystem.rs index fe504b7d85a..608d4740347 100644 --- a/src/uu/df/src/filesystem.rs +++ b/src/uu/df/src/filesystem.rs @@ -2,25 +2,23 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + +// spell-checker:ignore canonicalized + //! Provides a summary representation of a filesystem. //! //! A [`Filesystem`] struct represents a device containing a //! filesystem mounted at a particular directory. It also includes //! information on amount of space available and amount of space used. -// spell-checker:ignore canonicalized -#[cfg(unix)] -use std::io; -#[cfg(unix)] -use std::path::PathBuf; -use std::{ffi::OsString, path::Path}; +use std::ffi::OsString; #[cfg(unix)] -use std::os::unix::fs::MetadataExt; +use std::path::Path; -#[cfg(unix)] -use uucore::fsext::{FsMeta, pretty_fstype, statfs}; use uucore::fsext::{FsUsage, MountInfo}; +use crate::platform; + /// Summary representation of a filesystem. /// /// A [`Filesystem`] struct represents a device containing a @@ -29,7 +27,7 @@ use uucore::fsext::{FsUsage, MountInfo}; /// [`Filesystem::usage`] field provides information on the amount of /// space available on the filesystem and the amount of space used. #[derive(Debug, Clone)] -pub(crate) struct Filesystem { +pub struct Filesystem { /// The file given on the command line if any. /// /// When invoking `df` with a positional argument, it displays @@ -52,47 +50,6 @@ pub(crate) enum FsError { MountMissing, } -/// Check whether `mount` has been over-mounted. -/// -/// `mount` is considered over-mounted if it there is an element in -/// `mounts` after mount that has the same `mount_dir`. -#[cfg(not(windows))] -fn is_over_mounted(mounts: &[MountInfo], mount: &MountInfo) -> bool { - let last_mount_for_dir = mounts.iter().rfind(|m| m.mount_dir == mount.mount_dir); - - if let Some(lmi) = last_mount_for_dir { - lmi.dev_name != mount.dev_name - } else { - // Should be unreachable if `mount` is in `mounts` - false - } -} - -/// Find mount point by walking up the directory tree until device ID changes. -#[cfg(unix)] -pub(crate) fn find_mount_point>(path: P) -> io::Result { - let mut current = path.as_ref().canonicalize()?; - let current_dev = current.metadata()?.dev(); - - loop { - let parent = match current.parent() { - Some(p) if !p.as_os_str().is_empty() => p, - _ => return Ok(current), - }; - - let parent_dev = parent.metadata()?.dev(); - if parent_dev != current_dev { - return Ok(current); - } - - if parent == current { - return Ok(current); - } - - current = parent.to_path_buf(); - } -} - /// Find the mount info that best matches a given filesystem path. /// /// This function returns the element of `mounts` on which `path` is @@ -107,6 +64,7 @@ pub(crate) fn find_mount_point>(path: P) -> io::Result { /// /// * [`Path::canonicalize`] /// * [`MountInfo::mount_dir`] +#[cfg(unix)] fn mount_info_from_path

( mounts: &[MountInfo], path: P, @@ -152,23 +110,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() { - #[cfg(unix)] - { - mount_info.dev_name.as_ref() - } - #[cfg(windows)] - { - // On windows, we expect the volume id - mount_info.dev_id.as_ref() - } - } else { - mount_info.mount_dir.as_os_str() - }; - #[cfg(unix)] - let usage = FsUsage::new(statfs(stat_path).ok()?); - #[cfg(windows)] - let usage = FsUsage::new(Path::new(stat_path)).ok()?; + let usage = platform::fs_usage(&mount_info)?; Some(Self { file, mount_info, @@ -176,27 +118,6 @@ impl Filesystem { }) } - /// Find and create the filesystem from the given mount - /// after checking that the it hasn't been over-mounted - #[cfg(not(windows))] - pub(crate) fn from_mount( - mounts: &[MountInfo], - mount: &MountInfo, - file: Option, - ) -> Result { - if is_over_mounted(mounts, mount) { - Err(FsError::OverMounted) - } else { - Self::new(mount.clone(), file).ok_or(FsError::MountMissing) - } - } - - /// Find and create the filesystem from the given mount. - #[cfg(windows)] - pub(crate) fn from_mount(mount: &MountInfo, file: Option) -> Result { - Self::new(mount.clone(), file).ok_or(FsError::MountMissing) - } - /// Find and create the filesystem that best matches a given path. /// /// This function returns a new `Filesystem` derived from the @@ -213,6 +134,7 @@ impl Filesystem { /// * [`Path::canonicalize`] /// * [`MountInfo::mount_dir`] /// + #[cfg(unix)] pub(crate) fn from_path

(mounts: &[MountInfo], path: P) -> Result where P: AsRef, @@ -221,49 +143,12 @@ impl Filesystem { let canonicalize = true; let result = mount_info_from_path(mounts, path, canonicalize); - #[cfg(windows)] - return result.and_then(|mount_info| Self::from_mount(mount_info, Some(file))); - #[cfg(not(windows))] - return result.and_then(|mount_info| Self::from_mount(mounts, mount_info, Some(file))); - } - - /// Fallback using statfs when mount table is unavailable. - #[cfg(unix)] - pub(crate) fn from_path_direct

(path: P) -> Result - where - P: AsRef, - { - let path = path.as_ref(); - let file = path.as_os_str().to_owned(); - - let canonical_path = path.canonicalize().map_err(|_| FsError::InvalidPath)?; - - let stat_result = statfs(canonical_path.as_os_str()).map_err(|_| FsError::MountMissing)?; - let mount_dir = find_mount_point(&canonical_path).map_err(|_| FsError::MountMissing)?; - let fs_type = pretty_fstype(stat_result.fs_type()).into_owned(); - - let mount_info = MountInfo { - dev_id: String::new(), - dev_name: "-".to_string(), - fs_type, - mount_dir: mount_dir.into_os_string(), - mount_option: String::new(), - mount_root: OsString::new(), - remote: false, - dummy: false, - }; - - let usage = FsUsage::new(stat_result); - - Ok(Self { - file: Some(file), - mount_info, - usage, - }) + result + .and_then(|mount_info| platform::filesystem_from_mount(mounts, mount_info, Some(file))) } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { mod mount_info_from_path { @@ -371,54 +256,4 @@ mod tests { assert!(mount_info_eq(actual, &mounts[0])); } } - - #[cfg(not(windows))] - mod over_mount { - use std::ffi::OsString; - - use crate::filesystem::{Filesystem, FsError, is_over_mounted}; - use uucore::fsext::MountInfo; - - fn mount_info_with_dev_name(mount_dir: &str, dev_name: Option<&str>) -> MountInfo { - MountInfo { - dev_id: String::default(), - dev_name: dev_name.map(String::from).unwrap_or_default(), - fs_type: String::default(), - mount_dir: OsString::from(mount_dir), - mount_option: String::default(), - mount_root: OsString::default(), - remote: Default::default(), - dummy: Default::default(), - } - } - - #[test] - fn test_over_mount() { - let mount_info1 = mount_info_with_dev_name("/foo", Some("dev_name_1")); - let mount_info2 = mount_info_with_dev_name("/foo", Some("dev_name_2")); - let mounts = [mount_info1, mount_info2]; - assert!(is_over_mounted(&mounts, &mounts[0])); - } - - #[test] - fn test_over_mount_not_over_mounted() { - let mount_info1 = mount_info_with_dev_name("/foo", Some("dev_name_1")); - let mount_info2 = mount_info_with_dev_name("/foo", Some("dev_name_2")); - let mounts = [mount_info1, mount_info2]; - assert!(!is_over_mounted(&mounts, &mounts[1])); - } - - #[test] - fn test_from_mount_over_mounted() { - let mount_info1 = mount_info_with_dev_name("/foo", Some("dev_name_1")); - let mount_info2 = mount_info_with_dev_name("/foo", Some("dev_name_2")); - - let mounts = [mount_info1, mount_info2]; - - assert_eq!( - Filesystem::from_mount(&mounts, &mounts[0], None).unwrap_err(), - FsError::OverMounted - ); - } - } } diff --git a/src/uu/df/src/platform/mod.rs b/src/uu/df/src/platform/mod.rs new file mode 100644 index 00000000000..999bd45735b --- /dev/null +++ b/src/uu/df/src/platform/mod.rs @@ -0,0 +1,20 @@ +// 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. + +//! Platform-specific pieces of `df`: the `--sync` flush, the usage probe, +//! over-mount detection, the fallback used when the mount table cannot be +//! read, and the Windows `-i` bail-out. `df.rs` and `filesystem.rs` only talk +//! to the facade functions re-exported here, which both submodules provide +//! with identical signatures. + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub(crate) use unix::*; + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub(crate) use windows::*; diff --git a/src/uu/df/src/platform/unix.rs b/src/uu/df/src/platform/unix.rs new file mode 100644 index 00000000000..7fd8c8f7928 --- /dev/null +++ b/src/uu/df/src/platform/unix.rs @@ -0,0 +1,216 @@ +// 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. + +//! Unix backend of `df`: `statfs` usage probes, over-mount detection and the +//! fallback used when the mount table is unavailable. + +use std::ffi::OsString; +use std::io; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; + +use clap::ArgMatches; +use uucore::error::UResult; +use uucore::fsext::{FsMeta, FsUsage, MountInfo, pretty_fstype, statfs}; + +use crate::filesystem::{Filesystem, FsError}; + +/// Flush filesystem buffers for `--sync`. +pub(crate) fn sync() { + #[cfg(not(target_os = "redox"))] + rustix::fs::sync(); +} + +/// Usage of the filesystem at `mount_info`, `None` if it cannot be queried. +pub(crate) fn fs_usage(mount_info: &MountInfo) -> Option { + let stat_path = if mount_info.mount_dir.is_empty() { + mount_info.dev_name.as_ref() + } else { + mount_info.mount_dir.as_os_str() + }; + Some(FsUsage::new(statfs(stat_path).ok()?)) +} + +/// Check whether `mount` has been over-mounted. +/// +/// `mount` is considered over-mounted if it there is an element in +/// `mounts` after mount that has the same `mount_dir`. +fn is_over_mounted(mounts: &[MountInfo], mount: &MountInfo) -> bool { + let last_mount_for_dir = mounts.iter().rfind(|m| m.mount_dir == mount.mount_dir); + + if let Some(lmi) = last_mount_for_dir { + lmi.dev_name != mount.dev_name + } else { + // Should be unreachable if `mount` is in `mounts` + false + } +} + +/// Find and create the filesystem from the given mount +/// after checking that the it hasn't been over-mounted +pub(crate) fn filesystem_from_mount( + mounts: &[MountInfo], + mount: &MountInfo, + file: Option, +) -> Result { + if is_over_mounted(mounts, mount) { + Err(FsError::OverMounted) + } else { + Filesystem::new(mount.clone(), file).ok_or(FsError::MountMissing) + } +} + +/// Find and create the filesystem that contains `path`, through the mount +/// table or, when that could not be read, through `statfs` alone. +pub(crate) fn filesystem_for_path

( + mounts: &[MountInfo], + use_fallback: bool, + path: P, +) -> Result +where + P: AsRef, +{ + let path = path.as_ref(); + if use_fallback { + from_path_direct(path) + } else { + Filesystem::from_path(mounts, path).or_else(|_| from_path_direct_with_mounts(mounts, path)) + } +} + +/// Find mount point by walking up the directory tree until device ID changes. +fn find_mount_point>(path: P) -> io::Result { + let mut current = path.as_ref().canonicalize()?; + let current_dev = current.metadata()?.dev(); + + while let Some(parent) = current.parent().filter(|p| !p.as_os_str().is_empty()) { + let parent_dev = parent.metadata()?.dev(); + if parent_dev != current_dev || parent == current { + return Ok(current); + } + + current = parent.to_path_buf(); + } + Ok(current) +} + +/// Fallback using statfs with a mount table available. +fn from_path_direct_with_mounts

(mounts: &[MountInfo], path: P) -> Result +where + P: AsRef, +{ + let file = path.as_ref().as_os_str().to_owned(); + + let canonical_path = path + .as_ref() + .canonicalize() + .map_err(|_| FsError::InvalidPath)?; + + let stat_result = statfs(canonical_path.as_os_str()).map_err(|_| FsError::MountMissing)?; + + // GNU coreutils always appear to return the last match + let mut last_match = None; + for mount in mounts { + if let Ok(stat_result_mount) = statfs(&mount.mount_dir) + && stat_result_mount.fsid() == stat_result.fsid() + { + last_match = Some(mount); + } + } + + last_match + .ok_or(FsError::MountMissing) + .and_then(|mount_info| filesystem_from_mount(mounts, mount_info, Some(file))) +} + +/// Fallback using statfs when mount table is unavailable. +fn from_path_direct

(path: P) -> Result +where + P: AsRef, +{ + let path = path.as_ref(); + let file = path.as_os_str().to_owned(); + + let canonical_path = path.canonicalize().map_err(|_| FsError::InvalidPath)?; + + let stat_result = statfs(canonical_path.as_os_str()).map_err(|_| FsError::MountMissing)?; + let mount_dir = find_mount_point(&canonical_path).map_err(|_| FsError::MountMissing)?; + let fs_type = pretty_fstype(stat_result.fs_type()).into_owned(); + + let mount_info = MountInfo { + dev_id: String::new(), + dev_name: "-".to_string(), + fs_type, + mount_dir: mount_dir.into_os_string(), + mount_option: String::new(), + mount_root: OsString::new(), + remote: false, + dummy: false, + }; + + let usage = FsUsage::new(stat_result); + + Ok(Filesystem { + file: Some(file), + mount_info, + usage, + }) +} + +/// Every option is supported on unix. +pub(crate) fn maybe_unsupported_options(_matches: &ArgMatches) -> Option> { + None +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + + use super::{filesystem_from_mount, is_over_mounted}; + use crate::filesystem::FsError; + use uucore::fsext::MountInfo; + + fn mount_info_with_dev_name(mount_dir: &str, dev_name: Option<&str>) -> MountInfo { + MountInfo { + dev_id: String::default(), + dev_name: dev_name.map(String::from).unwrap_or_default(), + fs_type: String::default(), + mount_dir: OsString::from(mount_dir), + mount_option: String::default(), + mount_root: OsString::default(), + remote: Default::default(), + dummy: Default::default(), + } + } + + #[test] + fn test_over_mount() { + let mount_info1 = mount_info_with_dev_name("/foo", Some("dev_name_1")); + let mount_info2 = mount_info_with_dev_name("/foo", Some("dev_name_2")); + let mounts = [mount_info1, mount_info2]; + assert!(is_over_mounted(&mounts, &mounts[0])); + } + + #[test] + fn test_over_mount_not_over_mounted() { + let mount_info1 = mount_info_with_dev_name("/foo", Some("dev_name_1")); + let mount_info2 = mount_info_with_dev_name("/foo", Some("dev_name_2")); + let mounts = [mount_info1, mount_info2]; + assert!(!is_over_mounted(&mounts, &mounts[1])); + } + + #[test] + fn test_from_mount_over_mounted() { + let mount_info1 = mount_info_with_dev_name("/foo", Some("dev_name_1")); + let mount_info2 = mount_info_with_dev_name("/foo", Some("dev_name_2")); + + let mounts = [mount_info1, mount_info2]; + + assert_eq!( + filesystem_from_mount(&mounts, &mounts[0], None).unwrap_err(), + FsError::OverMounted + ); + } +} diff --git a/src/uu/df/src/platform/windows.rs b/src/uu/df/src/platform/windows.rs new file mode 100644 index 00000000000..5f694721498 --- /dev/null +++ b/src/uu/df/src/platform/windows.rs @@ -0,0 +1,78 @@ +// 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 SUBST + +//! Windows backend of `df`: volume usage probes, path resolution and the `-i` notice. + +use std::ffi::OsString; +use std::path::Path; + +use clap::ArgMatches; +use uucore::error::UResult; +use uucore::fsext::{FsUsage, MountInfo}; +use uucore::translate; + +use crate::OPT_INODES; +use crate::filesystem::{Filesystem, FsError}; + +/// Windows has no call to flush every filesystem for `--sync`. +pub(crate) fn sync() {} + +/// Usage of the filesystem at `mount_info`, `None` if it cannot be queried. +pub(crate) fn fs_usage(mount_info: &MountInfo) -> Option { + FsUsage::new(Path::new(&mount_info.mount_dir)).ok() +} + +/// Find and create the filesystem from the given mount. +pub(crate) fn filesystem_from_mount( + _mounts: &[MountInfo], + mount: &MountInfo, + file: Option, +) -> Result { + Filesystem::new(mount.clone(), file).ok_or(FsError::MountMissing) +} + +/// Find and create the filesystem that contains `path`: the mount with the +/// longest directory prefixing it, or, when the mount table does not list it +/// (UNC paths, unreadable table), the volume root the OS reports for it. +pub(crate) fn filesystem_for_path

( + mounts: &[MountInfo], + _use_fallback: bool, + path: P, +) -> Result +where + P: AsRef, +{ + let path = path.as_ref(); + let file = path.as_os_str().to_owned(); + // Not `canonicalize`: it resolves SUBST drives and junctions away and + // yields `\\?\` prefixes that never match a mount directory. + let absolute = std::path::absolute(path).map_err(|_| FsError::InvalidPath)?; + absolute.metadata().map_err(|_| FsError::InvalidPath)?; + let longest = mounts + .iter() + .filter(|m| absolute.starts_with(&m.mount_dir)) + .max_by_key(|m| m.mount_dir.len()); + let mount_info = if let Some(mount_info) = longest { + mount_info.clone() + } else { + let root = uucore::fs::volume_path_name(&absolute).map_err(|_| FsError::MountMissing)?; + MountInfo::from_mount_dir(root.into_os_string()) + }; + Filesystem::new(mount_info, Some(file)).ok_or(FsError::MountMissing) +} + +/// `-i` is not supported: say so and stop successfully. +pub(crate) fn maybe_unsupported_options(matches: &ArgMatches) -> Option> { + if matches.get_flag(OPT_INODES) { + println!( + "{}", + translate!("df-error-inodes-not-supported-windows", "program" => "df") + ); + return Some(Ok(())); + } + None +} diff --git a/src/uu/df/src/table.rs b/src/uu/df/src/table.rs index 3bfb651b702..e878c1bb555 100644 --- a/src/uu/df/src/table.rs +++ b/src/uu/df/src/table.rs @@ -2,11 +2,14 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore tmpfs Pcent Itotal Iused Iavail Ipcent nosuid nodev + //! The filesystem usage data table. //! //! A table ([`Table`]) comprises a header row ([`Header`]) and a //! collection of data rows ([`Row`]), one per filesystem. + use unicode_width::UnicodeWidthStr; use crate::blocks::{SuffixType, to_magnitude_and_suffix}; @@ -56,7 +59,7 @@ pub(crate) struct Row { /// These are the bytes that are available to non-privileged processes. /// /// If the filesystem has zero bytes, then this is `None`. - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] bytes_capacity: Option, /// Total number of inodes in the filesystem. @@ -85,7 +88,7 @@ impl Row { bytes_used: BytesCell::default(), bytes_avail: BytesCell::default(), bytes_usage: None, - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] bytes_capacity: None, inodes: 0, inodes_used: 0, @@ -123,7 +126,7 @@ impl AddAssign for Row { 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")] + #[cfg(target_vendor = "apple")] bytes_capacity: None, inodes, inodes_used, @@ -174,7 +177,7 @@ impl Row { // https://www.gnu.org/software/coreutils/faq/coreutils-faq.html#df-Size-and-Used-and-Available-do-not-add-up Some(bused as f64 / (bused + bavail) as f64) }, - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] bytes_capacity: if bavail == 0 { None } else { @@ -379,7 +382,7 @@ impl<'a> RowFormatter<'a> { .map_or(Cell::from_ascii_string("-"), Cell::from_os_string), Column::Fstype => Cell::from_string(&self.row.fs_type), - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] Column::Capacity => Self::percentage(self.row.bytes_capacity), }; @@ -449,7 +452,7 @@ impl Header { Column::Ipcent => translate!("df-header-iuse-percent"), Column::File => translate!("df-header-file"), Column::Fstype => translate!("df-header-type"), - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] Column::Capacity => translate!("df-header-capacity"), }; @@ -618,7 +621,7 @@ mod tests { bytes_avail: BytesCell::new(75, &BlockSize::Bytes(1)), bytes_usage: Some(0.25), - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] bytes_capacity: Some(0.5), inodes: 10, diff --git a/src/uu/dir/Cargo.toml b/src/uu/dir/Cargo.toml index 5c6eac1ea65..fa02e7e6ad3 100644 --- a/src/uu/dir/Cargo.toml +++ b/src/uu/dir/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/dir.rs" test = false @@ -21,8 +18,14 @@ doctest = false [dependencies] clap = { workspace = true, features = ["env"] } -uucore = { workspace = true, features = ["entries", "fs", "quoting-style"] } uu_ls = { workspace = true } +uucore = { workspace = true, features = ["entries", "fs", "quoting-style"] } + +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true [[bin]] name = "dir" diff --git a/src/uu/dir/src/dir.rs b/src/uu/dir/src/dir.rs index 069a0c7156b..114cac1d560 100644 --- a/src/uu/dir/src/dir.rs +++ b/src/uu/dir/src/dir.rs @@ -6,49 +6,19 @@ use clap::Command; use std::ffi::OsString; use std::path::Path; -use uu_ls::{Config, Format, options}; -use uucore::{error::UResult, format_usage, quoting_style::QuotingStyle, translate}; +use uu_ls::{Config, options}; +use uucore::{error::UResult, format_usage, translate}; #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { let command = uu_app(); - let matches = uucore::clap_localization::handle_clap_result_with_exit_code(command, args, 2)?; + // The arguments are kept for the caret in SIZE diagnostics, which echoes + // the command line. + let (matches, diag_args) = + uucore::clap_localization::handle_clap_result_with_diagnostics(command, args.collect(), 2)?; - let mut default_quoting_style = false; - let mut default_format_style = false; - - // We check if any options on formatting or quoting style have been given. - // If not, we will use dir default formatting and quoting style options - - if !matches.contains_id(options::QUOTING_STYLE) - && !matches.get_flag(options::quoting::C) - && !matches.get_flag(options::quoting::ESCAPE) - && !matches.get_flag(options::quoting::LITERAL) - { - default_quoting_style = true; - } - if !matches.contains_id(options::FORMAT) - && !matches.get_flag(options::format::ACROSS) - && !matches.get_flag(options::format::COLUMNS) - && !matches.get_flag(options::format::COMMAS) - && !matches.get_flag(options::format::LONG) - && !matches.get_flag(options::format::LONG_NO_GROUP) - && !matches.get_flag(options::format::LONG_NO_OWNER) - && !matches.get_flag(options::format::LONG_NUMERIC_UID_GID) - && !matches.get_flag(options::format::ONE_LINE) - { - default_format_style = true; - } - - let mut config = Config::from(&matches)?; - - if default_quoting_style { - config.quoting_style = QuotingStyle::C_NO_QUOTES; - } - if default_format_style { - config.format = Format::Columns; - } + let config = Config::from_dir(&matches, diag_args.as_deref())?; let locs = matches .get_many::(options::PATHS) diff --git a/src/uu/dircolors/Cargo.toml b/src/uu/dircolors/Cargo.toml index 37628bf8291..7b793078ef2 100644 --- a/src/uu/dircolors/Cargo.toml +++ b/src/uu/dircolors/Cargo.toml @@ -11,17 +11,17 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/dircolors.rs" doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["colors", "parser"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["colors", "parser"] } + +[lints] +workspace = true [[bin]] name = "dircolors" diff --git a/src/uu/dircolors/src/dircolors.rs b/src/uu/dircolors/src/dircolors.rs index de842fea877..27bf3ddb3a2 100644 --- a/src/uu/dircolors/src/dircolors.rs +++ b/src/uu/dircolors/src/dircolors.rs @@ -251,7 +251,7 @@ pub fn uu_app() -> Command { ) } -pub trait StrUtils { +trait StrUtils { /// Remove comments and trim whitespace fn purify(&self) -> &Self; /// Like `split_whitespace()` but only produce 2 parts @@ -525,4 +525,24 @@ mod tests { assert_eq!(Some(OutputFmt::Shell), guess_syntax("foo")); assert_eq!(None, guess_syntax("")); } + + #[test] + fn test_purify() { + let s = " asd#zcv #hk\t\n "; + assert_eq!("asd#zcv", s.purify()); + } + + #[test] + fn test_fnmatch() { + let s = "con256asd"; + assert!(s.fnmatch("*[2][3-6][5-9]?sd")); // spell-checker:disable-line + } + + #[test] + fn test_split_two() { + let s = "zxc \t\nqwe jlk hjl"; // spell-checker:disable-line + let (k, v) = s.split_two(); + assert_eq!("zxc", k); + assert_eq!("qwe jlk hjl", v); + } } diff --git a/src/uu/dirname/Cargo.toml b/src/uu/dirname/Cargo.toml index 21b84b406b0..727367c2fda 100644 --- a/src/uu/dirname/Cargo.toml +++ b/src/uu/dirname/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/dirname.rs" test = false @@ -21,8 +18,11 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true } + +[lints] +workspace = true [[bin]] name = "dirname" diff --git a/src/uu/dirname/src/dirname.rs b/src/uu/dirname/src/dirname.rs index 0728a919303..517105033bb 100644 --- a/src/uu/dirname/src/dirname.rs +++ b/src/uu/dirname/src/dirname.rs @@ -19,6 +19,46 @@ mod options { pub const DIR: &str = "dir"; } +#[cfg(not(windows))] +fn is_sep(b: u8) -> bool { + std::path::is_separator(b as char) +} + +#[cfg(windows)] +fn is_sep(b: u8) -> bool { + // `std::path::is_separator` does not optimize well on Windows. It is essentially + // `c.is_ascii() && is_sep_byte(c as u8)`, and LLVM does not make the assumption + // that the `c.is_ascii()` is unintentional here. The result is a useless branch. + b == b'/' || b == b'\\' +} + +#[cfg(not(windows))] +fn root_prefix_len(_: &[u8]) -> usize { + 0 +} + +#[cfg(windows)] +fn root_prefix_len(bytes: &[u8]) -> usize { + use std::ffi::OsStr; + use std::path::Component; + use std::path::Path; + + // SAFETY: These bytes came from `OsStr` via `uucore::os_str_as_bytes`. + let path = Path::new(unsafe { OsStr::from_encoded_bytes_unchecked(bytes) }); + let Some(Component::Prefix(p)) = path.components().next() else { + return 0; + }; + + let mut len = p.as_os_str().len(); + + // Include the root directory separator after the prefix, if present. + if len > 0 && len < bytes.len() && is_sep(bytes[len]) { + len += 1; + } + + len +} + /// Perform dirname as pure string manipulation per POSIX/GNU behavior. /// /// dirname should NOT normalize paths. It does simple string manipulation: @@ -33,6 +73,9 @@ mod options { /// - `foo/./bar` → `foo/.` /// - `foo/bar` → `foo` /// - `a/b/c` → `a/b` +/// - `C:\foo\bar` → `C:\foo` (Windows) +/// - `C:\foo` → `C:\` (Windows) +/// - `\\server\share\foo` → `\\server\share\` (Windows) /// /// Per POSIX.1-2017 dirname specification and GNU coreutils manual: /// - POSIX: @@ -45,57 +88,67 @@ fn dirname_string_manipulation(path_bytes: &[u8]) -> &[u8] { } let mut bytes = path_bytes; + let root_len = root_prefix_len(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 b"/"; + if bytes[root_len..].iter().copied().all(is_sep) { + return &bytes[..root_len.max(1)]; } - while bytes.len() > 1 && bytes.ends_with(b"/") { + while bytes.len() > root_len.max(1) && is_sep(bytes[bytes.len() - 1]) { bytes = &bytes[..bytes.len() - 1]; } // Step 2: Check if it ends with `/.` and strip the `/+.` pattern - if bytes.ends_with(b".") && bytes.len() >= 2 { + if bytes.ends_with(b".") && bytes.len() >= 2 && is_sep(bytes[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"/") { - b"/" - } else { - b"." - }; - } - return &bytes[..slash_start]; + // Find where the slashes before the dot start + let mut slash_start = dot_pos - 1; + while slash_start > 0 && is_sep(bytes[slash_start - 1]) { + slash_start -= 1; + } + // Return the stripped result + if slash_start == 0 { + // Result would be empty + return if is_sep(path_bytes[0]) { + &path_bytes[..1] + } else { + b"." + }; } + if slash_start < root_len { + // NOTE: This gets optimized away on Unix, because root_len is 0. + return &path_bytes[..root_len]; + } + return &bytes[..slash_start]; } // Step 3: Normal dirname - find last / and remove everything after it - if let Some(last_slash_pos) = bytes.iter().rposition(|&b| b == b'/') { + if let Some(last_slash_pos) = bytes.iter().copied().rposition(is_sep) { // 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"/") { + while result.len() > 1 && is_sep(result[result.len() - 1]) { result = &result[..result.len() - 1]; } if result.is_empty() { - return b"/"; + return &bytes[..1]; } + if result.len() < root_len { + // NOTE: This gets optimized away on Unix, because root_len is 0. + return &bytes[..root_len]; + } return result; } // No slash found, return "." + if root_len > 0 { + // NOTE: This gets optimized away on Unix, because root_len is 0. + return &bytes[..root_len]; + } b"." } @@ -105,16 +158,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let line_ending = LineEnding::from_zero_flag(matches.get_flag(options::ZERO)); + // todo: use .required(true) of clap and sed GnuTests to provide better error message let dirnames: Vec = matches .get_many::(options::DIR) - .unwrap_or_default() + .ok_or_else(|| UUsageError::new(1, translate!("dirname-missing-operand")))? .cloned() .collect(); - if dirnames.is_empty() { - return Err(UUsageError::new(1, translate!("dirname-missing-operand"))); - } - 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); diff --git a/src/uu/du/Cargo.toml b/src/uu/du/Cargo.toml index b2c4cda1d7a..f674b5ed0b0 100644 --- a/src/uu/du/Cargo.toml +++ b/src/uu/du/Cargo.toml @@ -11,33 +11,30 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/du.rs" doctest = false [dependencies] -# For the --exclude & --exclude-from options -glob = { workspace = true } clap = { workspace = true } +fluent = { workspace = true } +glob = { workspace = true } +rustc-hash = { workspace = true } +thiserror = { workspace = true } uucore = { workspace = true, features = [ "format", + "fs", "fsext", "i18n-decimal", "parser-size", "parser-glob", "time", ] } -rustc-hash = { workspace = true } -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] +[target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = [ "Win32_Storage_FileSystem", "Win32_Foundation", @@ -52,6 +49,12 @@ divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true + [[bench]] name = "du_bench" harness = false diff --git a/src/uu/du/benches/du_bench.rs b/src/uu/du/benches/du_bench.rs index 0b63ce9a2ac..bb32708a796 100644 --- a/src/uu/du/benches/du_bench.rs +++ b/src/uu/du/benches/du_bench.rs @@ -6,18 +6,7 @@ use divan::{Bencher, black_box}; use tempfile::TempDir; use uu_du::uumain; -use uucore::benchmark::{fs_tree, run_util_function}; - -/// Helper to run du with given arguments on a directory -fn bench_du_with_args(bencher: Bencher, temp_dir: &TempDir, args: &[&str]) { - let temp_path_str = temp_dir.path().to_str().unwrap(); - let mut full_args = args.to_vec(); - full_args.push(temp_path_str); - - bencher.bench(|| { - black_box(run_util_function(uumain, &full_args)); - }); -} +use uucore::benchmark::{fs_tree, get_bench_args}; /* too much variance /// Benchmark default du on balanced tree @@ -27,8 +16,12 @@ fn du_balanced_tree( (depth, dirs_per_level, files_per_dir): (usize, usize, usize), ) { let temp_dir = TempDir::new().unwrap(); - fs_tree::create_balanced_tree(temp_dir.path(), depth, dirs_per_level, files_per_dir); - bench_du_with_args(bencher, &temp_dir, &[]); + let temp_path = temp_dir.path(); + fs_tree::create_balanced_tree(temp_path, depth, dirs_per_level, files_per_dir); + + bencher + .with_inputs(|| get_bench_args(&[&temp_path])) + .bench_values(|args| black_box(uumain(args))); } */ @@ -40,8 +33,12 @@ fn du_all_balanced_tree( (depth, dirs_per_level, files_per_dir): (usize, usize, usize), ) { let temp_dir = TempDir::new().unwrap(); - fs_tree::create_balanced_tree(temp_dir.path(), depth, dirs_per_level, files_per_dir); - bench_du_with_args(bencher, &temp_dir, &["-a"]); + let temp_path = temp_dir.path(); + fs_tree::create_balanced_tree(temp_path, depth, dirs_per_level, files_per_dir); + + bencher + .with_inputs(|| get_bench_args(&[&"-a", &temp_path])) + .bench_values(|args| black_box(uumain(args))); } */ @@ -53,57 +50,57 @@ fn du_human_balanced_tree( (depth, dirs_per_level, files_per_dir): (usize, usize, usize), ) { let temp_dir = TempDir::new().unwrap(); - fs_tree::create_balanced_tree(temp_dir.path(), depth, dirs_per_level, files_per_dir); - bench_du_with_args(bencher, &temp_dir, &["-h"]); + let temp_path = temp_dir.path(); + fs_tree::create_balanced_tree(temp_path, depth, dirs_per_level, files_per_dir); + + bencher + .with_inputs(|| get_bench_args(&[&"-h", &temp_path])) + .bench_values(|args| black_box(uumain(args))); } */ -/// Benchmark du on wide directory structures (many files/dirs, shallow) +/// Run a throwaway traversal to warm the OS page/inode cache before timing. +fn warm_cache(path: &std::path::Path) { + let _ = black_box(uumain(get_bench_args(&[&path]).into_iter())); +} + +/// 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(); + let temp_path = temp_dir.path(); + fs_tree::create_wide_tree(temp_path, total_files, total_dirs); + warm_cache(temp_path); + 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)); - }); + .with_inputs(|| get_bench_args(&[&temp_path]).into_iter()) + .bench_values(|args| black_box(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(); + let temp_path = temp_dir.path(); + fs_tree::create_wide_tree(temp_path, total_files, total_dirs); + warm_cache(temp_path); + 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)); - }); + .with_inputs(|| get_bench_args(&[&"-a", &temp_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark du on deep directory structures #[divan::bench(args = [(100, 3)])] fn du_deep_tree(bencher: Bencher, (depth, files_per_level): (usize, usize)) { + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path(); + fs_tree::create_deep_tree(temp_path, depth, files_per_level); + warm_cache(temp_path); + 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)); - }); + .with_inputs(|| get_bench_args(&[&temp_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark du -s (summarize) on balanced tree @@ -113,19 +110,13 @@ fn du_summarize_balanced_tree( (depth, dirs_per_level, files_per_dir): (usize, usize, usize), ) { let temp_dir = TempDir::new().unwrap(); - fs_tree::create_balanced_tree(temp_dir.path(), depth, dirs_per_level, files_per_dir); - bench_du_with_args(bencher, &temp_dir, &["-s"]); -} + let temp_path = temp_dir.path(); + fs_tree::create_balanced_tree(temp_path, depth, dirs_per_level, files_per_dir); + warm_cache(temp_path); -/// Benchmark du with --max-depth -#[divan::bench(args = [(6, 4, 10)])] -fn du_max_depth_balanced_tree( - bencher: Bencher, - (depth, dirs_per_level, files_per_dir): (usize, usize, usize), -) { - let temp_dir = TempDir::new().unwrap(); - fs_tree::create_balanced_tree(temp_dir.path(), depth, dirs_per_level, files_per_dir); - bench_du_with_args(bencher, &temp_dir, &["--max-depth=2"]); + bencher + .with_inputs(|| get_bench_args(&[&"-s", &temp_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/du/locales/en-US.ftl b/src/uu/du/locales/en-US.ftl index d7141c7c079..cf69ccdbb99 100644 --- a/src/uu/du/locales/en-US.ftl +++ b/src/uu/du/locales/en-US.ftl @@ -11,9 +11,10 @@ du-after-help = Display values are in units of the first available SIZE from --b PATTERN allows some advanced exclusions. For example, the following syntaxes are supported: - ? will match only one character - { "*" } will match zero or more characters - {"{"}a,b{"}"} will match a or b + + - ? will match only one character + - { "*" } will match zero or more characters + - {"{"}a,b{"}"} will match a or b # Help messages du-help-print-help = Print help information. @@ -63,7 +64,6 @@ du-error-cannot-open-for-reading = cannot open { $file } for reading: No such fi du-error-invalid-zero-length-file-name = { $file }:{ $line }: invalid zero-length file name du-error-extra-operand-with-files0-from = extra operand { $file } file operands cannot be combined with --files0-from -du-error-invalid-block-size-argument = invalid --{ $option } argument { $value } du-error-cannot-access-no-such-file = cannot access { $path }: No such file or directory du-error-printing-thread-panicked = Printing thread panicked. du-error-invalid-suffix = invalid suffix in --{ $option } argument { $value } diff --git a/src/uu/du/locales/fr-FR.ftl b/src/uu/du/locales/fr-FR.ftl index e4e55ed0f0f..27bed339a41 100644 --- a/src/uu/du/locales/fr-FR.ftl +++ b/src/uu/du/locales/fr-FR.ftl @@ -11,9 +11,10 @@ du-after-help = Les valeurs affichées sont en unités de la première TAILLE di MOTIF permet des exclusions avancées. Par exemple, les syntaxes suivantes sont prises en charge : - ? correspondra à un seul caractère - { "*" } correspondra à zéro ou plusieurs caractères - {"{"}a,b{"}"} correspondra à a ou b + + - ? correspondra à un seul caractère + - { "*" } correspondra à zéro ou plusieurs caractères + - {"{"}a,b{"}"} correspondra à a ou b # Messages d'aide du-help-print-help = Afficher les informations d'aide. @@ -63,7 +64,6 @@ du-error-cannot-open-for-reading = impossible d'ouvrir { $file } en lecture : Au du-error-invalid-zero-length-file-name = { $file }:{ $line } : nom de fichier de longueur zéro invalide du-error-extra-operand-with-files0-from = opérande supplémentaire { $file } les opérandes de fichier ne peuvent pas être combinées avec --files0-from -du-error-invalid-block-size-argument = argument --{ $option } invalide { $value } du-error-cannot-access-no-such-file = impossible d'accéder à { $path } : Aucun fichier ou répertoire de ce type du-error-printing-thread-panicked = Le thread d'affichage a paniqué. du-error-invalid-suffix = suffixe invalide dans l'argument --{ $option } { $value } diff --git a/src/uu/du/src/du.rs b/src/uu/du/src/du.rs index fdd0bcbaf3f..cd95da9fa2f 100644 --- a/src/uu/du/src/du.rs +++ b/src/uu/du/src/du.rs @@ -2,16 +2,16 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// + // spell-checker:ignore fstatat openat dirfd use clap::{Arg, ArgAction, ArgMatches, Command, builder::PossibleValue}; use glob::{Pattern, PatternError}; -use rustc_hash::FxHashSet as HashSet; +use rustc_hash::FxHashSet; use std::env; use std::ffi::{OsStr, OsString}; use std::fs::{self, DirEntry, File, Metadata}; -use std::io::{BufRead, BufReader, Write, stdout}; +use std::io::{self, BufRead, BufReader, Write, stdout}; #[cfg(not(windows))] use std::os::unix::fs::MetadataExt; #[cfg(windows)] @@ -19,12 +19,13 @@ use std::os::windows::fs::OpenOptionsExt; #[cfg(windows)] use std::os::windows::io::AsRawHandle; use std::path::{Path, PathBuf}; -use std::str::FromStr; use std::sync::mpsc; use std::thread; -use thiserror::Error; +use std::time::SystemTime; +use uucore::diagnostics::OptionValue; use uucore::display::{Quotable, print_verbatim}; use uucore::error::{FromIo, UError, UResult, USimpleError, set_exit_code}; +use uucore::fs::SYMLINK_FOLLOW_LIMIT; use uucore::fsext::{MetadataTimeField, metadata_get_time}; use uucore::line_ending::LineEnding; #[cfg(all(unix, not(target_os = "redox")))] @@ -84,6 +85,7 @@ struct TraversalOptions { count_links: bool, verbose: bool, excludes: Vec, + time: Option, } struct StatPrinter { @@ -127,6 +129,7 @@ struct Stat { inodes: u64, inode: Option, metadata: Metadata, + latest_time: Option, } impl Stat { @@ -134,7 +137,7 @@ impl Stat { path: &Path, dir_entry: Option<&DirEntry>, options: &TraversalOptions, - ) -> std::io::Result { + ) -> io::Result { // Determine whether to dereference (follow) the symbolic link let should_dereference = match &options.dereference { Deref::All => true, @@ -155,6 +158,9 @@ impl Stat { let file_info = get_file_info(path, &metadata); let blocks = get_blocks(path, &metadata); + let latest_time = options + .time + .and_then(|time| metadata_get_time(&metadata, time)); Ok(Self { path: path.to_path_buf(), @@ -163,12 +169,17 @@ impl Stat { inodes: 1, inode: file_info, metadata, + latest_time, }) } /// Create a Stat using safe traversal methods with `DirFd` for the root directory #[cfg(all(unix, not(target_os = "redox")))] - fn new_from_dirfd(dir_fd: &DirFd, full_path: &Path) -> std::io::Result { + fn new_from_dirfd( + dir_fd: &DirFd, + full_path: &Path, + time: Option, + ) -> io::Result { // Get metadata for the directory itself using fstat let safe_metadata = dir_fd.metadata()?; @@ -185,6 +196,7 @@ impl Stat { // This is still needed for compatibility but should work since we're dealing with // the root path which should be accessible let std_metadata = fs::symlink_metadata(full_path)?; + let latest_time = time.and_then(|time| metadata_get_time(&std_metadata, time)); Ok(Self { path: full_path.to_path_buf(), @@ -197,6 +209,7 @@ impl Stat { inodes: 1, inode: file_info_option, metadata: std_metadata, + latest_time, }) } } @@ -210,7 +223,7 @@ fn get_blocks(_path: &Path, metadata: &Metadata) -> u64 { // fails with access denied unless `FILE_FLAG_BACKUP_SEMANTICS` is set), so // use that flag explicitly to be able to query directories as well as files. #[cfg(windows)] -fn open_for_query(path: &Path) -> std::io::Result { +fn open_for_query(path: &Path) -> io::Result { fs::OpenOptions::new() .read(true) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) @@ -288,10 +301,29 @@ fn get_file_info(path: &Path, _metadata: &Metadata) -> Option { result } -fn read_block_size(s: Option<&str>) -> UResult { +fn read_block_size(s: Option<&str>, diag_args: Option<&[OsString]>) -> UResult { if let Some(s) = s { parse_size_u64(s) - .map_err(|e| USimpleError::new(1, format_error_message(&e, s, options::BLOCK_SIZE))) + .and_then(|bytes| { + // A block size of zero is rejected here rather than by the + // caller, so that it goes through the caret path like every + // other bad SIZE. + if bytes == 0 { + Err(ParseSizeError::ParseFailure(s.to_string())) + } else { + Ok(bytes) + } + }) + .map_err(|e| { + let message = format_error_message(&e, s, options::BLOCK_SIZE); + e.size_value_error( + diag_args, + &OptionValue::new(s, 'B', options::BLOCK_SIZE), + 0, + &message, + USimpleError::new(1, message.clone()), + ) + }) } else if let Some(bytes) = parse_block_size::block_size_from_env(&["DU_BLOCK_SIZE", "BLOCK_SIZE", "BLOCKSIZE"]).found() { @@ -301,6 +333,19 @@ fn read_block_size(s: Option<&str>) -> UResult { } } +#[cfg(all(unix, not(target_os = "redox")))] +fn time_from_raw_stat( + entry_stat: &uucore::safe_traversal::Metadata, + time: MetadataTimeField, +) -> Option { + match time { + MetadataTimeField::Modification => entry_stat.modified(), + MetadataTimeField::Access => entry_stat.accessed(), + MetadataTimeField::Change => entry_stat.changed(), + MetadataTimeField::Birth => None, + } +} + #[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 @@ -308,86 +353,36 @@ fn safe_du( path: &Path, options: &TraversalOptions, depth: usize, - seen_inodes: &mut HashSet, + seen_inodes: &mut FxHashSet, print_tx: &mpsc::Sender>, parent_fd: Option<&DirFd>, - initial_stat: Option>, + initial_stat: Option>, ) -> Result>>> { - // Get initial stat for this path - use DirFd if available to avoid path length issues - let mut my_stat = if let Some(parent_fd) = parent_fd { - // We have a parent fd, this is a subdirectory - use openat - let dir_name = path.file_name().unwrap_or(path.as_os_str()); - match parent_fd.metadata_at(dir_name, SymlinkBehavior::NoFollow) { - Ok(safe_metadata) => { - // Create Stat from safe metadata - let file_info = safe_metadata.file_info(); - let file_info_option = Some(FileInfo { - file_id: file_info.inode() as u128, - dev_id: file_info.device(), - }); - let blocks = safe_metadata.blocks(); - - // For compatibility, still try to get std::fs::Metadata - // but fallback to a minimal approach if it fails - let std_metadata = fs::symlink_metadata(path).unwrap_or_else(|_| { - // If we can't get std metadata, create a minimal fake one - // This should rarely happen but provides a fallback - fs::symlink_metadata("/").expect("root should be accessible") - }); - - Stat { - path: path.to_path_buf(), - size: if safe_metadata.is_dir() { - 0 - } else { - safe_metadata.len() - }, - blocks, - inodes: 1, - inode: file_info_option, - metadata: std_metadata, - } - } - Err(e) => { - let error = e.map_err_context( - || translate!("du-error-cannot-access", "path" => path.quote()), - ); - if let Err(send_error) = print_tx.send(Err(error)) { - return Err(Box::new(send_error)); - } - return Err(Box::new(mpsc::SendError(Err(USimpleError::new( - 0, - "Error already handled", - ))))); + // The caller provides an already-computed stat for this entry, which lets us + // avoid re-stating it here. For subdirectories this saves both a redundant + // `fstatat` and an expensive full-path `lstat` per directory (the dominant cost + // when traversing deep trees). The root directory may instead carry an error, + // in which case we fall back to opening it directly with a DirFd. + let initial_stat = initial_stat.unwrap_or_else(|| Stat::new(path, None, options)); + let mut my_stat = match initial_stat { + Ok(mut s) => { + // For subdirectories the `metadata` field is a cheap placeholder (the + // parent directory's metadata). It is only consulted by `--time`, so we + // only pay for a real stat of this entry when time reporting is asked for. + if options.time.is_some() + && parent_fd.is_some() + && let Ok(md) = fs::symlink_metadata(&s.path) + { + s.metadata = md; } + s } - } else { - // This is the initial directory - try regular Stat::new first, then fallback to DirFd - let initial_stat = match initial_stat { - Some(s) => s, - None => Stat::new(path, None, options), - }; - - match initial_stat { - Ok(s) => s, - Err(_e) => { - // Try using our new DirFd method for the root directory - match DirFd::open(path, SymlinkBehavior::Follow) { - Ok(dir_fd) => match Stat::new_from_dirfd(&dir_fd, path) { - Ok(s) => s, - Err(e) => { - let error = e.map_err_context( - || translate!("du-error-cannot-access", "path" => path.quote()), - ); - if let Err(send_error) = print_tx.send(Err(error)) { - return Err(Box::new(send_error)); - } - return Err(Box::new(mpsc::SendError(Err(USimpleError::new( - 0, - "Error already handled", - ))))); - } - }, + Err(_e) => { + // Only the root path can reach this branch (subdirectories always pass a + // valid stat). Try using our DirFd method for the root directory. + match DirFd::open(path, SymlinkBehavior::Follow) { + Ok(dir_fd) => match Stat::new_from_dirfd(&dir_fd, path, options.time) { + Ok(s) => s, Err(e) => { let error = e.map_err_context( || translate!("du-error-cannot-access", "path" => path.quote()), @@ -400,6 +395,18 @@ fn safe_du( "Error already handled", ))))); } + }, + Err(e) => { + let error = e.map_err_context( + || translate!("du-error-cannot-access", "path" => path.quote()), + ); + if let Err(send_error) = print_tx.send(Err(error)) { + return Err(Box::new(send_error)); + } + return Err(Box::new(mpsc::SendError(Err(USimpleError::new( + 0, + "Error already handled", + ))))); } } } @@ -478,6 +485,11 @@ fn safe_du( dev_id: entry_stat.st_dev as u64, }); + let safe_metadata = uucore::safe_traversal::Metadata::from_stat(entry_stat); + let latest_time = options + .time + .and_then(|time| time_from_raw_stat(&safe_metadata, time)); + // For safe traversal, we need to handle stats differently // We can't use std::fs::Metadata since that requires the full path let this_stat = if is_dir { @@ -492,6 +504,7 @@ fn safe_du( // We need a fake metadata - create one from symlink_metadata of parent // This is a workaround since we can't get real metadata without the full path metadata: my_stat.metadata.clone(), + latest_time, } } else { // For files @@ -504,6 +517,7 @@ fn safe_du( inodes: 1, inode: file_info, metadata: my_stat.metadata.clone(), + latest_time, } }; @@ -523,37 +537,44 @@ fn safe_du( } // Handle inodes - if let Some(inode) = this_stat.inode { - if seen_inodes.contains(&inode) && !options.count_links { - continue; - } - seen_inodes.insert(inode); + if let Some(inode) = this_stat.inode + && !seen_inodes.insert(inode) + && !options.count_links + { + continue; } // Process directories recursively if is_dir { - if options.one_file_system { - if let (Some(this_inode), Some(my_inode)) = (this_stat.inode, my_stat.inode) { - if this_inode.dev_id != my_inode.dev_id { - continue; - } - } + if options.one_file_system + && let (Some(this_inode), Some(my_inode)) = (this_stat.inode, my_stat.inode) + && this_inode.dev_id != my_inode.dev_id + { + continue; } + // Reuse the stat we already computed for this entry instead of + // re-stating it inside the recursive call. + let sub_path = this_stat.path.clone(); let this_stat = safe_du( - &this_stat.path, + &sub_path, options, depth + 1, seen_inodes, print_tx, Some(&dir_fd), - None, + Some(Ok(this_stat)), )?; if !options.separate_dirs { my_stat.size += this_stat.size; my_stat.blocks += this_stat.blocks; my_stat.inodes += this_stat.inodes; + my_stat.latest_time = match (my_stat.latest_time, this_stat.latest_time) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, None) => a, + (None, b) => b, + } } print_tx.send(Ok(StatPrintInfo { stat: this_stat, @@ -563,6 +584,11 @@ fn safe_du( my_stat.size += this_stat.size; my_stat.blocks += this_stat.blocks; my_stat.inodes += 1; + my_stat.latest_time = match (my_stat.latest_time, this_stat.latest_time) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, None) => a, + (None, b) => b, + }; if options.all { print_tx.send(Ok(StatPrintInfo { stat: this_stat, @@ -584,15 +610,12 @@ fn du_regular( mut my_stat: Stat, options: &TraversalOptions, depth: usize, - seen_inodes: &mut HashSet, + seen_inodes: &mut FxHashSet, print_tx: &mpsc::Sender>, - ancestors: Option<&mut HashSet>, + ancestors: Option<&mut FxHashSet>, symlink_depth: Option, ) -> Result>>> { - // Maximum symlink depth to prevent infinite loops - const MAX_SYMLINK_DEPTH: usize = 40; - - let mut default_ancestors = HashSet::default(); + let mut default_ancestors = FxHashSet::default(); let ancestors = ancestors.unwrap_or(&mut default_ancestors); let symlink_depth = symlink_depth.unwrap_or(0); @@ -634,9 +657,9 @@ fn du_regular( current_symlink_depth += 1; // Check symlink depth limit - if current_symlink_depth > MAX_SYMLINK_DEPTH { - print_tx.send(Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, + if current_symlink_depth > SYMLINK_FOLLOW_LIMIT { + print_tx.send(Err(io::Error::new( + io::ErrorKind::InvalidData, "Too many levels of symbolic links", ).map_err_context( || translate!("du-error-cannot-access", "path" => entry_path.quote()), @@ -651,13 +674,11 @@ fn du_regular( if is_symlink && options.dereference == Deref::All && this_stat.metadata.is_dir() + && let Some(inode) = this_stat.inode + && ancestors.contains(&inode) { - if let Some(inode) = this_stat.inode { - if ancestors.contains(&inode) { - // This symlink points to an ancestor directory - skip to avoid cycle - continue 'file_loop; - } - } + // This symlink points to an ancestor directory - skip to avoid cycle + continue 'file_loop; } // We have an exclude list @@ -680,25 +701,20 @@ 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 { - // Skip further processing for this inode - continue; - } - // Mark this inode as seen - seen_inodes.insert(inode); + if let Some(inode) = this_stat.inode + && !seen_inodes.insert(inode) + && !options.count_links + { + continue; } if this_stat.metadata.is_dir() { - if options.one_file_system { - if let (Some(this_inode), Some(my_inode)) = + if options.one_file_system + && let (Some(this_inode), Some(my_inode)) = (this_stat.inode, my_stat.inode) - { - if this_inode.dev_id != my_inode.dev_id { - continue; - } - } + && this_inode.dev_id != my_inode.dev_id + { + continue; } let this_stat = du_regular( @@ -715,6 +731,12 @@ fn du_regular( my_stat.size += this_stat.size; my_stat.blocks += this_stat.blocks; my_stat.inodes += this_stat.inodes; + my_stat.latest_time = + match (my_stat.latest_time, this_stat.latest_time) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, None) => a, + (None, b) => b, + }; } print_tx.send(Ok(StatPrintInfo { stat: this_stat, @@ -724,6 +746,12 @@ fn du_regular( my_stat.size += this_stat.size; my_stat.blocks += this_stat.blocks; my_stat.inodes += 1; + my_stat.latest_time = + match (my_stat.latest_time, this_stat.latest_time) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, None) => a, + (None, b) => b, + }; if options.all { print_tx.send(Ok(StatPrintInfo { stat: this_stat, @@ -752,7 +780,7 @@ fn du_regular( Ok(my_stat) } -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum DuError { #[error("{}", translate!("du-error-invalid-max-depth", "depth" => _0.quote()))] InvalidMaxDepthArg(String), @@ -767,7 +795,12 @@ enum DuError { InvalidGlob(PatternError), } -impl UError for DuError {} +impl UError for DuError { + fn usage(&self) -> bool { + // GNU points at --help for a bad --max-depth argument. + matches!(self, Self::InvalidMaxDepthArg(_)) + } +} /// Read a file and return each line in a vector of String fn file_as_vec(filename: impl AsRef) -> UResult> { @@ -798,7 +831,7 @@ fn build_exclude_patterns(matches: &ArgMatches) -> UResult> { if matches.get_flag(options::VERBOSE) { println!( "{}", - translate!("du-verbose-adding-to-exclude-list", "pattern" => f.clone()) + translate!("du-verbose-adding-to-exclude-list", "pattern" => f) ); } let glob = parse_glob::from_str(&f).map_err(DuError::InvalidGlob)?; @@ -827,32 +860,27 @@ impl StatPrinter { fn print_stats(&self, rx: &mpsc::Receiver>) -> UResult<()> { let mut grand_total = 0; - loop { - let received = rx.recv(); - + while let Ok(received) = rx.recv() { match received { - Ok(message) => match message { - Ok(stat_info) => { - let size = self.choose_size(&stat_info.stat); + Ok(stat_info) => { + let size = self.choose_size(&stat_info.stat); - if stat_info.depth == 0 { - grand_total += size; - } + if stat_info.depth == 0 { + grand_total += size; + } - if !self - .threshold - .is_some_and(|threshold| threshold.should_exclude(size)) - && self - .max_depth - .is_none_or(|max_depth| stat_info.depth <= max_depth) - && (!self.summarize || stat_info.depth == 0) - { - self.print_stat(&stat_info.stat, size)?; - } + if !self + .threshold + .is_some_and(|threshold| threshold.should_exclude(size)) + && self + .max_depth + .is_none_or(|max_depth| stat_info.depth <= max_depth) + && (!self.summarize || stat_info.depth == 0) + { + self.print_stat(&stat_info.stat, size)?; } - Err(e) => show!(e), - }, - Err(_) => break, + } + Err(e) => show!(e), } } @@ -893,8 +921,8 @@ impl StatPrinter { fn print_stat(&self, stat: &Stat, size: u64) -> UResult<()> { write!(stdout(), "{}\t", self.convert_size(size))?; - if let Some(md_time) = &self.time { - if let Some(time) = metadata_get_time(&stat.metadata, *md_time) { + if self.time.is_some() { + if let Some(time) = stat.latest_time { format_system_time( &mut stdout(), time, @@ -915,15 +943,15 @@ impl StatPrinter { } /// Read file paths from the specified file, separated by null characters -fn read_files_from(file_name: &OsStr) -> Result, std::io::Error> { +fn read_files_from(file_name: &OsStr) -> io::Result> { let reader: Box = if file_name == "-" { // Read from standard input - Box::new(BufReader::new(std::io::stdin())) + Box::new(BufReader::new(io::stdin())) } else { // First, check if the file_name is a directory let path = PathBuf::from(file_name); if path.is_dir() { - return Err(std::io::Error::other( + return Err(io::Error::other( translate!("du-error-read-error-is-directory", "file" => file_name.maybe_quote()), )); } @@ -931,8 +959,8 @@ fn read_files_from(file_name: &OsStr) -> Result, std::io::Error> { // Attempt to open the file and handle the error if it does not exist match File::open(file_name) { Ok(file) => Box::new(BufReader::new(file)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Err(std::io::Error::other( + Err(e) if e.kind() == io::ErrorKind::NotFound => { + return Err(io::Error::other( translate!("du-error-cannot-open-for-reading", "file" => file_name.quote()), )); } @@ -960,7 +988,7 @@ fn read_files_from(file_name: &OsStr) -> Result, std::io::Error> { // relies on inode-based deduplication during traversal (which is // disabled by --count-links). Deduplicating by name would, e.g., // collapse repeated missing files into a single error. - paths.push(PathBuf::from(&*uucore::os_str_from_bytes(&path).unwrap())); + paths.push(PathBuf::from(uucore::os_str_from_bytes(&path).unwrap())); } } @@ -977,18 +1005,15 @@ fn get_size_format_flag_arg_index_if_present(matches: &ArgMatches, arg: &str) -> } } -fn parse_block_size_arg_or_default_fallback(matches: &ArgMatches) -> UResult { - let block_size_str = matches.get_one::(options::BLOCK_SIZE); - let block_size = read_block_size(block_size_str.map(AsRef::as_ref))?; - if block_size == 0 { - return Err(std::io::Error::other(translate!("du-error-invalid-block-size-argument", "option" => options::BLOCK_SIZE, "value" => block_size_str.map_or("???BUG", |v| v).quote())) - .into()); - } - Ok(SizeFormat::BlockSize(block_size)) -} - -fn parse_size_format(matches: &ArgMatches) -> UResult { - let block_size_value_or_default_fallback = parse_block_size_arg_or_default_fallback(matches)?; +fn parse_size_format(matches: &ArgMatches, diag_args: Option<&[OsString]>) -> UResult { + // `read_block_size` falls back to the environment and rejects a zero block + // size itself, so that the caret can point at the offending value. + let block_size_value_or_default_fallback = SizeFormat::BlockSize(read_block_size( + matches + .get_one::(options::BLOCK_SIZE) + .map(AsRef::as_ref), + diag_args, + )?); let candidates = [ ( SizeFormat::BlockSize(1), @@ -1027,7 +1052,13 @@ fn parse_size_format(matches: &ArgMatches) -> UResult { #[uucore::main] #[allow(clippy::cognitive_complexity)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + // The arguments are kept for the caret in SIZE diagnostics, which echoes + // the command line. + let (matches, diag_args) = uucore::clap_localization::handle_clap_result_with_diagnostics( + uu_app(), + args.collect(), + 1, + )?; let summarize = matches.get_flag(options::SUMMARIZE); @@ -1042,15 +1073,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let files = if let Some(file_from) = matches.get_one::(options::FILES0_FROM) { if file_from == "-" && matches.get_one::(options::FILE).is_some() { - return Err(std::io::Error::other( - translate!("du-error-extra-operand-with-files0-from", + return Err( + io::Error::other(translate!("du-error-extra-operand-with-files0-from", "file" => matches .get_one::(options::FILE) .unwrap() .quote() - ), - ) - .into()); + )) + .into(), + ); } read_files_from(file_from)? @@ -1069,7 +1100,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .map_or(MetadataTimeField::Modification, |s| s.as_str().into()) }); - let size_format = parse_size_format(&matches)?; + let size_format = parse_size_format(&matches, diag_args.as_deref())?; let traversal_options = TraversalOptions { all: matches.get_flag(options::ALL), @@ -1086,6 +1117,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { count_links, verbose: matches.get_flag(options::VERBOSE), excludes: build_exclude_patterns(&matches)?, + time, }; let time_format = if time.is_some() { @@ -1103,8 +1135,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { threshold: matches .get_one::(options::THRESHOLD) .map(|s| { - Threshold::from_str(s).map_err(|e| { - USimpleError::new(1, format_error_message(&e, s, options::THRESHOLD)) + parse_threshold(s).map_err(|(e, size_at)| { + let message = format_error_message(&e, s, options::THRESHOLD); + e.size_value_error( + diag_args.as_deref(), + &OptionValue::new(s, 't', options::THRESHOLD), + size_at, + &message, + USimpleError::new(1, message.clone()), + ) }) }) .transpose()?, @@ -1129,7 +1168,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let printing_thread = thread::spawn(move || stat_printer.print_stats(&rx)); // Check existence of path provided in argument - let mut seen_inodes: HashSet = HashSet::default(); + let mut seen_inodes: FxHashSet = FxHashSet::default(); 'loop_file: for path in files { // Skip if we don't want to ignore anything @@ -1157,13 +1196,12 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Pre-populate seen_inodes with the starting directory to detect cycles let stat = Stat::new(&path, None, &traversal_options); - if let Ok(stat) = stat.as_ref() { - if let Some(inode) = stat.inode { - if !traversal_options.count_links && seen_inodes.contains(&inode) { - continue 'loop_file; - } - seen_inodes.insert(inode); - } + if let Ok(stat) = stat.as_ref() + && let Some(inode) = stat.inode + && !traversal_options.count_links + && !seen_inodes.insert(inode) + { + continue 'loop_file; } if use_safe_traversal { @@ -1186,11 +1224,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } Err(e) => { // Check if this is our "already handled" error - if let mpsc::SendError(Err(simple_error)) = e.as_ref() { - if simple_error.code() == 0 { - // Error already handled, continue to next file - continue 'loop_file; - } + if let mpsc::SendError(Err(simple_error)) = e.as_ref() + && simple_error.code() == 0 + { + // Error already handled, continue to next file + continue 'loop_file; } return Err(USimpleError::new(1, e.to_string())); } @@ -1347,6 +1385,8 @@ pub fn uu_app() -> Command { .long("max-depth") .value_name("N") .help(translate!("du-help-max-depth")) + // Accept a negative depth so it is rejected by us, not by clap. + .allow_hyphen_values(true) .overrides_with(options::MAX_DEPTH), ) .arg( @@ -1530,24 +1570,31 @@ enum Threshold { Upper(u64), } -impl FromStr for Threshold { - type Err = ParseSizeError; - - fn from_str(s: &str) -> Result { - let offset = usize::from(s.starts_with(&['-', '+'][..])); - - let size = parse_size_u64(&s[offset..])?; - - if s.starts_with('-') { - // Threshold of '-0' excludes everything besides 0 sized entries - // GNU's du treats '-0' as an invalid argument - if size == 0 { - return Err(ParseSizeError::ParseFailure(s.to_string())); - } - Ok(Self::Upper(size)) - } else { - Ok(Self::Lower(size)) +/// Parse a `--threshold` value, and say where its caret should start. +/// +/// The parser only ever sees the part after the sign, so on failure the +/// offset of the size within `s` comes back with the error: the caret has to +/// count the sign back in. The one exception is the `-0` rejection below, +/// which is about the sign and the zero together and so starts at 0. +/// +/// The sign is stripped here rather than with +/// `uucore::parser::parse_signed_num::number_offset`, which head and tail use +/// for the same purpose: that one skips leading whitespace as well, and GNU du +/// rejects a padded `--threshold` such as `' -1K'`. +fn parse_threshold(s: &str) -> Result { + let offset = usize::from(s.starts_with(['-', '+'])); + + let size = parse_size_u64(&s[offset..]).map_err(|e| (e, offset))?; + + if s.starts_with('-') { + // Threshold of '-0' excludes everything besides 0 sized entries + // GNU's du treats '-0' as an invalid argument + if size == 0 { + return Err((ParseSizeError::ParseFailure(s.to_string()), 0)); } + Ok(Threshold::Upper(size)) + } else { + Ok(Threshold::Lower(size)) } } @@ -1585,7 +1632,7 @@ mod test_du { fn test_read_block_size() { let test_data = [Some("1024".to_string()), Some("K".to_string()), None]; for it in &test_data { - assert!(matches!(read_block_size(it.as_deref()), Ok(1024))); + assert!(matches!(read_block_size(it.as_deref(), None), Ok(1024))); } } } diff --git a/src/uu/echo/Cargo.toml b/src/uu/echo/Cargo.toml index e23f9176214..d56a18df63e 100644 --- a/src/uu/echo/Cargo.toml +++ b/src/uu/echo/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/echo.rs" test = false @@ -21,8 +18,8 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["format"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["format"] } [[bin]] name = "echo" @@ -32,6 +29,9 @@ path = "src/main.rs" divan = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bench]] name = "echo_bench" harness = false diff --git a/src/uu/echo/locales/en-US.ftl b/src/uu/echo/locales/en-US.ftl index 04a442620c8..97ca47e1d26 100644 --- a/src/uu/echo/locales/en-US.ftl +++ b/src/uu/echo/locales/en-US.ftl @@ -4,18 +4,18 @@ echo-after-help = Echo the STRING(s) to standard output. If -e is in effect, the following sequences are recognized: - - \ backslash - - \a alert (BEL) - - \b backspace - - \c produce no further output - - \e escape - - \f form feed - - \n new line - - \r carriage return - - \t horizontal tab - - \v vertical tab - - \0NNN byte with octal value NNN (1 to 3 digits) - - \xHH byte with hexadecimal value HH (1 to 2 digits) + - \ backslash + - \a alert (BEL) + - \b backspace + - \c produce no further output + - \e escape + - \f form feed + - \n new line + - \r carriage return + - \t horizontal tab + - \v vertical tab + - \0NNN byte with octal value NNN (1 to 3 digits) + - \xHH byte with hexadecimal value HH (1 to 2 digits) echo-help-no-newline = do not output the trailing newline echo-help-enable-escapes = enable interpretation of backslash escapes diff --git a/src/uu/echo/locales/fr-FR.ftl b/src/uu/echo/locales/fr-FR.ftl index 457017faf5e..48de10eae1e 100644 --- a/src/uu/echo/locales/fr-FR.ftl +++ b/src/uu/echo/locales/fr-FR.ftl @@ -4,18 +4,18 @@ echo-after-help = Affiche la ou les CHAÎNE(s) sur la sortie standard. Si -e est activé, les séquences suivantes sont reconnues : - - \ barre oblique inverse - - \a alerte (BEL) - - \b retour arrière - - \c ne produit aucune sortie supplémentaire - - \e échappement - - \f saut de page - - \n nouvelle ligne - - \r retour chariot - - \t tabulation horizontale - - \v tabulation verticale - - \0NNN octet avec valeur octale NNN (1 à 3 chiffres) - - \xHH octet avec valeur hexadécimale HH (1 à 2 chiffres) + - \ barre oblique inverse + - \a alerte (BEL) + - \b retour arrière + - \c ne produit aucune sortie supplémentaire + - \e échappement + - \f saut de page + - \n nouvelle ligne + - \r retour chariot + - \t tabulation horizontale + - \v tabulation verticale + - \0NNN octet avec valeur octale NNN (1 à 3 chiffres) + - \xHH octet avec valeur hexadécimale HH (1 à 2 chiffres) echo-help-no-newline = ne pas afficher la nouvelle ligne finale echo-help-enable-escapes = activer l'interprétation des séquences d'échappement diff --git a/src/uu/echo/src/echo.rs b/src/uu/echo/src/echo.rs index 8a76155dae2..7f497d86702 100644 --- a/src/uu/echo/src/echo.rs +++ b/src/uu/echo/src/echo.rs @@ -9,7 +9,7 @@ use std::env; use std::ffi::{OsStr, OsString}; use std::io::{StdoutLock, Write, stdout}; use uucore::error::UResult; -use uucore::format::{FormatChar, OctalParsing, parse_escape_only}; +use uucore::format::{EscapeSet, FormatChar, OctalParsing, parse_escape_only}; use uucore::{crate_version, format_usage, os_str_as_bytes}; use uucore::translate; @@ -238,7 +238,11 @@ fn execute( } if options.escape { - for item in parse_escape_only(bytes, OctalParsing::ThreeDigits) { + for item in parse_escape_only( + bytes, + OctalParsing::ThreeDigits, + EscapeSet::WithoutUnicodeAndQuote, + ) { if item.write(&mut *stdout)?.is_break() { return Ok(()); } diff --git a/src/uu/env/Cargo.toml b/src/uu/env/Cargo.toml index 46aa1be7c99..08ac8585cda 100644 --- a/src/uu/env/Cargo.toml +++ b/src/uu/env/Cargo.toml @@ -11,22 +11,24 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/env.rs" doctest = false [dependencies] clap = { workspace = true } -rust-ini = { workspace = true } +fluent = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true, features = ["signals"] } -fluent = { workspace = true } [target.'cfg(unix)'.dependencies] -nix = { workspace = true, features = ["signal"] } +nix = { workspace = true, features = ["process", "signal"] } + +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true [[bin]] name = "env" diff --git a/src/uu/env/locales/en-US.ftl b/src/uu/env/locales/en-US.ftl index 4f1c17d4d6c..60f9261816e 100644 --- a/src/uu/env/locales/en-US.ftl +++ b/src/uu/env/locales/en-US.ftl @@ -6,7 +6,6 @@ env-after-help = A mere - implies -i. If no COMMAND, print the resulting environ env-help-ignore-environment = start with an empty environment env-help-chdir = change working directory to DIR env-help-null = end each output line with a 0 byte rather than a newline (only valid when printing the environment) -env-help-file = read and set variables from a ".env"-style configuration file (prior to any unset and/or set) env-help-unset = remove variable from the environment env-help-debug = print verbose information for each processing step env-help-split-string = process and split S into separate arguments; used to pass multiple arguments on shebang lines @@ -36,9 +35,16 @@ env-error-cannot-unset-invalid = cannot unset { $name }: Invalid argument env-error-must-specify-command-with-chdir = must specify command with --chdir (-C) env-error-cannot-change-directory = cannot change directory to { $directory }: { $error } env-error-argv0-not-supported = --argv0 is currently not supported on this platform -env-error-permission-denied = { $program }: Permission denied -env-error-unknown = unknown error: { $error } env-error-failed-set-signal-action = failed to set signal action for signal { $signal }: { $error } # Warning messages env-warning-no-name-specified = no name specified for value { $value } + +# Diagnostic labels: what the caret points at in a -S string +env-diag-label-variable-digit = a variable name cannot start with a digit +env-diag-label-missing-brace = this {"{"} is never closed +env-diag-help-quoting = -S quotes as the shell does: ' and " come in pairs, and \' escapes a quote +env-diag-help-backslash = a backslash escapes the character after it, so one cannot end the string +env-diag-help-backslash-c = \c ends the -S string, and has no meaning inside double quotes +env-diag-help-escape = -S understands \r, \n, \t, \f, \v, \_, \#, \$, \" and \c +env-diag-help-variable = only $NAME and ${"{"}NAME{"}"} are expanded; the other shell forms are not diff --git a/src/uu/env/locales/fr-FR.ftl b/src/uu/env/locales/fr-FR.ftl index 326f97fef48..997456baca3 100644 --- a/src/uu/env/locales/fr-FR.ftl +++ b/src/uu/env/locales/fr-FR.ftl @@ -6,7 +6,6 @@ env-after-help = Un simple - implique -i. Si aucune COMMANDE, afficher l'environ env-help-ignore-environment = commencer avec un environnement vide env-help-chdir = changer le répertoire de travail vers RÉP env-help-null = terminer chaque ligne de sortie avec un octet 0 plutôt qu'un retour à la ligne (valide uniquement lors de l'affichage de l'environnement) -env-help-file = lire et définir les variables à partir d'un fichier de configuration de style ".env" (avant toute suppression et/ou définition) env-help-unset = supprimer la variable de l'environnement env-help-debug = afficher des informations détaillées pour chaque étape de traitement env-help-split-string = traiter et diviser S en arguments séparés ; utilisé pour passer plusieurs arguments sur les lignes shebang @@ -36,9 +35,16 @@ env-error-cannot-unset-invalid = impossible de supprimer { $name } : Argument in env-error-must-specify-command-with-chdir = doit spécifier une commande avec --chdir (-C) env-error-cannot-change-directory = impossible de changer de répertoire vers { $directory } : { $error } env-error-argv0-not-supported = --argv0 n'est actuellement pas pris en charge sur cette plateforme -env-error-permission-denied = { $program } : Permission refusée -env-error-unknown = erreur inconnue : { $error } env-error-failed-set-signal-action = échec de la définition de l'action du signal pour le signal { $signal } : { $error } # Messages d'avertissement env-warning-no-name-specified = aucun nom spécifié pour la valeur { $value } + +# Étiquettes de diagnostic : ce que le caret désigne dans une chaîne -S +env-diag-label-variable-digit = un nom de variable ne peut pas commencer par un chiffre +env-diag-label-missing-brace = cette {"{"} n'est jamais fermée +env-diag-help-quoting = -S cite comme le shell : ' et " vont par paires, et \' échappe une apostrophe +env-diag-help-backslash = une barre oblique inverse échappe le caractère qui suit, elle ne peut donc pas terminer la chaîne +env-diag-help-backslash-c = \c termine la chaîne -S et n'a aucun sens entre guillemets +env-diag-help-escape = -S comprend \r, \n, \t, \f, \v, \_, \#, \$, \" et \c +env-diag-help-variable = seuls $NOM et ${"{"}NOM{"}"} sont développés ; les autres formes du shell ne le sont pas diff --git a/src/uu/env/src/diagnostics.rs b/src/uu/env/src/diagnostics.rs new file mode 100644 index 00000000000..a94a079ae2a --- /dev/null +++ b/src/uu/env/src/diagnostics.rs @@ -0,0 +1,150 @@ +// 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. + +//! Maps an [`EnvError`] onto the part of the `-S` string it came from, so that +//! [`uucore::diagnostics`] can render it with a caret. +//! +//! Every error the splitter raises already knows the offset it stopped at — +//! the plain message reports it as `at position 12` — so the caret has only to +//! be placed there. + +use std::ffi::OsString; +use std::ops::Range; + +use uucore::diagnostics::{self, Snapshot}; +use uucore::translate; + +use crate::EnvError; + +/// Render `err`, raised while splitting `payload`, against `args`. +/// +/// # Arguments +/// +/// * `args` - The whole argument list, program name included. +/// * `index` - Position of the argument carrying the `-S` string. +/// * `payload` - The `-S` string as typed. It is the tail of the argument at +/// `index`, whether it was written `-S 'a b'`, `-S'a b'` or +/// `--split-string='a b'`. +/// * `message` - The error message, already localized. +/// +/// # Returns +/// +/// `false` when the error carries no position to point at, in which case the +/// caller should fall back to the plain one-line message. +pub fn render( + args: &[OsString], + index: usize, + payload: &str, + message: &str, + err: &EnvError, +) -> bool { + let Some((span, label, help)) = describe(payload, err) else { + return false; + }; + Snapshot::with_program(args).render_inside_at( + index, + payload, + span, + message, + label.map(|key| translate!(key)).as_deref(), + Some(&translate!(help)), + ) +} + +/// Where to point inside `payload` for `err`, the caret label, and the advice +/// that goes under it. +/// +/// Labelled only where a label would add to the message, per the convention in +/// [`uucore::diagnostics`]. +fn describe( + payload: &str, + err: &EnvError, +) -> Option<(Range, Option<&'static str>, &'static str)> { + match err { + // The string ran out while a quote was still open. The parser stopped + // at the end, which is exactly where the missing quote belongs. + EnvError::EnvMissingClosingQuote(pos, _) => { + let at = byte_offset(payload, *pos); + Some((at..at, None, "env-diag-help-quoting")) + } + EnvError::EnvInvalidBackslashAtEndOfStringInMinusS(pos, _) => { + let at = byte_offset(payload, *pos); + Some((at..at, None, "env-diag-help-backslash")) + } + EnvError::EnvBackslashCNotAllowedInDoubleQuotes(pos) => Some(( + span_back_to(payload, *pos, '\\'), + None, + "env-diag-help-backslash-c", + )), + // The offset is the character after the backslash; the sequence that + // was rejected is the two of them together. + EnvError::EnvInvalidSequenceBackslashXInMinusS(pos, _) => Some(( + span_back_to(payload, *pos, '\\'), + None, + "env-diag-help-escape", + )), + // Raised anywhere inside a `$…` reference, so the caret covers the + // reference from its `$` rather than the one character that stopped + // the parse. + EnvError::EnvParsingOfVariableUnexpectedNumber(pos, _) => Some(( + span_back_to(payload, *pos, '$'), + Some("env-diag-label-variable-digit"), + "env-diag-help-variable", + )), + EnvError::EnvParsingOfVariableMissingClosingBrace(pos) => Some(( + span_back_to(payload, *pos, '$'), + Some("env-diag-label-missing-brace"), + "env-diag-help-variable", + )), + EnvError::EnvParsingOfMissingVariable(pos) + | EnvError::EnvParsingOfVariableOnlyBracedName(pos) => Some(( + span_back_to(payload, *pos, '$'), + None, + "env-diag-help-variable", + )), + // Control-flow signals and internal errors: nothing a reader typed. + EnvError::EnvReachedEnd + | EnvError::EnvContinueWithDelimiter + | EnvError::EnvInternalError(_, _) => None, + } +} + +/// `pos`, as a byte offset into `payload`. +/// +/// The splitter counts in `NativeCharInt`s, which are bytes on unix but UTF-16 +/// units on Windows; a caret needs the offset in the UTF-8 bytes the report is +/// drawn from. Anything past the end of `payload` stays past it, so that +/// [`diagnostics::char_span`] still reads it as "nothing left to point at". +fn byte_offset(payload: &str, pos: usize) -> usize { + #[cfg(not(windows))] + { + let _ = payload; + pos + } + #[cfg(windows)] + { + let mut units = 0; + for (offset, c) in payload.char_indices() { + if units >= pos { + return offset; + } + units += c.len_utf16(); + } + payload.len() + } +} + +/// The character the parser stopped at, extended back to the nearest `opener` +/// in front of it — the `\` of an escape, or the `$` of a variable reference. +/// +/// Blaming the whole of what was written reads better than a caret on the one +/// character the parse gave up on, which on its own says little. +fn span_back_to(payload: &str, pos: usize, opener: char) -> Range { + let stopped = diagnostics::char_span(payload, byte_offset(payload, pos)); + let start = payload[..stopped.start] + .rfind(opener) + .unwrap_or(stopped.start); + start..stopped.end +} diff --git a/src/uu/env/src/env.rs b/src/uu/env/src/env.rs index 59478eacf72..e486a7fce81 100644 --- a/src/uu/env/src/env.rs +++ b/src/uu/env/src/env.rs @@ -5,6 +5,7 @@ // spell-checker:ignore (ToDO) chdir progname subcommand subcommands unsets setenv putenv spawnp SIGSEGV SIGBUS sigaction Sigmask sigprocmask elidable sigset sigaddset sigemptyset +pub mod diagnostics; pub mod native_int_str; pub mod split_iterator; pub mod string_expander; @@ -13,36 +14,38 @@ pub mod variable_parser; use clap::builder::ValueParser; use clap::{Arg, ArgAction, Command}; -use ini::Ini; use native_int_str::{ Convert, NCvt, NativeIntStr, NativeIntString, NativeStr, from_native_int_representation, from_native_int_representation_owned, get_single_native_int_value, }; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] use nix::libc; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] use nix::sys::signal::{SigSet, SigmaskHow, Signal, sigprocmask}; #[cfg(unix)] use nix::unistd::execvp; use std::borrow::Cow; +#[cfg(all(unix, not(target_os = "fuchsia")))] +use std::collections::BTreeMap; #[cfg(unix)] -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::env; #[cfg(unix)] use std::ffi::CString; use std::ffi::{OsStr, OsString}; +#[cfg(not(unix))] use std::io; use std::io::Write as _; use std::io::stderr; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] use std::mem::zeroed; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; use uucore::display::{Quotable, print_all_env_vars}; -use uucore::error::{ExitCode, UError, UResult, USimpleError, UUsageError}; +use uucore::error::{ExitCode, UError, UResult, USimpleError, UUsageError, strip_errno}; use uucore::line_ending::LineEnding; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] use uucore::signals::{ realtime_signal_bounds, signal_by_name_or_value, signal_name_by_value, signal_number_upper_bound, @@ -50,13 +53,11 @@ use uucore::signals::{ use uucore::translate; use uucore::{format_usage, show_warning}; -use thiserror::Error; - -#[derive(Debug, Error, PartialEq)] +#[derive(Debug, PartialEq, thiserror::Error)] pub enum EnvError { #[error("{}", translate!("env-error-missing-closing-quote", "position" => .0, "quote" => .1))] EnvMissingClosingQuote(usize, char), - #[error("{}", translate!("env-error-invalid-backslash-at-end", "position" => .0, "context" => .1.clone()))] + #[error("{}", translate!("env-error-invalid-backslash-at-end", "position" => .0, "context" => .1))] EnvInvalidBackslashAtEndOfStringInMinusS(usize, String), #[error("{}", translate!("env-error-backslash-c-not-allowed", "position" => .0))] EnvBackslashCNotAllowedInDoubleQuotes(usize), @@ -68,7 +69,7 @@ pub enum EnvError { EnvParsingOfMissingVariable(usize), #[error("{}", translate!("env-error-only-braced-variable", "position" => .0))] EnvParsingOfVariableOnlyBracedName(usize), - #[error("{}", translate!("env-error-unexpected-number", "position" => .0, "char" => .1.clone()))] + #[error("{}", translate!("env-error-unexpected-number", "position" => .0, "char" => .1))] EnvParsingOfVariableUnexpectedNumber(usize, String), #[error("")] EnvReachedEnd, @@ -88,7 +89,6 @@ mod options { pub const IGNORE_ENVIRONMENT: &str = "ignore-environment"; pub const CHDIR: &str = "chdir"; pub const NULL: &str = "null"; - pub const FILE: &str = "file"; pub const UNSET: &str = "unset"; pub const DEBUG: &str = "debug"; pub const SPLIT_STRING: &str = "split-string"; @@ -103,18 +103,17 @@ struct Options<'a> { ignore_env: bool, line_ending: LineEnding, running_directory: Option<&'a OsStr>, - files: Vec<&'a OsStr>, unsets: Vec<&'a OsStr>, sets: Vec<(Cow<'a, OsStr>, Cow<'a, OsStr>)>, program: Vec<&'a OsStr>, argv0: Option<&'a OsStr>, - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] ignore_signal: SignalRequest, - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] default_signal: SignalRequest, - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] block_signal: SignalRequest, - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] list_signal_handling: bool, } @@ -144,7 +143,7 @@ fn parse_program_opt<'a>(opts: &mut Options<'a>, opt: &'a OsStr) -> UResult<()> } } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] fn parse_signal_value(signal_name: &str) -> UResult { let signal_name_upcase = signal_name.to_uppercase(); let optional_signal_value = signal_by_name_or_value(&signal_name_upcase); @@ -164,7 +163,7 @@ fn parse_signal_value(signal_name: &str) -> UResult { } } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] fn parse_signal_opt(target: &mut SignalRequest, opt: &OsStr) -> UResult<()> { if opt.is_empty() { return Ok(()); @@ -189,14 +188,14 @@ fn parse_signal_opt(target: &mut SignalRequest, opt: &OsStr) -> UResult<()> { Ok(()) } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] #[derive(Default, Debug)] struct SignalRequest { apply_all: bool, signals: BTreeSet, } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] impl SignalRequest { fn is_empty(&self) -> bool { !self.apply_all && self.signals.is_empty() @@ -228,7 +227,7 @@ impl SignalRequest { } } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] #[derive(Copy, Clone)] enum SignalActionKind { Default, @@ -236,20 +235,20 @@ enum SignalActionKind { Block, } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] #[derive(Copy, Clone)] struct SignalActionRecord { kind: SignalActionKind, explicit: bool, } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] #[derive(Default)] struct SignalActionLog { records: BTreeMap, } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] impl SignalActionLog { fn record(&mut self, sig_value: usize, kind: SignalActionKind, explicit: bool) { self.records @@ -264,7 +263,7 @@ impl SignalActionLog { } } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] fn build_signal_request( matches: &clap::ArgMatches, option: &str, @@ -295,7 +294,7 @@ fn build_signal_request( Ok(request) } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] fn signal_is_valid(sig: usize) -> bool { if Signal::try_from(sig as i32).is_err() { // nix::sys::signal does not know about real-time signals, so check that @@ -310,34 +309,6 @@ fn signal_is_valid(sig: usize) -> bool { true } -fn load_config_file(opts: &mut Options) -> UResult<()> { - // NOTE: config files are parsed using an INI parser b/c it's available and compatible with ".env"-style files - // ... * but support for actual INI files, although working, is not intended, nor claimed - for &file in &opts.files { - let conf = if file == "-" { - let stdin = io::stdin(); - let mut stdin_locked = stdin.lock(); - Ini::read_from(&mut stdin_locked) - } else { - Ini::load_from_file(file) - }; - - let conf = - conf.map_err(|e| USimpleError::new(1, format!("{}: {e}", file.maybe_quote())))?; - - for (_, prop) in &conf { - // ignore all INI section lines (treat them as comments) - for (key, value) in prop { - unsafe { - env::set_var(key, value); - } - } - } - } - - Ok(()) -} - pub fn uu_app() -> Command { Command::new("env") .version(uucore::crate_version!()) @@ -371,16 +342,6 @@ pub fn uu_app() -> Command { .help(translate!("env-help-null")) .action(ArgAction::SetTrue), ) - .arg( - Arg::new(options::FILE) - .short('f') - .long(options::FILE) - .value_name("PATH") - .value_hint(clap::ValueHint::FilePath) - .value_parser(ValueParser::os_string()) - .action(ArgAction::Append) - .help(translate!("env-help-file")), - ) .arg( Arg::new(options::UNSET) .short('u') @@ -463,44 +424,72 @@ pub fn uu_app() -> Command { } pub fn parse_args_from_str(text: &NativeIntStr) -> UResult> { + parse_args_from_str_at(text, None) +} + +/// As [`parse_args_from_str`], pointing a caret into the argument the string +/// came from. +/// +/// # Arguments +/// +/// * `text` - The `-S` string to split. +/// * `located` - The whole argument list and the index of the argument +/// carrying `text`, or `None` when no diagnostic is wanted. The argument is +/// named rather than searched for, so a command that repeats the string +/// elsewhere still points at the right one. +fn parse_args_from_str_at( + text: &NativeIntStr, + located: Option<(&[OsString], usize)>, +) -> UResult> { split_iterator::split(text).map_err(|e| { - let var_error = |pos: usize| { - // Find the '$' that started this variable reference and format - // the error like GNU: "only ${VARNAME} expansion is supported, error at: $..." - let dollar = get_single_native_int_value('$'); - let dollar_pos = text[..pos] - .iter() - .rposition(|&c| Some(c) == dollar) - .unwrap_or(pos); - let rest = from_native_int_representation(Cow::Borrowed(&text[dollar_pos..])); - USimpleError::new( - 125, - translate!("env-error-only-braced-variable-at", "rest" => rest.to_string_lossy()), - ) - }; - match e { - EnvError::EnvBackslashCNotAllowedInDoubleQuotes(_) => { - USimpleError::new(125, e.to_string()) - } - EnvError::EnvInvalidBackslashAtEndOfStringInMinusS(_, _) => { - USimpleError::new(125, e.to_string()) - } - EnvError::EnvInvalidSequenceBackslashXInMinusS(_, _) => { - USimpleError::new(125, e.to_string()) - } - EnvError::EnvMissingClosingQuote(_, _) => USimpleError::new(125, e.to_string()), - EnvError::EnvParsingOfVariableMissingClosingBrace(pos) - | EnvError::EnvParsingOfMissingVariable(pos) - | EnvError::EnvParsingOfVariableOnlyBracedName(pos) - | EnvError::EnvParsingOfVariableUnexpectedNumber(pos, _) => var_error(pos), - _ => USimpleError::new( - 125, - translate!("env-error-generic", "error" => format!("{e:?}")), - ), - } + let error = to_error(text, &e); + // The `-S` string is echoed back as typed, so a string that is not + // text cannot be pointed into. + let reported = located.is_some_and(|(args, index)| { + from_native_int_representation(Cow::Borrowed(text)) + .to_str() + .is_some_and(|payload| { + diagnostics::render(args, index, payload, &error.to_string(), &e) + }) + }); + uucore::error::quiet_if_reported(reported, error) }) } +/// The user-facing error for a `-S` string that does not split. +fn to_error(text: &NativeIntStr, e: &EnvError) -> Box { + let var_error = |pos: usize| { + // Find the '$' that started this variable reference and format + // the error like GNU: "only ${VARNAME} expansion is supported, error at: $..." + let dollar = get_single_native_int_value('$'); + let dollar_pos = text[..pos] + .iter() + .rposition(|&c| Some(c) == dollar) + .unwrap_or(pos); + let rest = from_native_int_representation(Cow::Borrowed(&text[dollar_pos..])); + USimpleError::new( + 125, + translate!("env-error-only-braced-variable-at", "rest" => rest.to_string_lossy()), + ) + }; + match e { + EnvError::EnvBackslashCNotAllowedInDoubleQuotes(_) + | EnvError::EnvInvalidBackslashAtEndOfStringInMinusS(_, _) + | EnvError::EnvInvalidSequenceBackslashXInMinusS(_, _) => { + USimpleError::new(125, e.to_string()) + } + EnvError::EnvMissingClosingQuote(_, _) => USimpleError::new(125, e.to_string()), + EnvError::EnvParsingOfVariableMissingClosingBrace(pos) + | EnvError::EnvParsingOfMissingVariable(pos) + | EnvError::EnvParsingOfVariableOnlyBracedName(pos) + | EnvError::EnvParsingOfVariableUnexpectedNumber(pos, _) => var_error(*pos), + _ => USimpleError::new( + 125, + translate!("env-error-generic", "error" => format!("{e:?}")), + ), + } +} + fn debug_print_args(args: &[OsString]) { let mut error = stderr().lock(); let _ = writeln!(error, "input args:"); @@ -516,6 +505,7 @@ fn check_and_handle_string_args( do_debug_print_args: Option<&Vec>, require_non_empty_payload: bool, strip_optional_leading_equals: bool, + located: Option<(&[OsString], usize)>, ) -> UResult { let native_arg = NCvt::convert(arg); if let Some(remaining_arg) = native_arg.strip_prefix(&*NCvt::convert(prefix_to_test)) { @@ -537,7 +527,7 @@ fn check_and_handle_string_args( remaining_arg }; - let arg_strings = parse_args_from_str(remaining_arg)?; + let arg_strings = parse_args_from_str_at(remaining_arg, located)?; all_args.extend( arg_strings .into_iter() @@ -560,11 +550,13 @@ struct EnvAppData { struct ParsedArguments { original_args: Vec, matches: clap::ArgMatches, - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] signal_apply_all: BTreeSet<&'static str>, } impl EnvAppData { + #[cold] + #[inline(never)] fn make_error_no_such_file_or_dir(&self, prog: &OsStr) -> Box { uucore::show_error!( "{}", @@ -584,13 +576,8 @@ impl EnvAppData { let mut process_flags = true; let mut expecting_arg = false; // Leave out split-string since it's a special case below - let flags_with_args = [ - options::ARGV0, - options::CHDIR, - options::FILE, - options::UNSET, - ]; - let short_flags_with_args = ['a', 'C', 'f', 'u']; + let flags_with_args = [options::ARGV0, options::CHDIR, options::UNSET]; + let short_flags_with_args = ['a', 'C', 'u']; let mut consumed_split_payload_arg: Option = None; for (n, arg) in original_args.iter().enumerate() { if consumed_split_payload_arg == Some(n) { @@ -610,6 +597,9 @@ impl EnvAppData { continue; } expecting_arg = false; + // Where the `-S` string sits, for the caret: the argument the + // parser is looking at right now. + let located = uucore::diagnostics::enabled().then_some((original_args.as_slice(), n)); match arg { b if check_and_handle_string_args( b, @@ -618,14 +608,33 @@ impl EnvAppData { None, true, true, + located, )? => { self.had_string_argument = true; } - b if check_and_handle_string_args(b, "-S", &mut all_args, None, true, false)? => { + b if check_and_handle_string_args( + b, + "-S", + &mut all_args, + None, + true, + false, + located, + )? => + { self.had_string_argument = true; } - b if check_and_handle_string_args(b, "-vS", &mut all_args, None, true, false)? => { + b if check_and_handle_string_args( + b, + "-vS", + &mut all_args, + None, + true, + false, + located, + )? => + { self.do_debug_printing = true; self.had_string_argument = true; } @@ -636,6 +645,7 @@ impl EnvAppData { Some(original_args), true, false, + located, )? => { self.do_debug_printing = true; @@ -657,7 +667,10 @@ impl EnvAppData { } let native_next_arg = NCvt::convert(next_arg); - let arg_strings = parse_args_from_str(native_next_arg.as_ref())?; + // The string is the argument after this one, detached. + let located = + uucore::diagnostics::enabled().then_some((original_args.as_slice(), n + 1)); + let arg_strings = parse_args_from_str_at(native_next_arg.as_ref(), located)?; all_args.extend( arg_strings .into_iter() @@ -741,7 +754,7 @@ impl EnvAppData { Ok(ParsedArguments { original_args, matches, - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] signal_apply_all, }) } @@ -750,7 +763,7 @@ impl EnvAppData { let ParsedArguments { original_args, matches, - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] signal_apply_all, } = self.parse_arguments(original_args)?; @@ -758,16 +771,14 @@ impl EnvAppData { self.do_input_debug_printing = self .do_input_debug_printing .or(Some(matches.get_count("debug") >= 2)); - if let Some(value) = self.do_input_debug_printing { - if value { - debug_print_args(&original_args); - self.do_input_debug_printing = Some(false); - } + if Some(true) == self.do_input_debug_printing { + debug_print_args(&original_args); + self.do_input_debug_printing = Some(false); } - let mut opts = make_options( + let opts = make_options( &matches, - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] &signal_apply_all, )?; @@ -776,14 +787,11 @@ impl EnvAppData { apply_removal_of_all_env_vars(&opts); - // load .env-style config file prior to those given on the command-line - load_config_file(&mut opts)?; - apply_unset_env_vars(&opts)?; apply_specified_env_vars(&opts); - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] { let mut signal_action_log = SignalActionLog::default(); apply_signal_action( @@ -898,31 +906,13 @@ impl EnvAppData { // Execute the program using execvp. this replaces the current // process. The execvp function takes care of appending a NULL // argument to the argument list so that we don't have to. - match execvp(&prog_cstring, &argv) { - Err(nix::errno::Errno::ENOENT) => Err(self.make_error_no_such_file_or_dir(&prog)), - Err(nix::errno::Errno::EACCES) => { - uucore::show_error!( - "{}", - translate!( - "env-error-permission-denied", - "program" => prog.quote() - ) - ); - Err(126.into()) - } - Err(_) => { - uucore::show_error!( - "{}", - translate!( - "env-error-unknown", - "error" => "execvp failed" - ) - ); + // unwrap_err since execvp should never return on success + match execvp(&prog_cstring, &argv).unwrap_err() { + nix::errno::Errno::ENOENT => Err(self.make_error_no_such_file_or_dir(&prog)), + e => { + uucore::show_error!("{}: {}", prog.quote(), strip_errno(&e.into())); Err(126.into()) } - Ok(_) => { - unreachable!("execvp should never return on success") - } } } @@ -934,25 +924,18 @@ impl EnvAppData { match cmd.status() { Ok(exit) if !exit.success() => Err(exit.code().unwrap_or(1).into()), - Err(ref err) => match err.kind() { - io::ErrorKind::NotFound | io::ErrorKind::InvalidInput => { - Err(self.make_error_no_such_file_or_dir(&prog)) - } - io::ErrorKind::PermissionDenied => { - uucore::show_error!( - "{}", - translate!("env-error-permission-denied", "program" => prog.quote()) - ); - Err(126.into()) - } - _ => { - uucore::show_error!( - "{}", - translate!("env-error-unknown", "error" => format!("{err:?}")) - ); - Err(126.into()) - } - }, + Err(e) + if matches!( + e.kind(), + io::ErrorKind::NotFound | io::ErrorKind::InvalidInput + ) => + { + Err(self.make_error_no_such_file_or_dir(&prog)) + } + Err(e) => { + uucore::show_error!("{}: {}", prog.quote(), strip_errno(&e)); + Err(126.into()) + } Ok(_) => Ok(()), } } @@ -973,50 +956,45 @@ fn apply_removal_of_all_env_vars(opts: &Options<'_>) { #[cfg_attr(not(unix), allow(clippy::elidable_lifetime_names))] fn make_options<'a>( matches: &'a clap::ArgMatches, - #[cfg(unix)] signal_apply_all: &BTreeSet<&'static str>, + #[cfg(all(unix, not(target_os = "fuchsia")))] signal_apply_all: &BTreeSet<&'static str>, ) -> UResult> { let ignore_env = matches.get_flag("ignore-environment"); let line_ending = LineEnding::from_zero_flag(matches.get_flag("null")); let running_directory = matches .get_one::("chdir") .map(OsString::as_os_str); - let files = match matches.get_many::("file") { - Some(v) => v.map(OsString::as_os_str).collect(), - None => Vec::with_capacity(0), - }; let unsets = match matches.get_many::("unset") { Some(v) => v.map(OsString::as_os_str).collect(), - None => Vec::with_capacity(0), + None => Vec::new(), }; let argv0 = matches .get_one::("argv0") .map(OsString::as_os_str); - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] let ignore_signal = build_signal_request(matches, options::IGNORE_SIGNAL, signal_apply_all)?; - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] let default_signal = build_signal_request(matches, options::DEFAULT_SIGNAL, signal_apply_all)?; - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] let block_signal = build_signal_request(matches, options::BLOCK_SIGNAL, signal_apply_all)?; - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] let list_signal_handling = matches.get_flag(options::LIST_SIGNAL_HANDLING); let mut opts = Options { ignore_env, line_ending, running_directory, - files, unsets, sets: vec![], program: vec![], argv0, - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] ignore_signal, - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] default_signal, - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] block_signal, - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] list_signal_handling, }; @@ -1124,7 +1102,7 @@ fn apply_specified_env_vars(opts: &Options<'_>) { } } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] fn apply_signal_action( request: &SignalRequest, log: &mut SignalActionLog, @@ -1156,13 +1134,13 @@ where }) } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] fn ignore_signal(sig: usize) -> UResult<()> { // SAFETY: This is safe because we write the handler for each signal only once, and therefore "the current handler is the default", as the documentation requires it. // nix::sys::signal::Signal does not cover real-time signals, so we need to call // libc::signal directly. let result = unsafe { - let res = libc::signal(sig as libc::c_int, libc::SIG_IGN); + let res = libc::signal(sig as core::ffi::c_int, libc::SIG_IGN); nix::errno::Errno::result(res) }; if let Err(err) = result { @@ -1174,12 +1152,12 @@ fn ignore_signal(sig: usize) -> UResult<()> { Ok(()) } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] fn reset_signal(sig: usize) -> UResult<()> { // nix::sys::signal::Signal does not cover real-time signals, so we need to call // libc::signal directly. let result = unsafe { - let res = libc::signal(sig as libc::c_int, libc::SIG_DFL); + let res = libc::signal(sig as core::ffi::c_int, libc::SIG_DFL); nix::errno::Errno::result(res) }; if let Err(err) = result { @@ -1191,7 +1169,7 @@ fn reset_signal(sig: usize) -> UResult<()> { Ok(()) } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] fn sigset_from_signal_value(sig: usize) -> UResult { // nix::sys::signal::Signal does not cover real time signals, so we need to build // sigset_t manually using libc. @@ -1208,9 +1186,9 @@ fn sigset_from_signal_value(sig: usize) -> UResult { )); } - if let Err(err) = - unsafe { nix::errno::Errno::result(libc::sigaddset(&raw mut sigset, sig as libc::c_int)) } - { + if let Err(err) = unsafe { + nix::errno::Errno::result(libc::sigaddset(&raw mut sigset, sig as core::ffi::c_int)) + } { return Err(USimpleError::new( 125, translate!( @@ -1224,7 +1202,7 @@ fn sigset_from_signal_value(sig: usize) -> UResult { Ok(unsafe { SigSet::from_sigset_t_unchecked(sigset) }) } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] fn block_signal(sig: usize) -> UResult<()> { let set = sigset_from_signal_value(sig)?; @@ -1241,7 +1219,7 @@ fn block_signal(sig: usize) -> UResult<()> { Ok(()) } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] fn list_signal_handling(log: &SignalActionLog) { for (&sig_value, record) in &log.records { if !record.explicit { diff --git a/src/uu/env/src/native_int_str.rs b/src/uu/env/src/native_int_str.rs index a52686b9dc1..2f289f3a874 100644 --- a/src/uu/env/src/native_int_str.rs +++ b/src/uu/env/src/native_int_str.rs @@ -13,15 +13,17 @@ // this conversion needs to be done only once in the beginning and at the end. use std::ffi::OsString; -#[cfg(not(target_os = "windows"))] +#[cfg(unix)] use std::os::unix::ffi::{OsStrExt, OsStringExt}; -#[cfg(target_os = "windows")] +#[cfg(target_os = "wasi")] +use std::os::wasi::ffi::{OsStrExt, OsStringExt}; +#[cfg(windows)] use std::os::windows::prelude::*; use std::{borrow::Cow, ffi::OsStr}; -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] use u8 as NativeIntCharU; -#[cfg(target_os = "windows")] +#[cfg(windows)] use u16 as NativeIntCharU; pub type NativeCharInt = NativeIntCharU; @@ -38,12 +40,12 @@ pub trait Convert { impl<'a> Convert<&'a str, Cow<'a, NativeIntStr>> for NCvt { fn convert(f: &'a str) -> Cow<'a, NativeIntStr> { - #[cfg(target_os = "windows")] + #[cfg(windows)] { Cow::Owned(f.encode_utf16().collect()) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { Cow::Borrowed(f.as_bytes()) } @@ -52,12 +54,12 @@ impl<'a> Convert<&'a str, Cow<'a, NativeIntStr>> for NCvt { impl<'a> Convert<&'a String, Cow<'a, NativeIntStr>> for NCvt { fn convert(f: &'a String) -> Cow<'a, NativeIntStr> { - #[cfg(target_os = "windows")] + #[cfg(windows)] { Cow::Owned(f.encode_utf16().collect()) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { Cow::Borrowed(f.as_bytes()) } @@ -66,12 +68,12 @@ impl<'a> Convert<&'a String, Cow<'a, NativeIntStr>> for NCvt { impl<'a> Convert> for NCvt { fn convert(f: String) -> Cow<'a, NativeIntStr> { - #[cfg(target_os = "windows")] + #[cfg(windows)] { Cow::Owned(f.encode_utf16().collect()) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { Cow::Owned(f.into_bytes()) } @@ -94,12 +96,12 @@ impl<'a> Convert<&'a OsString, Cow<'a, NativeIntStr>> for NCvt { impl<'a> Convert> for NCvt { fn convert(f: OsString) -> Cow<'a, NativeIntStr> { - #[cfg(target_os = "windows")] + #[cfg(windows)] { Cow::Owned(f.encode_wide().collect()) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { Cow::Owned(f.into_vec()) } @@ -133,12 +135,12 @@ impl<'a> Convert, Vec>> for NCvt { } pub fn to_native_int_representation(input: &OsStr) -> Cow<'_, NativeIntStr> { - #[cfg(target_os = "windows")] + #[cfg(windows)] { Cow::Owned(input.encode_wide().collect()) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { Cow::Borrowed(input.as_bytes()) } @@ -146,12 +148,12 @@ pub fn to_native_int_representation(input: &OsStr) -> Cow<'_, NativeIntStr> { #[allow(clippy::needless_pass_by_value)] // needed on windows pub fn from_native_int_representation(input: Cow<'_, NativeIntStr>) -> Cow<'_, OsStr> { - #[cfg(target_os = "windows")] + #[cfg(windows)] { Cow::Owned(OsString::from_wide(&input)) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { match input { Cow::Borrowed(borrow) => Cow::Borrowed(OsStr::from_bytes(borrow)), @@ -162,26 +164,26 @@ pub fn from_native_int_representation(input: Cow<'_, NativeIntStr>) -> Cow<'_, O #[allow(clippy::needless_pass_by_value)] // needed on windows pub fn from_native_int_representation_owned(input: NativeIntString) -> OsString { - #[cfg(target_os = "windows")] + #[cfg(windows)] { OsString::from_wide(&input) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { OsString::from_vec(input) } } pub fn get_single_native_int_value(c: char) -> Option { - #[cfg(target_os = "windows")] + #[cfg(windows)] { let mut buf = [0u16, 0]; let s = c.encode_utf16(&mut buf); if s.len() == 1 { Some(buf[0]) } else { None } } - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { let mut buf = [0u8, 0, 0, 0]; let s = c.encode_utf8(&mut buf); @@ -191,12 +193,12 @@ pub fn get_single_native_int_value(c: char) -> Option { pub fn get_char_from_native_int(ni: NativeCharInt) -> Option<(char, NativeCharInt)> { let c_opt; - #[cfg(target_os = "windows")] + #[cfg(windows)] { c_opt = char::decode_utf16([ni; 1]).next().unwrap().ok(); }; - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { c_opt = std::str::from_utf8(&[ni; 1]) .ok() @@ -225,10 +227,6 @@ impl<'a> NativeStr<'a> { self.native.clone() } - pub fn into_native(self) -> Cow<'a, NativeIntStr> { - self.native - } - pub fn contains(&self, x: char) -> Option { let n_c = get_single_native_int_value(x)?; Some(self.native.contains(&n_c)) diff --git a/src/uu/env/src/split_iterator.rs b/src/uu/env/src/split_iterator.rs index 692f4601630..ec2b1d9d283 100644 --- a/src/uu/env/src/split_iterator.rs +++ b/src/uu/env/src/split_iterator.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// + +// spell-checker:ignore (words) Tomasz Miąsko rntfv FFFD varname + // This file is based on work from Tomasz Miąsko who published it as "shell_words" crate, // licensed under the Apache License, Version 2.0 // or the MIT license , at your option. @@ -14,7 +16,6 @@ //! Apart from the grammar differences, there is a new feature integrated: $VARIABLE expansion. //! //! [GNU env] -// spell-checker:ignore (words) Tomasz Miąsko rntfv FFFD varname #![forbid(unsafe_code)] diff --git a/src/uu/env/src/string_expander.rs b/src/uu/env/src/string_expander.rs index 7774205612f..d3a5172f4fc 100644 --- a/src/uu/env/src/string_expander.rs +++ b/src/uu/env/src/string_expander.rs @@ -81,10 +81,6 @@ impl<'a> StringExpander<'a> { self.output.extend(&*native); } - pub fn put_native_string(&mut self, n_str: &NativeIntStr) { - self.output.extend(n_str); - } - pub fn take_collected_output(&mut self) -> Vec { mem::take(&mut self.output) } diff --git a/src/uu/env/src/string_parser.rs b/src/uu/env/src/string_parser.rs index a0d0ed7bc79..30b4870e2e8 100644 --- a/src/uu/env/src/string_parser.rs +++ b/src/uu/env/src/string_parser.rs @@ -2,8 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// + // spell-checker:ignore (words) FFFD + #![forbid(unsafe_code)] use std::{borrow::Cow, ffi::OsStr}; @@ -93,24 +94,23 @@ impl<'a> StringParser<'a> { fn get_chunk_with_length_at(&self, pointer: usize) -> Result<(Chunk<'a>, usize), Error> { let (_before, after) = self.input.split_at(pointer); - if after.is_empty() { + let Some(&after_0) = after.first() else { return Err(self.make_err(ErrorType::EndOfInput)); - } - - if let Some(c_ni) = get_char_from_native_int(after[0]) { - Ok((Chunk::ValidSingleIntChar(c_ni), 1)) - } else { - let mut i = 1; - while i < after.len() { - if let Some(_c) = get_char_from_native_int(after[i]) { - break; - } - i += 1; - } + }; - let chunk = &after[0..i]; - Ok((Chunk::InvalidEncoding(chunk), chunk.len())) + if let Some(c_ni) = get_char_from_native_int(after_0) { + return Ok((Chunk::ValidSingleIntChar(c_ni), 1)); } + let mut i = 1; + while after + .get(i) + .is_some_and(|&a| get_char_from_native_int(a).is_none()) + { + i += 1; + } + + let chunk = &after[0..i]; + Ok((Chunk::InvalidEncoding(chunk), chunk.len())) } pub fn peek_chunk(&self) -> Option> { diff --git a/src/uu/env/src/variable_parser.rs b/src/uu/env/src/variable_parser.rs index 032a801604c..025dc2491b9 100644 --- a/src/uu/env/src/variable_parser.rs +++ b/src/uu/env/src/variable_parser.rs @@ -18,13 +18,11 @@ impl<'a> VariableParser<'a, '_> { } fn check_variable_name_start(&self) -> Result<(), EnvError> { - if let Some(c) = self.get_current_char() { - if c.is_ascii_digit() { - return Err(EnvError::EnvParsingOfVariableUnexpectedNumber( - self.parser.get_peek_position(), - c.to_string(), - )); - } + if let Some(c) = self.get_current_char().filter(char::is_ascii_digit) { + return Err(EnvError::EnvParsingOfVariableUnexpectedNumber( + self.parser.get_peek_position(), + c.to_string(), + )); } Ok(()) } diff --git a/src/uu/expand/Cargo.toml b/src/uu/expand/Cargo.toml index f481ab0b626..9d639b12ed0 100644 --- a/src/uu/expand/Cargo.toml +++ b/src/uu/expand/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/expand.rs" doctest = false @@ -21,16 +18,20 @@ doctest = false [dependencies] clap = { workspace = true } fluent = { workspace = true } -rustix = { workspace = true } thiserror = { workspace = true } -unicode-width = { workspace = true } uucore = { workspace = true } +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))'.dependencies] +rustix = { workspace = true, features = ["fs"] } + [dev-dependencies] divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bin]] name = "expand" path = "src/main.rs" diff --git a/src/uu/expand/benches/expand_bench.rs b/src/uu/expand/benches/expand_bench.rs index ff9c24ef15a..1fbe86e61a4 100644 --- a/src/uu/expand/benches/expand_bench.rs +++ b/src/uu/expand/benches/expand_bench.rs @@ -5,23 +5,9 @@ use divan::{Bencher, black_box}; use std::fmt::Write; +use tempfile::TempDir; use uu_expand::uumain; -use uucore::benchmark::{create_test_file, run_util_function}; - -/// Helper function to run expand benchmark with generated data -fn bench_expand(bencher: Bencher, data: impl AsRef<[u8]>, args: &[&str]) { - let temp_dir = tempfile::tempdir().unwrap(); - let file_path = create_test_file(data.as_ref(), temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - - let mut all_args = vec![]; - all_args.extend_from_slice(args); - all_args.push(file_path_str); - - bencher.bench(|| { - black_box(run_util_function(uumain, &all_args)); - }); -} +use uucore::benchmark::{create_test_file, get_bench_args}; /// Benchmark expanding tabs on files with many short lines #[divan::bench(args = [100_000])] @@ -30,7 +16,12 @@ fn expand_many_lines(bencher: Bencher, num_lines: usize) { writeln!(&mut acc, "line{i}\tvalue{}\tdata{}", i * 2, i * 3).unwrap(); acc }); - bench_expand(bencher, data, &[]); + let temp_dir = TempDir::new().unwrap(); + let file_path = create_test_file(data.as_ref(), temp_dir.path()); + + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark expanding tabs with custom tab stops @@ -40,7 +31,12 @@ fn expand_custom_tabstops(bencher: Bencher, num_lines: usize) { writeln!(&mut acc, "a\tb\tc\td\te{i}").unwrap(); acc }); - bench_expand(bencher, data, &["--tabs=4,8,12"]); + let temp_dir = TempDir::new().unwrap(); + let file_path = create_test_file(data.as_ref(), temp_dir.path()); + + bencher + .with_inputs(|| get_bench_args(&[&"--tabs=4,8,12", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/expand/src/expand.rs b/src/uu/expand/src/expand.rs index 890c1958a08..d6683e39005 100644 --- a/src/uu/expand/src/expand.rs +++ b/src/uu/expand/src/expand.rs @@ -12,10 +12,9 @@ use std::io::{BufReader, BufWriter, Read, Write, stdin, stdout}; use std::num::IntErrorKind; use std::path::Path; use std::str::from_utf8; -use thiserror::Error; -use unicode_width::UnicodeWidthChar; +use uucore::char_width::char_width_at; use uucore::display::Quotable; -use uucore::error::{FromIo, UError, UResult, USimpleError, set_exit_code}; +use uucore::error::{FromIo, UError, UResult, set_exit_code}; use uucore::{format_usage, show, translate}; pub mod options { @@ -36,26 +35,13 @@ enum RemainingMode { Plus, } -/// Decide whether the character is either a space or a comma. -/// -/// # Examples -/// -/// ```rust,ignore -/// assert!(is_space_or_comma(' ')) -/// assert!(is_space_or_comma(',')) -/// assert!(!is_space_or_comma('a')) -/// ``` -fn is_space_or_comma(c: char) -> bool { - c == ' ' || c == ',' -} - /// Decide whether the character is either a digit or a comma. fn is_digit_or_comma(c: char) -> bool { c.is_ascii_digit() || c == ',' } /// Errors that can occur when parsing a `--tabs` argument. -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum ParseError { #[error("{}", translate!("expand-error-invalid-character", "char" => .0.quote()))] InvalidCharacter(String), @@ -76,16 +62,16 @@ impl UError for ParseError {} /// Parse a list of tabstops from a `--tabs` argument. /// /// This function returns both the vector of numbers appearing in the -/// comma- or space-separated list, and also an optional mode, specified +/// space-, tab-, or comma-separated list, and also an optional mode, specified /// by either a "/" or a "+" character appearing before the final number /// in the list. This mode defines the strategy to use for computing the /// number of spaces to use for columns beyond the end of the tab stop /// list specified here. fn tabstops_parse(s: &str) -> Result<(RemainingMode, Vec), ParseError> { - // Leading commas and spaces are ignored. - let s = s.trim_start_matches(is_space_or_comma); + // Leading spaces, tabs, and commas are ignored. + let s = s.trim_start_matches([' ', '\t', ',']); - // If there were only commas and spaces in the string, just use the + // If there were only spaces, tabs, and commas in the string, just use the // default tabstops. if s.is_empty() { return Ok((RemainingMode::None, vec![DEFAULT_TABSTOP])); @@ -94,7 +80,7 @@ fn tabstops_parse(s: &str) -> Result<(RemainingMode, Vec), ParseError> { let mut nums = vec![]; let mut remaining_mode = RemainingMode::None; let mut is_specifier_already_used = false; - for word in s.split(is_space_or_comma) { + for word in s.split([' ', '\t', ',']) { let bytes = word.as_bytes(); for i in 0..bytes.len() { match bytes[i] { @@ -104,19 +90,13 @@ fn tabstops_parse(s: &str) -> Result<(RemainingMode, Vec), ParseError> { // Parse a number from the byte sequence. let s = from_utf8(&bytes[i..]).unwrap(); match s.parse::() { + // Tab size must be positive. + Ok(0) => return Err(ParseError::TabSizeCannotBeZero), + // Tab sizes must be ascending. + Ok(num) if nums.last().is_some_and(|last| *last >= num) => { + return Err(ParseError::TabSizesMustBeAscending); + } Ok(num) => { - // Tab size must be positive. - if num == 0 { - return Err(ParseError::TabSizeCannotBeZero); - } - - // Tab sizes must be ascending. - if let Some(last_stop) = nums.last() - && *last_stop >= num - { - return Err(ParseError::TabSizesMustBeAscending); - } - if is_specifier_already_used { let specifier = if remaining_mode == RemainingMode::Slash { "/".to_string() @@ -275,22 +255,22 @@ pub fn uu_app() -> Command { } fn open(path: &OsString) -> UResult>> { - let file_buf; if path == "-" { - 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())?; - #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] - let _ = rustix::fs::fadvise(&file_buf, 0, None, rustix::fs::Advice::Sequential); - Ok(BufReader::new(Box::new(file_buf) as Box)) + return Ok(BufReader::new(Box::new(stdin()) as Box)); } + let path_ref = Path::new(path); + // some platforms cannot catch this as read error. accept additional overhead. + #[cfg(any(target_os = "wasi", windows))] + if path_ref.is_dir() { + return Err(uucore::error::USimpleError::new( + 1, + translate!("expand-error-is-directory", "file" => path.maybe_quote()), + )); + } + let file = File::open(path_ref).map_err_context(|| path.maybe_quote().to_string())?; + #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] + let _ = rustix::fs::fadvise(&file, 0, None, rustix::fs::Advice::Sequential); + Ok(BufReader::new(Box::new(file) as Box)) } /// Compute the number of spaces to the next tabstop. @@ -361,17 +341,8 @@ fn classify_char(buf: &[u8], byte: usize, utf8: bool) -> (CharType, usize, usize } if utf8 { - let nbytes = char::from(b).len_utf8(); - let Some(slice) = buf.get(byte..byte + nbytes) else { - // don't overrun buffer because of invalid UTF-8 - return (Other, 1, 1); - }; - - if let Ok(t) = from_utf8(slice) - && let Some(c) = t.chars().next() - { - return (Other, UnicodeWidthChar::width(c).unwrap_or(0), nbytes); - } + let (width, nbytes) = char_width_at(buf, byte); + return (Other, width, nbytes); } (Other, 1, 1) // implicit assumption: non-UTF-8 char is 1 col wide } diff --git a/src/uu/expr/Cargo.toml b/src/uu/expr/Cargo.toml index 9206aa0edef..fe4de8d651c 100644 --- a/src/uu/expr/Cargo.toml +++ b/src/uu/expr/Cargo.toml @@ -11,26 +11,29 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/expr.rs" doctest = false [dependencies] clap = { workspace = true } +fancy-regex = { workspace = true, default-features = false } +fluent = { workspace = true } num-bigint = { workspace = true } num-traits = { workspace = true } -onig = { workspace = true } -uucore = { workspace = true, features = ["i18n-collator"] } thiserror = { workspace = true } -fluent = { workspace = true } +uucore = { workspace = true, features = ["i18n-collator"] } [dev-dependencies] divan = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true + [[bin]] name = "expr" path = "src/main.rs" diff --git a/src/uu/expr/locales/en-US.ftl b/src/uu/expr/locales/en-US.ftl index 2c09eee7840..6c600aee0ba 100644 --- a/src/uu/expr/locales/en-US.ftl +++ b/src/uu/expr/locales/en-US.ftl @@ -1,32 +1,69 @@ expr-about = Print the value of EXPRESSION to standard output expr-usage = expr [EXPRESSION] expr [OPTIONS] -expr-after-help = Print the value of EXPRESSION to standard output. A blank line below - separates increasing precedence groups. +expr-after-help = A blank line below separates increasing precedence groups. EXPRESSION may be: - - ARG1 | ARG2: ARG1 if it is neither null nor 0, otherwise ARG2 - - ARG1 & ARG2: ARG1 if neither argument is null or 0, otherwise 0 - - ARG1 < ARG2: ARG1 is less than ARG2 - - ARG1 <= ARG2: ARG1 is less than or equal to ARG2 - - ARG1 = ARG2: ARG1 is equal to ARG2 - - ARG1 != ARG2: ARG1 is unequal to ARG2 - - ARG1 >= ARG2: ARG1 is greater than or equal to ARG2 - - ARG1 > ARG2: ARG1 is greater than ARG2 - - ARG1 + ARG2: arithmetic sum of ARG1 and ARG2 - - ARG1 - ARG2: arithmetic difference of ARG1 and ARG2 - - ARG1 * ARG2: arithmetic product of ARG1 and ARG2 - - ARG1 / ARG2: arithmetic quotient of ARG1 divided by ARG2 - - ARG1 % ARG2: arithmetic remainder of ARG1 divided by ARG2 - - STRING : REGEXP: anchored pattern match of REGEXP in STRING - - match STRING REGEXP: same as STRING : REGEXP - - substr STRING POS LENGTH: substring of STRING, POS counted from 1 - - index STRING CHARS: index in STRING where any CHARS is found, or 0 - - length STRING: length of STRING - - + TOKEN: interpret TOKEN as a string, even if it is a keyword like match - or an operator like / - - ( EXPRESSION ): value of EXPRESSION + ARG1 | ARG2 + ARG1 if it is neither null nor 0, otherwise ARG2 + + ARG1 & ARG2 + ARG1 if neither argument is null or 0, otherwise 0 + + ARG1 < ARG2 + ARG1 is less than ARG2 + + ARG1 <= ARG2 + ARG1 is less than or equal to ARG2 + + ARG1 = ARG2 + ARG1 is equal to ARG2 + + ARG1 != ARG2 + ARG1 is unequal to ARG2 + + ARG1 >= ARG2 + ARG1 is greater than or equal to ARG2 + + ARG1 > ARG2 + ARG1 is greater than ARG2 + + ARG1 + ARG2 + arithmetic sum of ARG1 and ARG2 + + ARG1 - ARG2 + arithmetic difference of ARG1 and ARG2 + + ARG1 * ARG2 + arithmetic product of ARG1 and ARG2 + + ARG1 / ARG2 + arithmetic quotient of ARG1 divided by ARG2 + + ARG1 % ARG2 + arithmetic remainder of ARG1 divided by ARG2 + + STRING : REGEXP + anchored pattern match of REGEXP in STRING + + match STRING REGEXP + same as STRING : REGEXP + + substr STRING POS LENGTH + substring of STRING, POS counted from 1 + + index STRING CHARS + index in STRING where any CHARS is found, or 0 + + length STRING + length of STRING + + + TOKEN + interpret TOKEN as a string, even if it is a keyword like match or an operator like / + + ( EXPRESSION ) + value of EXPRESSION Beware that many operators need to be escaped or quoted for shells. Comparisons are arithmetic if both ARGs are numbers, else lexicographical. @@ -39,10 +76,14 @@ expr-after-help = Print the value of EXPRESSION to standard output. A blank line Environment variables: - - EXPR_DEBUG_TOKENS=1: dump expression's tokens - - EXPR_DEBUG_RPN=1: dump expression represented in reverse polish notation - - EXPR_DEBUG_SYA_STEP=1: dump each parser step - - EXPR_DEBUG_AST=1: dump expression represented abstract syntax tree + - EXPR_DEBUG_TOKENS=1: dump expression's tokens + - EXPR_DEBUG_RPN=1: dump expression represented in reverse polish notation + - EXPR_DEBUG_SYA_STEP=1: dump each parser step + - EXPR_DEBUG_AST=1: dump expression represented abstract syntax tree + + Errors are reported with the expression echoed back and a caret under the + offending argument whenever stderr is a terminal; anything else reading + stderr gets the usual single-line message. # Help messages expr-help-version = output version information and exit @@ -64,3 +105,8 @@ expr-error-invalid-bracket-content = Invalid content of {"\\{\\}"} expr-error-trailing-backslash = Trailing backslash expr-error-too-big-range-quantifier-index = Regular expression too big expr-error-match-utf8 = match does not support invalid UTF-8 encoding in { $arg } + +# Diagnostic labels, used when errors are rendered with a source snippet +expr-diag-help-missing-argument = every operator needs a value on both sides +expr-diag-help-unexpected-argument = the shell may have expanded an operator; quote it as '{"*"}' or escape it as {"\\*"} +expr-diag-help-non-integer-argument = arithmetic operators need integers; use = or {"!"}= to compare strings instead diff --git a/src/uu/expr/locales/fr-FR.ftl b/src/uu/expr/locales/fr-FR.ftl index b529db9d523..8a376fa0186 100644 --- a/src/uu/expr/locales/fr-FR.ftl +++ b/src/uu/expr/locales/fr-FR.ftl @@ -1,32 +1,69 @@ expr-about = Afficher la valeur de EXPRESSION sur la sortie standard expr-usage = expr [EXPRESSION] expr [OPTIONS] -expr-after-help = Afficher la valeur de EXPRESSION sur la sortie standard. Une ligne vide ci-dessous - sépare les groupes de précédence croissante. +expr-after-help = Une ligne vide ci-dessous sépare les groupes de précédence croissante. EXPRESSION peut être : - - ARG1 | ARG2: ARG1 s'il n'est ni nul ni 0, sinon ARG2 - - ARG1 & ARG2: ARG1 si aucun argument n'est nul ou 0, sinon 0 - - ARG1 < ARG2: ARG1 est inférieur à ARG2 - - ARG1 <= ARG2: ARG1 est inférieur ou égal à ARG2 - - ARG1 = ARG2: ARG1 est égal à ARG2 - - ARG1 != ARG2: ARG1 est différent de ARG2 - - ARG1 >= ARG2: ARG1 est supérieur ou égal à ARG2 - - ARG1 > ARG2: ARG1 est supérieur à ARG2 - - ARG1 + ARG2: somme arithmétique de ARG1 et ARG2 - - ARG1 - ARG2: différence arithmétique de ARG1 et ARG2 - - ARG1 * ARG2: produit arithmétique de ARG1 et ARG2 - - ARG1 / ARG2: quotient arithmétique de ARG1 divisé par ARG2 - - ARG1 % ARG2: reste arithmétique de ARG1 divisé par ARG2 - - STRING : REGEXP: correspondance de motif ancré de REGEXP dans STRING - - match STRING REGEXP: identique à STRING : REGEXP - - substr STRING POS LENGTH: sous-chaîne de STRING, POS compté à partir de 1 - - index STRING CHARS: index dans STRING où l'un des CHARS est trouvé, ou 0 - - length STRING: longueur de STRING - - + TOKEN: interpréter TOKEN comme une chaîne, même si c'est un mot-clé comme match - ou un opérateur comme / - - ( EXPRESSION ): valeur de EXPRESSION + ARG1 | ARG2 + ARG1 s'il n'est ni nul ni 0, sinon ARG2 + + ARG1 & ARG2 + ARG1 si aucun argument n'est nul ou 0, sinon 0 + + ARG1 < ARG2 + ARG1 est inférieur à ARG2 + + ARG1 <= ARG2 + ARG1 est inférieur ou égal à ARG2 + + ARG1 = ARG2 + ARG1 est égal à ARG2 + + ARG1 != ARG2 + ARG1 est différent de ARG2 + + ARG1 >= ARG2 + ARG1 est supérieur ou égal à ARG2 + + ARG1 > ARG2 + ARG1 est supérieur à ARG2 + + ARG1 + ARG2 + somme arithmétique de ARG1 et ARG2 + + ARG1 - ARG2 + différence arithmétique de ARG1 et ARG2 + + ARG1 * ARG2 + produit arithmétique de ARG1 et ARG2 + + ARG1 / ARG2 + quotient arithmétique de ARG1 divisé par ARG2 + + ARG1 % ARG2 + reste arithmétique de ARG1 divisé par ARG2 + + STRING : REGEXP + correspondance de motif ancré de REGEXP dans STRING + + match STRING REGEXP + identique à STRING : REGEXP + + substr STRING POS LENGTH + sous-chaîne de STRING, POS compté à partir de 1 + + index STRING CHARS + index dans STRING où l'un des CHARS est trouvé, ou 0 + + length STRING + longueur de STRING + + + TOKEN + interpréter TOKEN comme une chaîne, même si c'est un mot-clé comme match ou un opérateur comme / + + ( EXPRESSION ) + valeur de EXPRESSION Attention : de nombreux opérateurs doivent être échappés ou mis entre guillemets pour les shells. Les comparaisons sont arithmétiques si les deux ARG sont des nombres, sinon lexicographiques. @@ -39,10 +76,14 @@ expr-after-help = Afficher la valeur de EXPRESSION sur la sortie standard. Une l Variables d'environnement : - - EXPR_DEBUG_TOKENS=1: afficher les jetons de l'expression - - EXPR_DEBUG_RPN=1: afficher l'expression représentée en notation polonaise inverse - - EXPR_DEBUG_SYA_STEP=1: afficher chaque étape de l'analyseur - - EXPR_DEBUG_AST=1: afficher l'arbre de syntaxe abstraite représentant l'expression + - EXPR_DEBUG_TOKENS=1: afficher les jetons de l'expression + - EXPR_DEBUG_RPN=1: afficher l'expression représentée en notation polonaise inverse + - EXPR_DEBUG_SYA_STEP=1: afficher chaque étape de l'analyseur + - EXPR_DEBUG_AST=1: afficher l'arbre de syntaxe abstraite représentant l'expression + + Les erreurs sont signalées en réaffichant l'expression avec un caret sous + l'argument fautif dès que la sortie d'erreur est un terminal ; sinon, le + message habituel sur une seule ligne est conservé. # Messages d'aide expr-help-version = afficher les informations de version et quitter @@ -64,3 +105,8 @@ expr-error-invalid-bracket-content = Contenu invalide de {"\\{\\}"} expr-error-trailing-backslash = Barre oblique inverse en fin expr-error-too-big-range-quantifier-index = Expression régulière trop grande expr-error-match-utf8 = match ne supporte pas l'encodage UTF-8 invalide dans { $arg } + +# Étiquettes de diagnostic, utilisées quand les erreurs sont rendues avec un extrait +expr-diag-help-missing-argument = chaque opérateur a besoin d'une valeur de chaque côté +expr-diag-help-unexpected-argument = le shell a peut-être développé un opérateur ; protégez-le avec '{"*"}' ou échappez-le avec {"\\*"} +expr-diag-help-non-integer-argument = les opérateurs arithmétiques attendent des entiers ; utilisez = ou {"!"}= pour comparer des chaînes diff --git a/src/uu/expr/src/diagnostics.rs b/src/uu/expr/src/diagnostics.rs new file mode 100644 index 00000000000..3a2893c9a8f --- /dev/null +++ b/src/uu/expr/src/diagnostics.rs @@ -0,0 +1,99 @@ +// 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. + +//! Maps an [`ExprError`] onto the argument it points at, so that +//! [`uucore::diagnostics`] can render it with a caret. + +use uucore::diagnostics::Snapshot; +use uucore::translate; + +use crate::{ExprError, FailurePoint}; + +/// What to point at: the argument index, optional text for the caret, and +/// optional advice. +struct Located { + index: usize, + label: Option, + help: Option, +} + +/// Render `err` against `args`. +/// +/// `at` says where the expression failed: how many arguments the parser had +/// consumed, or which argument evaluation blames. Returns `false` when the +/// error cannot be tied to an argument, in which case the caller should fall +/// back to the plain one-line message. +pub fn render(args: &[Vec], err: &ExprError, at: &FailurePoint) -> bool { + let snapshot = Snapshot::from_bytes(args); + let Some(located) = locate(&snapshot, err, at) else { + return false; + }; + snapshot.render( + located.index, + &err.to_string(), + located.label.as_deref(), + located.help.as_deref(), + ) +} + +fn locate(snapshot: &Snapshot, err: &ExprError, at: &FailurePoint) -> Option { + if snapshot.is_empty() { + return None; + } + + let stopped_at = match at { + FailurePoint::Parse(index) => Some(*index), + FailurePoint::Eval(_) => None, + }; + // The parser ran out of arguments, so the culprit is the last one it did + // consume: the operator or parenthesis left dangling. + let previous = || stopped_at.map(|index| index.saturating_sub(1)); + + // Labelled only where a label would add to the message, per the convention + // in `uucore::diagnostics`. + let (index, label, help) = match err { + ExprError::UnexpectedArgument(_) => ( + // The parser stopped on the argument it did not expect. + stopped_at, + Some("diagnostics-label-expression-complete"), + Some("expr-diag-help-unexpected-argument"), + ), + ExprError::MissingArgument(_) => { + (previous(), None, Some("expr-diag-help-missing-argument")) + } + ExprError::ExpectedClosingBraceAfter(_) | ExprError::ExpectedClosingBraceInsteadOf(_) => { + (previous(), None, None) + } + // Raised while evaluating; the evaluator says which argument it + // blames. An operand computed by a subexpression has no argument of + // its own, and the error falls back to its plain form. + ExprError::NonIntegerArgument(_) => ( + match at { + FailurePoint::Eval(index) => *index, + FailurePoint::Parse(_) => None, + }, + None, + Some("expr-diag-help-non-integer-argument"), + ), + // An empty expression, a malformed regex, division by zero: nothing to + // single out. + ExprError::MissingOperand + | ExprError::DivisionByZero + | ExprError::InvalidRegexExpression + | ExprError::UnmatchedOpeningParenthesis + | ExprError::UnmatchedClosingParenthesis + | ExprError::UnmatchedOpeningBrace + | ExprError::InvalidBracketContent + | ExprError::TrailingBackslash + | ExprError::TooBigRangeQuantifierIndex + | ExprError::UnsupportedNonUtf8Match(_) => return None, + }; + + Some(Located { + index: index?, + label: label.map(|key| translate!(key)), + help: help.map(|key| translate!(key)), + }) +} diff --git a/src/uu/expr/src/expr.rs b/src/uu/expr/src/expr.rs index 9392fcee72c..30c600826b6 100644 --- a/src/uu/expr/src/expr.rs +++ b/src/uu/expr/src/expr.rs @@ -6,15 +6,15 @@ use clap::{Arg, ArgAction, Command}; use std::io::{Write, stdout}; use syntax_tree::{AstNode, is_truthy}; -use thiserror::Error; use uucore::os_string_to_vec; use uucore::translate; use uucore::{ display::Quotable, - error::{UError, UResult}, - format_usage, + error::{UError, UResult, strip_errno}, + format_usage, show_error, }; +mod diagnostics; mod locale_aware; mod syntax_tree; @@ -26,14 +26,17 @@ mod options { pub type ExprResult = Result; -#[derive(Error, Clone, Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum ExprError { #[error("{}", translate!("expr-error-unexpected-argument", "arg" => _0.quote()))] UnexpectedArgument(String), #[error("{}", translate!("expr-error-missing-argument", "arg" => _0.quote()))] MissingArgument(String), + // The offending operand is carried for diagnostics only; GNU prints the bare + // message, so it is deliberately absent from `Display`. Raw bytes, so that a + // non-UTF-8 operand can still be matched back to its argument. #[error("{}", translate!("expr-error-non-integer-argument"))] - NonIntegerArgument, + NonIntegerArgument(Vec), #[error("{}", translate!("expr-error-missing-operand"))] MissingOperand, #[error("{}", translate!("expr-error-division-by-zero"))] @@ -99,6 +102,23 @@ pub fn uu_app() -> Command { ) } +/// Where an expression failed, in terms a diagnostic can point at. +pub enum FailurePoint { + /// The parser failed after consuming this many arguments. + Parse(usize), + /// Evaluation failed, at this argument when one is to blame. + Eval(Option), +} + +/// Parse and evaluate the expression. +fn evaluate(args: &[Vec]) -> Result, (ExprError, FailurePoint)> { + let ast = AstNode::parse_located(args).map_err(|(e, at)| (e, FailurePoint::Parse(at)))?; + let value = ast + .eval_located() + .map_err(|(e, at)| (e, FailurePoint::Eval(at)))?; + Ok(value.eval_as_string()) +} + #[uucore::main(no_signals)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { // For expr utility we do not want getopts. @@ -108,24 +128,30 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .map(os_string_to_vec) .collect::, _>>()?; - if args.len() == 1 && args[0] == b"--help" { - uu_app().print_help()?; - } else if args.len() == 1 && args[0] == b"--version" { - writeln!(stdout(), "expr {}", uucore::crate_version!())?; - } else { - // The first argument may be "--" and should be be ignored. - let args = if !args.is_empty() && args[0] == b"--" { - &args[1..] - } else { - &args - }; - - let res = AstNode::parse(args)?.eval()?.eval_as_string(); - let _ = stdout().write_all(&res); - let _ = stdout().write_all(b"\n"); + let mut args = &args[..]; + match args { + [a] if a == b"--help" => uu_app().print_help()?, + [a] if a == b"--version" => writeln!(stdout(), "expr {}", uucore::crate_version!())?, + _ => { + // ignore -- as the 1st argument + if let [a, rest @ ..] = args + && a == b"--" + { + args = rest; + } - if !is_truthy(&res.into()) { - return Err(1.into()); + let res = evaluate(args).map_err(|(e, at)| { + let reported = uucore::diagnostics::enabled() && diagnostics::render(args, &e, &at); + uucore::error::quiet_if_reported(reported, e) + })?; + stdout().write_all(&res)?; + if let Err(e) = stdout().write_all(b"\n") { + show_error!("{}", strip_errno(&e)); + return Err(3.into()); + } + if !is_truthy(&res.into()) { + return Err(1.into()); + } } } diff --git a/src/uu/expr/src/locale_aware.rs b/src/uu/expr/src/locale_aware.rs index 9bb13e536e4..52ddce4e917 100644 --- a/src/uu/expr/src/locale_aware.rs +++ b/src/uu/expr/src/locale_aware.rs @@ -87,7 +87,7 @@ fn substr_with_locale( UEncoding::Utf8 => { // Create a buffer with the heuristic that all the chars are ASCII // and are 1-byte long. - let mut string = MaybeNonUtf8String::with_capacity(len); + let mut string = MaybeNonUtf8String::with_capacity(s.len().min(len)); let mut buf = [0; 4]; // Iterate on char-bytes, and skip them accordingly. diff --git a/src/uu/expr/src/syntax_tree.rs b/src/uu/expr/src/syntax_tree.rs index f9c7037ed27..2121fa34a6c 100644 --- a/src/uu/expr/src/syntax_tree.rs +++ b/src/uu/expr/src/syntax_tree.rs @@ -3,13 +3,13 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) ints paren prec multibytes aaaabc +// spell-checker:ignore (ToDO) ints paren prec multibytes aaaabc metacharacter use std::{cell::Cell, collections::BTreeMap}; +use fancy_regex::{Regex, RegexBuilder}; use num_bigint::BigInt; use num_traits::ToPrimitive; -use onig::{MatchParam, Regex, RegexOptions, SearchOptions, Syntax}; use crate::{ ExprError, ExprResult, @@ -208,8 +208,8 @@ where // Check if parsed quantifier is valid let re = Regex::new(r"^([0-9]*,[0-9]*|[0-9]+)$").expect("valid regular expression"); - if let Some(captures) = re.captures(&quantifier) { - let matched = captures.at(0).unwrap_or_default(); + if let Ok(Some(captures)) = re.captures(&quantifier) { + let matched = captures.get(0).map_or("", |m| m.as_str()); match matched.split_once(',') { Some(("", "")) => Ok(()), Some((x, "") | ("", x)) if x.parse::().is_ok() => Ok(()), @@ -230,14 +230,13 @@ where /// Check for errors in a supplied regular expression /// /// GNU coreutils shows messages for invalid regular expressions -/// differently from the oniguruma library used by the regex crate. +/// differently from standard regex engines. /// This method attempts to do these checks manually in one pass /// through the regular expression. /// /// This method is not comprehensively checking all cases in which /// a regular expression could be invalid; any cases not caught will -/// result in a [`ExprError::InvalidRegexExpression`] when passing the -/// regular expression through the Oniguruma bindings. This method is +/// result in a [`ExprError::InvalidRegexExpression`]. This method is /// intended to just identify a few situations for which GNU coreutils /// has specific error messages. fn check_posix_regex_errors(pattern: &str) -> ExprResult<()> { @@ -265,24 +264,16 @@ fn check_posix_regex_errors(pattern: &str) -> ExprResult<()> { } } -/// Build a regex from a pattern string with locale-aware encoding -fn build_regex(pattern_bytes: Vec) -> ExprResult<(Regex, String)> { - use onig::EncodedBytes; - use uucore::i18n::UEncoding; - - let encoding = uucore::i18n::get_locale_encoding(); - - // For pattern processing, we need to handle it based on locale - let pattern_str = String::from_utf8(pattern_bytes.clone()) - .unwrap_or_else(|_| String::from_utf8_lossy(&pattern_bytes).into()); - check_posix_regex_errors(&pattern_str)?; - - // Transpile the input pattern from BRE syntax to `onig` crate's `Syntax::grep` - let mut re_string = String::with_capacity(pattern_str.len() + 1); +/// Transpile the input pattern from BRE syntax to ERE for fancy-regex +fn transpile_bre_to_ere(pattern_str: &str) -> ExprResult { + let mut re_string = String::with_capacity(pattern_str.len() + 8); let mut pattern_chars = pattern_str.chars().peekable(); let mut prev = '\0'; let mut prev_is_escaped = false; + let mut in_bracket = false; + let mut bracket_start_idx = 0; let mut is_start_of_expression = true; + let mut after_anchor_caret = false; // All patterns are anchored so they begin with a caret (^) if pattern_chars.peek() != Some(&'^') { @@ -291,243 +282,243 @@ fn build_regex(pattern_bytes: Vec) -> ExprResult<(Regex, String)> { while let Some(curr) = pattern_chars.next() { let curr_is_escaped = prev == '\\' && !prev_is_escaped; - let is_first_character = prev == '\0'; - - match curr { - // Character class negation "[^a]" - // Explicitly escaped caret "\^" - '^' if !is_start_of_expression && !matches!(prev, '[' | '\\') => { - re_string.push_str(r"\^"); - } - '$' if !curr_is_escaped && !is_end_of_expression(&pattern_chars) => { - re_string.push_str(r"\$"); + let mut next_is_start = false; + let mut next_after_anchor = false; + + if in_bracket { + let is_first = re_string.len() == bracket_start_idx + 1 + || (re_string.ends_with("[^") && re_string.len() == bracket_start_idx + 2); + if curr == ']' && !curr_is_escaped && !is_first { + in_bracket = false; } - '\\' if !curr_is_escaped && pattern_chars.peek().is_none() => { - return Err(ExprError::TrailingBackslash); - } - '{' if curr_is_escaped => { - // Handle '{' literally at the start of an expression - if is_start_of_expression { + re_string.push(curr); + } else { + match curr { + '[' if !curr_is_escaped => { + in_bracket = true; + bracket_start_idx = re_string.len(); + re_string.push('['); + } + // In BRE, '(', ')', '|', '+', '?', '{', '}' are literal by default, + // and become operators only when escaped. ERE has the exact opposite convention. + '(' | ')' | '|' | '+' | '?' | '{' | '}' => { + if curr_is_escaped { + if re_string.ends_with('\\') { + re_string.pop(); + } + match curr { + '(' | '|' => { + re_string.push(curr); + next_is_start = true; + } + '+' | '?' => { + if is_start_of_expression || after_anchor_caret { + re_string.push('\\'); + } + re_string.push(curr); + } + '{' => { + // Handle '{' literally at the start of an expression + if is_start_of_expression || after_anchor_caret { + re_string.push_str(r"\{"); + } else { + // Check if the following section is a valid range quantifier + verify_range_quantifier(&pattern_chars)?; + re_string.push('{'); + // Set the lower bound of range quantifier to 0 if it is missing + if pattern_chars.peek() == Some(&',') { + re_string.push('0'); + } + } + } + _ => re_string.push(curr), // ')' and '}' + } + } else { + // Unescaped metacharacter in BRE -> literal in ERE + re_string.push('\\'); + re_string.push(curr); + } + } + '*' => { + if curr_is_escaped { + re_string.push('*'); + } else if is_start_of_expression || after_anchor_caret { + re_string.push_str(r"\*"); + } else { + re_string.push('*'); + } + } + // Character class negation "[^a]" + // Explicitly escaped caret "\^" + '^' => { + if curr_is_escaped { + re_string.push('^'); + } else if is_start_of_expression { + re_string.push('^'); + next_after_anchor = true; + } else if prev == '[' && !prev_is_escaped { + re_string.push('^'); + } else { + re_string.push_str(r"\^"); + } + } + '$' if !curr_is_escaped && !is_end_of_expression(&pattern_chars) => { + re_string.push_str(r"\$"); + } + '<' if curr_is_escaped => { if re_string.ends_with('\\') { - let _ = re_string.pop(); + re_string.pop(); } - re_string.push(curr); - } else { - // Check if the following section is a valid range quantifier - verify_range_quantifier(&pattern_chars)?; - - re_string.push(curr); - // Set the lower bound of range quantifier to 0 if it is missing - if pattern_chars.peek() == Some(&',') { - re_string.push('0'); + re_string.push_str(r"\b(?=\w)"); + } + '>' if curr_is_escaped => { + if re_string.ends_with('\\') { + re_string.pop(); + } + re_string.push_str(r"\b(?<=\w)"); + } + '\\' if !curr_is_escaped => { + if pattern_chars.peek().is_none() { + return Err(ExprError::TrailingBackslash); } + // Carry the expression-start / after-anchor state over the + // backslash so the escaped character is still treated as the + // first token of a (sub)expression. + next_is_start = is_start_of_expression; + next_after_anchor = after_anchor_caret; + re_string.push('\\'); + } + _ => { + if curr_is_escaped + && !"123456789.*^$[]\\wWsSbB".contains(curr) + && re_string.ends_with('\\') + { + re_string.pop(); + } + re_string.push(curr); } } - _ => re_string.push(curr), } - // Capturing group "\(abc\)" - // Alternative pattern "a\|b" - is_start_of_expression = curr == '\\' && is_first_character - || curr_is_escaped && matches!(curr, '(' | '|') - || curr == '\\' && prev_is_escaped && matches!(prev, '(' | '|'); - + is_start_of_expression = next_is_start; + after_anchor_caret = next_after_anchor; prev_is_escaped = curr_is_escaped; prev = curr; } - // Create regex with proper encoding - let re = match encoding { - UEncoding::Utf8 => { - // For UTF-8 locale, use UTF-8 encoding - Regex::with_options_and_encoding( - &re_string, - RegexOptions::REGEX_OPTION_SINGLELINE | RegexOptions::REGEX_OPTION_MULTILINE, - Syntax::grep(), - ) - } - UEncoding::Ascii => { - // 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_MULTILINE, - Syntax::grep(), - ) - } - } - .map_err(|error| match error.code() { - // "invalid repeat range {lower,upper}" - -123 => ExprError::InvalidBracketContent, - // "too big number for repeat range" - -201 => ExprError::TooBigRangeQuantifierIndex, - _ => ExprError::InvalidRegexExpression, - })?; - - Ok((re, re_string)) + Ok(re_string) } -/// Run a regex search, treating runtime match errors as no match. -fn regex_search( - regex: &Regex, - chars: T, - to: usize, - region: &mut onig::Region, -) -> Option { - regex - .search_with_param( - chars, - 0, - to, - SearchOptions::SEARCH_OPTION_NONE, - Some(region), - MatchParam::default(), - ) - .unwrap_or(None) +/// Build a regex from a pattern string with locale-aware encoding +fn build_regex(pattern_bytes: Vec) -> ExprResult { + use uucore::i18n::UEncoding; + + let encoding = uucore::i18n::get_locale_encoding(); + + // For pattern processing, we need to handle it based on locale + let pattern_str = match encoding { + UEncoding::Utf8 => String::from_utf8(pattern_bytes.clone()) + .unwrap_or_else(|_| String::from_utf8_lossy(&pattern_bytes).into()), + UEncoding::Ascii => pattern_bytes.iter().map(|&b| b as char).collect(), + }; + check_posix_regex_errors(&pattern_str)?; + + let re_string = transpile_bre_to_ere(&pattern_str)?; + + RegexBuilder::new(&format!("(?s){re_string}")) + .oniguruma_mode(true) + .leftmost_longest(true) + .build() + .map_err(|_| ExprError::InvalidRegexExpression) } /// Find matches in the input using the compiled regex -fn find_match(regex: Regex, re_string: String, left_bytes: Vec) -> String { - use onig::EncodedBytes; +fn find_match(regex: Regex, left_bytes: Vec) -> String { use uucore::i18n::UEncoding; let encoding = uucore::i18n::get_locale_encoding(); + let has_captures = regex.captures_len() > 1; // Match against the input using the appropriate encoding - let mut region = onig::Region::new(); match encoding { UEncoding::Utf8 => { // In UTF-8 locale, check if input is valid UTF-8 if let Ok(left_str) = std::str::from_utf8(&left_bytes) { // Valid UTF-8, match as UTF-8 - let pos = regex_search(®ex, left_str, left_str.len(), &mut region); - - if pos.is_some() { - if regex.captures_len() > 0 { + if let Ok(Some(caps)) = regex.captures(left_str) { + return if has_captures { // Get first capture group - region - .pos(1) - .map(|(start, end)| left_str[start..end].to_string()) - .unwrap_or_default() + caps.get(1) + .map_or(String::new(), |m| m.as_str().to_string()) } else { // Count characters in the match - let (start, end) = region.pos(0).unwrap(); - left_str[start..end].chars().count().to_string() - } - } else { - // No match - if regex.captures_len() > 0 { - String::new() - } else { - "0".to_string() - } + caps.get(0).unwrap().as_str().chars().count().to_string() + }; } } else { - // Invalid UTF-8 in UTF-8 locale - // Try to match as bytes using ASCII encoding - let left_encoded = EncodedBytes::ascii(&left_bytes); - // 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_MULTILINE, - Syntax::grep(), - ) - .ok(); - - if let Some(re_ascii) = re_ascii { - let pos = regex_search(&re_ascii, left_encoded, left_bytes.len(), &mut region); - - if pos.is_some() { - if re_ascii.captures_len() > 0 { - // Get first capture group - region - .pos(1) - .map(|(start, end)| { - // Return empty string for invalid UTF-8 capture in UTF-8 locale - if std::str::from_utf8(&left_bytes[start..end]).is_err() { - String::new() - } else { - String::from_utf8_lossy(&left_bytes[start..end]) - .into_owned() - } - }) - .unwrap_or_default() - } else { - // No capture groups - return 0 for invalid UTF-8 in UTF-8 locale - "0".to_string() - } - } else { - // No match - if re_ascii.captures_len() > 0 { - String::new() - } else { - "0".to_string() + // Invalid UTF-8 in UTF-8 locale: match on Latin-1 byte mapping + let left_str: String = left_bytes.iter().map(|&b| b as char).collect(); + if let Ok(Some(caps)) = regex.captures(&left_str) { + if has_captures { + if let Some(m) = caps.get(1) { + let bytes: Vec = m.as_str().chars().map(|c| c as u8).collect(); + // Return empty string for invalid UTF-8 capture in UTF-8 locale + if let Ok(s) = String::from_utf8(bytes) { + return s; + } } + return String::new(); } - } else { - // Couldn't create ASCII regex - no match - if regex.captures_len() > 0 { - String::new() - } else { - "0".to_string() - } + // No capture groups - return 0 for invalid UTF-8 in UTF-8 locale + return "0".to_string(); } } } UEncoding::Ascii => { - // In ASCII/C locale, work with bytes directly - let left_encoded = EncodedBytes::ascii(&left_bytes); - let pos = regex_search(®ex, left_encoded, left_bytes.len(), &mut region); - - if pos.is_some() { - if regex.captures_len() > 0 { - // Get first capture group - return raw bytes for C locale - if let Some((start, end)) = region.pos(1) { - let capture_bytes = &left_bytes[start..end]; - // Return raw bytes as String for consistency with other cases - return String::from_utf8_lossy(capture_bytes).into_owned(); - } - String::new() - } else { - // Return byte count of match - let (start, end) = region.pos(0).unwrap(); - (end - start).to_string() - } - } else { - // No match - if regex.captures_len() > 0 { - String::new() + // In ASCII/C locale, work with Latin-1 byte mapping + let left_str: String = left_bytes.iter().map(|&b| b as char).collect(); + if let Ok(Some(caps)) = regex.captures(&left_str) { + return if has_captures { + caps.get(1).map_or_else(String::new, |m| { + let bytes: Vec = m.as_str().chars().map(|c| c as u8).collect(); + String::from_utf8_lossy(&bytes).into_owned() + }) } else { - "0".to_string() - } + caps.get(0).unwrap().as_str().chars().count().to_string() + }; } } } + + // No match + if has_captures { + String::new() + } else { + "0".to_string() + } } /// Evaluate a match expression with locale-aware regex matching fn evaluate_match_expression(left_bytes: Vec, right_bytes: Vec) -> ExprResult { use uucore::i18n::UEncoding; - let (regex, re_string) = build_regex(right_bytes)?; + let regex = build_regex(right_bytes)?; // Special case for ASCII locale with capture groups that need to return raw bytes let encoding = uucore::i18n::get_locale_encoding(); - if matches!(encoding, UEncoding::Ascii) && regex.captures_len() > 0 { + if matches!(encoding, UEncoding::Ascii) && regex.captures_len() > 1 { // Try to find the actual capture bytes for ASCII locale - let mut region = onig::Region::new(); - let left_encoded = onig::EncodedBytes::ascii(&left_bytes); - let pos = regex_search(®ex, left_encoded, left_bytes.len(), &mut region); - - if pos.is_some() { - if let Some((start, end)) = region.pos(1) { - let capture_bytes = &left_bytes[start..end]; - return Ok(MaybeNonUtf8String::from(capture_bytes.to_vec()).into()); - } + let left_str: String = left_bytes.iter().map(|&b| b as char).collect(); + if let Ok(Some(caps)) = regex.captures(&left_str) + && let Some(m) = caps.get(1) + { + let bytes: Vec = m.as_str().chars().map(|c| c as u8).collect(); + return Ok(MaybeNonUtf8String::from(bytes).into()); } } - Ok(find_match(regex, re_string, left_bytes).into()) + Ok(find_match(regex, left_bytes).into()) } /// Precedence for infix binary operators @@ -595,10 +586,10 @@ impl NumOrStr { pub fn eval_as_bigint(self) -> ExprResult { match self { Self::Num(num) => Ok(num), - Self::Str(str) => String::from_utf8(str) - .map_err(|_| ExprError::NonIntegerArgument)? - .parse::() - .map_err(|_| ExprError::NonIntegerArgument), + Self::Str(str) => std::str::from_utf8(&str) + .ok() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| ExprError::NonIntegerArgument(str.clone())), } } @@ -616,12 +607,14 @@ pub struct AstNode { inner: AstNodeInner, } -// We derive Eq and PartialEq only for tests because we want to ignore the id field. +// Eq and PartialEq are implemented only for tests, ignoring the id and +// position fields. #[derive(Debug, Clone)] -#[cfg_attr(test, derive(Eq, PartialEq))] pub enum AstNodeInner { Leaf { value: MaybeNonUtf8String, + /// Index of the argument the value came from, for diagnostics. + position: usize, }, BinOp { op_type: BinOp, @@ -646,17 +639,33 @@ impl AstNode { } } - pub fn parse(input: &[impl AsRef]) -> ExprResult { - Parser::new(input).parse() + /// Parse `input`, reporting on failure how many arguments the parser had + /// consumed. Together with the error kind that is enough to say which + /// argument is at fault. + pub fn parse_located( + input: &[impl AsRef], + ) -> Result { + let mut parser = Parser::new(input); + parser.parse().map_err(|e| (e, parser.index)) } + /// [`AstNode::eval_located`] without the location, for tests that only + /// care about the value. + #[cfg(test)] pub fn eval(&self) -> ExprResult { + self.eval_located().map_err(|(error, _)| error) + } + + /// Evaluate, reporting on failure the index of the argument the error is + /// about, when there is one single argument to blame. + pub fn eval_located(&self) -> Result)> { // This function implements a recursive tree-walking algorithm, but uses an explicit // stack approach instead of native recursion to avoid potential stack overflow // on deeply nested expressions. let mut stack = vec![self]; - let mut result_stack = BTreeMap::new(); + let mut result_stack: BTreeMap)>> = + BTreeMap::new(); while let Some(node) = stack.pop() { match &node.inner { @@ -668,7 +677,7 @@ impl AstNode { left, right, } => { - let (Some(right), Some(left)) = ( + let (Some(right_result), Some(left_result)) = ( result_stack.remove(&right.id), result_stack.remove(&left.id), ) else { @@ -678,7 +687,23 @@ impl AstNode { continue; }; - let result = op_type.eval(left, right); + // The operator takes plain results — some, like `|`, + // swallow their children's errors — so the positions are + // held back and re-attached if an error comes out. + let (left_result, left_position) = split(left_result); + let (right_result, right_position) = split(right_result); + let result = op_type.eval(left_result, right_result).map_err(|error| { + let position = match &error { + // Born here from a leaf operand, unless a child + // already located it deeper in the expression. + ExprError::NonIntegerArgument(value) => left_position + .or(right_position) + .or_else(|| leaf_position(left, value)) + .or_else(|| leaf_position(right, value)), + _ => left_position.or(right_position), + }; + (error, position) + }); result_stack.insert(node.id, result); } AstNodeInner::Substr { @@ -745,13 +770,39 @@ impl AstNode { } } +/// Take the position out of a located result, leaving the plain result the +/// operator evaluators work on. +fn split( + result: Result)>, +) -> (ExprResult, Option) { + match result { + Ok(value) => (Ok(value), None), + Err((error, position)) => (Err(error), position), + } +} + +/// The position of `node`, when it is a leaf holding exactly `value`. +fn leaf_position(node: &AstNode, value: &MaybeNonUtf8Str) -> Option { + match &node.inner { + AstNodeInner::Leaf { + value: leaf, + position, + } if leaf == value => Some(*position), + _ => None, + } +} + impl Drop for AstNode { // This is a tree-walking algorithm, so like `eval` it uses an explicit // stack instead of native recursion to avoid a stack overflow when // dropping a deeply nested AST. fn drop(&mut self) { fn detach_children(inner: &mut AstNodeInner, stack: &mut Vec) { - match std::mem::replace(inner, AstNodeInner::Leaf { value: Vec::new() }) { + let empty = AstNodeInner::Leaf { + value: Vec::new(), + position: 0, + }; + match std::mem::replace(inner, empty) { AstNodeInner::Leaf { .. } => {} AstNodeInner::BinOp { left, right, .. } => { stack.push(*left); @@ -973,12 +1024,16 @@ impl<'a, S: AsRef> Parser<'a, S> { } b"+" => ParseState::Value(AstNode::new(AstNodeInner::Leaf { value: self.next()?.into(), + position: self.index - 1, })), b"(" => { stack.push(ParseFrame::CloseParen); ParseState::Expression { min_prec: 0 } } - s => ParseState::Value(AstNode::new(AstNodeInner::Leaf { value: s.into() })), + s => ParseState::Value(AstNode::new(AstNodeInner::Leaf { + value: s.into(), + position: self.index - 1, + })), }, ParseState::Value(value) => match stack.pop() { None => return Ok(value), @@ -1060,14 +1115,19 @@ pub fn is_truthy(s: &NumOrStr) -> bool { #[cfg(test)] mod test { - use crate::ExprError; use crate::syntax_tree::verify_range_quantifier; + use crate::{ExprError, ExprResult}; use super::{ - AstNode, AstNodeInner, BinOp, NumericOp, RelationOp, StringOp, check_posix_regex_errors, - get_next_id, + AstNode, AstNodeInner, BinOp, MaybeNonUtf8Str, NumericOp, RelationOp, StringOp, + check_posix_regex_errors, get_next_id, }; + /// Parse an expression, discarding how far the parser got. + fn parse>(input: &[S]) -> ExprResult { + AstNode::parse_located(input).map_err(|(e, _)| e) + } + impl PartialEq for AstNode { fn eq(&self, other: &Self) -> bool { self.inner == other.inner @@ -1076,12 +1136,51 @@ mod test { impl Eq for AstNode {} + // Hand-built expectations cannot know real argument positions, so + // equality ignores them, like it ignores ids. + impl PartialEq for AstNodeInner { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Leaf { value: a, .. }, Self::Leaf { value: b, .. }) => a == b, + ( + Self::BinOp { + op_type: a_op, + left: a_left, + right: a_right, + }, + Self::BinOp { + op_type: b_op, + left: b_left, + right: b_right, + }, + ) => a_op == b_op && a_left == b_left && a_right == b_right, + ( + Self::Substr { + string: a_string, + pos: a_pos, + length: a_length, + }, + Self::Substr { + string: b_string, + pos: b_pos, + length: b_length, + }, + ) => a_string == b_string && a_pos == b_pos && a_length == b_length, + (Self::Length { string: a }, Self::Length { string: b }) => a == b, + _ => false, + } + } + } + + impl Eq for AstNodeInner {} + impl From<&str> for AstNode { fn from(value: &str) -> Self { Self { id: get_next_id(), inner: AstNodeInner::Leaf { value: value.into(), + position: 0, }, } } @@ -1141,31 +1240,28 @@ mod test { (":", BinOp::String(StringOp::Match)), ]; for (string, value) in cases { - assert_eq!(AstNode::parse(&["1", string, "2"]), Ok(op(value, "1", "2"))); + assert_eq!(parse(&["1", string, "2"]), Ok(op(value, "1", "2"))); } } #[test] fn other_operators() { assert_eq!( - AstNode::parse(&["match", "1", "2"]), + parse(&["match", "1", "2"]), Ok(op(BinOp::String(StringOp::Match), "1", "2")), ); assert_eq!( - AstNode::parse(&["index", "1", "2"]), + parse(&["index", "1", "2"]), Ok(op(BinOp::String(StringOp::Index), "1", "2")), ); - assert_eq!(AstNode::parse(&["length", "1"]), Ok(length("1"))); - assert_eq!( - AstNode::parse(&["substr", "1", "2", "3"]), - Ok(substr("1", "2", "3")), - ); + assert_eq!(parse(&["length", "1"]), Ok(length("1"))); + assert_eq!(parse(&["substr", "1", "2", "3"]), Ok(substr("1", "2", "3")),); } #[test] fn precedence() { assert_eq!( - AstNode::parse(&["1", "+", "2", "*", "3"]), + parse(&["1", "+", "2", "*", "3"]), Ok(op( BinOp::Numeric(NumericOp::Add), "1", @@ -1173,7 +1269,7 @@ mod test { )) ); assert_eq!( - AstNode::parse(&["(", "1", "+", "2", ")", "*", "3"]), + parse(&["(", "1", "+", "2", ")", "*", "3"]), Ok(op( BinOp::Numeric(NumericOp::Mul), op(BinOp::Numeric(NumericOp::Add), "1", "2"), @@ -1181,7 +1277,7 @@ mod test { )) ); assert_eq!( - AstNode::parse(&["1", "*", "2", "+", "3"]), + parse(&["1", "*", "2", "+", "3"]), Ok(op( BinOp::Numeric(NumericOp::Add), op(BinOp::Numeric(NumericOp::Mul), "1", "2"), @@ -1198,23 +1294,23 @@ mod test { let mut input: Vec<&str> = vec!["("; depth]; input.push("1"); input.extend(std::iter::repeat_n(")", depth)); - let result = AstNode::parse(&input).unwrap().eval().unwrap(); + let result = parse(&input).unwrap().eval().unwrap(); assert_eq!(result.eval_as_string(), b"1"); let mut input: Vec<&str> = vec!["length"; depth]; input.push("1"); - let result = AstNode::parse(&input).unwrap().eval().unwrap(); + let result = parse(&input).unwrap().eval().unwrap(); assert_eq!(result.eval_as_string(), b"1"); } #[test] fn missing_closing_parenthesis() { assert_eq!( - AstNode::parse(&["(", "42"]), + parse(&["(", "42"]), Err(ExprError::ExpectedClosingBraceAfter("42".to_string())) ); assert_eq!( - AstNode::parse(&["(", "42", "a"]), + parse(&["(", "42", "a"]), Err(ExprError::ExpectedClosingBraceInsteadOf("a".to_string())) ); } @@ -1222,34 +1318,22 @@ mod test { #[test] fn empty_substitution() { // causes a panic in 0.0.25 - let result = AstNode::parse(&["a", ":", r"\(b\)*"]) - .unwrap() - .eval() - .unwrap(); + let result = parse(&["a", ":", r"\(b\)*"]).unwrap().eval().unwrap(); assert_eq!(result.eval_as_string(), b""); } #[test] fn starting_stars_become_escaped() { - let result = AstNode::parse(&["cats", ":", r"*cats"]) - .unwrap() - .eval() - .unwrap(); + let result = parse(&["cats", ":", r"*cats"]).unwrap().eval().unwrap(); assert_eq!(result.eval_as_string(), b"0"); - let result = AstNode::parse(&["*cats", ":", r"*cats"]) - .unwrap() - .eval() - .unwrap(); + let result = parse(&["*cats", ":", r"*cats"]).unwrap().eval().unwrap(); assert_eq!(result.eval_as_string(), b"5"); } #[test] fn only_match_in_beginning() { - let result = AstNode::parse(&["budget", ":", r"get"]) - .unwrap() - .eval() - .unwrap(); + let result = parse(&["budget", ":", r"get"]).unwrap().eval().unwrap(); assert_eq!(result.eval_as_string(), b"0"); } @@ -1507,4 +1591,94 @@ mod test { let result = evaluate_match_expression(b"hello".to_vec(), b"a\\{invalid\\}".to_vec()); assert!(matches!(result, Err(ExprError::InvalidBracketContent))); } + + #[test] + fn test_evaluate_match_expression_multibyte_character_class() { + use super::evaluate_match_expression; + use uucore::i18n::{UEncoding, get_locale_encoding}; + + let result = evaluate_match_expression( + vec![0xce, 0xb1, b'b', b'c', 0xce, 0xb4, b'e', b'f'], + vec![b'[', 0xce, 0xb1, b']'], + ) + .unwrap(); + assert_eq!(result.eval_as_string(), b"1"); + + let result = evaluate_match_expression( + vec![0xce, 0xb1, b'b', b'c', 0xce, 0xb4, b'e', b'f'], + vec![b'\\', b'(', b'[', 0xce, 0xb1, b']', b'\\', b')'], + ) + .unwrap(); + match get_locale_encoding() { + UEncoding::Utf8 => assert_eq!(result.eval_as_string(), &[0xce, 0xb1]), + UEncoding::Ascii => assert_eq!(result.eval_as_string(), &[0xce]), + } + } + + #[test] + fn test_adjacent_quantifiers() { + use super::evaluate_match_expression; + + let result = evaluate_match_expression(b"aaa".to_vec(), br"\(a\)\{2\}*".to_vec()).unwrap(); + assert_eq!(result.eval_as_string(), b"a"); + + let result = evaluate_match_expression(b"aaa".to_vec(), br"a**".to_vec()).unwrap(); + assert_eq!(result.eval_as_string(), b"3"); + } + + #[test] + fn test_leftmost_longest_match_semantics() { + use super::evaluate_match_expression; + + // This test verifies leftmost-longest (POSIX) match semantics. + // Pattern `(a|ab)` against `ab` should capture the longest alternative (`ab`) + // not the first (`a`). + let result = evaluate_match_expression(b"ab".to_vec(), br"\(a\|ab\)".to_vec()).unwrap(); + assert_eq!(result.eval_as_string(), b"ab"); + } + + #[test] + fn test_gnu_bre_extensions_and_escaped_caret() { + use super::evaluate_match_expression; + + // Word character \w + let result = evaluate_match_expression(b"a1b".to_vec(), br"\w".to_vec()).unwrap(); + assert_eq!(result.eval_as_string(), b"1"); + + // Word boundary \b + let result = evaluate_match_expression(b"abc".to_vec(), br"\ba".to_vec()).unwrap(); + assert_eq!(result.eval_as_string(), b"1"); + + // Beginning of word \< + let result = evaluate_match_expression(b"abc".to_vec(), br"\ + let result = evaluate_match_expression(b"b".to_vec(), br"b\>".to_vec()).unwrap(); + assert_eq!(result.eval_as_string(), b"1"); + + // Escaped caret with quantifier \^* + let result = evaluate_match_expression(b"^".to_vec(), br"\^*".to_vec()).unwrap(); + assert_eq!(result.eval_as_string(), b"1"); + } + + #[test] + fn test_invalid_utf8_with_high_bytes_before_capture() { + use super::evaluate_match_expression; + use uucore::i18n::{UEncoding, get_locale_encoding}; + + // Input contains non-UTF-8 byte >= 0x80 (0xFF) before a valid ASCII capture + let result = + evaluate_match_expression(vec![0xff, b'a', b'b', b'c'], br".\(abc\)".to_vec()).unwrap(); + assert_eq!(result.eval_as_string(), b"abc"); + + // In UTF-8 locale, capture group containing invalid UTF-8 returns empty string; + // in ASCII/C locale (e.g. WASI default), raw bytes are captured. + let result = + evaluate_match_expression(vec![0xff, b'a', b'b', b'c'], br"\(.*\)".to_vec()).unwrap(); + match get_locale_encoding() { + UEncoding::Utf8 => assert_eq!(result.eval_as_string(), b""), + UEncoding::Ascii => assert_eq!(result.eval_as_string(), &[0xff, b'a', b'b', b'c']), + } + } } diff --git a/src/uu/factor/Cargo.toml b/src/uu/factor/Cargo.toml index 7e8566bdca3..b32e40b96d0 100644 --- a/src/uu/factor/Cargo.toml +++ b/src/uu/factor/Cargo.toml @@ -11,16 +11,13 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [dependencies] clap = { workspace = true } -uucore = { workspace = true } +fluent = { workspace = true } memchr = { workspace = true } num-bigint = { workspace = true } num-prime = { workspace = true } -fluent = { workspace = true } +uucore = { workspace = true } [[bin]] name = "factor" @@ -30,6 +27,9 @@ path = "src/main.rs" divan = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [lib] path = "src/factor.rs" doctest = false diff --git a/src/uu/factor/benches/factor_bench.rs b/src/uu/factor/benches/factor_bench.rs index 952ea09a616..f4a9d8f25f5 100644 --- a/src/uu/factor/benches/factor_bench.rs +++ b/src/uu/factor/benches/factor_bench.rs @@ -3,23 +3,104 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore funcs +// spell-checker:ignore funcs semiprime use divan::{Bencher, black_box}; use uu_factor::uumain; use uucore::benchmark::run_util_function; -/// Benchmark multiple u64 digits +/// Benchmark multiple u64 digits. #[divan::bench(args = [(2)])] fn factor_multiple_u64s(bencher: Bencher, start_num: u64) { - bencher - // this is a range of 5000 different u128 integers - .with_inputs(|| (start_num, start_num + 2500)) - .bench_values(|(start_u64, end_u64)| { - for u64_digit in start_u64..=end_u64 { - black_box(run_util_function(uumain, &[&u64_digit.to_string()])); - } - }); + bencher.bench(|| { + for n in start_num..=start_num + 2500 { + black_box(run_util_function(uumain, &[&n.to_string()])); + } + }); +} + +/// Benchmark a large u64 prime. +#[divan::bench] +fn factor_large_u64_prime(bencher: Bencher) { + bencher.bench(|| { + black_box(run_util_function(uumain, &["18446744073709551557"])); + }); +} + +/// Benchmark a 64-bit semiprime made from two 32-bit primes. +#[divan::bench] +fn factor_64bit_semiprime(bencher: Bencher) { + bencher.bench(|| { + black_box(run_util_function(uumain, &["18446743979220271189"])); + }); +} + +/// Products of several primes of similar, moderate size. +/// +/// These are the numbers factorization regressions show up in first: trial +/// division finds nothing, Pollard's rho has to run for a while before it +/// splits anything, and any O(n^(1/4)) fallback never finishes. Each of them +/// is an input GNU factor handles in well under a second. +mod hard { + use super::{Bencher, black_box, run_util_function, uumain}; + + fn factor(bencher: Bencher, number: &str) { + bencher.bench(|| black_box(run_util_function(uumain, &[number]))); + } + + /// 529341446939 * 529341447079 * 529341447139 + #[divan::bench(sample_count = 10, sample_size = 1)] + fn three_39_bit_primes(bencher: Bencher) { + factor(bencher, "148322726715648124896087586879631159"); + } + + /// Five primes near 2^38, just past the u128 range. + #[divan::bench(sample_count = 10, sample_size = 1)] + fn five_38_bit_primes(bencher: Bencher) { + factor( + bencher, + "1569275491456096801522790424087360918295323588350447935207", + ); + } + + /// Thirteen primes near 2^39. + #[divan::bench(sample_count = 3, sample_size = 1)] + fn thirteen_39_bit_primes(bencher: Bencher) { + factor( + bencher, + "256192672085272469290287843204387360975152374284235599731951768269391636066386517575760286247162035537155995319918598846421204855240141082924971355328149", + ); + } + + /// 34359738421^7. Pollard's rho cannot split a prime power at all, so this + /// one is fast only as long as perfect powers are peeled off beforehand. + #[divan::bench(sample_count = 10, sample_size = 1)] + fn a_prime_to_the_seventh(bencher: Bencher) { + factor( + bencher, + "56539106683390492137844827055225747632151249167945695848217966183182073341", + ); + } + + /// 2^70 * 3^5 * 5 * 340282366920938463463374607431768211507: a few tiny + /// factors in front of one large prime, which should never reach rho. + #[divan::bench(sample_count = 10, sample_size = 1)] + fn small_factors_and_a_large_prime(bencher: Bencher) { + factor( + bencher, + "488107430943668296195870985548628140589274519139277715139461120", + ); + } + + /// The Mersenne prime 2^1279 - 1, which is all primality testing and no + /// factoring. + #[divan::bench(sample_count = 10, sample_size = 1)] + fn a_1279_bit_prime(bencher: Bencher) { + factor( + bencher, + "10407932194664399081925240327364085538615262247266704805319112350403608059673360298012239441732324184842421613954281007791383566248323464908139906605677320762924129509389220345773183349661583550472959420547689811211693677147548478866962501384438260291732348885311160828538416585028255604666224831890918801847068222203140521026698435488732958028878050869736186900714720710555703168729087", + ); + } } fn main() { diff --git a/src/uu/factor/src/factor.rs b/src/uu/factor/src/factor.rs index 3f8cc93fb02..d6fa4ba8c5f 100644 --- a/src/uu/factor/src/factor.rs +++ b/src/uu/factor/src/factor.rs @@ -17,7 +17,7 @@ use memchr::memchr3_iter; use num_bigint::BigUint; use num_prime::nt_funcs::{factorize64, factorize128, factors}; use uucore::display::Quotable; -use uucore::error::{FromIo, UResult, USimpleError, set_exit_code}; +use uucore::error::{FromIo, UResult, USimpleError, set_exit_code, strip_errno}; use uucore::translate; use uucore::{format_usage, show_error, show_if_err}; @@ -85,14 +85,14 @@ fn parse_num(slice: &[u8]) -> UResult { match num.parse::() { Ok(x) => return Ok(Number::U64(x)), // If overflown, attempt a greater width - Err(e) if matches!(e.kind(), IntErrorKind::PosOverflow) => {} + Err(e) if *e.kind() == IntErrorKind::PosOverflow => {} Err(_) => return Err(err_invalid(num)), } match num.parse::() { Ok(x) => return Ok(Number::U128(x)), // If overflown, attempt a greater width - Err(e) if matches!(e.kind(), IntErrorKind::PosOverflow) => {} + Err(e) if *e.kind() == IntErrorKind::PosOverflow => {} Err(_) => return Err(err_invalid(num)), } @@ -154,7 +154,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let print_exponents = matches.get_flag(options::EXPONENTS); let stdout = stdout(); - // We use a smaller buffer here to pass a gnu test. 4KiB appears to be the default pipe size for bash. + // use a smaller buffer here to pass a GNU test. let mut w = io::BufWriter::with_capacity(4 * 1024, stdout.lock()); if let Some(values) = matches.get_many::(options::NUMBER) { @@ -189,7 +189,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } Err(e) => { set_exit_code(1); - show_error!("{}", translate!("factor-error-reading-input", "error" => e)); + show_error!( + "{}", + translate!("factor-error-reading-input", "error" => strip_errno(&e)) + ); return Ok(()); } } diff --git a/src/uu/false/Cargo.toml b/src/uu/false/Cargo.toml index 3628f82b00a..16ef2ba3d84 100644 --- a/src/uu/false/Cargo.toml +++ b/src/uu/false/Cargo.toml @@ -12,9 +12,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/false.rs" test = false @@ -22,8 +19,8 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true } [[bin]] name = "false" @@ -33,6 +30,9 @@ path = "src/main.rs" divan = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bench]] name = "false_bench" harness = false diff --git a/src/uu/false/src/false.rs b/src/uu/false/src/false.rs index d99d8ece4aa..81aab226236 100644 --- a/src/uu/false/src/false.rs +++ b/src/uu/false/src/false.rs @@ -2,32 +2,42 @@ // // 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 std::io::Write; -use uucore::{crate_version, translate}; +use std::io::{self, Write as _}; +use uucore::error::strip_errno; +use uucore::{crate_version, show_error, translate}; // uucore::main does not support no-result pub fn uumain(mut args: impl uucore::Args) -> i32 { + #[cold] + #[inline(never)] + fn cold_error(e: io::Error) -> i32 { + if e.kind() != io::ErrorKind::BrokenPipe { + show_error!("{}", strip_errno(&e)); + return 1; + } + 1 + } + // skip binary name let (Some(flag), None) = (args.nth(1), args.next()) else { return 1; }; - let error = if flag == "--help" { + let res = if flag == "--help" { uu_app().print_help() } else if flag == "--version" { // avoid uu_app for smaller binary size - writeln!(std::io::stdout(), "false {}", crate_version!()) + writeln!(io::stdout(), "false {}", crate_version!()) } else { return 1; }; - if let Err(print_fail) = error - && print_fail.kind() != std::io::ErrorKind::BrokenPipe - { - let _ = writeln!(std::io::stderr(), "false: {print_fail}"); + match res { + Ok(()) => 1, + Err(e) => cold_error(e), } - 1 } pub fn uu_app() -> Command { diff --git a/src/uu/fmt/Cargo.toml b/src/uu/fmt/Cargo.toml index 5b918e21c4d..fb77fe93f11 100644 --- a/src/uu/fmt/Cargo.toml +++ b/src/uu/fmt/Cargo.toml @@ -11,19 +11,18 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/fmt.rs" doctest = false [dependencies] clap = { workspace = true } -unicode-width = { workspace = true } -uucore = { workspace = true } -thiserror = { workspace = true } fluent = { workspace = true } +thiserror = { workspace = true } +uucore = { workspace = true } + +[lints] +workspace = true [[bin]] name = "fmt" diff --git a/src/uu/fmt/src/fmt.rs b/src/uu/fmt/src/fmt.rs index 06cac3cb3ea..b3f1a1bb126 100644 --- a/src/uu/fmt/src/fmt.rs +++ b/src/uu/fmt/src/fmt.rs @@ -18,12 +18,11 @@ use uucore::format_usage; use linebreak::break_lines; use parasplit::ParagraphStream; -use thiserror::Error; mod linebreak; mod parasplit; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum FmtError { #[error("{}", translate!("fmt-error-invalid-goal", "goal" => .0.quote()))] InvalidGoal(String), @@ -54,6 +53,8 @@ const DEFAULT_GOAL: usize = 70; const DEFAULT_WIDTH: usize = 75; // by default, goal is 93% of width const DEFAULT_GOAL_TO_WIDTH_RATIO: usize = 93; +// When only --goal is given, GNU sets the maximum width to goal + 10. +const DEFAULT_GOAL_WIDTH_SLACK: usize = 10; mod options { pub const CROWN_MARGIN: &str = "crown-margin"; @@ -108,8 +109,8 @@ impl FmtOptions { tagged = false; } - let xprefix = matches.contains_id(options::EXACT_PREFIX); - let xanti_prefix = matches.contains_id(options::SKIP_PREFIX); + let xprefix = matches.get_flag(options::EXACT_PREFIX); + let xanti_prefix = matches.get_flag(options::EXACT_SKIP_PREFIX); let prefix = matches.get_one::(options::PREFIX).map(String::from); let anti_prefix = matches @@ -119,12 +120,10 @@ impl FmtOptions { let width_opt = extract_width(matches)?; let goal_opt_str = matches.get_one::(options::GOAL); let goal_opt = if let Some(goal_str) = goal_opt_str { - match goal_str.parse::() { - Ok(goal) => Some(goal), - Err(_) => { - return Err(FmtError::InvalidGoal(goal_str.clone()).into()); - } - } + let goal = goal_str + .parse::() + .map_err(|_| FmtError::InvalidGoal(goal_str.clone()))?; + Some(goal) } else { None }; @@ -151,7 +150,7 @@ impl FmtOptions { if g > DEFAULT_WIDTH { return Err(FmtError::GoalGreaterThanWidth.into()); } - let w = (g * 100 / DEFAULT_GOAL_TO_WIDTH_RATIO).max(g + 3); + let w = g + DEFAULT_GOAL_WIDTH_SLACK; (w, g) } (None, None) => (DEFAULT_WIDTH, DEFAULT_GOAL), @@ -165,19 +164,12 @@ impl FmtOptions { return Err(FmtError::WidthOutOfRange(width).into()); } - let mut tabwidth = 8; - if let Some(s) = matches.get_one::(options::TAB_WIDTH) { - tabwidth = match s.parse::() { - Ok(t) => t, - Err(_) => { - return Err(FmtError::InvalidTabWidth(s.clone()).into()); - } - }; - } - - if tabwidth < 1 { - tabwidth = 1; - } + #[expect(clippy::unwrap_used, reason = "clap provides default width")] + let tabwidth_str = matches.get_one::(options::TAB_WIDTH).unwrap(); + let tabwidth = tabwidth_str + .parse::() + .map_err(|_| FmtError::InvalidTabWidth(tabwidth_str.to_owned()))? + .clamp(1, usize::MAX); Ok(Self { crown, @@ -457,6 +449,7 @@ pub fn uu_app() -> Command { .short('T') .long("tab-width") .help(translate!("fmt-tab-width-help")) + .default_value("8") .value_name("TABWIDTH"), ) .arg( diff --git a/src/uu/fmt/src/parasplit.rs b/src/uu/fmt/src/parasplit.rs index 67673747dd3..b1fcbb5d70d 100644 --- a/src/uu/fmt/src/parasplit.rs +++ b/src/uu/fmt/src/parasplit.rs @@ -8,22 +8,18 @@ use std::io::BufRead; use std::iter::Peekable; use std::slice::Iter; -use unicode_width::UnicodeWidthChar; use crate::FileOrStdReader; use crate::FmtOptions; +/// Column contribution of a decoded character toward GNU `fmt` `-w`/`--width`. +/// +/// Current GNU `fmt` (through at least 9.11) is not multibyte-aware: it measures +/// line length in UTF-8 bytes (pointer differences in `fmt.c`), not Unicode +/// display columns. Match that so wrapping agrees on CJK and other multibyte text. +/// When GNU switches to display-column width, revisit this. fn char_width(c: char) -> usize { - if (c as usize) < 0xA0 { - // if it is ASCII, call it exactly 1 wide (including control chars) - // calling control chars' widths 1 is consistent with OpenBSD fmt - 1 - } else { - // otherwise, get the unicode width - // note that we shouldn't actually get None here because only c < 0xA0 - // can return None, but for safety and future-proofing we do it this way - UnicodeWidthChar::width(c).unwrap_or(1) - } + c.len_utf8() } /// Return the UTF-8 sequence length implied by a leading byte, or `None` if invalid. @@ -98,8 +94,9 @@ fn decode_char_info(bytes: &[u8], start: usize) -> DecodedCharInfo { } } -/// Compute display width for a UTF-8 byte slice, treating invalid bytes as width 1. -fn byte_display_width(bytes: &[u8]) -> usize { +/// Compute GNU-compatible width for a UTF-8 byte slice (byte length). +/// Invalid UTF-8 bytes are treated as width 1 each via `decode_char_info`. +fn byte_width(bytes: &[u8]) -> usize { let mut width = 0; let mut idx = 0; while idx < bytes.len() { @@ -157,7 +154,7 @@ pub struct FileLine { indent_end: usize, /// The end of the PREFIX's indent, that is, the spaces before the prefix prefix_indent_end: usize, - /// Display length of indent taking into account tabs + /// Printed length of indent taking into account tabs (GNU: byte-oriented) indent_len: usize, /// PREFIX indent length taking into account tabs prefix_len: usize, @@ -255,11 +252,10 @@ impl Iterator for FileLines<'_> { fn next(&mut self) -> Option { let mut buf = Vec::new(); - match self.reader.read_until(b'\n', &mut buf) { - Ok(0) => return None, - Ok(_) => {} - Err(_) => return None, - } + self.reader + .read_until(b'\n', &mut buf) + .ok() + .filter(|n| *n > 0)?; if buf.ends_with(b"\n") { buf.pop(); if buf.ends_with(b"\r") { @@ -289,10 +285,9 @@ impl Iterator for FileLines<'_> { // treat it like a blank line, except that since it's // not truly blank we will not allow mail headers on the // following line) - if pmatch - && n[poffset + self.opts.prefix.as_ref().map_or(0, String::len)..] - .iter() - .all(|&b| is_fmt_whitespace_byte(b)) + if n[poffset + self.opts.prefix.as_ref().map_or(0, String::len)..] + .iter() + .all(|&b| is_fmt_whitespace_byte(b)) { return Some(Line::NoFormatLine(n, false)); } @@ -562,7 +557,7 @@ impl<'a> ParaWords<'a> { .map(|x| WordInfo { word: x, word_start: 0, - word_nchars: byte_display_width(x), + word_nchars: byte_width(x), before_tab: None, after_tab: 0, sentence_start: false, diff --git a/src/uu/fold/Cargo.toml b/src/uu/fold/Cargo.toml index 7f867fe5c6c..cbb9c878953 100644 --- a/src/uu/fold/Cargo.toml +++ b/src/uu/fold/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/fold.rs" test = false @@ -21,15 +18,18 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } unicode-width = { workspace = true } +uucore = { workspace = true } [dev-dependencies] divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bin]] name = "fold" path = "src/main.rs" diff --git a/src/uu/fold/benches/fold_bench.rs b/src/uu/fold/benches/fold_bench.rs index d76ddbeaf74..c4b0d969e54 100644 --- a/src/uu/fold/benches/fold_bench.rs +++ b/src/uu/fold/benches/fold_bench.rs @@ -4,43 +4,42 @@ // file that was distributed with this source code. use divan::{Bencher, black_box}; +use tempfile::TempDir; use uu_fold::uumain; -use uucore::benchmark::{create_test_file, run_util_function}; +use uucore::benchmark::{create_test_file, get_bench_args}; /// Benchmark folding many short lines #[divan::bench(args = [100_000])] fn fold_many_lines(bencher: Bencher, num_lines: usize) { - let temp_dir = tempfile::tempdir().unwrap(); let mut data = String::with_capacity(num_lines * 110); for i in 0..num_lines { data.push_str("This is a very long line number "); append_usize(&mut data, i); data.push_str(" that definitely needs to be folded at the default width of 80 columns\n"); } + let temp_dir = TempDir::new().unwrap(); let file_path = create_test_file(data.as_bytes(), temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &[file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark folding with custom width #[divan::bench(args = [50_000])] fn fold_custom_width(bencher: Bencher, num_lines: usize) { - let temp_dir = tempfile::tempdir().unwrap(); let mut data = String::with_capacity(num_lines * 80); for i in 0..num_lines { data.push_str("Line "); append_usize(&mut data, i); data.push_str(" with enough text to exceed width 40 characters and require folding\n"); } + let temp_dir = TempDir::new().unwrap(); let file_path = create_test_file(data.as_bytes(), temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-w", "40", file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-w", &"40", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/fold/locales/en-US.ftl b/src/uu/fold/locales/en-US.ftl index d4241666715..c17874ba621 100644 --- a/src/uu/fold/locales/en-US.ftl +++ b/src/uu/fold/locales/en-US.ftl @@ -5,5 +5,6 @@ fold-bytes-help = count using bytes rather than columns (meaning control charact fold-characters-help = count using character positions rather than display columns fold-spaces-help = break lines at word boundaries rather than a hard cut-off fold-width-help = set WIDTH as the maximum line width rather than 80 -fold-error-illegal-width = illegal width value +fold-error-illegal-width = invalid number of columns: { $width } +fold-error-width-out-of-range = invalid number of columns: { $width }: Numerical result out of range fold-error-readline = failed to read line diff --git a/src/uu/fold/locales/fr-FR.ftl b/src/uu/fold/locales/fr-FR.ftl index ce313160cf4..fd54c2b878e 100644 --- a/src/uu/fold/locales/fr-FR.ftl +++ b/src/uu/fold/locales/fr-FR.ftl @@ -4,5 +4,6 @@ fold-bytes-help = compter en octets plutôt qu'en colonnes (les caractères de c fold-characters-help = compter en caractères plutôt qu'en colonnes d'affichage fold-spaces-help = couper les lignes aux limites de mots plutôt qu'à une largeur fixe fold-width-help = définir WIDTH comme largeur de ligne maximale au lieu de 80 -fold-error-illegal-width = valeur de largeur illégale +fold-error-illegal-width = nombre de colonnes invalide : { $width } +fold-error-width-out-of-range = nombre de colonnes invalide : { $width } : Le résultat numérique est en dehors de l'intervalle fold-error-readline = échec de lecture de la ligne diff --git a/src/uu/fold/src/fold.rs b/src/uu/fold/src/fold.rs index 3412dde1801..a01e531b178 100644 --- a/src/uu/fold/src/fold.rs +++ b/src/uu/fold/src/fold.rs @@ -8,6 +8,7 @@ use clap::{Arg, ArgAction, Command}; use std::fs::File; use std::io::{BufRead, BufReader, BufWriter, Read, Write, stdin, stdout}; +use std::num::IntErrorKind; use std::path::Path; use unicode_width::UnicodeWidthChar; use uucore::display::Quotable; @@ -53,30 +54,33 @@ struct FoldContext<'a, W: Write> { pub fn uumain(args: impl uucore::Args) -> UResult<()> { let args = args.collect_lossy(); - let (args, obs_width) = handle_obsolete(&args[..]); + let args = handle_obsolete(&args[..]); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; let bytes = matches.get_flag(options::BYTES); let characters = matches.get_flag(options::CHARACTERS); let spaces = matches.get_flag(options::SPACES); - let poss_width = match matches.get_one::(options::WIDTH) { - Some(v) => Some(v.clone()), - None => obs_width, - }; + let poss_width = matches.get_one::(options::WIDTH).cloned(); let width = match poss_width { Some(inp_width) => match inp_width.parse::() { Ok(0) => { return Err(USimpleError::new( 1, - translate!("fold-error-illegal-width", "width" => inp_width.quote()), + translate!("fold-error-width-out-of-range", "width" => inp_width.quote()), )); } Ok(parsed_width) => parsed_width, - Err(e) => { + Err(e) if *e.kind() == IntErrorKind::PosOverflow => { return Err(USimpleError::new( 1, - translate!("fold-error-illegal-width", "width" => inp_width.quote(), "error" => e), + translate!("fold-error-width-out-of-range", "width" => inp_width.quote()), + )); + } + Err(_) => { + return Err(USimpleError::new( + 1, + translate!("fold-error-illegal-width", "width" => inp_width.quote()), )); } }, @@ -98,6 +102,8 @@ pub fn uu_app() -> Command { .override_usage(format_usage(&translate!("fold-usage"))) .about(translate!("fold-about")) .infer_long_args(true) + // GNU lets a later width override an earlier one, e.g. `fold -w3 -w5`. + .args_override_self(true) .arg( Arg::new(options::BYTES) .long(options::BYTES) @@ -136,16 +142,75 @@ pub fn uu_app() -> Command { ) } -fn handle_obsolete(args: &[String]) -> (Vec, Option) { - for (i, arg) in args.iter().enumerate() { - let slice = &arg; - if slice.starts_with('-') && slice.chars().nth(1).is_some_and(|c| c.is_ascii_digit()) { - let mut v = args.to_vec(); - v.remove(i); - return (v, Some(slice[1..].to_owned())); +/// Whether `arg` is the obsolete `-WIDTH` form, such as `-5`. +/// +/// The check is deliberately loose: GNU rejects `-5x` with +/// `invalid number of columns: '5x'` rather than treating it as a file, so +/// anything starting with a digit is taken as a (possibly invalid) width. +fn is_obsolete_width(arg: &str) -> bool { + arg.strip_prefix('-') + .and_then(|rest| rest.chars().next()) + .is_some_and(|c| c.is_ascii_digit()) +} + +/// Whether `arg` is a `-w`/`--width` spelling that consumes the *next* +/// argument as its value. +fn takes_width_value(arg: &str) -> bool { + if let Some(long) = arg.strip_prefix("--") { + // `--width=5` carries its own value. A bare `--width`, or an + // unambiguous abbreviation of it (`infer_long_args` is enabled), + // takes the following argument. + !long.is_empty() && !long.contains('=') && options::WIDTH.starts_with(long) + } else if let Some(short) = arg.strip_prefix('-') { + // Only a trailing `w` takes the next argument: in `-sw` it does, but + // in `-w3` and `-wb` the value is attached to the flag instead. A + // bare `-` has no trailing `w` either -- `ends_with` on an empty + // string is false, where comparing two `find`/`checked_sub` failures + // (both `None`) would wrongly call it a match. + short.ends_with('w') + } else { + false + } +} + +/// Rewrite the obsolete `-WIDTH` syntax into the equivalent `--width=WIDTH`. +/// +/// GNU processes options left to right, so the last width given wins no matter +/// which spelling was used (`fold -w3 -5` folds at 5). Rewriting in place keeps +/// that ordering and lets clap apply the same last-one-wins rule, via +/// `args_override_self`. +/// +/// An argument is only the obsolete form when it is not already being consumed +/// as the value of `-w`/`--width`, and has not been placed after a `--` +/// terminator. Otherwise `fold -w -1` would misreport the file name as the +/// invalid width, and `fold -- -3` would silently read stdin instead of +/// failing to open the file named `-3`. +fn handle_obsolete(args: &[String]) -> Vec { + let mut result = Vec::with_capacity(args.len()); + let mut iter = args.iter(); + + // argv[0] is the program name, never an option. + if let Some(program) = iter.next() { + result.push(program.clone()); + } + + let mut end_of_options = false; + let mut expecting_width = false; + for arg in iter { + if end_of_options || expecting_width { + expecting_width = false; + } else if arg == "--" { + end_of_options = true; + } else if is_obsolete_width(arg) { + result.push(format!("--{}={}", options::WIDTH, &arg[1..])); + continue; + } else { + expecting_width = takes_width_value(arg); } + result.push(arg.clone()); } - (args.to_vec(), None) + + result } fn fold( @@ -241,9 +306,11 @@ fn fold_file_bytewise( // We have a full `width`-byte chunk plus at least one lookahead byte. let chunk = &line[..width]; - // An existing newline within the chunk ends the line naturally; the - // newline is part of the slice, so no extra newline is emitted. - if let Some(end) = chunk.iter().position(|c| *c == NL).map(|i| i + 1) { + // A newline no further than the `width`-th byte ends the line + // naturally; the newline is part of the slice, so no extra newline is + // emitted. The lookahead byte is included because a line of exactly + // `width` bytes needs no fold at all. + if let Some(end) = line[..=width].iter().position(|c| *c == NL).map(|i| i + 1) { output.write_all(&line[..end])?; line.drain(..end); continue; @@ -260,12 +327,10 @@ fn fold_file_bytewise( width }; + // Width/space-driven fold: `end <= width` and the check above ruled out + // a newline there, so the break always needs one. output.write_all(&line[..end])?; - // Width/space-driven fold: insert a newline unless the next byte is - // already a newline (it is emitted on the next pass). - if line[end] != NL { - output.write_all(&[NL])?; - } + output.write_all(&[NL])?; line.drain(..end); } Ok(()) diff --git a/src/uu/groups/Cargo.toml b/src/uu/groups/Cargo.toml index 050d127d70a..b3e64bb3e48 100644 --- a/src/uu/groups/Cargo.toml +++ b/src/uu/groups/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/groups.rs" test = false @@ -21,9 +18,12 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true, features = ["entries", "process"] } -fluent = { workspace = true } + +[lints] +workspace = true [[bin]] name = "groups" diff --git a/src/uu/groups/src/groups.rs b/src/uu/groups/src/groups.rs index 86128fc63b8..9ea41e185ef 100644 --- a/src/uu/groups/src/groups.rs +++ b/src/uu/groups/src/groups.rs @@ -6,7 +6,6 @@ // spell-checker:ignore (ToDO) passwd use std::io::{Write, stdout}; -use thiserror::Error; use uucore::{ display::Quotable, entries::{Locate, Passwd, get_groups_gnu, gid2grp}, @@ -21,7 +20,7 @@ mod options { pub const USERS: &str = "USERNAME"; } -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum GroupsError { #[error("{message}", message = translate!("groups-error-fetch"))] GetGroupsFailed, @@ -55,9 +54,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .unwrap_or_default(); if users.is_empty() { - let Ok(gids) = get_groups_gnu(None) else { - return Err(GroupsError::GetGroupsFailed.into()); - }; + let gids = get_groups_gnu(None).map_err(|_| GroupsError::GetGroupsFailed)?; let groups: Vec = gids.into_iter().map(infallible_gid2grp).collect(); writeln!(stdout(), "{}", groups.join(" "))?; return Ok(()); diff --git a/src/uu/head/Cargo.toml b/src/uu/head/Cargo.toml index f3985b4e4a3..28d40ac93df 100644 --- a/src/uu/head/Cargo.toml +++ b/src/uu/head/Cargo.toml @@ -11,15 +11,13 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/head.rs" doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } memchr = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true, features = [ @@ -28,8 +26,14 @@ uucore = { workspace = true, features = [ "ringbuffer", "lines", "fs", + "quoting-style", ] } -fluent = { workspace = true } + +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true [[bin]] name = "head" diff --git a/src/uu/head/locales/en-US.ftl b/src/uu/head/locales/en-US.ftl index 262c889a3db..7acd17980bf 100644 --- a/src/uu/head/locales/en-US.ftl +++ b/src/uu/head/locales/en-US.ftl @@ -28,4 +28,4 @@ head-error-writing-stdout = error writing 'standard output': {$err} head-error-cannot-open = cannot open {$name} for reading # Output headers -head-header-stdin = ==> standard input <== +head-name-stdin = standard input diff --git a/src/uu/head/locales/fr-FR.ftl b/src/uu/head/locales/fr-FR.ftl index db24da4a6e6..473e02aca16 100644 --- a/src/uu/head/locales/fr-FR.ftl +++ b/src/uu/head/locales/fr-FR.ftl @@ -28,4 +28,4 @@ head-error-writing-stdout = erreur lors de l'écriture sur 'sortie standard' : { head-error-cannot-open = impossible d'ouvrir {$name} en lecture # En-têtes de sortie -head-header-stdin = ==> entrée standard <== +head-name-stdin = entrée standard diff --git a/src/uu/head/src/head.rs b/src/uu/head/src/head.rs index 24ff9e5c045..8164d28f769 100644 --- a/src/uu/head/src/head.rs +++ b/src/uu/head/src/head.rs @@ -16,10 +16,13 @@ use std::os::fd::AsFd; #[cfg(windows)] use std::path::Path; use std::path::PathBuf; -use thiserror::Error; -use uucore::display::{Quotable, print_verbatim}; -use uucore::error::{FromIo, UError, UResult, USimpleError}; +use uucore::diagnostics::OptionValue; +use uucore::display::Quotable; +use uucore::error::{FromIo, UError, UResult, USimpleError, strip_errno}; use uucore::line_ending::LineEnding; +use uucore::parser::parse_signed_num::number_offset; +use uucore::parser::parse_size::ParseSizeError; +use uucore::quoting_style::locale_aware_shell_escape; use uucore::show; use uucore::translate; @@ -35,7 +38,7 @@ use take::copy_all_but_n_bytes; use take::copy_all_but_n_lines; use take::take_lines; -#[derive(Error, Debug)] +#[derive(Debug, thiserror::Error)] enum HeadError { /// Wrapper around `io::Error` #[error("{}", translate!("head-error-reading-file", "name" => name.quote(), "err" => err))] @@ -76,19 +79,60 @@ impl Default for Mode { } } +/// A `-c` or `-n` value that does not parse. +/// +/// The message is built where it always was; the rest is what a caret needs: +/// the value as typed, the option it was given to, and what the size parser +/// made of it. +#[derive(Debug)] +pub struct SizeError { + pub message: String, + option: OptionValue, + error: ParseSizeError, +} + +impl SizeError { + /// The error to raise, a caret under the part of the value at fault when + /// the arguments as typed were kept. + fn into_error(self, diag_args: Option<&[OsString]>) -> Box { + self.error.size_value_error( + diag_args, + &self.option, + // The parser never saw the sign; the caret has to count it back in. + number_offset(&self.option.value), + &self.message, + HeadError::MatchOption(self.message.clone()), + ) + } +} + impl Mode { - fn from(matches: &ArgMatches) -> Result { + fn from(matches: &ArgMatches) -> Result { + fn failed( + value: &str, + short: char, + long: &'static str, + key: &'static str, + ) -> impl FnOnce(ParseSizeError) -> SizeError { + let option = OptionValue::new(value, short, long); + move |error| SizeError { + message: translate!(key, "err" => &error), + option, + error, + } + } + if let Some(v) = matches.get_one::(options::BYTES) { - let (n, all_but_last) = parse::parse_num(v) - .map_err(|err| translate!("head-error-invalid-bytes", "err" => err))?; + let (n, all_but_last) = + parse::parse_num(v).map_err(failed(v, 'c', "bytes", "head-error-invalid-bytes"))?; if all_but_last { Ok(Self::AllButLastBytes(n)) } else { Ok(Self::FirstBytes(n)) } } else if let Some(v) = matches.get_one::(options::LINES) { - let (n, all_but_last) = parse::parse_num(v) - .map_err(|err| translate!("head-error-invalid-lines", "err" => err))?; + let (n, all_but_last) = + parse::parse_num(v).map_err(failed(v, 'n', "lines", "head-error-invalid-lines"))?; if all_but_last { Ok(Self::AllButLastLines(n)) } else { @@ -129,7 +173,7 @@ fn arg_iterate<'a>( } } -#[derive(Debug, PartialEq, Default)] +#[derive(Debug, PartialEq)] struct HeadOptions { pub quiet: bool, pub verbose: bool, @@ -140,22 +184,21 @@ struct HeadOptions { } impl HeadOptions { - ///Construct options from matches - pub fn get_from(matches: &ArgMatches) -> Result { - let mut options = Self::default(); - - options.quiet = matches.get_flag(options::QUIET); - options.verbose = matches.get_flag(options::VERBOSE); - options.line_ending = LineEnding::from_zero_flag(matches.get_flag(options::ZERO)); - options.presume_input_pipe = matches.get_flag(options::PRESUME_INPUT_PIPE); - - options.mode = Mode::from(matches)?; - // #[allow(clippy::unwrap_used, reason = "clap provides '-' by default")] - options.files = matches - .get_many::(options::FILES) - .unwrap() - .cloned() - .collect(); + /// Construct options from matches + pub fn get_from(matches: &ArgMatches) -> Result { + let options = Self { + quiet: matches.get_flag(options::QUIET), + verbose: matches.get_flag(options::VERBOSE), + line_ending: LineEnding::from_zero_flag(matches.get_flag(options::ZERO)), + presume_input_pipe: matches.get_flag(options::PRESUME_INPUT_PIPE), + mode: Mode::from(matches)?, + // #[allow(clippy::unwrap_used, reason = "clap provides '-' by default")] + files: matches + .get_many::(options::FILES) + .unwrap() + .cloned() + .collect(), + }; Ok(options) } @@ -165,7 +208,7 @@ impl HeadOptions { fn wrap_in_stdout_error(err: io::Error) -> io::Error { io::Error::new( err.kind(), - translate!("head-error-writing-stdout", "err" => uucore::error::strip_errno(&err)), + translate!("head-error-writing-stdout", "err" => strip_errno(&err)), ) } @@ -212,13 +255,9 @@ fn print_n_lines(input: &mut impl io::BufRead, n: u64, separator: u8) -> io::Res Ok(bytes_written) } -fn catch_too_large_numbers_in_backwards_bytes_or_lines(n: u64) -> Option { - usize::try_from(n).ok() -} - fn print_but_last_n_bytes(mut input: impl Read, n: u64) -> io::Result { let mut bytes_written: u64 = 0; - if let Some(n) = catch_too_large_numbers_in_backwards_bytes_or_lines(n) { + if let Ok(n) = usize::try_from(n) { let stdout = io::stdout(); let mut stdout = stdout.lock(); @@ -242,7 +281,7 @@ fn print_but_last_n_lines(mut input: impl Read, n: u64, separator: u8) -> io::Re return io::copy(&mut input, &mut stdout).map_err(wrap_in_stdout_error); } let mut bytes_written: u64 = 0; - if let Some(n) = catch_too_large_numbers_in_backwards_bytes_or_lines(n) { + if let Ok(n) = usize::try_from(n) { bytes_written = copy_all_but_n_lines(input, &mut stdout, n, separator) .map_err(wrap_in_stdout_error)? .try_into() @@ -410,7 +449,8 @@ fn uu_head(options: &HeadOptions) -> UResult<()> { if !first { writeln!(stdout)?; } - writeln!(stdout, "{}", translate!("head-header-stdin"))?; + let name = locale_aware_shell_escape(translate!("head-name-stdin")); + writeln!(stdout, "==> {name} <==")?; } let stdin = io::stdin(); @@ -460,9 +500,8 @@ fn uu_head(options: &HeadOptions) -> UResult<()> { if !first { writeln!(stdout)?; } - write!(stdout, "==> ")?; - print_verbatim(file).unwrap(); - writeln!(stdout, " <==")?; + let name = locale_aware_shell_escape(file); + writeln!(stdout, "==> {name} <==")?; first = false; } Ok(()) @@ -473,20 +512,18 @@ fn uu_head(options: &HeadOptions) -> UResult<()> { Err(err) => { #[cfg(windows)] // On Windows, `File::open` on a directory fails with "Permission denied". - if err.kind() == io::ErrorKind::PermissionDenied { - if let Ok(m) = Path::new(file).metadata() { - if m.is_dir() { - // We need to print the header, as we have an existing directory - print_header()?; - if !zero_output { - show!(USimpleError::new( - 1, - translate!("head-error-reading-file", "name" => file.quote(), "err" => "Is a directory") - )); - } - continue; - } + if err.kind() == io::ErrorKind::PermissionDenied + && Path::new(file).metadata().is_ok_and(|m| m.is_dir()) + { + // We need to print the header, as we have an existing directory + print_header()?; + if !zero_output { + show!(USimpleError::new( + 1, + translate!("head-error-reading-file", "name" => file.quote(), "err" => "Is a directory") + )); } + continue; } show!(err.map_err_context( @@ -538,9 +575,13 @@ fn uu_head(options: &HeadOptions) -> UResult<()> { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let args: Vec<_> = arg_iterate(args)?.collect(); + let raw_args: Vec<_> = args.collect(); + // Capture before obsolete options such as `-5` are rewritten to `-n 5`. + let diag_args = uucore::diagnostics::capture(&raw_args); + let args: Vec<_> = arg_iterate(raw_args.into_iter())?.collect(); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - let options = HeadOptions::get_from(&matches).map_err(HeadError::MatchOption)?; + let options = + HeadOptions::get_from(&matches).map_err(|e| e.into_error(diag_args.as_deref()))?; uu_head(&options) } @@ -552,11 +593,12 @@ mod tests { use super::*; fn options(args: &str) -> Result { + // The unit tests compare messages, not the rest of the failure. let combined = "head ".to_owned() + args; let args = combined.split_whitespace().map(OsString::from); let matches = uu_app() .get_matches_from(arg_iterate(args).map_err(|_| String::from("Arg iterate failed"))?); - HeadOptions::get_from(&matches) + HeadOptions::get_from(&matches).map_err(|e| e.message) } #[test] @@ -604,13 +646,14 @@ mod tests { #[test] fn test_options_correct_defaults() { - let opts = HeadOptions::default(); + let matches = uu_app().get_matches(); + let opts = HeadOptions::get_from(&matches).unwrap(); assert!(!opts.verbose); assert!(!opts.quiet); assert_eq!(opts.line_ending, LineEnding::Newline); assert_eq!(opts.mode, Mode::FirstLines(10)); - assert!(opts.files.is_empty()); + assert_eq!(opts.files, vec!(OsString::from("-"))); } fn arg_outputs(src: &str) -> Result { diff --git a/src/uu/head/src/take.rs b/src/uu/head/src/take.rs index 0496635dee9..bb0dedf9108 100644 --- a/src/uu/head/src/take.rs +++ b/src/uu/head/src/take.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + //! Take all but the last elements of an iterator. + use memchr::memchr_iter; use std::collections::VecDeque; use std::io::{ErrorKind, Read, Write}; diff --git a/src/uu/hostid/Cargo.toml b/src/uu/hostid/Cargo.toml index 91f82212a75..962fa5d1ed4 100644 --- a/src/uu/hostid/Cargo.toml +++ b/src/uu/hostid/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/hostid.rs" test = false @@ -21,9 +18,17 @@ doctest = false [dependencies] clap = { workspace = true } -libc = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true } + +[target.'cfg(windows)'.dependencies] +hostname = { workspace = true } + +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true, features = ["system"] } + +[lints] +workspace = true [[bin]] name = "hostid" diff --git a/src/uu/hostid/src/hostid.rs b/src/uu/hostid/src/hostid.rs index 04074b0bbda..9d27f7c9951 100644 --- a/src/uu/hostid/src/hostid.rs +++ b/src/uu/hostid/src/hostid.rs @@ -3,31 +3,71 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) gethostid +// spell-checker:ignore (ToDO) gethostid nodename addrs Addrs use clap::Command; -use libc::{c_long, gethostid}; +use core::ffi::c_long; use std::io::{Write, stdout}; -use uucore::{error::UResult, format_usage}; +use uucore::{error::UResult, format_usage, translate}; -use uucore::translate; +// support targets missing libc::gethostid +fn gethostid() -> c_long { + // /etc/hostid is still useful when + // - Windows native hostid is called on MSYS shell + // - wasi binary is called on unix host + if let Ok(data) = std::fs::read("/etc/hostid") + && let Some(bytes) = data.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()) + { + return u32::from_ne_bytes(bytes) as c_long; + } + #[cfg(unix)] + { + use std::net::{IpAddr, ToSocketAddrs as _}; + let uname = rustix::system::uname(); + if let Ok(hostname) = uname.nodename().to_str() { + let query = format!("{hostname}:0"); + if let Ok(mut addrs) = query.to_socket_addrs() + && let Some(addr) = addrs.find(|a| a.ip().is_ipv4()) + && let IpAddr::V4(ipv4) = addr.ip() + { + return u32::from_ne_bytes(ipv4.octets()).rotate_left(16) as c_long; + } + } + } + // todo: remove to_string_lossy and use this for unix + // todo: use std::net::hostname when is was stabilized + #[cfg(windows)] + { + use std::net::{IpAddr, ToSocketAddrs as _}; + if let Ok(mut hostname) = hostname::get() { + hostname.push(":0"); + if let Ok(mut addrs) = hostname.to_string_lossy().to_socket_addrs() + && let Some(addr) = addrs.find(|a| a.ip().is_ipv4()) + && let IpAddr::V4(ipv4) = addr.ip() + { + return u32::from_ne_bytes(ipv4.octets()).rotate_left(16) as c_long; + } + } + } + 0 +} #[uucore::main(no_signals)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { uucore::clap_localization::handle_clap_result(uu_app(), args)?; - /* - * POSIX says gethostid returns a "32-bit identifier" but is silent - * whether it's sign-extended. Turn off any sign-extension. This - * is a no-op unless unsigned int is wider than 32 bits. - */ + // The identifier `gethostid` reports is 32 bits wide, but it arrives in a + // `c_long`, and nothing promises how the unused upper bits are filled. Mask + // them off so a value with the high bit set prints as its own eight digits + // rather than as a sign-extended one; where `c_long` is itself 32 bits the + // mask changes nothing. - let mut result: c_long = unsafe { gethostid() }; + let mut result = gethostid(); #[allow(overflowing_literals)] let mask = 0xffff_ffff; result &= mask; - writeln!(stdout().lock(), "{result:0>8x}")?; + writeln!(stdout(), "{result:0>8x}")?; Ok(()) } diff --git a/src/uu/hostname/Cargo.toml b/src/uu/hostname/Cargo.toml index a4ba3183d2c..b339adb87a9 100644 --- a/src/uu/hostname/Cargo.toml +++ b/src/uu/hostname/Cargo.toml @@ -14,38 +14,31 @@ readme.workspace = true [package.metadata.cargo-udeps.ignore] normal = ["uucore_procs"] -[lints] -workspace = true - [lib] path = "src/hostname.rs" test = false doctest = false -[dependencies] +[target.'cfg(any(all(unix, not(any(target_os = "aix", target_os = "redox"))), windows))'.dependencies] clap = { workspace = true } +fluent = { workspace = true } hostname = { workspace = true, features = ["set"] } uucore = { workspace = true, features = ["wide"] } -fluent = { workspace = true } - -[target.'cfg(any(target_os = "freebsd", target_os = "openbsd"))'.dependencies] -dns-lookup = { workspace = true } - -[target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { workspace = true, features = [ - "Win32_Networking_WinSock", - "Win32_Foundation", -] } [[bin]] name = "hostname" path = "src/main.rs" +required-features = ["uucore/default"] [[bench]] name = "hostname_bench" harness = false +required-features = ["uucore/default"] [dev-dependencies] divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } + +[lints] +workspace = true diff --git a/src/uu/hostname/benches/hostname_bench.rs b/src/uu/hostname/benches/hostname_bench.rs index 602e1ba9673..9946d7a10fa 100644 --- a/src/uu/hostname/benches/hostname_bench.rs +++ b/src/uu/hostname/benches/hostname_bench.rs @@ -19,6 +19,8 @@ //! //! Without NSS_WRAPPER, the benchmark tests with the system's real /etc/hosts. +#![cfg(any(all(unix, not(any(target_os = "aix", target_os = "redox"))), windows))] + use divan::{Bencher, black_box}; use std::io::Write; use uu_hostname::uumain; diff --git a/src/uu/hostname/locales/en-US.ftl b/src/uu/hostname/locales/en-US.ftl index f7a9a26a8ef..31de4ab4069 100644 --- a/src/uu/hostname/locales/en-US.ftl +++ b/src/uu/hostname/locales/en-US.ftl @@ -7,7 +7,6 @@ hostname-help-short = Display the short hostname (the portion before the first d hostname-error-permission = hostname: you must be root to change the host name hostname-error-invalid-name = hostname: invalid hostname '{ $name }' hostname-error-resolve-failed = hostname: unable to resolve host name '{ $name }' -hostname-error-winsock = failed to start Winsock hostname-error-set-hostname = failed to set hostname hostname-error-get-hostname = failed to get hostname hostname-error-resolve-socket = failed to resolve socket addresses diff --git a/src/uu/hostname/locales/fr-FR.ftl b/src/uu/hostname/locales/fr-FR.ftl index 700b5ff409d..fb5cb7a1cca 100644 --- a/src/uu/hostname/locales/fr-FR.ftl +++ b/src/uu/hostname/locales/fr-FR.ftl @@ -7,7 +7,6 @@ hostname-help-short = Afficher le nom d'hôte court (la partie avant le premier hostname-error-permission = hostname : vous devez être root pour changer le nom d'hôte hostname-error-invalid-name = hostname : nom d'hôte invalide '{ $name }' hostname-error-resolve-failed = hostname : impossible de résoudre le nom d'hôte '{ $name }' -hostname-error-winsock = échec du démarrage de Winsock hostname-error-set-hostname = échec de la définition du nom d'hôte hostname-error-get-hostname = échec de l'obtention du nom d'hôte hostname-error-resolve-socket = échec de la résolution des adresses de socket diff --git a/src/uu/hostname/src/hostname.rs b/src/uu/hostname/src/hostname.rs index 53f0ac35e93..1c06c944335 100644 --- a/src/uu/hostname/src/hostname.rs +++ b/src/uu/hostname/src/hostname.rs @@ -5,8 +5,9 @@ // spell-checker:ignore hashset Addrs addrs +#![cfg(any(all(unix, not(any(target_os = "aix", target_os = "redox"))), windows))] + use std::io::{Write, stdout}; -#[cfg(not(any(target_os = "freebsd", target_os = "openbsd")))] use std::net::ToSocketAddrs; use std::str; use std::{collections::hash_set::HashSet, ffi::OsString}; @@ -14,8 +15,6 @@ use std::{collections::hash_set::HashSet, ffi::OsString}; use clap::builder::ValueParser; use clap::{Arg, ArgAction, ArgMatches, Command}; -#[cfg(any(target_os = "freebsd", target_os = "openbsd"))] -use dns_lookup::lookup_host; use uucore::translate; use uucore::{ @@ -29,39 +28,10 @@ static OPT_FQDN: &str = "fqdn"; static OPT_SHORT: &str = "short"; static OPT_HOST: &str = "host"; -#[cfg(windows)] -mod wsa { - use std::io; - - use windows_sys::Win32::Networking::WinSock::{WSACleanup, WSADATA, WSAStartup}; - - pub(super) struct WsaHandle(()); - - pub(super) fn start() -> io::Result { - let mut data = std::mem::MaybeUninit::::uninit(); - let err = unsafe { WSAStartup(0x0202, data.as_mut_ptr()) }; - if err == 0 { - Ok(WsaHandle(())) - } else { - Err(io::Error::from_raw_os_error(err)) - } - } - - impl Drop for WsaHandle { - fn drop(&mut self) { - // This possibly returns an error but we can't handle it - let _ = unsafe { WSACleanup() }; - } - } -} - #[uucore::main(no_signals)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - #[cfg(windows)] - let _handle = wsa::start().map_err_context(|| translate!("hostname-error-winsock"))?; - match matches.get_one::(OPT_HOST) { None => display_hostname(&matches), Some(host) => { @@ -123,32 +93,15 @@ fn display_hostname(matches: &ArgMatches) -> UResult<()> { .into_owned(); if matches.get_flag(OPT_IP_ADDRESS) { - let addresses; - - #[cfg(not(any(target_os = "freebsd", target_os = "openbsd")))] - { - let hostname = hostname + ":1"; - let addrs = hostname - .to_socket_addrs() - .map_err_context(|| "failed to resolve socket addresses".to_owned())?; - addresses = addrs; - } - - // DNS reverse lookup via "hostname:1" does not work on FreeBSD and OpenBSD - // use dns-lookup crate instead - #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] - { - let addrs: Vec = lookup_host(hostname.as_str()) - .map_err_context(|| "failed to lookup hostname".to_owned())? - .collect(); - addresses = addrs; - } + let addresses = (hostname, 1) + .to_socket_addrs() + .map_err_context(|| "failed to resolve socket addresses".to_owned())?; let mut hashset = HashSet::new(); let mut output = String::new(); for addr in addresses { // XXX: not sure why this is necessary... - if !hashset.contains(&addr) { + if hashset.insert(addr) { let mut ip = addr.to_string(); if ip.ends_with(":1") { let len = ip.len(); @@ -156,7 +109,6 @@ fn display_hostname(matches: &ArgMatches) -> UResult<()> { } output.push_str(&ip); output.push(' '); - hashset.insert(addr); } } let len = output.len(); diff --git a/src/uu/hostname/src/main.rs b/src/uu/hostname/src/main.rs index 7ad3364c79f..3609547136e 100644 --- a/src/uu/hostname/src/main.rs +++ b/src/uu/hostname/src/main.rs @@ -3,4 +3,4 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -uucore::bin!(uu_hostname); +uucore::bin!(uu_hostname, no_flush); diff --git a/src/uu/id/Cargo.toml b/src/uu/id/Cargo.toml index 3890aadc944..71a6386c9d9 100644 --- a/src/uu/id/Cargo.toml +++ b/src/uu/id/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/id.rs" test = false @@ -21,9 +18,9 @@ doctest = false [dependencies] clap = { workspace = true } -rustix = { workspace = true } -uucore = { workspace = true, features = ["entries", "process"] } fluent = { workspace = true } +rustix = { workspace = true, features = ["process"] } +uucore = { workspace = true, features = ["entries", "process"] } [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] selinux = { workspace = true, optional = true } @@ -33,5 +30,8 @@ name = "id" path = "src/main.rs" [features] -feat_selinux = ["selinux"] +selinux = ["dep:selinux"] smack = ["uucore/smack"] + +[lints] +workspace = true diff --git a/src/uu/id/src/id.rs b/src/uu/id/src/id.rs index 24b80e8eb41..37dd419e130 100644 --- a/src/uu/id/src/id.rs +++ b/src/uu/id/src/id.rs @@ -34,7 +34,7 @@ #![allow(dead_code)] use clap::{Arg, ArgAction, Command}; -use std::ffi::CStr; +use core::ffi::CStr; use std::io::{self, Write}; use uucore::display::Quotable; use uucore::entries::{self, Group, Locate, Passwd}; @@ -315,11 +315,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { )?; } - let groups = entries::get_groups_gnu(Some(gid))?; + // GNU heads the two forms with opposite gids: `groups=` with the + // effective gid, `-G` with the real gid. Don't share one list. let groups = if state.user_specified { possible_pw.as_ref().map(Passwd::belongs_to).unwrap() + } else if state.gsflag { + group_list_real_gid_first()? } else { - groups.clone() + entries::get_groups_gnu(Some(getegid().as_raw()))? }; if state.gsflag { @@ -630,6 +633,29 @@ fn auditid() -> io::Result<()> { Ok(()) } +/// The group list as `id -G` prints it. `-r` does not affect it, matching GNU. +fn group_list_real_gid_first() -> io::Result> { + Ok(sort_real_gid_first( + getgid().as_raw(), + getegid().as_raw(), + entries::get_groups_gnu(None)?, + )) +} + +/// Real gid, then effective gid when it differs, then `getgroups()` minus those. +fn sort_real_gid_first(rgid: u32, egid: u32, supplementary: Vec) -> Vec { + let mut ordered = vec![rgid]; + if egid != rgid { + ordered.push(egid); + } + for group in supplementary { + if !ordered.contains(&group) { + ordered.push(group); + } + } + ordered +} + fn id_print(state: &State, groups: &[u32]) -> io::Result<()> { let uid = state.ids.as_ref().unwrap().uid; let gid = state.ids.as_ref().unwrap().gid; @@ -638,6 +664,10 @@ fn id_print(state: &State, groups: &[u32]) -> io::Result<()> { let mut lock = io::stdout().lock(); + // Name lookup failures in the default output are non-fatal: GNU `id` + // happily prints just the numeric id and exits 0. Docker containers + // routinely run as uids that don't have a /etc/passwd entry, and exit + // 1 here breaks init scripts that probe with `id`. write!( lock, "uid={uid}({})", @@ -646,7 +676,6 @@ fn id_print(state: &State, groups: &[u32]) -> io::Result<()> { "{}", translate!("id-error-cannot-find-user-name", "uid" => uid) ); - set_exit_code(1); uid.to_string() }) )?; @@ -658,7 +687,6 @@ fn id_print(state: &State, groups: &[u32]) -> io::Result<()> { "{}", translate!("id-error-cannot-find-group-name", "gid" => gid) ); - set_exit_code(1); gid.to_string() }) )?; @@ -671,7 +699,6 @@ fn id_print(state: &State, groups: &[u32]) -> io::Result<()> { "{}", translate!("id-error-cannot-find-user-name", "uid" => euid) ); - set_exit_code(1); euid.to_string() }) )?; @@ -686,7 +713,6 @@ fn id_print(state: &State, groups: &[u32]) -> io::Result<()> { "{}", translate!("id-error-cannot-find-group-name", "gid" => egid) ); - set_exit_code(1); egid.to_string() }) )?; @@ -703,7 +729,6 @@ fn id_print(state: &State, groups: &[u32]) -> io::Result<()> { "{}", translate!("id-error-cannot-find-group-name", "gid" => gr) ); - set_exit_code(1); gr.to_string() }) )) diff --git a/src/uu/install/Cargo.toml b/src/uu/install/Cargo.toml index d4f8b52bffb..bee20905846 100644 --- a/src/uu/install/Cargo.toml +++ b/src/uu/install/Cargo.toml @@ -11,34 +11,37 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/install.rs" [dependencies] clap = { workspace = true } -filetime = { workspace = true } -file_diff = { workspace = true } +fluent = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true, default-features = true, features = [ "backup-control", "buf-copy", + "entries", "fs", "mode", "perms", - "entries", "process", ] } -fluent = { workspace = true } [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +rustix = { workspace = true, features = ["fs"] } selinux = { workspace = true, optional = true } +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true, features = ["process"] } + [features] +diagnostics = ["uucore/diagnostics"] selinux = ["dep:selinux", "uucore/selinux"] +[lints] +workspace = true + [[bin]] name = "install" path = "src/main.rs" diff --git a/src/uu/install/locales/en-US.ftl b/src/uu/install/locales/en-US.ftl index 1b5b92e5320..0a4037ecc4e 100644 --- a/src/uu/install/locales/en-US.ftl +++ b/src/uu/install/locales/en-US.ftl @@ -39,6 +39,7 @@ install-error-invalid-user = invalid user: { $user } install-error-invalid-group = invalid group: { $group } install-error-omitting-directory = omitting directory { $path } install-error-not-a-directory = failed to access { $path }: Not a directory +install-error-target-dir-access = failed to access { $path }: { $error } install-error-existing-file-not-directory = cannot create directory { $path }: File exists install-error-override-directory-failed = cannot overwrite directory { $dir } with non-directory { $file } install-error-same-file = { $file1 } and { $file2 } are the same file @@ -51,6 +52,7 @@ install-error-mutually-exclusive-compare-strip = Options --compare and --strip a install-error-missing-file-operand = missing file operand install-error-missing-destination-operand = missing destination file operand after { $path } install-error-failed-to-remove = Failed to remove existing file { $path }. Error: { $error } +install-error-will-not-overwrite-just-created = will not overwrite just-created { $dest } with { $source } # Warning messages install-warning-compare-ignored = the --compare (-C) option is ignored when you specify a mode with non-permission bits @@ -61,3 +63,4 @@ install-verbose-creating-directory = creating directory { $path } install-verbose-creating-directory-step = install: creating directory { $path } install-verbose-removed = removed { $path } install-verbose-backup = (backup: { $backup }) +install-error-backing-up-destroy-source = backing up { $dest } might destroy source; { $source } not copied diff --git a/src/uu/install/locales/fr-FR.ftl b/src/uu/install/locales/fr-FR.ftl index 1503c0c7c7d..70ae931697b 100644 --- a/src/uu/install/locales/fr-FR.ftl +++ b/src/uu/install/locales/fr-FR.ftl @@ -39,6 +39,7 @@ install-error-invalid-user = utilisateur invalide : { $user } install-error-invalid-group = groupe invalide : { $group } install-error-omitting-directory = omission du répertoire { $path } install-error-not-a-directory = échec de l'accès à { $path } : N'est pas un répertoire +install-error-target-dir-access = échec de l'accès à { $path } : { $error } install-error-existing-file-not-directory = impossible de créer le répertoire { $path } : Le fichier existe install-error-override-directory-failed = impossible d'écraser le répertoire { $dir } avec un non-répertoire { $file } install-error-same-file = { $file1 } et { $file2 } sont le même fichier @@ -50,6 +51,7 @@ install-error-mutually-exclusive-compare-strip = Les options --compare et --stri install-error-missing-file-operand = opérande de fichier manquant install-error-missing-destination-operand = opérande de fichier de destination manquant après { $path } install-error-failed-to-remove = Échec de la suppression du fichier existant { $path }. Erreur : { $error } +install-error-will-not-overwrite-just-created = n'écrasera pas le fichier tout juste créé { $dest } avec { $source } # Messages d'avertissement install-warning-compare-ignored = l'option --compare (-C) est ignorée quand un mode est indiqué avec des bits non liés à des droits diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index 58e16a381ee..a42d805f04d 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -8,10 +8,10 @@ mod mode; use clap::{Arg, ArgAction, ArgMatches, Command}; -use file_diff::diff; -use filetime::{FileTime, set_file_times}; +use rustix::process::{getegid, geteuid}; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use selinux::SecurityContext; +use std::collections::HashSet; use std::ffi::OsString; use std::fmt::Debug; use std::fs::{self, metadata}; @@ -19,15 +19,13 @@ use std::fs::{File, OpenOptions}; use std::io::{Write, stdout}; use std::path::{MAIN_SEPARATOR, Path, PathBuf}; use std::process; -use thiserror::Error; -use uucore::backup_control::{self, BackupMode}; +use uucore::backup_control::{self, BackupMode, backup_would_destroy_source}; use uucore::buf_copy::copy_fast; use uucore::display::Quotable; use uucore::entries::{grp2gid, usr2uid}; use uucore::error::{FromIo, UError, UResult, UUsageError, strip_errno}; -use uucore::fs::dir_strip_dot_for_creation; +use uucore::fs::{are_files_identical, dir_strip_dot_for_creation}; use uucore::perms::{Verbosity, VerbosityLevel, wrap_chown}; -use uucore::process::{getegid, geteuid}; #[cfg(unix)] use uucore::safe_traversal::{DirFd, SymlinkBehavior, create_dir_all_safe}; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] @@ -60,7 +58,7 @@ pub struct Behavior { strip: bool, strip_program: String, create_leading: bool, - target_dir: Option, + target_dir: Option, no_target_dir: bool, preserve_context: bool, context: Option, @@ -68,7 +66,7 @@ pub struct Behavior { privileged: bool, } -#[derive(Error, Debug)] +#[derive(Debug, thiserror::Error)] enum InstallError { #[error("{}", translate!("install-error-dir-needs-arg", "util_name" => "install"))] DirNeedsArg, @@ -79,7 +77,7 @@ enum InstallError { #[error("{}", translate!("install-error-chmod-failed", "path" => .0.quote()))] ChmodFailed(PathBuf), - #[error("{}", translate!("install-error-chown-failed", "path" => .0.quote(), "error" => .1.clone()))] + #[error("{}", translate!("install-error-chown-failed", "path" => .0.quote(), "error" => .1))] ChownFailed(PathBuf, String), #[error("{}", translate!("install-error-invalid-target", "path" => .0.quote()))] @@ -91,10 +89,13 @@ enum InstallError { #[error("{}", translate!("install-error-backup-failed", "from" => .0.quote(), "error" => strip_errno(.1)))] BackupFailed(PathBuf, #[source] std::io::Error), - #[error("{}", translate!("install-error-install-failed", "from" => .0.quote(), "to" => .1.quote(), "error" => .2.clone()))] + #[error("{}", translate!("install-error-backing-up-destroy-source", "dest" => .0.quote(), "source" => .1.quote()))] + BackupWouldDestroySource(PathBuf, PathBuf), + + #[error("{}", translate!("install-error-install-failed", "from" => .0.quote(), "to" => .1.quote(), "error" => .2))] InstallFailed(PathBuf, PathBuf, String), - #[error("{}", translate!("install-error-strip-failed", "error" => .0.clone()))] + #[error("{}", translate!("install-error-strip-failed", "error" => .0))] StripProgramFailed(String), #[error("{}", translate!("install-error-strip-terminated"))] @@ -115,6 +116,9 @@ enum InstallError { #[error("{}", translate!("install-error-not-a-directory", "path" => .0.quote()))] NotADirectory(PathBuf), + #[error("{}", translate!("install-error-target-dir-access", "path" => .0.quote(), "error" => strip_errno(.1)))] + TargetDirAccess(PathBuf, #[source] std::io::Error), + #[error("{}", translate!("install-error-existing-file-not-directory", "path" => .0.quote()))] ExistingFileNotADirectory(PathBuf), @@ -124,7 +128,7 @@ enum InstallError { #[error("{}", translate!("install-error-same-file", "file1" => .0.quote(), "file2" => .1.quote()))] SameFile(PathBuf, PathBuf), - #[error("{}", translate!("install-error-extra-operand", "operand" => .0.quote(), "usage" => .1.clone()))] + #[error("{}", translate!("install-error-extra-operand", "operand" => .0.quote(), "usage" => .1))] ExtraOperand(OsString, String), #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] @@ -133,6 +137,9 @@ enum InstallError { #[error("{}", translate!("install-error-not-permitted", "path" => .0.quote()))] NotPermitted(PathBuf), + + #[error("{}", translate!("install-error-will-not-overwrite-just-created", "dest" => .0.quote(), "source" => .1.quote()))] + WillNotOverwriteJustCreated(PathBuf, PathBuf), } impl UError for InstallError { @@ -186,6 +193,9 @@ static ARG_FILES: &str = "files"; /// #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let args: Vec = args.collect(); + // Kept for the caret in mode diagnostics, which needs the mode as typed. + let diag_args = uucore::diagnostics::operands(&args); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; let paths: Vec = matches @@ -193,7 +203,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .map(|v| v.cloned().collect()) .unwrap_or_default(); - let behavior = behavior(&matches)?; + let behavior = behavior(&matches, diag_args.as_deref())?; match behavior.main_function { MainFunction::Directory => directory(&paths, &behavior), @@ -287,7 +297,8 @@ pub fn uu_app() -> Command { .long(OPT_TARGET_DIRECTORY) .help(translate!("install-help-target-directory")) .value_name("DIRECTORY") - .value_hint(clap::ValueHint::DirPath), + .value_hint(clap::ValueHint::DirPath) + .value_parser(clap::value_parser!(OsString)), ) .arg( Arg::new(OPT_NO_TARGET_DIRECTORY) @@ -340,6 +351,16 @@ pub fn uu_app() -> Command { ) } +/// Resolve an owner/group argument to a numeric id. +/// +/// First looks the value up by name (or by id) via `lookup`. When no matching +/// entry exists but the value is a plain integer, that integer is used as the +/// id directly. This matches GNU, which accepts unused numeric ids such as +/// `install -o 1100`. Returns `None` when the value is neither known nor numeric. +fn resolve_id(value: &str, lookup: impl Fn(&str) -> std::io::Result) -> Option { + lookup(value).ok().or_else(|| value.parse::().ok()) +} + /// Determine behavior, given command line arguments. /// /// If successful, returns a filled-out Behavior struct. @@ -348,7 +369,7 @@ pub fn uu_app() -> Command { /// /// In event of failure, returns an integer intended as a program return code. /// -fn behavior(matches: &ArgMatches) -> UResult { +fn behavior(matches: &ArgMatches, diag_args: Option<&[OsString]>) -> UResult { let main_function = if matches.get_flag(OPT_DIRECTORY) { MainFunction::Directory } else { @@ -360,18 +381,20 @@ fn behavior(matches: &ArgMatches) -> UResult { let specified_mode: Option = if matches.contains_id(OPT_MODE) { let x = matches.get_one::(OPT_MODE).ok_or(1)?; Some(uucore::mode::parse(x, considering_dir, 0).map_err(|err| { - show_error!( - "{}", - translate!("install-error-invalid-mode", "error" => err) - ); + let message = translate!("install-error-invalid-mode", "error" => err.to_string()); + // When the diagnostic is rendered it is already on stderr; exit quietly. + if !diag_args.is_some_and(|args| err.render_mode_value(args, x, 0, &message)) { + show_error!("{message}"); + } 1 })?) } else { None }; - let backup_mode = backup_control::determine_backup_mode(matches)?; - let target_dir = matches.get_one::(OPT_TARGET_DIRECTORY).cloned(); + let backup_mode = + backup_control::determine_backup_mode(std::env::var("VERSION_CONTROL").ok(), matches)?; + let target_dir = matches.get_one::(OPT_TARGET_DIRECTORY).cloned(); let no_target_dir = matches.get_flag(OPT_NO_TARGET_DIRECTORY); if target_dir.is_some() && no_target_dir { show_error!("{}", translate!("install-error-mutually-exclusive-target")); @@ -415,46 +438,16 @@ fn behavior(matches: &ArgMatches) -> UResult { } } - let owner = matches - .get_one::(OPT_OWNER) - .map_or("", |s| s.as_str()) - .to_string(); - - let owner_id = if owner.is_empty() { - None + let owner_id = if let Some(owner) = matches.get_one::(OPT_OWNER) { + Some(resolve_id(owner, usr2uid).ok_or_else(|| InstallError::InvalidUser(owner.clone()))?) } else { - match usr2uid(&owner) { - Ok(u) => Some(u), - // When using -o500 option and there's no user with uid 500 on the system - // usr2uid returns an Err value and the whole install operation fails. - // GNU coreutils installs a file with uid 500 in the same situation - // so just return the supplied owner as uid if it's an integer value - Err(_) => match owner.parse::() { - Ok(u) => Some(u), - Err(_) => return Err(InstallError::InvalidUser(owner.clone()).into()), - }, - } + None }; - let group = matches - .get_one::(OPT_GROUP) - .map_or("", |s| s.as_str()) - .to_string(); - - let group_id = if group.is_empty() { - None + let group_id = if let Some(group) = matches.get_one::(OPT_GROUP) { + Some(resolve_id(group, grp2gid).ok_or_else(|| InstallError::InvalidGroup(group.clone()))?) } else { - match grp2gid(&group) { - Ok(g) => Some(g), - // When using -g500 option and there's no group with gid 500 on the system - // grp2gid returns an Err value and the whole install operation fails. - // GNU coreutils installs a file with gid 500 in the same situation - // so just return the supplied group as gid if it's an integer value - Err(_) => match group.parse::() { - Ok(g) => Some(g), - Err(_) => return Err(InstallError::InvalidGroup(group.clone()).into()), - }, - } + None }; let context = matches.get_one::(OPT_CONTEXT).cloned(); @@ -540,17 +533,19 @@ fn directory(paths: &[OsString], b: &Behavior) -> UResult<()> { } } - if mode::chmod(path, b.mode()).is_err() { - // Error messages are printed by the mode::chmod function! + // Ownership before mode; on failure skip the chmod. + // See set_ownership_and_permissions for why. + if b.privileged + && let Err(e) = chown_optional_user_group(path, b) + { + show!(e); uucore::error::set_exit_code(1); continue; } + // Set SELinux context for directory if needed + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] if b.privileged { - show_if_err!(chown_optional_user_group(path, b)); - - // Set SELinux context for directory if needed - #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] if b.default_context { show_if_err!(set_selinux_default_context(path)); } else if b.context.is_some() { @@ -558,6 +553,11 @@ fn directory(paths: &[OsString], b: &Behavior) -> UResult<()> { show_if_err!(set_selinux_security_context(path, context)); } } + + if mode::chmod(path, b.mode()).is_err() { + // Error messages are printed by the mode::chmod function! + uucore::error::set_exit_code(1); + } } // If the exit code was set, or show! has been called at least once // (which sets the exit code as well), function execution will end after @@ -574,6 +574,16 @@ fn is_new_file_path(path: &Path) -> bool { .is_none_or(|p| p.as_os_str().is_empty() || p.is_dir()) } +/// Test if the path is a valid target for a single-file install. +/// +/// A valid target is a regular file, a path that can be created as a new file, +/// or any existing non-directory entry (including device files, FIFOs, sockets +/// and symlinks). Directories are handled by `copy_files_into_dir`. +#[inline] +fn is_valid_target(path: &Path) -> bool { + path.is_file() || is_new_file_path(path) || path.symlink_metadata().is_ok_and(|m| !m.is_dir()) +} + /// Test if the path is an existing directory or ends with a trailing separator. /// /// Returns true, if one of the conditions above is met; else false. @@ -586,8 +596,10 @@ fn is_potential_directory_path(path: &Path) -> bool { #[cfg(not(unix))] fn is_potential_directory_path(path: &Path) -> bool { - let path_str = path.to_string_lossy(); - path_str.ends_with(MAIN_SEPARATOR) || path_str.ends_with('/') || path.is_dir() + matches!( + path.as_os_str().as_encoded_bytes().last(), + Some(b'/') | Some(b'\\') + ) || path.is_dir() } /// Perform an install, given a list of paths and behavior. @@ -650,7 +662,7 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { // If -t is used, check if target exists as a file before trying to create directories if b.target_dir.is_some() && target.exists() && !target.is_dir() { - return Err(InstallError::NotADirectory(target.clone()).into()); + return Err(InstallError::NotADirectory(target).into()); } if let Some(to_create) = to_create { @@ -749,7 +761,9 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { } } - if sources.len() > 1 { + // A `-t` target is always the directory to install into, never the file + // to install as, even with a single source. + if sources.len() > 1 || b.target_dir.is_some() { copy_files_into_dir(sources, &target, b) } else { let source = sources.first().unwrap(); @@ -760,9 +774,7 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { } if b.no_target_dir && target.is_dir() { - return Err( - InstallError::OverrideDirectoryFailed(target.clone(), source.clone()).into(), - ); + return Err(InstallError::OverrideDirectoryFailed(target, source.clone()).into()); } if is_potential_directory_path(&target) { @@ -776,14 +788,14 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { return Err(InstallError::SameFile(source.clone(), target.clone()).into()); } - if target.is_file() || is_new_file_path(&target) { + if is_valid_target(&target) { #[cfg(unix)] if let (Some(ref parent_fd), Some(ref filename)) = (target_parent_fd, target_filename) { if b.compare && !need_copy(source, &target, b) { return Ok(()); } - let backup_path = perform_backup(&target, b)?; + let backup_path = perform_backup(source, &target, b)?; if let Err(e) = parent_fd.unlink_at(filename.as_os_str(), false) && e.kind() != std::io::ErrorKind::NotFound @@ -827,8 +839,16 @@ fn metadata_for_source(path: &Path) -> UResult { /// fn copy_files_into_dir(files: &[PathBuf], target_dir: &Path, b: &Behavior) -> UResult<()> { if !target_dir.is_dir() { + if b.target_dir.is_some() { + // GNU names why the `-t` directory cannot be used. + let reason = metadata(target_dir) + .err() + .unwrap_or_else(|| rustix::io::Errno::NOTDIR.into()); + return Err(InstallError::TargetDirAccess(target_dir.to_path_buf(), reason).into()); + } return Err(InstallError::TargetDirIsntDir(target_dir.to_path_buf()).into()); } + let mut installed_destinations: HashSet = HashSet::with_capacity(files.len()); for sourcepath in files { let source_metadata = match metadata_for_source(sourcepath) { Ok(metadata) => metadata, @@ -848,7 +868,17 @@ fn copy_files_into_dir(files: &[PathBuf], target_dir: &Path, b: &Behavior) -> UR let filename = sourcepath.components().next_back().unwrap(); targetpath.push(filename); - show_if_err!(copy(sourcepath, &targetpath, b)); + if installed_destinations.contains(&targetpath) && b.backup_mode != BackupMode::Numbered { + let err = InstallError::WillNotOverwriteJustCreated(targetpath, sourcepath.clone()); + show!(err); + continue; + } + + let res = copy(sourcepath, &targetpath, b); + if res.is_ok() { + installed_destinations.insert(targetpath); + } + show_if_err!(res); } // If the exit code was set, or show! has been called at least once // (which sets the exit code as well), function execution will end after @@ -898,6 +928,8 @@ fn chown_optional_user_group(path: &Path, b: &Behavior) -> UResult<()> { /// /// # Parameters /// +/// * `from` - The source file path, needed to refuse a backup that would +/// destroy it. /// * `to` - The destination file path. /// * `b` - The behavior configuration. /// @@ -905,7 +937,15 @@ fn chown_optional_user_group(path: &Path, b: &Behavior) -> UResult<()> { /// /// Returns an Option containing the backup path, or None if backup is not needed. /// -fn perform_backup(to: &Path, b: &Behavior) -> UResult> { +fn perform_backup(from: &Path, to: &Path, b: &Behavior) -> UResult> { + // Renaming the destination over the source would leave nothing to install + // from, so refuse instead. + if backup_would_destroy_source(from, to, &b.suffix, b.backup_mode, true) { + return Err( + InstallError::BackupWouldDestroySource(to.to_path_buf(), from.to_path_buf()).into(), + ); + } + if to.exists() { if b.verbose { writeln!( @@ -978,11 +1018,9 @@ fn copy_file(from: &Path, to: &Path) -> UResult<()> { } if to.is_dir() && !from.is_dir() { - return Err(InstallError::OverrideDirectoryFailed( - to.to_path_buf().clone(), - from.to_path_buf().clone(), - ) - .into()); + return Err( + InstallError::OverrideDirectoryFailed(to.to_path_buf(), from.to_path_buf()).into(), + ); } // Remove existing file (create_new below provides TOCTOU protection) @@ -1007,6 +1045,11 @@ fn copy_file(from: &Path, to: &Path) -> UResult<()> { .mode(0o600) .open(to)?; + #[cfg(any(target_os = "linux", target_os = "android"))] + if rustix::fs::ioctl_ficlone(&dest, &handle).is_ok() { + return Ok(()); + } + copy_fast(&mut handle, &mut dest).map_err(|err| { InstallError::InstallFailed(from.to_path_buf(), to.to_path_buf(), err.to_string()) })?; @@ -1054,7 +1097,7 @@ fn strip_file(to: &Path, b: &Behavior) -> UResult<()> { Err(e) => { // Follow GNU's behavior: if strip fails, removes the target let _ = fs::remove_file(to); - return Err(InstallError::StripProgramFailed(e.to_string()).into()); + return Err(InstallError::StripProgramFailed(strip_errno(&e)).into()); } } Ok(()) @@ -1072,11 +1115,13 @@ fn strip_file(to: &Path, b: &Behavior) -> UResult<()> { /// Returns an empty Result or an error in case of failure. /// fn set_ownership_and_permissions(to: &Path, b: &Behavior) -> UResult<()> { - // Silent the warning as we want to the error message - mode::chmod(to, b.mode()).map_err(|_| InstallError::ChmodFailed(to.to_path_buf()))?; + // Ownership first, then mode, as GNU does: chown(2) clears setuid/setgid, and + // a failed chown must not leave a mode carrying them already applied. if b.privileged { chown_optional_user_group(to, b)?; } + // Silent the warning as we want to the error message + mode::chmod(to, b.mode()).map_err(|_| InstallError::ChmodFailed(to.to_path_buf()))?; Ok(()) } @@ -1094,10 +1139,14 @@ fn set_ownership_and_permissions(to: &Path, b: &Behavior) -> UResult<()> { /// fn preserve_timestamps(from: &Path, to: &Path) -> UResult<()> { let meta = metadata(from).map_err(InstallError::MetadataFailed)?; - let modified_time = FileTime::from_last_modification_time(&meta); - let accessed_time = FileTime::from_last_access_time(&meta); - - if let Err(e) = set_file_times(to, accessed_time, modified_time) { + let modified_time = meta.modified()?; + let accessed_time = meta.accessed()?; + + let times = fs::FileTimes::new() + .set_accessed(accessed_time) + .set_modified(modified_time); + let file = File::options().write(true).open(to)?; + if let Err(e) = file.set_times(times) { show_error!("{e}"); // ignore error } @@ -1169,7 +1218,7 @@ fn copy(from: &Path, to: &Path, b: &Behavior) -> UResult<()> { return Ok(()); } // Declare the path here as we may need it for the verbose output below. - let backup_path = perform_backup(to, b)?; + let backup_path = perform_backup(from, to, b)?; copy_file(from, to)?; @@ -1196,7 +1245,7 @@ fn needs_copy_for_ownership(to: &Path, to_meta: &fs::Metadata) -> bool { use std::os::unix::fs::MetadataExt; // Check if the destination file's owner differs from the effective user ID - if to_meta.uid() != geteuid() { + if to_meta.uid() != geteuid().as_raw() { return true; } @@ -1208,7 +1257,7 @@ fn needs_copy_for_ownership(to: &Path, to_meta: &fs::Metadata) -> bool { .parent() .and_then(|parent| metadata(parent).ok()) .filter(|parent_meta| parent_meta.mode() & 0o2000 != 0) - .map_or(getegid(), |parent_meta| parent_meta.gid()); + .map_or(getegid().as_raw(), |parent_meta| parent_meta.gid()); to_meta.gid() != expected_gid } @@ -1309,14 +1358,13 @@ fn need_copy(from: &Path, to: &Path, b: &Behavior) -> bool { } // Check if the contents of the source and destination files differ. - if !diff(&from.to_string_lossy(), &to.to_string_lossy()) { + if !are_files_identical(from, to).unwrap_or(false) { return true; } false } -#[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, @@ -1330,6 +1378,7 @@ fn need_copy(from: &Path, to: &Path, b: &Behavior) -> bool { /// # Returns /// /// Returns `Ok(())` if the context was successfully set, or a `SeLinuxError` if the operation failed. +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] pub fn set_selinux_default_context(path: &Path) -> Result<(), SeLinuxError> { if !is_selinux_enabled() { return Err(SeLinuxError::SELinuxNotEnabled); @@ -1350,7 +1399,6 @@ pub fn set_selinux_default_context(path: &Path) -> Result<(), SeLinuxError> { } } -#[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 @@ -1370,6 +1418,7 @@ pub fn set_selinux_default_context(path: &Path) -> Result<(), SeLinuxError> { /// * `Ok(Some(String))` - The default context string if successfully determined /// * `Ok(None)` - No default context could be determined /// * `Err(SeLinuxError)` - An error occurred while determining the context +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn get_default_context_for_path(path: &Path) -> Result, SeLinuxError> { if !is_selinux_enabled() { return Err(SeLinuxError::SELinuxNotEnabled); @@ -1396,7 +1445,7 @@ fn get_default_context_for_path(path: &Path) -> Result, SeLinuxEr break; } - if current_path == Path::new("/") || current_path == Path::new("") { + if current_path == Path::new("/") || current_path.as_os_str().is_empty() { break; } } @@ -1405,12 +1454,12 @@ fn get_default_context_for_path(path: &Path) -> Result, SeLinuxEr Ok(None) } -#[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 /// context for a file based on its parent directory's context and file type. /// The goal is to mimic what `restorecon` would do based on `SELinux` policy. +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn derive_context_from_parent(parent_context: &str) -> String { // Parse the parent context (format: user:role:type:level) let parts: Vec<&str> = parent_context.split(':').collect(); @@ -1438,11 +1487,11 @@ fn derive_context_from_parent(parent_context: &str) -> String { } } -#[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. /// Returns a vector of paths in reverse order (from parent to child). +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn collect_paths_for_context_setting(starting_path: &Path) -> Vec<&Path> { let mut paths: Vec<&Path> = starting_path .ancestors() @@ -1452,7 +1501,6 @@ fn collect_paths_for_context_setting(starting_path: &Path) -> Vec<&Path> { paths } -#[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 @@ -1486,13 +1534,13 @@ fn collect_paths_for_context_setting(starting_path: &Path) -> Vec<&Path> { /// // let context = String::from("user_u:object_r:tmp_t:s0"); /// // set_selinux_context_for_directories(Path::new("/tmp/new/deep/dir"), Some(&context)); /// ``` +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn set_selinux_context_for_directories(target_path: &Path, context: Option<&String>) { for path in collect_paths_for_context_setting(target_path) { show_if_err!(set_selinux_security_context(path, context)); } } -#[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 @@ -1502,6 +1550,7 @@ fn set_selinux_context_for_directories(target_path: &Path, context: Option<&Stri /// /// * `target_path` - The target path (typically the deepest directory in a hierarchy) /// * `context` - Optional `SELinux` context string to set. If None, uses install's default derivation. +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] pub fn set_selinux_context_for_directories_install(target_path: &Path, context: Option<&String>) { if context.is_some() { // Use the standard function for explicit contexts diff --git a/src/uu/install/src/mode.rs b/src/uu/install/src/mode.rs index 1f5e6651da8..a0744e2626f 100644 --- a/src/uu/install/src/mode.rs +++ b/src/uu/install/src/mode.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use std::fs; use std::path::Path; use uucore::translate; diff --git a/src/uu/join/Cargo.toml b/src/uu/join/Cargo.toml index c3749a4dacc..194a98e71ce 100644 --- a/src/uu/join/Cargo.toml +++ b/src/uu/join/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/join.rs" test = false @@ -21,10 +18,10 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["i18n-collator"] } +fluent = { workspace = true } memchr = { workspace = true } thiserror = { workspace = true } -fluent = { workspace = true } +uucore = { workspace = true, features = ["i18n-collator"] } [[bin]] name = "join" @@ -35,6 +32,9 @@ divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bench]] name = "join_bench" harness = false diff --git a/src/uu/join/locales/en-US.ftl b/src/uu/join/locales/en-US.ftl index 81a339a2e76..c23cf02e897 100644 --- a/src/uu/join/locales/en-US.ftl +++ b/src/uu/join/locales/en-US.ftl @@ -24,7 +24,7 @@ join-help-z = line delimiter is NUL, not newline join-error-io = io error: { $error } join-error-non-utf8-tab = non-UTF-8 multi-byte tab join-error-unprintable-separators = unprintable field separators are only supported on unix-like platforms -join-error-multi-character-tab = multi-character tab { $value } +join-error-multi-character-tab = multi-character tab '{ $value }' join-error-both-files-stdin = both files cannot be standard input join-error-invalid-field-specifier = invalid field specifier: { $spec } join-error-invalid-file-number = invalid file number in field spec: { $spec } @@ -33,3 +33,6 @@ join-error-invalid-field-number = invalid field number: { $value } join-error-incompatible-fields = incompatible join fields { $field1 }, { $field2 } join-error-not-sorted = { $file }:{ $line_num }: is not sorted: { $content } join-error-input-not-sorted = input is not in sorted order + +# Diagnostics +join-diag-help-format = an output field is FILENUM.FIELD, as in -o 1.2,2.1; 0 stands for the join field diff --git a/src/uu/join/locales/fr-FR.ftl b/src/uu/join/locales/fr-FR.ftl index 75ed4235532..7f947cf600e 100644 --- a/src/uu/join/locales/fr-FR.ftl +++ b/src/uu/join/locales/fr-FR.ftl @@ -24,7 +24,7 @@ join-help-z = le délimiteur de ligne est NUL, pas de nouvelle ligne join-error-io = erreur d'E/S : { $error } join-error-non-utf8-tab = tabulation multi-octets non-UTF-8 join-error-unprintable-separators = les séparateurs de champs non imprimables ne sont pris en charge que sur les plateformes de type unix -join-error-multi-character-tab = tabulation multi-caractères { $value } +join-error-multi-character-tab = tabulation multi-caractères '{ $value }' join-error-both-files-stdin = les deux fichiers ne peuvent pas être l'entrée standard join-error-invalid-field-specifier = spécificateur de champ invalide : { $spec } join-error-invalid-file-number = numéro de fichier invalide dans la spécification de champ : { $spec } @@ -33,3 +33,6 @@ join-error-invalid-field-number = numéro de champ invalide : { $value } join-error-incompatible-fields = champs de jointure incompatibles { $field1 }, { $field2 } join-error-not-sorted = { $file }:{ $line_num } : n'est pas trié : { $content } join-error-input-not-sorted = l'entrée n'est pas dans l'ordre trié + +# Diagnostics +join-diag-help-format = un champ de sortie s'écrit NUMFICHIER.CHAMP, comme dans -o 1.2,2.1 ; 0 désigne le champ de jointure diff --git a/src/uu/join/src/join.rs b/src/uu/join/src/join.rs index ddd56584131..257c2de818f 100644 --- a/src/uu/join/src/join.rs +++ b/src/uu/join/src/join.rs @@ -11,11 +11,11 @@ use memchr::{Memchr3, memchr_iter, memmem::Finder}; use std::cmp::Ordering; use std::ffi::OsString; use std::fs::File; -use std::io::{BufRead, BufReader, BufWriter, Split, Stdin, Write, stdin, stdout}; +use std::io::{self, BufRead, BufReader, BufWriter, Split, Stdin, Write, stdin, stdout}; use std::num::IntErrorKind; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; -use thiserror::Error; +use uucore::diagnostics::OptionValue; use uucore::display::Quotable; use uucore::error::{FromIo, UError, UResult, USimpleError, set_exit_code}; use uucore::i18n::collator::{ @@ -24,10 +24,10 @@ use uucore::i18n::collator::{ use uucore::line_ending::LineEnding; use uucore::{format_usage, show_error, translate}; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum JoinError { #[error("{}", translate!("join-error-io", "error" => .0))] - IOError(#[from] std::io::Error), + IOError(#[from] io::Error), #[error("{0}")] UnorderedInput(String), @@ -219,38 +219,37 @@ impl<'a, Sep: Separator> Repr<'a, Sep> { !self.format.is_empty() } - /// Write the field or empty filler if the field is not set. - fn write_field( - &self, - writer: &mut impl Write, - field: Option<&[u8]>, - ) -> Result<(), std::io::Error> { - let value = match field { - Some(field) => field, - None => self.empty, - }; + /// Resolve an output field to the bytes that should be printed for it. + /// + /// The `-e` filler stands in for output fields that are empty, which + /// covers both fields missing from the input line and fields that are + /// present but zero length. When `-e` is not given the filler is itself + /// empty, so this leaves the output unchanged. + fn field_or_empty<'b>(&'b self, field: Option<&'b [u8]>) -> &'b [u8] { + match field { + Some(field) if !field.is_empty() => field, + _ => self.empty, + } + } - writer.write_all(value) + /// Write the field or the empty filler if the field is empty or not set. + fn write_field(&self, writer: &mut impl Write, field: Option<&[u8]>) -> io::Result<()> { + writer.write_all(self.field_or_empty(field)) } /// Write each field except the one at the index. - fn write_fields( - &self, - writer: &mut impl Write, - line: &Line, - index: usize, - ) -> Result<(), std::io::Error> { + fn write_fields(&self, writer: &mut impl Write, line: &Line, index: usize) -> io::Result<()> { for i in 0..line.field_ranges.len() { if i != index { writer.write_all(self.separator.output_separator())?; - writer.write_all(line.get_field(i).unwrap())?; + writer.write_all(self.field_or_empty(line.get_field(i)))?; } } Ok(()) } - /// 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> + /// Write each field or the empty filler if the field is empty or not set. + fn write_format(&self, writer: &mut impl Write, f: F) -> io::Result<()> where F: Fn(&Spec) -> Option<&'a [u8]>, { @@ -259,17 +258,12 @@ impl<'a, Sep: Separator> Repr<'a, Sep> { writer.write_all(self.separator.output_separator())?; } - let field = match f(&self.format[i]) { - Some(value) => value, - None => self.empty, - }; - - writer.write_all(field)?; + writer.write_all(self.field_or_empty(f(&self.format[i])))?; } Ok(()) } - fn write_line_ending(&self, writer: &mut impl Write) -> Result<(), std::io::Error> { + fn write_line_ending(&self, writer: &mut impl Write) -> io::Result<()> { writer.write_all(&[self.line_ending as u8]) } } @@ -496,7 +490,7 @@ impl<'a> State<'a> { writer: &mut impl Write, other: &State, repr: &Repr<'a, Sep>, - ) -> Result<(), std::io::Error> { + ) -> io::Result<()> { if self.has_line() { if other.has_line() { self.combine(writer, other, repr)?; @@ -516,7 +510,7 @@ impl<'a> State<'a> { writer: &mut impl Write, other: &State, repr: &Repr<'a, Sep>, - ) -> Result<(), std::io::Error> { + ) -> io::Result<()> { let key = self.get_current_key(); for line1 in &self.seq { @@ -558,10 +552,7 @@ impl<'a> State<'a> { } } - fn reset_read_line( - &mut self, - input: &Input, - ) -> Result<(), std::io::Error> { + fn reset_read_line(&mut self, input: &Input) -> io::Result<()> { let line = self.read_line(&input.separator)?; self.reset(line); Ok(()) @@ -581,7 +572,7 @@ impl<'a> State<'a> { &mut self, read_sep: &Sep, autoformat: bool, - ) -> std::io::Result { + ) -> io::Result { if let Some(line) = self.read_line(read_sep)? { self.seq.push(line); @@ -617,7 +608,7 @@ impl<'a> State<'a> { } /// Get the next line without the order check. - fn read_line(&mut self, sep: &Sep) -> Result, std::io::Error> { + fn read_line(&mut self, sep: &Sep) -> io::Result> { match self.lines.next() { Some(value) => { self.line_num += 1; @@ -638,6 +629,13 @@ impl<'a> State<'a> { return Ok(Some(line)); } + // In Default mode the warning is only emitted when there are + // already unpaired lines, so skip the (potentially expensive) + // locale comparison on every line when no violation is possible yet. + if input.check_order == CheckOrder::Default && (!self.has_unpaired || self.has_failed) { + return Ok(Some(line)); + } + let diff = input.compare(self.get_current_key(), line.get_field(self.key)); if diff == Ordering::Greater @@ -669,7 +667,7 @@ impl<'a> State<'a> { writer: &mut impl Write, line: &Line, repr: &Repr<'a, Sep>, - ) -> Result<(), std::io::Error> { + ) -> io::Result<()> { if repr.uses_format() { repr.write_format(writer, |spec| match *spec { Spec::Key => line.get_field(self.key), @@ -693,7 +691,7 @@ impl<'a> State<'a> { &self, writer: &mut impl Write, repr: &Repr<'a, Sep>, - ) -> Result<(), std::io::Error> { + ) -> io::Result<()> { self.write_line(writer, &self.seq[0], repr) } } @@ -773,7 +771,7 @@ fn get_and_parse_field_number(matches: &clap::ArgMatches, key: &str) -> UResult< /// This function takes the matches from the command-line arguments, processes them, /// and returns a `Settings` struct that encapsulates the configuration for the program. #[allow(clippy::field_reassign_with_default)] -fn parse_settings(matches: &clap::ArgMatches) -> UResult { +fn parse_settings(matches: &clap::ArgMatches, diag_args: Option<&[OsString]>) -> UResult { let keys = get_and_parse_field_number(matches, "j")?; let key1 = get_and_parse_field_number(matches, "1")?; let key2 = get_and_parse_field_number(matches, "2")?; @@ -792,13 +790,36 @@ fn parse_settings(matches: &clap::ArgMatches) -> UResult { if let Some(value_os) = matches.get_one::("t") { settings.separator = parse_separator(value_os)?; } - if let Some(format) = matches.get_one::("o") { - if format == "auto" { + if let Some(formats) = matches.get_many::("o") { + let formats: Vec<&String> = formats.collect(); + // GNU lets `-o` repeat and accumulates the fields. Auto mode applies only + // when every value is exactly `auto`; otherwise each `auto` is ignored. + if formats.iter().all(|f| *f == "auto") { settings.autoformat = true; } else { let mut specs = vec![]; - for part in format.split([' ', ',', '\t']) { - specs.push(Spec::parse(part)?); + for format in formats { + if format == "auto" { + continue; + } + // `-o` has no long form. + let option = OptionValue::with_names(format, Some('o'), None); + // Each field carries its place in the value, so that the caret can + // take the one that is at fault out of a long list. + for (part, span) in uucore::diagnostics::list_items(format, &[' ', ',', '\t']) { + specs.push(Spec::parse(part).map_err(|error| { + let message = error.to_string(); + uucore::diagnostics::error_after_report(diag_args, error, |args, _| { + uucore::diagnostics::Snapshot::with_program(args).render_option( + &option, + span, + &message, + None, + Some(&translate!("join-diag-help-format")), + ) + }) + })?); + } } settings.format = specs; } @@ -827,13 +848,19 @@ fn parse_settings(matches: &clap::ArgMatches) -> UResult { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + // The command line is kept for the caret in `-o` diagnostics, which needs + // the list as typed. + let (matches, diag_args) = uucore::clap_localization::handle_clap_result_with_diagnostics( + uu_app(), + args.collect(), + 1, + )?; let mut opts = CollatorOptions::default(); opts.alternate_handling = Some(AlternateHandling::Shifted); let _ = try_init_collator(opts); - let settings = parse_settings(&matches)?; + let settings = parse_settings(&matches, diag_args.as_deref())?; let file1 = matches.get_one::("file1").unwrap(); let file2 = matches.get_one::("file2").unwrap(); @@ -903,12 +930,15 @@ pub fn uu_app() -> Command { Arg::new("j") .short('j') .value_name("FIELD") + .allow_hyphen_values(true) .help(translate!("join-help-j")), ) .arg( Arg::new("o") .short('o') .value_name("FORMAT") + .action(ArgAction::Append) + .num_args(1) .help(translate!("join-help-o")), ) .arg( @@ -916,18 +946,21 @@ pub fn uu_app() -> Command { .short('t') .value_name("CHAR") .value_parser(ValueParser::os_string()) + .allow_hyphen_values(true) .help(translate!("join-help-t")), ) .arg( Arg::new("1") .short('1') .value_name("FIELD") + .allow_hyphen_values(true) .help(translate!("join-help-1")), ) .arg( Arg::new("2") .short('2') .value_name("FIELD") + .allow_hyphen_values(true) .help(translate!("join-help-2")), ) .arg( @@ -1108,7 +1141,13 @@ fn get_field_number(keys: Option, key: Option) -> UResult { // Show zero-based field numbers as one-based. (Some(k1), Some(k2)) if k1 != k2 => Err(USimpleError::new( 1, - translate!("join-error-incompatible-fields", "field1" => (k1 + 1), "field2" => (k2 + 1)), + // `parse_field_number` clamps an out-of-range field to `usize::MAX`, + // so the one-based increment has to saturate rather than overflow. + translate!( + "join-error-incompatible-fields", + "field1" => k1.saturating_add(1), + "field2" => k2.saturating_add(1) + ), )), (Some(k), _) | (_, Some(k)) => Ok(k), (None, None) => Ok(0), diff --git a/src/uu/kill/Cargo.toml b/src/uu/kill/Cargo.toml index 45072046e42..6826f8d82b8 100644 --- a/src/uu/kill/Cargo.toml +++ b/src/uu/kill/Cargo.toml @@ -11,23 +11,23 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/kill.rs" doctest = false -[dependencies] +[target.'cfg(not(target_os = "fuchsia"))'.dependencies] clap = { workspace = true } -thiserror = { workspace = true } -uucore = { workspace = true, features = ["signals"] } fluent = { workspace = true } +thiserror = { workspace = true } +uucore = { workspace = true, features = ["process", "signals"] } -[target.'cfg(unix)'.dependencies] +[target.'cfg(all(unix, not(target_os = "fuchsia")))'.dependencies] libc = { workspace = true } rustix = { workspace = true, features = ["process"] } +[lints] +workspace = true + [[bin]] name = "kill" path = "src/main.rs" diff --git a/src/uu/kill/locales/en-US.ftl b/src/uu/kill/locales/en-US.ftl index 61b8a4739a3..ff9491b0827 100644 --- a/src/uu/kill/locales/en-US.ftl +++ b/src/uu/kill/locales/en-US.ftl @@ -1,5 +1,23 @@ kill-about = Send signal to processes or list information about signals. kill-usage = kill [OPTIONS]... PID... +kill-after-help-windows = Windows notes: + Signalled processes are force-terminated (Windows has no signal delivery); + their exit status is 128 plus the signal number. Negative PIDs (another + process's group) and STOP are not supported. Permissions come from your + current token, with SeDebugPrivilege enabled when it is held, so run + elevated to reach processes a standard token cannot signal. Protected + (anti-malware) processes cannot be terminated at all. + + PID 0 targets the Job object kill runs in, the closest Windows analog of a + process group. Every process in that job and in its child jobs is signalled, + kill itself last, so kill dies with the group. Outside a job, PID 0 signals + only kill itself. + + Beware: a Job object is usually not yours. Terminals, IDEs, Docker, CI + agents and Windows' own Program Compatibility Assistant all run what they + launch inside a job, and a job captures every descendant from creation + onward. Under a CI agent, kill 0 signals the agent and every sibling step. + The blast radius can be far wider than a POSIX process group. # Help messages kill-help-list = Lists signals @@ -13,3 +31,5 @@ kill-error-invalid-signal = { $signal }: invalid signal kill-error-parse-argument = failed to parse argument { $argument }: { $error } kill-error-sending-signal = sending signal to { $pid } failed kill-error-write = write error: { $error } +kill-error-unsupported-signal = unsupported signal on Windows +kill-error-negative-pid-unsupported = a negative PID (another process's group) is not supported on Windows diff --git a/src/uu/kill/locales/fr-FR.ftl b/src/uu/kill/locales/fr-FR.ftl index 0e82c13da4d..729899b6806 100644 --- a/src/uu/kill/locales/fr-FR.ftl +++ b/src/uu/kill/locales/fr-FR.ftl @@ -1,5 +1,26 @@ kill-about = Envoyer un signal aux processus ou lister les informations sur les signaux. kill-usage = kill [OPTIONS]... PID... +kill-after-help-windows = Notes pour Windows : + Les processus signalés sont terminés de force (Windows ne délivre pas de + signaux) ; leur code de sortie est 128 plus le numéro du signal. Les PID + négatifs (le groupe d'un autre processus) et STOP ne sont pas pris en charge. + Les permissions proviennent de votre jeton actuel, avec SeDebugPrivilege + activé lorsqu'il est détenu ; exécutez kill en tant qu'administrateur pour + atteindre les processus qu'un jeton standard ne peut pas signaler. Les + processus protégés (anti-programmes malveillants) ne peuvent jamais être + terminés. + + Le PID 0 cible l'objet Job dans lequel kill s'exécute, l'équivalent Windows + le plus proche d'un groupe de processus. Tous les processus de ce Job et de + ses Jobs enfants sont signalés, kill lui-même en dernier, de sorte que kill + meurt avec le groupe. Hors d'un Job, le PID 0 ne signale que kill lui-même. + + Attention : un objet Job ne vous appartient généralement pas. Les terminaux, + les IDE, Docker, les agents d'intégration continue et l'Assistant de + compatibilité des programmes de Windows exécutent tous dans un Job ce qu'ils + lancent, et un Job capture chaque descendant dès sa création. Sous un agent + d'intégration continue, kill 0 signale l'agent et toutes les étapes voisines. + La portée peut être bien plus large que celle d'un groupe de processus POSIX. # Messages d'aide kill-help-list = Liste les signaux @@ -13,3 +34,5 @@ kill-error-invalid-signal = { $signal } : signal invalide kill-error-parse-argument = échec de l'analyse de l'argument { $argument } : { $error } kill-error-sending-signal = échec de l'envoi du signal au processus { $pid } kill-error-write = erreur d'écriture : { $error } +kill-error-unsupported-signal = signal non pris en charge sur Windows +kill-error-negative-pid-unsupported = un PID négatif (le groupe d'un autre processus) n'est pas pris en charge sur Windows diff --git a/src/uu/kill/src/kill.rs b/src/uu/kill/src/kill.rs index 7bfae0cd040..b40ed88ac14 100644 --- a/src/uu/kill/src/kill.rs +++ b/src/uu/kill/src/kill.rs @@ -5,14 +5,10 @@ // spell-checker:ignore (ToDO) signalname pids killpg NOPESIG +#![cfg(not(target_os = "fuchsia"))] + use clap::{Arg, ArgAction, Command}; -use rustix::process::{ - Pid, Signal, kill_current_process_group, kill_process, kill_process_group, - test_kill_current_process_group, test_kill_process, test_kill_process_group, -}; -use std::cmp::Ordering; use std::io::{self, BufWriter, Write}; -use thiserror::Error; use uucore::display::Quotable; use uucore::error::{FromIo, UError, UResult, USimpleError, strip_errno}; use uucore::translate; @@ -23,6 +19,8 @@ use uucore::signals::{ }; use uucore::{format_usage, show}; +mod platform; + // When the -l option is selected, the program displays the type of signal related to a certain // value or string. In case of a value, the program should control the lower 8 bits, but there is // a particular case in which if the value is in range [128, 159], it is translated to a signal @@ -42,7 +40,7 @@ pub enum Mode { List, } -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum KillError { #[error("{}", translate!("kill-error-write", "error" => strip_errno(.0)))] Write(io::Error), @@ -95,7 +93,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } pub fn uu_app() -> Command { - Command::new("kill") + let cmd = Command::new("kill") .version(uucore::crate_version!()) .help_template(uucore::localized_help_template("kill")) .about(translate!("kill-about")) @@ -131,7 +129,10 @@ pub fn uu_app() -> Command { Arg::new(options::PIDS_OR_SIGNALS) .hide(true) .action(ArgAction::Append), - ) + ); + #[cfg(windows)] + let cmd = cmd.after_help(translate!("kill-after-help-windows")); + cmd } fn handle_obsolete(args: &mut Vec) -> UResult> { @@ -244,18 +245,6 @@ fn list(signals: &Vec) -> UResult<()> { Ok(()) } -// rustix's `Signal` rejects libc-reserved realtime signals, so fall back to a -// raw `libc::kill` for any value its safe constructor doesn't recognize. -fn raw_kill(pid: i32, sig: usize) -> io::Result<()> { - let sig = i32::try_from(sig).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; - // SAFETY: plain FFI call; `kill` has no memory-safety preconditions. - if unsafe { libc::kill(pid as libc::pid_t, sig) } == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) - } -} - fn parse_signal_value(signal_name: &str) -> UResult { let optional_signal_value = signal_by_name_or_value(signal_name); match optional_signal_value { @@ -281,44 +270,8 @@ fn parse_pids(pids: &[String]) -> UResult> { } fn kill(sig: usize, pids: &[i32]) { - // Standard named signals use rustix's typed API; anything its safe - // constructor doesn't recognize (realtime/reserved) falls back to libc. - let named = (sig != 0) - .then(|| i32::try_from(sig).ok().and_then(Signal::from_named_raw)) - .flatten(); for &pid in pids { - let result = match pid.cmp(&0) { - Ordering::Equal => match named { - _ if sig == 0 => test_kill_current_process_group().map_err(io::Error::from), - Some(s) => kill_current_process_group(s).map_err(io::Error::from), - None => raw_kill(0, sig), - }, - Ordering::Greater => { - let pid = Pid::from_raw(pid).expect("pid > 0 guaranteed by Ordering::Greater"); - match named { - _ if sig == 0 => test_kill_process(pid).map_err(io::Error::from), - Some(s) => kill_process(pid, s).map_err(io::Error::from), - None => raw_kill(pid.as_raw_nonzero().get(), sig), - } - } - Ordering::Less => { - let Some(abs_pid) = pid.checked_neg() else { - show!(USimpleError::new( - 1, - translate!("kill-error-sending-signal", "pid" => pid), - )); - continue; - }; - let pid = - Pid::from_raw(abs_pid).expect("abs_pid > 0 since pid < 0 and pid != i32::MIN"); - match named { - _ if sig == 0 => test_kill_process_group(pid).map_err(io::Error::from), - Some(s) => kill_process_group(pid, s).map_err(io::Error::from), - None => raw_kill(-pid.as_raw_nonzero().get(), sig), - } - } - }; - if let Err(e) = result { + if let Err(e) = platform::send_signal(pid, sig) { show!(e.map_err_context(|| translate!("kill-error-sending-signal", "pid" => pid))); } } diff --git a/src/uu/kill/src/platform/mod.rs b/src/uu/kill/src/platform/mod.rs new file mode 100644 index 00000000000..716d768d082 --- /dev/null +++ b/src/uu/kill/src/platform/mod.rs @@ -0,0 +1,18 @@ +// 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. + +//! Platform-specific piece of `kill`: delivering one signal to one pid via +//! the [`send_signal`] facade, provided by both submodules with an identical +//! signature. The shared control flow stays in `kill.rs`. + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub(crate) use unix::*; + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub(crate) use windows::*; diff --git a/src/uu/kill/src/platform/unix.rs b/src/uu/kill/src/platform/unix.rs new file mode 100644 index 00000000000..f3c4a5f18a2 --- /dev/null +++ b/src/uu/kill/src/platform/unix.rs @@ -0,0 +1,64 @@ +// 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 pids ESRCH + +use std::cmp::Ordering; +use std::io; + +use rustix::process::{ + Pid, Signal, kill_current_process_group, kill_process, kill_process_group, + test_kill_current_process_group, test_kill_process, test_kill_process_group, +}; + +// rustix's `Signal` rejects libc-reserved realtime signals, so fall back to a +// raw `libc::kill` for any value its safe constructor doesn't recognize. +fn raw_kill(pid: i32, sig: usize) -> io::Result<()> { + let sig = i32::try_from(sig).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; + // SAFETY: plain FFI call; `kill` has no memory-safety preconditions. + if unsafe { libc::kill(pid as libc::pid_t, sig) } == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +/// Deliver `sig` to `pid` with kill(2) semantics: positive pids target one +/// process, 0 the current process group, negative pids the group `-pid`. +pub(crate) fn send_signal(pid: i32, sig: usize) -> io::Result<()> { + // Standard named signals use rustix's typed API; anything its safe + // constructor doesn't recognize (realtime/reserved) falls back to libc. + let named = (sig != 0) + .then(|| i32::try_from(sig).ok().and_then(Signal::from_named_raw)) + .flatten(); + match pid.cmp(&0) { + Ordering::Equal => match named { + _ if sig == 0 => test_kill_current_process_group().map_err(io::Error::from), + Some(s) => kill_current_process_group(s).map_err(io::Error::from), + None => raw_kill(0, sig), + }, + Ordering::Greater => { + let pid = Pid::from_raw(pid).expect("pid > 0 guaranteed by Ordering::Greater"); + match named { + _ if sig == 0 => test_kill_process(pid).map_err(io::Error::from), + Some(s) => kill_process(pid, s).map_err(io::Error::from), + None => raw_kill(pid.as_raw_nonzero().get(), sig), + } + } + Ordering::Less => { + // i32::MIN cannot be negated, so no such process group can exist. + let Some(abs_pid) = pid.checked_neg() else { + return Err(io::Error::from_raw_os_error(libc::ESRCH)); + }; + let pid = + Pid::from_raw(abs_pid).expect("abs_pid > 0 since pid < 0 and pid != i32::MIN"); + match named { + _ if sig == 0 => test_kill_process_group(pid).map_err(io::Error::from), + Some(s) => kill_process_group(pid, s).map_err(io::Error::from), + None => raw_kill(-pid.as_raw_nonzero().get(), sig), + } + } + } +} diff --git a/src/uu/kill/src/platform/windows.rs b/src/uu/kill/src/platform/windows.rs new file mode 100644 index 00000000000..3b96bd23d7a --- /dev/null +++ b/src/uu/kill/src/platform/windows.rs @@ -0,0 +1,67 @@ +// 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 pids + +//! Windows implementation of `kill`'s platform facade, built on the signal +//! emulation in [`uucore::process`]. PID 0 targets the Job object `kill` runs +//! in, the closest Windows analog of a process group. STOP (no process-suspend +//! API) and negative pids (no way to name another process's group) are +//! rejected. + +use std::io; + +use uucore::process::{enable_debug_privilege, send_signal_to_own_group, send_signal_to_pid}; +use uucore::translate; + +const SIGNAL_STOP: usize = 19; + +fn unsupported(message: String) -> io::Error { + io::Error::new(io::ErrorKind::Unsupported, message) +} + +pub(crate) fn send_signal(pid: i32, sig: usize) -> io::Result<()> { + if sig == SIGNAL_STOP { + return Err(unsupported(translate!("kill-error-unsupported-signal"))); + } + match u32::try_from(pid) { + // Fails exactly for pid < 0. + Err(_) => Err(unsupported(translate!( + "kill-error-negative-pid-unsupported" + ))), + Ok(pid) => { + // Group members need the same rights as a single target. + enable_debug_privilege(); + if pid == 0 { + send_signal_to_own_group(sig) + } else { + send_signal_to_pid(pid, sig) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::send_signal; + + #[test] + fn negative_pids_are_rejected_without_touching_any_process() { + for pid in [-1, -2, i32::MIN] { + assert_eq!( + send_signal(pid, 9).unwrap_err().kind(), + std::io::ErrorKind::Unsupported + ); + } + } + + #[test] + fn stop_is_rejected_for_every_pid_including_zero() { + assert_eq!( + send_signal(0, 19).unwrap_err().kind(), + std::io::ErrorKind::Unsupported + ); + } +} diff --git a/src/uu/link/Cargo.toml b/src/uu/link/Cargo.toml index 610627f9fb3..78829b4ebd6 100644 --- a/src/uu/link/Cargo.toml +++ b/src/uu/link/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/link.rs" test = false @@ -21,8 +18,11 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true } + +[lints] +workspace = true [[bin]] name = "link" diff --git a/src/uu/ln/Cargo.toml b/src/uu/ln/Cargo.toml index c1804de2b67..a28c8c28e97 100644 --- a/src/uu/ln/Cargo.toml +++ b/src/uu/ln/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/ln.rs" test = false @@ -21,12 +18,13 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["backup-control", "fs"] } -thiserror = { workspace = true } fluent = { workspace = true } - -[target.'cfg(target_os = "wasi")'.dependencies] rustix = { workspace = true, features = ["fs"] } +thiserror = { workspace = true } +uucore = { workspace = true, features = ["backup-control", "fs"] } + +[lints] +workspace = true [[bin]] name = "ln" diff --git a/src/uu/ln/locales/en-US.ftl b/src/uu/ln/locales/en-US.ftl index 5afa83d4314..0ff8f8a34bd 100644 --- a/src/uu/ln/locales/en-US.ftl +++ b/src/uu/ln/locales/en-US.ftl @@ -33,6 +33,7 @@ ln-error-will-not-overwrite = will not overwrite just-created {$target} with {$s ln-prompt-replace = replace {$file}? ln-cannot-backup = cannot backup {$file} ln-failed-to-access = failed to access {$file} -ln-failed-to-create-hard-link = failed to create hard link {$source} => {$dest} +ln-failed-to-create-hard-link = failed to create hard link {$dest} => {$source} +ln-failed-to-create-symbolic-link = failed to create symbolic link {$dest} ln-failed-to-create-hard-link-dir = {$source}: hard link not allowed for directory ln-backup = backup: {$backup} diff --git a/src/uu/ln/locales/fr-FR.ftl b/src/uu/ln/locales/fr-FR.ftl index 2be821c2734..0c3234f9a2d 100644 --- a/src/uu/ln/locales/fr-FR.ftl +++ b/src/uu/ln/locales/fr-FR.ftl @@ -34,6 +34,6 @@ ln-error-will-not-overwrite = ne remplacera pas le fichier {$target} qui vient d ln-prompt-replace = remplacer {$file} ? ln-cannot-backup = impossible de sauvegarder {$file} ln-failed-to-access = échec d'accès à {$file} -ln-failed-to-create-hard-link = échec de création du lien physique {$source} => {$dest} +ln-failed-to-create-hard-link = échec de création du lien physique {$dest} => {$source} ln-failed-to-create-hard-link-dir = {$source} : lien physique non autorisé pour un répertoire ln-backup = sauvegarde : {$backup} diff --git a/src/uu/ln/src/ln.rs b/src/uu/ln/src/ln.rs index 742ac5c4a2e..fa2ccd54da7 100644 --- a/src/uu/ln/src/ln.rs +++ b/src/uu/ln/src/ln.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) srcpath targetpath EEXIST +// spell-checker:ignore (ToDO) srcpath targetpath EEXIST renameat use clap::{Arg, ArgAction, Command}; use std::io::{self, Write, stdout}; use uucore::display::Quotable; use uucore::error::{UError, UIoError, UResult}; +use uucore::fs::replace_link; use uucore::fs::{make_path_relative_to, paths_refer_to_same_file}; use uucore::translate; use uucore::{format_usage, prompt_yes, show_error}; @@ -18,12 +19,7 @@ use std::borrow::Cow; use std::collections::HashSet; use std::ffi::OsString; use std::fs; -use thiserror::Error; -#[cfg(any(unix, target_os = "redox"))] -use std::os::unix::fs::symlink; -#[cfg(windows)] -use std::os::windows::fs::{symlink_dir, symlink_file}; use std::path::{Path, PathBuf}; use uucore::backup_control::{self, BackupMode}; use uucore::fs::{MissingHandling, ResolveMode, canonicalize}; @@ -50,7 +46,7 @@ pub enum OverwriteMode { Force, } -#[derive(Error, Debug)] +#[derive(Debug, thiserror::Error)] pub enum LnError { #[error("{}", translate!("ln-error-target-is-not-directory", "target" => _0.quote()))] TargetIsNotADirectory(PathBuf), @@ -70,7 +66,7 @@ pub enum LnError { #[error("{}", translate!("ln-error-missing-destination", "operand" => _0.quote()))] MissingDestination(PathBuf), - #[error("{}", translate!("ln-error-extra-operand", "operand" => _0.quote(), "program" => _1.clone()))] + #[error("{}", translate!("ln-error-extra-operand", "operand" => _0.quote(), "program" => _1))] ExtraOperand(OsString, String), #[error("{}", translate!("ln-failed-to-create-hard-link-dir", "source" => _0.to_string_lossy()))] @@ -128,7 +124,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { OverwriteMode::NoClobber }; - let backup_mode = backup_control::determine_backup_mode(&matches)?; + let backup_mode = + backup_control::determine_backup_mode(std::env::var("VERSION_CONTROL").ok(), &matches)?; let backup_suffix = backup_control::determine_backup_suffix(&matches); // When we have "-L" or "-L -P", false otherwise @@ -266,41 +263,30 @@ pub fn uu_app() -> Command { ) } -/// Executes the `ln` utility with the given paths and settings. -/// -/// This is made public to allow other apps to use `ln` as a library. -pub fn exec(files: &[PathBuf], settings: &Settings) -> LnResult<()> { +fn exec(files: &[PathBuf], settings: &Settings) -> LnResult<()> { // Handle cases where we create links in a directory first. if let Some(ref target_path) = settings.target_dir { // 4th form: a directory is specified by -t. return link_files_in_dir(files, target_path, settings); } - if !settings.no_target_dir { - if files.len() == 1 { - // 2nd form: the target directory is the current directory. - return link_files_in_dir(files, &PathBuf::from("."), settings); + + // if -T is specified we may have a wrong number of operands. + match files.split_last().expect("clap rejects empty") { + // the target directory is the current directory + (_, []) if !settings.no_target_dir => { + link_files_in_dir(files, &PathBuf::from("."), settings) } - let last_file = &PathBuf::from(files.last().unwrap()); - if files.len() > 2 || last_file.is_dir() { - // 3rd form: create links in the last argument. - return link_files_in_dir(&files[0..files.len() - 1], last_file, settings); + // create links in the last argument + (last, rest) if !settings.no_target_dir && (rest.len() > 1 || last.is_dir()) => { + link_files_in_dir(rest, last, settings) } - } - - // 1st form. Now there should be only two operands, but if -T is - // specified we may have a wrong number of operands. - if files.len() == 1 { - return Err(LnError::MissingDestination(files[0].clone())); - } - if files.len() > 2 { - return Err(LnError::ExtraOperand( - files[2].clone().into(), + (f0, []) => Err(LnError::MissingDestination(f0.clone())), + (last, [f]) => link(f, last, settings), + (extra, [_, _]) | (_, [_, _, extra, ..]) => Err(LnError::ExtraOperand( + extra.into(), uucore::execution_phrase().to_string(), - )); + )), } - assert!(!files.is_empty()); - - link(&files[0], &files[1], settings) } #[allow(clippy::cognitive_complexity)] @@ -315,27 +301,15 @@ fn link_files_in_dir(files: &[PathBuf], target_dir: &Path, settings: &Settings) for srcpath in files { let targetpath = if settings.no_dereference && target_dir.is_symlink() { let remove_target = || { - // In that case, we don't want to do link resolution - // We need to clean the target - if target_dir.is_file() { - if let Err(e) = fs::remove_file(target_dir) { - show_error!( - "{}", - translate!("ln-error-could-not-update", "target" => target_dir.quote(), "error" => e) - ); - } - } + // Not sure why but on Windows, the symlink can be + // considered as a dir + // See test_ln::test_symlink_no_deref_dir #[cfg(windows)] - if target_dir.is_dir() { - // Not sure why but on Windows, the symlink can be - // considered as a dir - // See test_ln::test_symlink_no_deref_dir - if let Err(e) = fs::remove_dir(target_dir) { - show_error!( - "{}", - translate!("ln-error-could-not-update", "target" => target_dir.quote(), "error" => e) - ); - } + if let Err(e) = fs::remove_dir(target_dir) { + show_error!( + "{}", + translate!("ln-error-could-not-update", "target" => target_dir.quote(), "error" => e) + ); } }; match settings.overwrite { @@ -376,7 +350,7 @@ fn link_files_in_dir(files: &[PathBuf], target_dir: &Path, settings: &Settings) all_successful = false; } - linked_destinations.insert(targetpath.clone()); + linked_destinations.insert(targetpath); } if all_successful { Ok(()) @@ -389,22 +363,38 @@ fn relative_path<'a>(src: &'a Path, dst: &Path) -> Cow<'a, Path> { // `dst.parent()` is None for a destination with no parent (`/`, `""`, or a // bare Windows prefix). Fall through to the non-relative `src` rather than // unwrapping it; the caller then reports the usual error. - if let (Ok(src_abs), Some(dst_parent)) = ( + let Some(dst_parent) = dst.parent() else { + return src.into(); + }; + let (Ok(src_abs), Ok(dst_abs)) = ( canonicalize(src, MissingHandling::Missing, ResolveMode::Physical), - dst.parent(), + canonicalize(dst_parent, MissingHandling::Missing, ResolveMode::Physical), + ) else { + return src.into(); + }; + + make_path_relative_to(src_abs, dst_abs).into() +} + +/// Decide whether `src` and `dst` are actually the same directory entry. +fn is_same_entry(src: &Path, dst: &Path) -> bool { + match ( + canonicalize(src, MissingHandling::Missing, ResolveMode::Physical), + canonicalize(dst, MissingHandling::Missing, ResolveMode::Physical), ) { - if let Ok(dst_abs) = - canonicalize(dst_parent, MissingHandling::Missing, ResolveMode::Physical) - { - return make_path_relative_to(src_abs, dst_abs).into(); - } + (Ok(src), Ok(dst)) => src == dst, + _ => true, } - src.into() } +/// Create symlink to src at dst with the given settings +/// +/// This is made public to allow other apps to use `ln` as a library. #[allow(clippy::cognitive_complexity)] -fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { +pub fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { let mut backup_path = None; + // Remove an existing destination only after a link attempt fails with EEXIST. + let mut overwrite_on_conflict = false; let source: Cow<'_, Path> = if settings.relative { relative_path(src, dst) } else { @@ -415,7 +405,7 @@ fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { backup_path = backup_control::get_backup_path(settings.backup, dst, &settings.suffix); if settings.backup == BackupMode::Existing && !settings.symbolic { // when ln --backup f f, it should detect that it is the same file - if paths_refer_to_same_file(src, dst, true) { + if paths_refer_to_same_file(src, dst, true) && is_same_entry(src, dst) { return Err(LnError::SameFile(src.to_owned(), dst.to_owned())); } } @@ -434,55 +424,68 @@ fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { return Err(LnError::SomeLinksFailed); } - let _ = fs::remove_file(dst); - // In case of error, don't do anything + overwrite_on_conflict = true; } OverwriteMode::Force => { - if !dst.is_symlink() && paths_refer_to_same_file(src, dst, true) { + if !dst.is_symlink() + && paths_refer_to_same_file(src, dst, true) + && is_same_entry(src, dst) + { // Even in force overwrite mode, verify we are not targeting the same entry and return a SameFile error if so - let same_entry = match ( - canonicalize(src, MissingHandling::Missing, ResolveMode::Physical), - canonicalize(dst, MissingHandling::Missing, ResolveMode::Physical), - ) { - (Ok(src), Ok(dst)) => src == dst, - _ => true, - }; - if same_entry { - return Err(LnError::SameFile(src.to_owned(), dst.to_owned())); - } + return Err(LnError::SameFile(src.to_owned(), dst.to_owned())); } - let _ = fs::remove_file(dst); - // In case of error, don't do anything + overwrite_on_conflict = true; } } } - let res = if settings.symbolic { - symlink(&source, dst).map_err(Into::into) + // Resolved once: this can fail independently of the destination. + let hard_link_src = if settings.symbolic { + None + } else if settings.logical && source.is_symlink() { + Some(fs::canonicalize(&source).map_err(|e| { + LnError::IoContext( + UIoError::from(e), + translate!("ln-failed-to-access", "file" => source.quote()), + ) + })?) } else { - let p = if settings.logical && source.is_symlink() { - fs::canonicalize(&source).map_err(|e| { - LnError::IoContext( - UIoError::from(e), - translate!("ln-failed-to-access", "file" => source.quote()), - ) - })? + Some(source.to_path_buf()) + }; + + // Link first, matching GNU. Replacing uses a temp name + `renameat`, so + // `dst` is never briefly free for another user to claim. + let link_target = hard_link_src.as_deref().unwrap_or(&source); + let raw = if overwrite_on_conflict { + replace_link(link_target, dst, settings.symbolic) + } else if settings.symbolic { + symlink(&source, dst) + } else { + fs::hard_link(link_target, dst) + }; + + let res = raw.map_err(|e| { + if settings.symbolic { + LnError::IoContext( + UIoError::from(e), + translate!( + "ln-failed-to-create-symbolic-link", + "dest" => dst.quote() + ), + ) + } else if hard_link_src.as_ref().is_some_and(|p| p.is_dir()) { + LnError::FailedToCreateHardLinkDir(source.to_path_buf()) } else { - source.to_path_buf() - }; - match fs::hard_link(&p, dst) { - Ok(()) => Ok(()), - Err(_) if p.is_dir() => Err(LnError::FailedToCreateHardLinkDir(source.to_path_buf())), - Err(e) => Err(LnError::IoContext( + LnError::IoContext( UIoError::from(e), translate!( "ln-failed-to-create-hard-link", "source" => source.quote(), "dest" => dst.quote() ), - )), + ) } - }; + }); if let Err(e) = res { if let Some(ref p) = backup_path { @@ -513,6 +516,7 @@ fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { #[cfg(windows)] pub fn symlink, P2: AsRef>(src: P1, dst: P2) -> io::Result<()> { + use std::os::windows::fs::{symlink_dir, symlink_file}; if src.as_ref().is_dir() { symlink_dir(src, dst) } else { @@ -520,7 +524,7 @@ pub fn symlink, P2: AsRef>(src: P1, dst: P2) -> io::Result } } -#[cfg(target_os = "wasi")] +#[cfg(any(unix, target_os = "wasi"))] pub fn symlink, P2: AsRef>(src: P1, dst: P2) -> io::Result<()> { rustix::fs::symlink(src.as_ref(), dst.as_ref()).map_err(io::Error::from) } diff --git a/src/uu/logname/Cargo.toml b/src/uu/logname/Cargo.toml index a4586157aeb..edc1b0e90f2 100644 --- a/src/uu/logname/Cargo.toml +++ b/src/uu/logname/Cargo.toml @@ -11,19 +11,19 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/logname.rs" test = false doctest = false [dependencies] -libc = { workspace = true } clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +libc = { workspace = true } +uucore = { workspace = true } + +[lints] +workspace = true [[bin]] name = "logname" diff --git a/src/uu/logname/src/logname.rs b/src/uu/logname/src/logname.rs index 075122725e8..0ecfe134a7c 100644 --- a/src/uu/logname/src/logname.rs +++ b/src/uu/logname/src/logname.rs @@ -6,7 +6,7 @@ // spell-checker:ignore (ToDO) getlogin userlogin use clap::Command; -use std::ffi::CStr; +use core::ffi::CStr; use std::io::{Write, stdout}; use uucore::translate; use uucore::{error::UResult, show_error}; diff --git a/src/uu/logname/src/main.rs b/src/uu/logname/src/main.rs index 9a1e92e8771..cb3f6b993b4 100644 --- a/src/uu/logname/src/main.rs +++ b/src/uu/logname/src/main.rs @@ -3,4 +3,4 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -uucore::bin!(uu_logname); +uucore::bin!(uu_logname, no_flush); diff --git a/src/uu/ls/Cargo.toml b/src/uu/ls/Cargo.toml index 260743a1f9b..a5a17d0168c 100644 --- a/src/uu/ls/Cargo.toml +++ b/src/uu/ls/Cargo.toml @@ -13,9 +13,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/ls.rs" doctest = false @@ -23,6 +20,7 @@ doctest = false [dependencies] ansi-width = { workspace = true } clap = { workspace = true, features = ["env"] } +fluent = { workspace = true } glob = { workspace = true } lscolors = { workspace = true } rustc-hash = { workspace = true } @@ -34,7 +32,7 @@ uucore = { workspace = true, features = [ "format", "fs", "fsext", - "fsxattr", + "i18n-collator", "parser-size", "parser-glob", "quoting-style", @@ -42,7 +40,9 @@ uucore = { workspace = true, features = [ "version-cmp", ] } uutils_term_grid = { workspace = true } -fluent = { workspace = true } + +[target.'cfg(any(target_os = "freebsd", target_os = "hurd", target_os = "linux", target_os = "netbsd"))'.dependencies] +uucore = { workspace = true, features = ["fsxattr"] } [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] selinux = { workspace = true, optional = true } @@ -65,5 +65,9 @@ name = "ls_bench" harness = false [features] -feat_selinux = ["selinux", "uucore/selinux"] +diagnostics = ["uucore/diagnostics"] +selinux = ["dep:selinux", "uucore/selinux"] smack = ["uucore/smack"] + +[lints] +workspace = true diff --git a/src/uu/ls/locales/en-US.ftl b/src/uu/ls/locales/en-US.ftl index 72380e3a356..075ec37bf86 100644 --- a/src/uu/ls/locales/en-US.ftl +++ b/src/uu/ls/locales/en-US.ftl @@ -18,7 +18,7 @@ ls-error-general-io = general io error: {$error} ls-error-cannot-access-no-such-file = cannot access {$path}: No such file or directory ls-error-cannot-access-operation-not-permitted = cannot access {$path}: Operation not permitted ls-error-cannot-open-directory-permission-denied = cannot open directory {$path}: Permission denied -ls-error-cannot-open-file-permission-denied = cannot open file {$path}: Permission denied +ls-error-cannot-access-permission-denied = cannot access {$path}: Permission denied ls-error-cannot-open-directory-bad-descriptor = cannot open directory {$path}: Bad file descriptor ls-error-unknown-io-error = unknown io error: {$path}, '{$error}' ls-error-invalid-block-size = invalid --block-size argument {$size} @@ -129,12 +129,11 @@ ls-help-full-time = like -l --time-style=full-iso ls-help-context = print any security context of each file ls-help-group-directories-first = group directories before files; can be augmented with a --sort option, but any use of --sort=none (-U) disables grouping -ls-invalid-quoting-style = {$program}: Ignoring invalid value of environment variable QUOTING_STYLE: '{$style}' 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-error-unrecognized-ls-colors-prefix = unrecognized prefix: {$prefix} +ls-error-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 06090f76393..06a1a6aa6d4 100644 --- a/src/uu/ls/locales/fr-FR.ftl +++ b/src/uu/ls/locales/fr-FR.ftl @@ -17,7 +17,7 @@ ls-error-general-io = erreur d'E/S générale : {$error} ls-error-cannot-access-no-such-file = impossible d'accéder à {$path} : Aucun fichier ou répertoire de ce type ls-error-cannot-access-operation-not-permitted = impossible d'accéder à {$path} : Opération non autorisée ls-error-cannot-open-directory-permission-denied = impossible d'ouvrir le répertoire {$path} : Permission refusée -ls-error-cannot-open-file-permission-denied = impossible d'ouvrir le fichier {$path} : Permission refusée +ls-error-cannot-access-permission-denied = impossible d'accéder à {$path} : Permission refusée ls-error-cannot-open-directory-bad-descriptor = impossible d'ouvrir le répertoire {$path} : Mauvais descripteur de fichier ls-error-unknown-io-error = erreur d'E/S inconnue : {$path}, '{$error}' ls-error-invalid-block-size = argument --block-size invalide {$size} @@ -127,10 +127,9 @@ ls-help-full-time = comme -l --time-style=full-iso ls-help-context = afficher tout contexte de sécurité de chaque fichier ls-help-group-directories-first = grouper les répertoires avant les fichiers ; peut être augmenté avec une option --sort, mais toute utilisation de --sort=none (-U) désactive le groupement -ls-invalid-quoting-style = {$program} : Ignorer la valeur invalide de la variable d'environnement QUOTING_STYLE : '{$style}' 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-error-unrecognized-ls-colors-prefix = préfixe non reconnu : {$prefix} +ls-error-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 a50418cd987..cd0b7cccd24 100644 --- a/src/uu/ls/src/colors.rs +++ b/src/uu/ls/src/colors.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use super::PathData; use lscolors::{Indicator, LsColors, Style}; use rustc_hash::FxHashMap; @@ -48,6 +49,9 @@ pub(crate) struct StyleManager<'a> { indicator_codes: FxHashMap, /// whether ln=target is active ln_color_from_target: bool, + /// Length of the escape sequence the last `apply_*` call prepended to the + /// name. Used by `--dired` to skip it. + last_style_prefix_len: usize, } impl<'a> StyleManager<'a> { @@ -59,6 +63,7 @@ impl<'a> StyleManager<'a> { colors, indicator_codes, ln_color_from_target, + last_style_prefix_len: 0, } } @@ -73,10 +78,10 @@ impl<'a> StyleManager<'a> { let mut force_suffix_reset: bool = false; let mut applied_raw_code = false; - 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 self.is_reset() + && let Some(norm_sty) = self.get_normal_style().copied() + { + style_code.push_str(&self.get_style_code(&norm_sty)); } if let Some(path) = path { @@ -110,6 +115,7 @@ impl<'a> StyleManager<'a> { // till the end of line let clear_to_eol = if wrap { ANSI_CLEAR_EOL } else { "" }; + self.last_style_prefix_len = style_code.len(); let mut ret: OsString = style_code.into(); ret.push(name); ret.push(self.reset(force_suffix_reset)); @@ -205,10 +211,9 @@ impl<'a> StyleManager<'a> { self.current_style = Some(*new_style); let mut nu_a_style = new_style.to_nu_ansi_term_style(); nu_a_style.prefix_with_reset = false; - let mut ret = nu_a_style.paint("").to_string(); - // remove the suffix reset - ret.truncate(ret.len() - 4); - ret + // `prefix()` yields the escape sequence on its own, and an empty string + // for a style without any attribute, so there is no trailing reset to strip + nu_a_style.prefix().to_string() } pub(crate) fn is_current_style(&self, new_style: &Style) -> bool { @@ -229,6 +234,11 @@ impl<'a> StyleManager<'a> { String::new() } + /// See [`StyleManager::last_style_prefix_len`]. + pub(crate) fn last_style_prefix_len(&self) -> usize { + self.last_style_prefix_len + } + pub(crate) fn apply_style_based_on_metadata( &mut self, path: &PathData, @@ -263,7 +273,9 @@ impl<'a> StyleManager<'a> { return self.apply_empty_style(name, wrap); } - let mut ret: OsString = self.build_raw_style_code(&raw).into(); + let style_code = self.build_raw_style_code(&raw); + self.last_style_prefix_len = style_code.len(); + let mut ret: OsString = style_code.into(); ret.push(name); ret.push(self.reset(true)); if wrap { @@ -302,6 +314,7 @@ impl<'a> StyleManager<'a> { style_code.push_str(self.reset(!self.initial_reset_is_done)); style_code.push_str(EMPTY_STYLE); + self.last_style_prefix_len = style_code.len(); let mut ret: OsString = style_code.into(); ret.push(name); ret.push(self.reset(true)); @@ -324,10 +337,10 @@ impl<'a> StyleManager<'a> { 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); - } + if target.is_relative() + && let Some(parent) = path.path().parent() + { + target = parent.join(target); } match fs::metadata(&target) { @@ -404,23 +417,21 @@ impl<'a> StyleManager<'a> { #[cfg(unix)] fn indicator_for_file(&self, path: &PathData) -> Option { - 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.needs_file_metadata() + && 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); } } @@ -442,22 +453,22 @@ impl<'a> StyleManager<'a> { #[cfg(unix)] fn indicator_for_directory(&self, path: &PathData) -> Option { - 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.needs_dir_metadata() + && 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); } } @@ -524,13 +535,18 @@ pub(crate) fn color_name( wrap: bool, ) -> OsString { // Check if the file has capabilities - #[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] + #[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "netbsd" + ))] { // Skip checking capabilities if LS_COLORS=ca=: let has_capabilities = style_manager .colors .has_explicit_style_for(Indicator::Capabilities) - && uucore::fsxattr::has_security_cap_acl(&path.p_buf); + && uucore::fsxattr::has_security_cap_acl(&path.p_buf, path.must_dereference); // If the file has capabilities, use a specific style for `ca` (capabilities) if has_capabilities { @@ -541,10 +557,11 @@ pub(crate) fn color_name( } } - if target_symlink.is_none() && path.file_type().is_some_and(fs::FileType::is_symlink) { - if let Some(colored) = style_manager.color_symlink_name(path, name.clone(), wrap) { - return colored; - } + if target_symlink.is_none() + && path.file_type().is_some_and(fs::FileType::is_symlink) + && let Some(colored) = style_manager.color_symlink_name(path, name.clone(), wrap) + { + return colored; } if let Some(target) = target_symlink { @@ -591,30 +608,30 @@ 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] { + while let Some(&byte) = bytes.get(idx) { + match byte { b':' => { idx += 1; } b'*' => { idx += 1; idx = parse_funky_string(bytes, idx, true)?; - if idx >= bytes.len() || bytes[idx] != b'=' { + if bytes.get(idx) != Some(&b'=') { return Err(LsColorsParseError::InvalidSyntax); } idx += 1; idx = parse_funky_string(bytes, idx, false)?; - if idx < bytes.len() && bytes[idx] == b':' { + if bytes.get(idx) == Some(&b':') { idx += 1; } } _ => { - if idx + 1 >= bytes.len() { + let Some(&byte_next) = bytes.get(idx + 1) else { return Err(LsColorsParseError::InvalidSyntax); - } - let label = [bytes[idx], bytes[idx + 1]]; + }; + let label = [byte, byte_next]; idx += 2; - if idx >= bytes.len() || bytes[idx] != b'=' { + if bytes.get(idx) != Some(&b'=') { return Err(LsColorsParseError::InvalidSyntax); } if !is_valid_ls_colors_prefix(label) { @@ -623,7 +640,7 @@ fn validate_ls_colors(ls_colors: &str) -> Result<(), LsColorsParseError> { } idx += 1; idx = parse_funky_string(bytes, idx, false)?; - if idx < bytes.len() && bytes[idx] == b':' { + if bytes.get(idx) == Some(&b':') { idx += 1; } } @@ -649,7 +666,7 @@ fn parse_funky_string( let mut state = State::Ground; loop { - let byte = if idx < bytes.len() { bytes[idx] } else { 0 }; + let byte = bytes.get(idx).unwrap_or(&0); match state { State::Ground => match byte { b':' | 0 => return Ok(idx), @@ -674,10 +691,6 @@ fn parse_funky_string( 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; @@ -786,7 +799,9 @@ fn parse_indicator_codes() -> (FxHashMap, bool) { } fn canonicalize_indicator_value(value: &str) -> Cow<'_, str> { - if value.len() == 1 && value.as_bytes()[0].is_ascii_digit() { + if let [first] = value.as_bytes() + && first.is_ascii_digit() + { let mut canonical = String::with_capacity(2); canonical.push('0'); canonical.push_str(value); @@ -821,6 +836,7 @@ mod tests { colors, indicator_codes, ln_color_from_target: false, + last_style_prefix_len: 0, } } diff --git a/src/uu/ls/src/config.rs b/src/uu/ls/src/config.rs index 22e7c535bc1..5fdd4a622cd 100644 --- a/src/uu/ls/src/config.rs +++ b/src/uu/ls/src/config.rs @@ -18,17 +18,23 @@ use lscolors::LsColors; use term_grid::SPACES_IN_TAB; use uucore::{ - display::Quotable, error::UResult, format::human::SizeFormat, fsext::MetadataTimeField, - line_ending::LineEnding, parser::parse_block_size, parser::parse_glob, - parser::parse_size::parse_size_non_zero_u64, quoting_style::QuotingStyle, show_error, - show_warning, time::format, translate, + diagnostics::OptionValue, + display::Quotable, + error::UResult, + format::human::SizeFormat, + fsext::MetadataTimeField, + line_ending::LineEnding, + parser::{parse_block_size, parse_glob, parse_size::parse_size_non_zero_u64}, + quoting_style::{QuotingStyle, quoting_style_from_env}, + show_error, show_warning, + time::format, + translate, }; use crate::{ LsError, colors::{LsColorsParseError, validate_ls_colors_env}, - dired::is_dired_arg_present, - display::{Format, IndicatorStyle, LocaleQuoting, LongFormat}, + display::{Format, IndicatorStyle, LongFormat}, options::QUOTING_STYLE, }; @@ -192,6 +198,17 @@ pub(crate) enum Files { Normal, } +/// Which listing program is constructing this [`Config`]. +/// +/// `ls` defaults depend on whether stdout is a terminal. `dir` and `vdir` +/// default to a fixed format and escape quoting. +#[derive(Clone, Copy)] +enum ProgramMode { + Ls, + Dir, + Vdir, +} + pub struct Config { // Dir and vdir needs access to this field pub format: Format, @@ -215,7 +232,7 @@ pub struct Config { pub(crate) width: u16, // Dir and vdir needs access to this field pub quoting_style: QuotingStyle, - pub(crate) locale_quoting: Option, + pub(crate) show_control_chars: bool, pub(crate) indicator_style: Option, pub(crate) time_format_recent: String, // Time format for recent dates pub(crate) time_format_older: Option, // Time format for older dates (optional, if not present, time_format_recent is used) @@ -233,11 +250,16 @@ pub struct Config { /// Extracts the format to display the information based on the options provided. /// +/// When no format option is given, `mode` selects the program default: `ls` +/// depends on whether stdout is a terminal; `dir` is columns; `vdir` is long. +/// A `None` option id means that default, so `-1` and `--zero` can still +/// override it. +/// /// # Returns /// /// A tuple containing the Format variant and an Option containing a &'static str /// which corresponds to the option used to define the format. -fn extract_format(options: &clap::ArgMatches) -> (Format, Option<&'static str>) { +fn extract_format(options: &clap::ArgMatches, mode: ProgramMode) -> (Format, Option<&'static str>) { if let Some(format_) = options.get_one::(options::FORMAT) { ( match format_.as_str() { @@ -259,10 +281,18 @@ fn extract_format(options: &clap::ArgMatches) -> (Format, Option<&'static str>) (Format::Commas, Some(options::format::COMMAS)) } else if options.get_flag(options::format::COLUMNS) { (Format::Columns, Some(options::format::COLUMNS)) - } else if stdout().is_terminal() { - (Format::Columns, None) } else { - (Format::OneLine, None) + match mode { + ProgramMode::Dir => (Format::Columns, None), + ProgramMode::Vdir => (Format::Long, None), + ProgramMode::Ls => { + if stdout().is_terminal() { + (Format::Columns, None) + } else { + (Format::OneLine, None) + } + } + } } } @@ -301,7 +331,7 @@ fn extract_files(options: &clap::ArgMatches) -> Files { /// # Returns /// /// A Sort variant representing the sorting method to use. -fn extract_sort(options: &clap::ArgMatches) -> Sort { +fn extract_sort(options: &clap::ArgMatches, format: &Format) -> Sort { let get_last_index = |flag: &str| -> usize { if options.value_source(flag) == Some(clap::parser::ValueSource::CommandLine) { options.index_of(flag).unwrap_or(0) @@ -332,7 +362,7 @@ fn extract_sort(options: &clap::ArgMatches) -> Sort { match max_sort_index { 0 => { // No sort flags specified, use default behavior - if !options.get_flag(options::format::LONG) + if *format != Format::Long && (options.get_flag(options::time::ACCESS) || options.get_flag(options::time::CHANGE) || options.get_one::(options::TIME).is_some()) @@ -473,73 +503,6 @@ fn extract_hyperlink(options: &clap::ArgMatches) -> bool { } } -/// Match the argument given to --quoting-style or the [`QUOTING_STYLE`] env variable. -/// -/// # Arguments -/// -/// * `style`: the actual argument string -/// * `show_control` - A boolean value representing whether to show control characters. -/// -/// # 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 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, - }, - fixed_control: true, - locale: Some(LocaleQuoting::Single), - }, - "clocale" => QuotingStyleSpec::with_locale(QuotingStyle::C_DOUBLE, LocaleQuoting::Double), - _ => return None, - }; - - 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. /// If no options are given, it looks if a default quoting style is provided /// through the [`QUOTING_STYLE`] environment variable. @@ -555,40 +518,33 @@ fn match_quoting_style_name( fn extract_quoting_style( options: &clap::ArgMatches, show_control: bool, -) -> (QuotingStyle, Option) { + mode: ProgramMode, +) -> QuotingStyle { 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(pair) => pair, + match QuotingStyle::parse(style) { + Some(qs) => qs.show_control(show_control), None => unreachable!("Should have been caught by Clap"), } } else if options.get_flag(options::quoting::LITERAL) { - (QuotingStyle::Literal { show_control }, None) + QuotingStyle::Literal { show_control } } else if options.get_flag(options::quoting::ESCAPE) { - (QuotingStyle::C_NO_QUOTES, None) + QuotingStyle::Escape } else if options.get_flag(options::quoting::C) { - (QuotingStyle::C_DOUBLE, None) - } else if options.get_flag(options::DIRED) { - (QuotingStyle::Literal { show_control }, None) + QuotingStyle::C_DOUBLE } 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(pair) => return pair, - None => eprintln!( - "{}", - translate!("ls-invalid-quoting-style", "program" => std::env::args().next().unwrap_or_else(|| "ls".to_string()), "style" => style.clone()) - ), - } + if let Some(qs) = quoting_style_from_env() { + return qs.show_control(show_control); } - // 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), None) - } else { - (QuotingStyle::Literal { show_control }, None) + match mode { + ProgramMode::Dir | ProgramMode::Vdir => QuotingStyle::Escape, + ProgramMode::Ls if stdout().is_terminal() => { + QuotingStyle::SHELL_ESCAPE.show_control(show_control) + } + ProgramMode::Ls => QuotingStyle::Literal { show_control }, } } } @@ -601,18 +557,16 @@ fn extract_quoting_style( fn extract_indicator_style(options: &clap::ArgMatches) -> Option { if let Some(field) = options.get_one::(options::INDICATOR_STYLE) { match field.as_str() { - "none" => None, "file-type" => Some(IndicatorStyle::FileType), "classify" => Some(IndicatorStyle::Classify), "slash" => Some(IndicatorStyle::Slash), - &_ => None, + "none" | &_ => None, } } else if let Some(field) = options.get_one::(options::indicator_style::CLASSIFY) { match field.as_str() { - "never" | "no" | "none" => None, "always" | "yes" | "force" => Some(IndicatorStyle::Classify), "auto" | "tty" | "if-tty" => stdout().is_terminal().then_some(IndicatorStyle::Classify), - &_ => None, + "never" | "no" | "none" | &_ => None, } } else if options.get_flag(options::indicator_style::SLASH) { Some(IndicatorStyle::Slash) @@ -683,10 +637,30 @@ fn parse_tab_size(size_str: &str) -> Result { } impl Config { + pub fn from(options: &clap::ArgMatches, diag_args: Option<&[OsString]>) -> UResult { + Self::from_with_program_mode(options, diag_args, ProgramMode::Ls) + } + + /// Construct a configuration with dir's default column format and escape quoting. + pub fn from_dir(options: &clap::ArgMatches, diag_args: Option<&[OsString]>) -> UResult { + Self::from_with_program_mode(options, diag_args, ProgramMode::Dir) + } + + /// Construct a configuration with vdir's default long format and escape quoting. + pub fn from_vdir(options: &clap::ArgMatches, diag_args: Option<&[OsString]>) -> UResult { + Self::from_with_program_mode(options, diag_args, ProgramMode::Vdir) + } + #[allow(clippy::cognitive_complexity)] - pub fn from(options: &clap::ArgMatches) -> UResult { + fn from_with_program_mode( + options: &clap::ArgMatches, + diag_args: Option<&[OsString]>, + mode: ProgramMode, + ) -> UResult { let context = options.get_flag(options::CONTEXT); - let (mut format, opt) = extract_format(options); + let (mut format, opt) = extract_format(options, mode); + // -1 and --zero override a default long format, but not an explicit one. + let mut explicit_long = format == Format::Long && opt.is_some(); let files = extract_files(options); // The -o, -n and -g options are tricky. They cannot override with each @@ -697,14 +671,19 @@ impl Config { // when switching to a different format option in-between like this: // -ogCl or "-og --format=vertical --format=long". // - // -1 has a similar issue: it does nothing if the format is long. This - // actually makes it distinct from the --format=singe-column option, + // -1 has a similar issue: it does nothing if long format was explicitly + // requested. This makes it distinct from the --format=singe-column option, // which always applies. // + // --dired (-D) implies long format the same way -g, -o and -n do: it + // wins over earlier format options, loses to later ones, and a -1 + // after it has no effect. Whether dired output is actually emitted is + // decided below, once the final format is known. + // // The idea here is to not let these options override with the other // options, but manually whether they have an index that's greater than // the other format options. If so, we set the appropriate format. - if format != Format::Long { + if !explicit_long { let idx = opt .and_then(|opt| options.indices_of(opt).map(|x| x.max().unwrap())) .unwrap_or(0); @@ -713,6 +692,7 @@ impl Config { options::format::LONG_NO_GROUP, options::format::LONG_NUMERIC_UID_GID, options::FULL_TIME, + options::DIRED, ] .iter() .filter_map(|opt| { @@ -726,17 +706,16 @@ impl Config { .any(|i| i >= idx) { format = Format::Long; - } else if let Some(mut indices) = options.indices_of(options::format::ONE_LINE) { - if options.value_source(options::format::ONE_LINE) + explicit_long = true; + } else if let Some(mut indices) = options.indices_of(options::format::ONE_LINE) + && options.value_source(options::format::ONE_LINE) == Some(clap::parser::ValueSource::CommandLine) - && indices.any(|i| i > idx) - { - format = Format::OneLine; - } + && indices.any(|i| i > idx) + { + format = Format::OneLine; } } - let sort = extract_sort(options); let time = extract_time(options); let mut needs_color = extract_color(options); let hyperlink = extract_hyperlink(options); @@ -763,13 +742,24 @@ impl Config { (DEFAULT_FILE_SIZE_BLOCK_SIZE, 1000) } else if opt_hr { (DEFAULT_FILE_SIZE_BLOCK_SIZE, DEFAULT_BLOCK_SIZE) - } else if let Ok(size) = parse_size_non_zero_u64(opt_block_size) { + } else { + let size = parse_size_non_zero_u64(opt_block_size).map_err(|error| { + let ls_error = LsError::BlockSizeParseError(opt_block_size.clone()); + let message = ls_error.to_string(); + error.size_value_error( + diag_args, + &OptionValue::with_names( + opt_block_size, + None, + Some(options::size::BLOCK_SIZE), + ), + 0, + &message, + ls_error, + ) + })?; // --block-size overrides -k (size, size) - } else { - return Err(Box::new(LsError::BlockSizeParseError( - opt_block_size.clone(), - ))); } } else if !opt_si && !opt_hr { resolve_block_sizes_from_env(opt_kb) @@ -784,13 +774,11 @@ impl Config { let group = !options.get_flag(options::NO_GROUP) && !options.get_flag(options::format::LONG_NO_GROUP); let owner = !options.get_flag(options::format::LONG_NO_OWNER); - #[cfg(unix)] let numeric_uid_gid = options.get_flag(options::format::LONG_NUMERIC_UID_GID); LongFormat { author, group, owner, - #[cfg(unix)] numeric_uid_gid, } }; @@ -799,18 +787,13 @@ impl Config { let mut show_control = if options.get_flag(options::HIDE_CONTROL_CHARS) { false } else { - options.get_flag(options::SHOW_CONTROL_CHARS) || !stdout().is_terminal() + options.get_flag(options::SHOW_CONTROL_CHARS) + || !matches!(mode, ProgramMode::Ls) + || !stdout().is_terminal() }; - let (mut quoting_style, mut locale_quoting) = extract_quoting_style(options, show_control); + let mut quoting_style = extract_quoting_style(options, show_control, mode); 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); - let (time_format_recent, time_format_older) = if format == Format::Long || dired { - parse_time_style(options)? - } else { - Default::default() - }; let mut ignore_patterns: Vec = Vec::new(); @@ -878,68 +861,43 @@ impl Config { options::quoting::LITERAL, ]; let get_last = |flag: &str| -> usize { - if options.value_source(flag) == Some(clap::parser::ValueSource::CommandLine) { - options.index_of(flag).unwrap_or(0) - } else { - 0 - } - }; - if get_last(options::ZERO) - > zero_formats_opts - .into_iter() - .map(get_last) - .max() + (options.value_source(flag) == Some(clap::parser::ValueSource::CommandLine)) + .then(|| options.index_of(flag)) + .flatten() .unwrap_or(0) - { - format = if format == Format::Long { - format - } else { - Format::OneLine - }; + }; + let zero_idx = get_last(options::ZERO); + let last_of = + |flag_list: &[&str]| flag_list.iter().copied().map(get_last).max().unwrap_or(0); + + if zero_idx > last_of(&zero_formats_opts) && !explicit_long { + format = Format::OneLine; } - if get_last(options::ZERO) - > zero_colors_opts - .into_iter() - .map(get_last) - .max() - .unwrap_or(0) - { + + if zero_idx > last_of(&zero_colors_opts) { needs_color = false; } - if get_last(options::ZERO) - > zero_show_control_opts - .into_iter() - .map(get_last) - .max() - .unwrap_or(0) - { + + if zero_idx > last_of(&zero_show_control_opts) { show_control = true; } - if get_last(options::ZERO) - > zero_quoting_style_opts - .into_iter() - .map(get_last) - .max() - .unwrap_or(0) - { + + if zero_idx > last_of(&zero_quoting_style_opts) { quoting_style = QuotingStyle::Literal { show_control }; - 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; + if needs_color && let Err(err) = validate_ls_colors_env() { + if let LsColorsParseError::UnrecognizedPrefix(prefix) = &err { + show_error!( + "{}", + translate!( + "ls-error-unrecognized-ls-colors-prefix", + "prefix" => prefix.quote() + ) + ); } + show_error!("{}", translate!("ls-error-unparsable-ls-colors")); + needs_color = false; } let color = if needs_color { @@ -948,16 +906,26 @@ impl Config { None }; - if dired || is_dired_arg_present() { - // --dired implies --format=long - // if we have --dired --hyperlink, we don't show dired but we still want to see the - // long format - format = Format::Long; - } + // Hyperlinks enabled after the last --dired cancel the dired output, + // and a --dired after --hyperlink disables them again. Dired output + // also requires the final format to be long: a later -C, -x, -m or + // --format= cancels it. + let dired_idx = get_last(options::DIRED); + let hyperlink = hyperlink && get_last(options::HYPERLINK) > dired_idx; + let dired = dired_idx > 0 && format == Format::Long && !hyperlink; if dired && options.get_flag(options::ZERO) { return Err(Box::new(LsError::DiredAndZeroAreIncompatible)); } + let sort = extract_sort(options, &format); + + // Only parse the time style after the final output format is known. + let (time_format_recent, time_format_older) = if format == Format::Long { + parse_time_style(options)? + } else { + Default::default() + }; + let dereference = if options.get_flag(options::dereference::ALL) { Dereference::All } else if options.get_flag(options::dereference::ARGS) { @@ -1001,7 +969,7 @@ impl Config { block_size, width, quoting_style, - locale_quoting, + show_control_chars: options.get_flag(options::SHOW_CONTROL_CHARS), indicator_style, time_format_recent, time_format_older, @@ -1057,6 +1025,16 @@ fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option style, + _ => field, + }; + match field { "full-iso" => ok((format::FULL_ISO, None)), "long-iso" => ok((format::LONG_ISO, None)), @@ -1069,13 +1047,14 @@ fn parse_time_style(options: &clap::ArgMatches) -> Result<(String, Option { - // recent/older formats are (optionally) separated by a newline - let mut it = field[1..].split('\n'); - let recent = it.next().unwrap_or_default(); - let older = it.next(); - match it.next() { - None => ok((recent, older)), - Some(_) => Err(LsError::TimeStyleParseError(String::from(field))), + // Formats are (optionally) separated by a newline: + // FORMAT1 NEWLINE FORMAT2 -> FORMAT1 for older files, FORMAT2 for recent. + // A single format applies to all files (stored as recent). + let formats: Vec<_> = field[1..].split('\n').collect(); + match formats.as_slice() { + [format] => ok((*format, None)), + [older, recent] => ok((*recent, Some(*older))), + _ => Err(LsError::TimeStyleParseError(String::from(field))), } } _ => Err(LsError::TimeStyleParseError(String::from(field))), diff --git a/src/uu/ls/src/dired.rs b/src/uu/ls/src/dired.rs index e3cda96b54f..ce96dbd3b82 100644 --- a/src/uu/ls/src/dired.rs +++ b/src/uu/ls/src/dired.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore dired subdired /// `dired` Module Documentation @@ -34,7 +35,7 @@ /// Overall, the module ensures each entry in the DIRED output has the correct /// byte position, considering additional lines or padding affecting positions. /// -use crate::Config; +use crate::{Config, LsError}; use std::fmt; use std::io::{BufWriter, Stdout, Write}; use uucore::error::UResult; @@ -45,6 +46,26 @@ pub struct BytePosition { pub end: usize, } +/// Where the file name sits inside the item that displays it. `--dired` +/// reports the name alone: no color escapes, no `-F` indicator. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct NameSpan { + /// Offset of the name, i.e. the color escape sequence preceding it, if any. + pub offset: usize, + /// Length of the quoted name. + pub len: usize, +} + +impl NameSpan { + /// Moves the name `n` bytes to the right, e.g. past a padding space. + pub fn shifted(self, n: usize) -> Self { + Self { + offset: self.offset + n, + len: self.len, + } + } +} + /// Represents the output structure for DIRED, containing positions for both DIRED and SUBDIRED. #[derive(Debug, Clone, Default, PartialEq)] pub struct DiredOutput { @@ -94,26 +115,35 @@ pub fn print_dired_output( dired: &DiredOutput, out: &mut BufWriter, ) -> UResult<()> { - out.flush()?; if !dired.dired_positions.is_empty() { - print_positions("//DIRED//", &dired.dired_positions); + print_positions(out, "//DIRED//", &dired.dired_positions)?; } // SUBDIRED is needed whenever directory headings are printed (multiple args or -R), // so don't gate it on config.recursive. if !dired.subdired_positions.is_empty() { - print_positions("//SUBDIRED//", &dired.subdired_positions); + print_positions(out, "//SUBDIRED//", &dired.subdired_positions)?; } - println!("//DIRED-OPTIONS// --quoting-style={}", config.quoting_style); + writeln!( + out, + "//DIRED-OPTIONS// --quoting-style={}", + config.quoting_style + ) + .map_err(LsError::WriteError)?; Ok(()) } /// Helper function to print positions with a given prefix. -fn print_positions(prefix: &str, positions: &[BytePosition]) { - print!("{prefix}"); +fn print_positions( + out: &mut BufWriter, + prefix: &str, + positions: &[BytePosition], +) -> UResult<()> { + write!(out, "{prefix}").map_err(LsError::WriteError)?; for c in positions { - print!(" {c}"); + write!(out, " {c}").map_err(LsError::WriteError)?; } - println!(); + writeln!(out).map_err(LsError::WriteError)?; + Ok(()) } pub fn add_total(dired: &mut DiredOutput, total_len: usize) { @@ -152,13 +182,6 @@ pub fn update_positions(dired: &mut DiredOutput, start: usize, end: usize, line_ dired.padding = 0; } -/// Checks if the "--dired" or "-D" argument is present in the command line arguments. -/// we don't use clap here because we need to know if the argument is present -/// as it can be overridden by --hyperlink -pub fn is_dired_arg_present() -> bool { - std::env::args_os().any(|x| x == "--dired" || x == "-D") -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/uu/ls/src/display.rs b/src/uu/ls/src/display.rs index d7f90d1be27..a6ec8066f48 100644 --- a/src/uu/ls/src/display.rs +++ b/src/uu/ls/src/display.rs @@ -33,7 +33,12 @@ use term_grid::{DEFAULT_SEPARATOR_SIZE, Direction, Filling, Grid, GridOptions}; #[cfg(unix)] use uucore::entries; -#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +#[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "netbsd" +))] use uucore::fsxattr::has_acl; #[cfg(unix)] use uucore::libc::{dev_t, major, minor}; @@ -42,8 +47,8 @@ use uucore::{ format::human::human_readable, fs::display_permissions, fsext::metadata_get_time, - i18n::{UEncoding, get_ctype_encoding}, - os_str_as_bytes_lossy, + line_ending::LineEnding, + os_str_as_bytes_lossy, os_string_from_vec, quoting_style::{QuotingStyle, locale_aware_escape_dir_name, locale_aware_escape_name}, show, time::{FormatSystemTimeFallback, format_system_time}, @@ -51,7 +56,7 @@ use uucore::{ use crate::colors::{StyleManager, color_name}; use crate::config::Files; -use crate::dired::{self, DiredOutput}; +use crate::dired::{self, DiredOutput, NameSpan}; use crate::{Config, ListState, LsError, PathData, get_block_size}; use lscolors::Indicator; @@ -64,7 +69,6 @@ pub(crate) struct LongFormat { pub(crate) author: bool, pub(crate) group: bool, pub(crate) owner: bool, - #[cfg(unix)] pub(crate) numeric_uid_gid: bool, } @@ -90,7 +94,8 @@ pub(crate) struct PaddingCollection { pub(crate) struct DisplayItemName { pub(crate) displayed: OsString, - pub(crate) dired_name_len: usize, + /// Where the name sits inside `displayed`, for `--dired`. + pub(crate) dired_name: NameSpan, /// Keep track of whether the quoted name started with a quote, so when /// needed, we don't have to look at the `displayed` field and skip @@ -98,7 +103,7 @@ pub(crate) struct DisplayItemName { pub(crate) starts_with_quote: bool, } -/// Same as above, but without the `dired_name_len` field. +/// Same as above, but without the `dired_name` field. pub(crate) struct DisplayWithQuote { pub(crate) displayed: OsString, pub(crate) starts_with_quote: bool, @@ -108,7 +113,7 @@ impl From for DisplayWithQuote { fn from( DisplayItemName { displayed, - dired_name_len: _, + dired_name: _, starts_with_quote, }: DisplayItemName, ) -> Self { @@ -126,12 +131,6 @@ pub(crate) enum IndicatorStyle { Classify, } -#[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum LocaleQuoting { - Single, - Double, -} - #[derive(PartialEq, Eq, Debug)] pub enum Format { Columns, @@ -157,12 +156,15 @@ enum SizeOrDeviceId { /// dir1: <- This as well /// file11 /// ``` +/// Returns the number of bytes the rendered name occupies, which `--dired` +/// needs to place the header in `//SUBDIRED//`. pub fn show_dir_name( path_data: &PathData, out: &mut BufWriter, config: &Config, -) -> std::io::Result<()> { - let escaped_name = escape_dir_name_with_locale(path_data.path().as_os_str(), config); +) -> std::io::Result { + let escaped_name = + locale_aware_escape_dir_name(path_data.path().as_os_str(), config.quoting_style); let name = if config.hyperlink && !config.dired { create_hyperlink(&escaped_name, path_data) @@ -171,106 +173,47 @@ pub fn show_dir_name( }; write_os_str(out, &name)?; - write!(out, ":") -} - -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 { - 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) + write!(out, ":")?; + Ok(name.len()) } fn escape_name_with_locale(name: &OsStr, config: &Config) -> OsString { - escape_with_locale(name, config, locale_aware_escape_name) -} + let comma_separated = + config.format == Format::Commas && os_str_as_bytes_lossy(name).contains(&b','); -fn locale_quote(name: &OsStr, style: LocaleQuoting) -> OsString { - let bytes = os_str_as_bytes_lossy(name); - - // In a UTF-8 locale GNU's locale/clocale quoting uses Unicode quotation - // marks U+2018 (LEFT) and U+2019 (RIGHT) as delimiters for both styles, - // keyed off LC_CTYPE. Since the delimiters differ from any ASCII quote, - // embedded apostrophes and double quotes are left untouched; only control - // characters, backslashes and invalid bytes are escaped. - if get_ctype_encoding() == UEncoding::Utf8 { - let mut quoted = String::with_capacity(name.len() + 6); - quoted.push('\u{2018}'); - for chunk in bytes.utf8_chunks() { - for c in chunk.valid().chars() { - if c == '\\' { - quoted.push_str("\\\\"); - } else if c.is_ascii() && c.is_control() { - push_basic_escape(&mut quoted, c as u8); - } else if c.is_control() { - // Non-ASCII control characters (the C1 range, e.g. - // U+0085 NEL) are not printable; octal-escape their - // UTF-8 bytes like GNU does for non-printable chars. - let mut buf = [0u8; 4]; - for &byte in c.encode_utf8(&mut buf).as_bytes() { - let _ = write!(quoted, "\\{byte:03o}"); - } - } else { - quoted.push(c); - } - } - for &byte in chunk.invalid() { - let _ = write!(quoted, "\\{byte:03o}"); - } - } - quoted.push('\u{2019}'); - return OsString::from(quoted); - } + // Format::Commas forces quoting. + let style = if comma_separated { + config.quoting_style.always_quote(true) + } else { + config.quoting_style + }; - let mut quoted = String::with_capacity(name.len() + 2); - 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) -} + let escaped = locale_aware_escape_name(name, style); -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), - } -} + let escaped = if comma_separated && style == QuotingStyle::Escape { + escaped.to_string_lossy().replace(',', "\\,").into() + } else { + escaped + }; -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}"); + if config.format == Format::Commas + && config.line_ending == LineEnding::Newline + && !config.show_control_chars + && config.width != 0 + { + let bytes = os_str_as_bytes_lossy(&escaped); + if bytes.contains(&b'\n') { + return os_string_from_vec( + bytes + .iter() + .map(|&byte| if byte == b'\n' { b'?' } else { byte }) + .collect(), + ) + .expect("escaped names are valid on the target platform"); } } + + escaped } pub fn should_display(entry: &DirEntry, config: &Config) -> bool { @@ -403,15 +346,6 @@ pub fn display_items( let padding_collection = calculate_padding_collection(items, config, state); for item in items { - #[cfg(unix)] - let should_display_leading_info = config.inode || config.alloc_size; - #[cfg(not(unix))] - let should_display_leading_info = config.alloc_size; - - if should_display_leading_info { - display_additional_leading_info(item, &padding_collection, config, &mut state.out)?; - } - display_item_long(item, &padding_collection, config, state, dired, quoted)?; } } else { @@ -464,7 +398,7 @@ pub fn display_items( names_vec.push(cell.into()); } - let mut names = names_vec.into_iter(); + let mut names = names_vec.into_iter().peekable(); match config.format { Format::Columns => { @@ -493,10 +427,12 @@ pub fn display_items( write_os_str(&mut state.out, &name.displayed)?; current_col = ansi_width(&name.displayed.to_string_lossy()) as u16 + 2; } - for name in names { + while let Some(name) = names.next() { let name_width = ansi_width(&name.displayed.to_string_lossy()) as u16; - // If the width is 0 we print one single line - if config.width != 0 && current_col + name_width + 1 > config.width { + if config.width != 0 + && current_col + name_width + u16::from(names.peek().is_some()) + > config.width + { current_col = name_width + 2; writeln!(state.out, ",")?; } else { @@ -667,12 +603,21 @@ fn display_group<'a>( } #[cfg(not(unix))] -fn display_uname(_metadata: &Metadata, _config: &Config, _uid_cache: &mut ()) -> &'static str { - "somebody" +fn display_uname(_metadata: &Metadata, config: &Config, _uid_cache: &mut ()) -> &'static str { + // No uid to report on this platform; with `-n` fall back to "0" so the + // output still looks numeric, matching the intent of --numeric-uid-gid. + if config.long.numeric_uid_gid { + "0" + } else { + "somebody" + } } #[cfg(not(unix))] -fn display_group(_metadata: &Metadata, _config: &Config, _gid_cache: &mut ()) -> &'static str { +fn display_group(_metadata: &Metadata, config: &Config, _gid_cache: &mut ()) -> &'static str { + if config.long.numeric_uid_gid { + return "0"; + } "somegroup" } @@ -755,31 +700,34 @@ fn display_item_name( name = create_hyperlink(&name, path); } + // `--dired` reports the name only, so measure it before coloring. + let mut dired_name = NameSpan { + offset: 0, + len: if config.dired { name.len() } else { 0 }, + }; + if let Some(style_manager) = style_manager.as_mut() { let len = name.len(); name = color_name(name, path, style_manager, None, is_wrap(len)); + dired_name.offset = style_manager.last_style_prefix_len(); } - if config.format != Format::Long { - if let Some(info) = more_info { - let old_name = name; - name = info.into(); - name.push(&old_name); - } + if config.format != Format::Long + && let Some(info) = more_info + { + let old_name = name; + name = info.into(); + name.push(&old_name); } let is_long_symlink = config.format == Format::Long && path.file_type().is_some_and(FileType::is_symlink) && !path.must_dereference; - if !is_long_symlink { - if let Some(c) = indicator_char(path, config.indicator_style) { - let _ = name.write_char(c); - } + if !is_long_symlink && let Some(c) = indicator_char(path, config.indicator_style) { + let _ = name.write_char(c); } - let dired_name_len = if config.dired { name.len() } else { 0 }; - if is_long_symlink { let has_mi_or_or = style_manager.as_ref().is_some_and(|sm| { sm.has_indicator_style(Indicator::OrphanedSymbolicLink) @@ -877,36 +825,41 @@ fn display_item_name( } } Err(err) => { - show!(LsError::IOErrorContext( - path.path().to_path_buf(), - err, - false - )); + // When the metadata could not be read either, the failure has already + // been reported by `PathData::metadata()`; GNU prints a single + // diagnostic and no link target in that case. + if path.metadata().is_some() { + show!(LsError::IOErrorContext( + path.path().to_path_buf(), + err, + false + )); + } } } } // Prepend the security context to the `name` and adjust `width` in order // to get correct alignment from later calls to`display_grid()`. - if config.context { - if let Some(pad_count) = prefix_context { - let security_context: Cow<'_, str> = if matches!(config.format, Format::Commas) { - path.security_context(config).into() - } else { - pad_left(path.security_context(config), pad_count).into() - }; + if config.context + && let Some(pad_count) = prefix_context + { + let security_context: Cow<'_, str> = if matches!(config.format, Format::Commas) { + path.security_context(config).into() + } else { + pad_left(path.security_context(config), pad_count).into() + }; - let old_name = name; - name = OsString::with_capacity(security_context.len() + 1 + old_name.len()); - name.push(security_context.as_ref()); - name.push(" "); - name.push(old_name); - } + let old_name = name; + name = OsString::with_capacity(security_context.len() + 1 + old_name.len()); + name.push(security_context.as_ref()); + name.push(" "); + name.push(old_name); } DisplayItemName { displayed: name, - dired_name_len, + dired_name, starts_with_quote, } } @@ -957,12 +910,34 @@ fn display_item_long( if config.dired { state.display_buf.extend(b" "); } + + #[cfg(unix)] + let should_display_leading_info = config.inode || config.alloc_size; + #[cfg(not(unix))] + let should_display_leading_info = config.alloc_size; + + if should_display_leading_info { + // Write into the display buffer, not straight to the output, so that the + // --dired byte offsets computed from its length include this prefix. + display_additional_leading_info(item, padding, config, &mut state.display_buf)?; + } + if let Some(md) = item.metadata() { - #[cfg(any(not(unix), target_os = "android", target_os = "macos"))] + #[cfg(not(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "netbsd" + )))] // 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.path()); + #[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "netbsd" + ))] + let is_acl_set = has_acl(item.path(), item.must_dereference); state .display_buf .extend(display_permissions(md, true).as_bytes()); @@ -1054,17 +1029,10 @@ fn display_item_long( let needs_space = quoted && !item_display.starts_with_quote; if config.dired { - let mut dired_name_len = item_display.dired_name_len; - if needs_space { - dired_name_len += 1; - } + // The alignment space is not part of the name, it only shifts it. + let dired_name = item_display.dired_name.shifted(usize::from(needs_space)); let displayed_len = item_display.displayed.len() + usize::from(needs_space); - update_dired_for_item( - dired, - state.display_buf.len(), - displayed_len, - dired_name_len, - ); + update_dired_for_item(dired, state.display_buf.len(), displayed_len, dired_name); } let item_name = item_display.displayed; @@ -1174,7 +1142,7 @@ fn display_item_long( dired, state.display_buf.len(), displayed_item.displayed.len(), - displayed_item.dired_name_len, + displayed_item.dired_name, ); } let displayed_item = displayed_item.displayed; @@ -1193,14 +1161,8 @@ fn indicator_char(path: &PathData, style: Option) -> Option sym, - IndicatorStyle::FileType => match sym { - Some('*') => None, - _ => sym, - }, - IndicatorStyle::Slash => match sym { - Some('/') => Some('/'), - _ => None, - }, + IndicatorStyle::FileType => sym.filter(|&c| c != '*'), + IndicatorStyle::Slash => sym.filter(|&c| c == '/'), } } @@ -1258,9 +1220,9 @@ fn create_hyperlink(name: &OsStr, path: &PathData) -> OsString { ret.push(HOSTNAME.as_os_str()); // a set of safe ASCII bytes that don't need encoding - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] let unencoded = |c| matches!(c, '_' | '-' | '.' | '~' | '/'); - #[cfg(target_os = "windows")] + #[cfg(windows)] let unencoded = |c| matches!(c, '_' | '-' | '.' | '~' | '/' | '\\' | ':'); for &b in absolute_path.as_os_str().as_encoded_bytes() { @@ -1295,10 +1257,15 @@ fn update_dired_for_item( dired: &mut DiredOutput, output_display_len: usize, displayed_len: usize, - dired_name_len: usize, + name: NameSpan, ) { let line_len = output_display_len + displayed_len + 1; // +1 for line ending - dired::calculate_and_update_positions(dired, output_display_len, dired_name_len, line_len); + dired::calculate_and_update_positions( + dired, + output_display_len + name.offset, + name.len, + line_len, + ); } #[cfg(unix)] @@ -1341,11 +1308,11 @@ fn calculate_padding_collection( padding_collections.inode = inode_len.max(padding_collections.inode); } - if config.alloc_size { - if let Some(md) = item.metadata() { - let block_size_len = display_size(get_block_size(md, config), config).len(); - padding_collections.block_size = block_size_len.max(padding_collections.block_size); - } + if config.alloc_size + && let Some(md) = item.metadata() + { + let block_size_len = display_size(get_block_size(md, config), config).len(); + padding_collections.block_size = block_size_len.max(padding_collections.block_size); } if config.format == Format::Long { @@ -1363,11 +1330,21 @@ fn calculate_padding_collection( // the permissions column by one to reserve space for the `+`/`.` // indicator. { - #[cfg(any(not(unix), target_os = "android", target_os = "macos"))] + #[cfg(not(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "netbsd" + )))] // 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.path()); + #[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "netbsd" + ))] + let is_acl_set = has_acl(item.path(), item.must_dereference); if context_len > 1 || is_acl_set { padding_collections.permissions = PERMISSIONS_WIDTH + 1; } @@ -1414,11 +1391,11 @@ fn calculate_padding_collection( }; for item in items { - if config.alloc_size { - if let Some(md) = item.metadata() { - let block_size_len = display_size(get_block_size(md, config), config).len(); - padding_collections.block_size = block_size_len.max(padding_collections.block_size); - } + if config.alloc_size + && let Some(md) = item.metadata() + { + let block_size_len = display_size(get_block_size(md, config), config).len(); + padding_collections.block_size = block_size_len.max(padding_collections.block_size); } let context_len = item.security_context(config).len(); diff --git a/src/uu/ls/src/ls.rs b/src/uu/ls/src/ls.rs index f080d6cd0ab..2054e34b1b2 100644 --- a/src/uu/ls/src/ls.rs +++ b/src/uu/ls/src/ls.rs @@ -4,7 +4,7 @@ // file that was distributed with this source code. // spell-checker:ignore (ToDO) somegroup nlink tabsize dired subdired dtype colorterm stringly -// spell-checker:ignore nohash strtime clocale +// spell-checker:ignore nohash strtime clocale inode use clap::{ Arg, ArgAction, Command, @@ -20,7 +20,6 @@ use std::cell::RefCell; use std::os::unix::fs::{FileTypeExt, MetadataExt}; use std::{ cell::OnceCell, - cmp::Reverse, ffi::{OsStr, OsString}, fs::{self, DirEntry, FileType, Metadata, ReadDir}, io::{BufWriter, ErrorKind, Stdout, Write, stdout}, @@ -28,13 +27,12 @@ use std::{ path::{Path, PathBuf}, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use thiserror::Error; #[cfg(unix)] use uucore::libc::{S_IXGRP, S_IXOTH, S_IXUSR}; use uucore::{ display::Quotable, - error::{UError, UResult, set_exit_code}, + error::{UError, UResult, set_exit_code, strip_errno}, format_usage, fs::FileInformation, fsext::metadata_get_time, @@ -60,7 +58,7 @@ use config::{Dereference, Files, Sort}; use dired::DiredOutput; use display::{display_items, display_size, should_display, show_dir_name}; -#[derive(Error, Debug)] +#[derive(Debug, thiserror::Error)] enum LsError { #[error("{}", translate!("ls-error-invalid-line-width", "width" => format!("'{_0}'")))] InvalidLineWidth(String), @@ -68,6 +66,9 @@ enum LsError { #[error("{}", translate!("ls-error-general-io", "error" => _0))] IOError(#[from] std::io::Error), + #[error("{}: {}", translate!("common-write-error"), strip_errno(.0))] + WriteError(std::io::Error), + #[error("{}", match .1.kind() { ErrorKind::NotADirectory => translate!("ls-error-not-directory", "path" => .0.quote()), ErrorKind::NotFound => translate!("ls-error-cannot-access-no-such-file", "path" => .0.quote()), @@ -76,7 +77,7 @@ enum LsError { _ => if .0.is_dir() { translate!("ls-error-cannot-open-directory-permission-denied", "path" => .0.quote()) } else { - translate!("ls-error-cannot-open-file-permission-denied", "path" => .0.quote()) + translate!("ls-error-cannot-access-permission-denied", "path" => .0.quote()) }, }, _ => if 9 == .1.raw_os_error().unwrap_or(1) { @@ -106,24 +107,25 @@ enum LsError { impl UError for LsError { fn code(&self) -> i32 { match self { - Self::InvalidLineWidth(_) => 2, - Self::IOError(_) => 1, - Self::IOErrorContext(_, _, false) => 1, - Self::IOErrorContext(_, _, true) => 2, - Self::BlockSizeParseError(_) => 2, - Self::DiredAndZeroAreIncompatible => 2, - Self::AlreadyListedError(_) => 2, - Self::TimeStyleParseError(_) => 2, - Self::InvalidTabSize(_) => 2, + Self::IOError(_) | Self::IOErrorContext(_, _, false) => 1, + _ => 2, } } } #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result_with_exit_code(uu_app(), args, 2)?; + // The arguments are kept for the caret in SIZE diagnostics, which echoes + // the command line. + let (matches, diag_args) = uucore::clap_localization::handle_clap_result_with_diagnostics( + uu_app(), + args.collect(), + 2, + )?; + + uucore::i18n::collator::init_locale_collation(); - let config = Config::from(&matches)?; + let config = Config::from(&matches, diag_args.as_deref())?; let locs = matches .get_many::(options::PATHS) @@ -170,7 +172,6 @@ pub fn uu_app() -> Command { options::format::LONG, options::format::ACROSS, options::format::COLUMNS, - options::DIRED, ]), ) .arg( @@ -246,8 +247,7 @@ pub fn uu_app() -> Command { .long(options::DIRED) .short('D') .help(translate!("ls-help-generate-dired-output")) - .action(ArgAction::SetTrue) - .overrides_with(options::HYPERLINK), + .action(ArgAction::SetTrue), ) .arg( Arg::new(options::HYPERLINK) @@ -262,8 +262,7 @@ pub fn uu_app() -> Command { .num_args(0..=1) .default_missing_value("always") .default_value("never") - .value_name("WHEN") - .overrides_with(options::DIRED), + .value_name("WHEN"), ) // The next four arguments do not override with the other format // options, see the comment in Config::from for the reason. @@ -798,11 +797,11 @@ enum PathDataDisplayName<'a> { /// Represents a Path along with it's associated data. /// Any data that will be reused several times makes sense to be added to this structure. /// Caching data here helps eliminate redundant syscalls to fetch same information. -#[derive(Debug)] /// Internal representation of file/directory entry data. /// /// This struct is used internally for file enumeration. It can be converted /// to [`EntryInfo`] for programmatic access via the [`LsOutput`] trait. +#[derive(Debug)] pub struct PathData<'a> { // Result got from symlink_metadata() or metadata() based on config md: OnceCell>, @@ -861,17 +860,7 @@ impl<'a> PathData<'a> { let must_dereference = match &config.dereference { Dereference::All => true, Dereference::Args => command_line, - Dereference::DirArgs => { - if command_line { - if let Ok(md) = p_buf.metadata() { - md.is_dir() - } else { - false - } - } else { - false - } - } + Dereference::DirArgs => command_line && p_buf.metadata().is_ok_and(|m| m.is_dir()), Dereference::None => false, }; @@ -886,11 +875,9 @@ impl<'a> PathData<'a> { let security_context: OnceCell> = OnceCell::new(); let de: RefCell> = if let Some(de) = dir_entry { - if must_dereference { - if let Ok(md_pb) = p_buf.metadata() { - ft.get_or_init(|| Some(md_pb.file_type())); - md.get_or_init(|| Some(md_pb)); - } + if must_dereference && let Ok(md_pb) = p_buf.metadata() { + ft.get_or_init(|| Some(md_pb.file_type())); + md.get_or_init(|| Some(md_pb)); } if let Ok(ft_de) = de.file_type() { @@ -918,13 +905,18 @@ impl<'a> PathData<'a> { fn metadata(&self) -> Option<&Metadata> { self.md .get_or_init(|| { - if !self.must_dereference { - if let Some(dir_entry) = RefCell::take(&self.de) { - return dir_entry.metadata().ok(); - } - } + // Prefer the metadata cached by `read_dir` when we don't have to + // dereference: it is cheaper than a fresh `stat()` call. Errors are + // reported below, just like for the non-cached path. + let md = if !self.must_dereference + && let Some(dir_entry) = RefCell::take(&self.de) + { + dir_entry.metadata() + } else { + get_metadata_with_deref_opt(self.path(), self.must_dereference) + }; - match get_metadata_with_deref_opt(self.path(), self.must_dereference) { + match md { Err(err) => { // FIXME: A bit tricky to propagate the result here let mut out: std::io::StdoutLock<'static> = stdout().lock(); @@ -934,10 +926,11 @@ impl<'a> PathData<'a> { // but GNU will not throw an error until a bad fd "dir" // is entered, here we match that GNU behavior, by handing // back the non-dereferenced metadata upon an EBADF - if self.must_dereference && errno == 9i32 { - if let Ok(file) = self.path().read_link() { - return file.symlink_metadata().ok(); - } + if self.must_dereference + && errno == 9i32 + && let Ok(file) = self.path().read_link() + { + return file.symlink_metadata().ok(); } show!(LsError::IOErrorContext( self.path().to_path_buf(), @@ -1081,29 +1074,23 @@ impl LsOutput for TextOutput<'_> { config: &Config, is_first: bool, ) -> UResult<()> { - if is_first { - if config.dired { - dired::indent(&mut self.state.out)?; - } - show_dir_name(path_data, &mut self.state.out, config)?; - writeln!(self.state.out)?; - if config.dired { - let dir_len = path_data.path().as_os_str().len(); - dired::calculate_subdired(&mut self.dired, dir_len); - dired::add_dir_name(&mut self.dired, dir_len); - } - } else { + if !is_first { writeln!(self.state.out)?; if config.dired { self.dired.line_offset += 1; // account for the blank line before recursive directory headings self.dired.padding = 0; - dired::indent(&mut self.state.out)?; - let dir_name_size = path_data.path().as_os_str().len(); - dired::calculate_subdired(&mut self.dired, dir_name_size); - dired::add_dir_name(&mut self.dired, dir_name_size); } - show_dir_name(path_data, &mut self.state.out, config)?; - writeln!(self.state.out)?; + } + if config.dired { + dired::indent(&mut self.state.out)?; + } + let name_len = show_dir_name(path_data, &mut self.state.out, config)?; + writeln!(self.state.out)?; + if config.dired { + // The header is rendered with the quoting style in force, so the + // offsets must follow the rendered name, not the raw path. + dired::calculate_subdired(&mut self.dired, name_len); + dired::add_dir_name(&mut self.dired, name_len); } Ok(()) } @@ -1123,7 +1110,7 @@ impl LsOutput for TextOutput<'_> { } fn flush(&mut self) -> UResult<()> { - self.state.out.flush()?; + self.state.out.flush().map_err(LsError::WriteError)?; Ok(()) } @@ -1134,11 +1121,19 @@ impl LsOutput for TextOutput<'_> { Ok(()) } - fn initialize(&mut self, _config: &Config) -> UResult<()> { - if let Some(style_manager) = self.state.style_manager.as_mut() { - if style_manager.get_normal_style().is_some() { - let to_write = style_manager.reset(true); - write!(self.state.out, "{to_write}")?; + fn initialize(&mut self, config: &Config) -> UResult<()> { + if let Some(style_manager) = self + .state + .style_manager + .as_mut() + .filter(|s| s.get_normal_style().is_some()) + { + let to_write = style_manager.reset(true); + write!(self.state.out, "{to_write}")?; + if config.dired { + // This reset is written before any listing, so the --dired + // offsets have to start after it. + self.dired.line_offset += to_write.len(); } } Ok(()) @@ -1163,7 +1158,7 @@ impl LsOutput for TextOutput<'_> { /// use uu_ls::{Config, list_with_output, StreamingOutput}; /// use std::path::Path; /// -/// let config = Config::from(&matches)?; +/// let config = Config::from(&matches, None)?; /// let mut output = StreamingOutput::new(); /// list_with_output(vec![Path::new(".")], &config, &mut output)?; /// @@ -1246,22 +1241,33 @@ pub fn list_with_output( output.write_dir_header(path_data, config, is_first)?; } + // Only recursion can revisit a directory, so only then is it worth a + // stat to remember this one; without -R the set is never consulted. let mut listed_ancestors = FxHashSet::default(); - listed_ancestors.insert(FileInformation::from_path( - path_data.path(), - path_data.must_dereference, - )?); + let id = if config.recursive { + let info = FileInformation::from_path(path_data.path(), path_data.must_dereference)?; + let id = DirId::new(&info); + listed_ancestors.insert(id); + Some(id) + } else { + None + }; + enter_directory( path_data, read_dir, config, + id, &mut listed_ancestors, output, &mut entries, )?; + + debug_assert!(listed_ancestors.is_empty()); } output.finalize(config)?; + output.flush()?; Ok(()) } @@ -1362,6 +1368,19 @@ fn write_directory_entries( } } +/// Wrapper for `(dev, inode)`. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)] +struct DirId(u64, u64); + +impl DirId { + fn new(info: &FileInformation) -> Self { + #[cfg(any(unix, target_os = "wasi"))] + return Self(info.dev(), info.inode()); + #[cfg(windows)] + return Self(info.dev(), info.file_index()); + } +} + /// Recursively traverse directories using an explicit stack. /// /// This avoids deep recursive call chains while preserving GNU-style @@ -1370,7 +1389,8 @@ fn enter_directory( path_data: &PathData, read_dir: ReadDir, config: &Config, - listed_ancestors: &mut FxHashSet, + id: Option, + listed_ancestors: &mut FxHashSet, output: &mut O, entries: &mut Vec, ) -> UResult<()> { @@ -1378,17 +1398,46 @@ fn enter_directory( path: PathBuf, command_line: bool, is_first: bool, + id: Option, // None on the base case. + } + + /// Controls inode freeing precisely in the loop, so we correctly thread + /// cycle detection and inode discarding. + enum StackItem { + Enter(StackEntry), + Exit(DirId), } let mut stack = Vec::new(); - let mut current = Some(StackEntry { + let mut current = Some(StackItem::Enter(StackEntry { path: path_data.path().to_path_buf(), command_line: path_data.command_line, is_first: true, - }); + id: None, + })); let mut initial_read_dir = Some(read_dir); while let Some(entry) = current.take().or_else(|| stack.pop()) { + let entry = match entry { + StackItem::Exit(id) => { + listed_ancestors.remove(&id); + continue; + } + StackItem::Enter(entry) => entry, + }; + + // Check for duplicates at entry time, so we can reliably track cycles. + if !entry.is_first && entry.id.is_some_and(|id| !listed_ancestors.insert(id)) { + output.flush()?; + show!(LsError::AlreadyListedError(entry.path.clone())); + continue; + } + + // Register clean-up now. + if let Some(id) = entry.id { + stack.push(StackItem::Exit(id)); + } + let path_data = PathData::new( entry.path.as_path().into(), None, @@ -1434,7 +1483,19 @@ fn enter_directory( let child_must_dereference = child.must_dereference; let child_command_line = child.command_line; - match fs::read_dir(&child_path) { + // Try to read_dir now to eagerly report errors, matching GNU. + if let Err(err) = fs::read_dir(&child_path) { + output.flush()?; + show!(LsError::IOErrorContext( + child_path.clone(), + err, + child_command_line, + )); + continue; + } + + let info = match FileInformation::from_path(&child_path, child_must_dereference) { + Ok(info) => info, Err(err) => { output.flush()?; show!(LsError::IOErrorContext( @@ -1442,27 +1503,24 @@ fn enter_directory( err, child_command_line, )); + continue; } - Ok(_) => { - if listed_ancestors.insert(FileInformation::from_path( - &child_path, - child_must_dereference, - )?) { - stack.push(StackEntry { - path: child_path, - command_line: child_command_line, - is_first: false, - }); - } else { - output.flush()?; - show!(LsError::AlreadyListedError(child_path)); - } - } - } + }; + stack.push(StackItem::Enter(StackEntry { + path: child_path, + command_line: child_command_line, + is_first: false, + id: Some(DirId::new(&info)), + })); } } } + // Pop base case + if let Some(ref id) = id { + listed_ancestors.remove(id); + } + Ok(()) } @@ -1476,13 +1534,30 @@ pub fn list(locs: Vec<&Path>, config: &Config) -> UResult<()> { } fn sort_entries(entries: &mut [PathData], config: &Config) { + // The order the name sort uses. Sorting by time falls back on it so that + // entries sharing a timestamp come out in a fixed order rather than in + // whatever order the directory was read in, which is what GNU ls does and + // what every other arm of this match already does. + let use_locale = uucore::i18n::collator::should_use_locale_collation(); + let name_cmp = |a: &PathData, b: &PathData| { + if use_locale { + uucore::i18n::collator::locale_cmp( + os_str_as_bytes_lossy(a.display_name()).as_ref(), + os_str_as_bytes_lossy(b.display_name()).as_ref(), + ) + } else { + a.display_name().cmp(b.display_name()) + } + }; + match config.sort { - Sort::Time => entries.sort_unstable_by_key(|k| { - Reverse( - k.metadata() + Sort::Time => entries.sort_unstable_by(|a, b| { + let time = |p: &PathData| { + p.metadata() .and_then(|md| metadata_get_time(md, config.time)) - .unwrap_or(UNIX_EPOCH), - ) + .unwrap_or(UNIX_EPOCH) + }; + time(b).cmp(&time(a)).then_with(|| name_cmp(a, b)) }), Sort::Size => { entries.sort_unstable_by(|a, b| { @@ -1493,11 +1568,11 @@ fn sort_entries(entries: &mut [PathData], config: &Config) { }); } // The default sort in GNU ls is case insensitive - Sort::Name => entries.sort_unstable_by(|a, b| a.display_name().cmp(b.display_name())), + Sort::Name => entries.sort_unstable_by(name_cmp), Sort::Version => entries.sort_unstable_by(|a, b| { version_cmp( - os_str_as_bytes_lossy(a.file_name()).as_ref(), - os_str_as_bytes_lossy(b.file_name()).as_ref(), + os_str_as_bytes_lossy(a.display_name()).as_ref(), + os_str_as_bytes_lossy(b.display_name()).as_ref(), ) .then(a.path().cmp(b.path())) }), @@ -1601,16 +1676,14 @@ fn get_security_context<'a>( // If we must dereference, ensure that the symlink is actually valid even if the system // does not support SELinux. // Conforms to the GNU coreutils where a dangling symlink results in exit code 1. - if must_dereference { - if let Err(err) = get_metadata_with_deref_opt(path, must_dereference) { - // The Path couldn't be dereferenced, so return early and set exit code 1 - // to indicate a minor error - // Only show error when context display is requested to avoid duplicate messages - if config.context { - show!(LsError::IOErrorContext(path.to_path_buf(), err, false)); - } - return Cow::Borrowed(SUBSTITUTE_STRING); + if must_dereference && let Err(err) = get_metadata_with_deref_opt(path, must_dereference) { + // The Path couldn't be dereferenced, so return early and set exit code 1 + // to indicate a minor error + // Only show error when context display is requested to avoid duplicate messages + if config.context { + show!(LsError::IOErrorContext(path.to_path_buf(), err, false)); } + return Cow::Borrowed(SUBSTITUTE_STRING); } #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] diff --git a/src/uu/ls/src/output.rs b/src/uu/ls/src/output.rs index 530ba26c23b..a4ead6c111c 100644 --- a/src/uu/ls/src/output.rs +++ b/src/uu/ls/src/output.rs @@ -37,7 +37,7 @@ //! } //! } //! -//! let config = Config::from(&matches)?; +//! let config = Config::from(&matches, None)?; //! let mut output = MySink::new(); //! list_with_output(vec![Path::new(".")], &config, &mut output)?; //! println!("processed {} entries", output.count); diff --git a/src/uu/md5sum/Cargo.toml b/src/uu/md5sum/Cargo.toml index 8a071ff2989..ea3d9df4272 100644 --- a/src/uu/md5sum/Cargo.toml +++ b/src/uu/md5sum/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/md5sum.rs" test = false @@ -21,6 +18,7 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } uu_checksum_common = { workspace = true } uucore = { workspace = true, features = [ "checksum", @@ -28,13 +26,15 @@ uucore = { workspace = true, features = [ "sum", "hardware", ] } -fluent = { workspace = true } + +[dev-dependencies] +uucore = { workspace = true, features = ["benchmark"] } [features] openssl = ["uucore/openssl"] -[dev-dependencies] -uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true [[bin]] name = "md5sum" diff --git a/src/uu/mkdir/Cargo.toml b/src/uu/mkdir/Cargo.toml index 95dfed3c0d1..f1fdc62e071 100644 --- a/src/uu/mkdir/Cargo.toml +++ b/src/uu/mkdir/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/mkdir.rs" test = false @@ -21,16 +18,20 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["fs", "mode", "fsxattr"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["fs", "fsxattr", "mode"] } [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["process", "fs"] } [features] +diagnostics = ["uucore/diagnostics"] selinux = ["uucore/selinux"] smack = ["uucore/smack"] +[lints] +workspace = true + [[bin]] name = "mkdir" path = "src/main.rs" diff --git a/src/uu/mkdir/locales/en-US.ftl b/src/uu/mkdir/locales/en-US.ftl index 44fd5f5adf3..fa2ef528d45 100644 --- a/src/uu/mkdir/locales/en-US.ftl +++ b/src/uu/mkdir/locales/en-US.ftl @@ -14,6 +14,7 @@ mkdir-error-empty-directory-name = cannot create directory '': No such file or d mkdir-error-file-exists = { $path }: File exists mkdir-error-failed-to-create-tree = failed to create whole tree mkdir-error-cannot-set-permissions = cannot set permissions { $path } +mkdir-error-cannot-create-directory = cannot create directory '{ $path }': { $error } # Verbose output mkdir-verbose-created-directory = { $util_name }: created directory { $path } diff --git a/src/uu/mkdir/locales/fr-FR.ftl b/src/uu/mkdir/locales/fr-FR.ftl index d92bed96520..988f7c21f44 100644 --- a/src/uu/mkdir/locales/fr-FR.ftl +++ b/src/uu/mkdir/locales/fr-FR.ftl @@ -14,6 +14,7 @@ mkdir-error-empty-directory-name = impossible de créer le répertoire '' : Aucu mkdir-error-file-exists = { $path } : Le fichier existe mkdir-error-failed-to-create-tree = échec de la création de l'arborescence complète mkdir-error-cannot-set-permissions = impossible de définir les permissions { $path } +mkdir-error-cannot-create-directory = impossible de créer le répertoire '{ $path }': { $error } # Sortie détaillée mkdir-verbose-created-directory = { $util_name } : répertoire créé { $path } diff --git a/src/uu/mkdir/src/mkdir.rs b/src/uu/mkdir/src/mkdir.rs index ff46ad59556..3553f7e39b1 100644 --- a/src/uu/mkdir/src/mkdir.rs +++ b/src/uu/mkdir/src/mkdir.rs @@ -11,11 +11,12 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use std::ffi::OsString; use std::io::{Write, stdout}; use std::path::{Path, PathBuf}; -use uucore::error::{UResult, USimpleError}; -use uucore::translate; - +#[cfg(not(windows))] +use uucore::error::ExitCode; +use uucore::error::{UResult, USimpleError, strip_errno}; #[cfg(not(windows))] use uucore::mode; +use uucore::translate; use uucore::{display::Quotable, fs::dir_strip_dot_for_creation}; use uucore::{format_usage, show_if_err}; @@ -53,19 +54,29 @@ pub struct Config<'a> { clippy::unnecessary_wraps, reason = "fn sig must match on all platforms" )] -fn get_mode(_matches: &ArgMatches) -> Result, String> { +fn get_mode(_matches: &ArgMatches, _diag_args: Option<&[OsString]>) -> UResult> { Ok(None) } +/// `diag_args` is the argument list a caret can point into, or `None` when the +/// plain one-line message is all that is wanted. #[cfg(not(windows))] -fn get_mode(matches: &ArgMatches) -> Result, String> { +fn get_mode(matches: &ArgMatches, diag_args: Option<&[OsString]>) -> UResult> { // Not tested on Windows - if let Some(m) = matches.get_one::(options::MODE) { - mode::parse_chmod(DEFAULT_PERM, m, true, mode::get_umask()).map(Some) - } else { + let Some(m) = matches.get_one::(options::MODE) else { // If no mode argument, let the kernel apply umask and ACLs naturally. - Ok(None) - } + return Ok(None); + }; + mode::parse_chmod(DEFAULT_PERM, m, true, mode::get_umask()) + .map(Some) + .map_err(|err| { + if diag_args.is_some_and(|args| err.render_mode_value(args, m, 0, &err.to_string())) { + // The diagnostic is already on stderr; exit quietly. + ExitCode::new(1) + } else { + USimpleError::new(1, err.to_string()) + } + }) } #[uucore::main] @@ -73,8 +84,12 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // Linux-specific options, not implemented // opts.optflag("Z", "context", "set SELinux security context" + // " of each created directory to CTX"), + let args: Vec = args.collect(); + // Kept for the caret in mode diagnostics, which needs the mode as typed. + let diag_args = uucore::diagnostics::operands(&args); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + let mode = get_mode(&matches, diag_args.as_deref())?; let dirs = matches .get_many::(options::DIRS) .unwrap_or_default(); @@ -85,20 +100,17 @@ 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); - match get_mode(&matches) { - Ok(mode) => { - let config = Config { - recursive, - mode, - verbose, - set_security_context: set_security_context || context.is_some(), - context, - }; - exec(dirs, &config); - Ok(()) - } - Err(f) => Err(USimpleError::new(1, f)), - } + let config = Config { + recursive, + mode, + verbose, + set_security_context: set_security_context || context.is_some(), + context, + }; + + exec(dirs, &config); + + Ok(()) } pub fn uu_app() -> Command { @@ -197,14 +209,13 @@ pub fn mkdir(path: &Path, config: &Config) -> UResult<()> { // 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<()> { - let path_exists = path.exists(); - if path_exists && !config.recursive { + if path.exists() && !config.recursive { return Err(USimpleError::new( 1, translate!("mkdir-error-file-exists", "path" => path.maybe_quote()), )); } - if path == Path::new("") { + if path.as_os_str().is_empty() { return Ok(()); } @@ -213,24 +224,21 @@ fn create_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<()> { if config.recursive { // Pre-allocate approximate capacity to avoid reallocations let mut dirs_to_create = Vec::with_capacity(16); - let mut current = path; // First pass: collect all parent directories - while let Some(parent) = current.parent() { - if parent == Path::new("") { - break; - } - dirs_to_create.push(parent); - current = parent; - } + dirs_to_create.extend( + path.ancestors() + .skip(1) + .take_while(|path| !path.as_os_str().is_empty()), + ); // Second pass: create directories from root to leaf // Only create those that don't exist - for dir in dirs_to_create.iter().rev() { - if !dir.exists() { - create_single_dir(dir, true, config)?; - } - } + dirs_to_create + .iter() + .rev() + .filter(|dir| !dir.exists()) + .try_for_each(|dir| create_single_dir(dir, true, config))?; } // Create the target directory @@ -309,6 +317,18 @@ fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<( #[cfg(not(unix))] let (mkdir_mode, shaped_umask) = (config.mode.unwrap_or(DEFAULT_PERM), 0u32); + // Label the directory at creation, as GNU does; relabelling after leaves a window. + #[cfg(all(feature = "selinux", any(target_os = "android", target_os = "linux")))] + let _selinux_guard = if config.set_security_context && uucore::selinux::is_selinux_enabled() { + let mode = uucore::libc::S_IFDIR | mkdir_mode as uucore::libc::mode_t; + match uucore::selinux::FsCreateContext::new(path, Some(mode), config.context) { + Ok(guard) => Some(guard), + Err(e) => return Err(USimpleError::new(1, e.to_string())), + } + } else { + None + }; + match create_dir_with_mode(path, mkdir_mode, shaped_umask) { Ok(()) => { if config.verbose { @@ -319,16 +339,6 @@ fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<( )?; } - // Apply SELinux context if requested - #[cfg(all(feature = "selinux", any(target_os = "android", target_os = "linux")))] - 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); - return Err(USimpleError::new(1, e.to_string())); - } - } - // Apply SMACK context if requested #[cfg(all(feature = "smack", target_os = "linux"))] if config.set_security_context { @@ -339,7 +349,7 @@ fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<( Ok(()) } - Err(_) if path.is_dir() => { + Err(_) if config.recursive && path.is_dir() => { // Directory already exists - check if this is a logical directory creation // (i.e., not just a parent reference like "test_dir/..") let ends_with_parent_dir = matches!( @@ -358,6 +368,9 @@ fn create_single_dir(path: &Path, is_parent: bool, config: &Config) -> UResult<( } Ok(()) } - Err(e) => Err(e.into()), + Err(e) => Err(USimpleError::new( + 1, + translate!("mkdir-error-cannot-create-directory", "path" => path.display(), "error" => strip_errno(&e)), + )), } } diff --git a/src/uu/mkfifo/Cargo.toml b/src/uu/mkfifo/Cargo.toml index 07c7b1e5dab..9957defd58f 100644 --- a/src/uu/mkfifo/Cargo.toml +++ b/src/uu/mkfifo/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/mkfifo.rs" test = false @@ -21,8 +18,8 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["fs", "mode"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["fs", "mode"] } [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["fs", "process"] } @@ -31,9 +28,13 @@ rustix = { workspace = true, features = ["fs", "process"] } libc = { workspace = true } [features] +diagnostics = ["uucore/diagnostics"] selinux = ["uucore/selinux"] smack = ["uucore/smack"] +[lints] +workspace = true + [[bin]] name = "mkfifo" path = "src/main.rs" diff --git a/src/uu/mkfifo/locales/en-US.ftl b/src/uu/mkfifo/locales/en-US.ftl index c6dcae83180..f66a6b0459a 100644 --- a/src/uu/mkfifo/locales/en-US.ftl +++ b/src/uu/mkfifo/locales/en-US.ftl @@ -8,7 +8,6 @@ mkfifo-help-context = like -Z, or if CTX is specified then set the SELinux or SM # Error messages 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-create-fifo = cannot create fifo { $path }: { $error } 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 14cfc6dd582..038d8284927 100644 --- a/src/uu/mkfifo/locales/fr-FR.ftl +++ b/src/uu/mkfifo/locales/fr-FR.ftl @@ -8,7 +8,6 @@ mkfifo-help-context = comme -Z, ou si CTX est spécifié, définir le contexte d # Messages d'erreur 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-create-fifo = impossible de créer le fifo { $path } : { $error } 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 46898ade7b6..0a94ea8a8b1 100644 --- a/src/uu/mkfifo/src/mkfifo.rs +++ b/src/uu/mkfifo/src/mkfifo.rs @@ -4,10 +4,11 @@ // file that was distributed with this source code. use clap::{Arg, ArgAction, Command, value_parser}; -use rustix::fs::Mode; +use rustix::fs::{Mode, RawMode}; use rustix::process::umask; +use std::ffi::OsString; use uucore::display::Quotable; -use uucore::error::{UResult, USimpleError}; +use uucore::error::{ExitCode, UResult, USimpleError, strip_errno}; use uucore::translate; use uucore::{format_usage, show}; @@ -21,10 +22,22 @@ mod options { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let args: Vec = args.collect(); + // Kept for the caret in mode diagnostics, which needs the mode as typed. + let diag_args = uucore::diagnostics::operands(&args); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - let mode = calculate_mode(matches.get_one::(options::MODE)) - .map_err(|e| USimpleError::new(1, translate!("mkfifo-error-invalid-mode", "error" => e)))?; + let mode = calculate_mode(matches.get_one::(options::MODE)).map_err(|err| { + let message = translate!("mkfifo-error-invalid-mode", "error" => err.to_string()); + if let Some(args) = &diag_args + && let Some(mode) = matches.get_one::(options::MODE) + && err.render_mode_value(args, mode, 0, &message) + { + // The diagnostic is already on stderr; exit quietly. + return ExitCode::new(1); + } + USimpleError::new(1, message) + })?; // Check if mode contains special bits let non_file_permission_bits = 0o7000; // setuid, setgid, sticky bits @@ -35,49 +48,53 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { )); } - let fifos: Vec = match matches.get_many::(options::FIFO) { - Some(v) => v.cloned().collect(), - None => { - return Err(USimpleError::new( - 1, - translate!("mkfifo-error-missing-operand"), - )); - } - }; + #[allow(clippy::unwrap_used, reason = "set as required by clap")] + let fifos: Vec = matches + .get_many::(options::FIFO) + .unwrap() + .cloned() + .collect(); for f in fifos { + // Label the FIFO at creation, as GNU does; relabelling after leaves a window. + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] + let _selinux_guard = { + let set_security_context = matches.get_flag(options::SECURITY_CONTEXT); + let context = matches.get_one::(options::CONTEXT); + if set_security_context || context.is_some() { + let mode = uucore::libc::S_IFIFO | mode as uucore::libc::mode_t; + match uucore::selinux::FsCreateContext::new( + std::path::Path::new(&f), + Some(mode), + context, + ) { + Ok(guard) => Some(guard), + Err(e) => return Err(USimpleError::new(1, e.to_string())), + } + } else { + None + } + }; + // Clear umask around mkfifo so the kernel applies the exact // requested mode atomically. Skipping the path-based chmod // that used to follow this call closes the TOCTOU window an // attacker could use to swap the FIFO for a symlink between // mkfifo and chmod (issue #10020). let prev_umask = umask(Mode::empty()); - let mkfifo_result = create_fifo(f.as_str(), mode); + let mkfifo_result = create_fifo(f.as_str(), mode as RawMode); umask(prev_umask); - if mkfifo_result.is_err() { + if let Err(e) = mkfifo_result { show!(USimpleError::new( 1, - translate!("mkfifo-error-cannot-create-fifo", "path" => f.quote()), + translate!( + "mkfifo-error-cannot-create-fifo", + "path" => f.quote(), + "error" => strip_errno(&e) + ), )); } else { - // Apply SELinux context if requested - #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] - { - let set_security_context = matches.get_flag(options::SECURITY_CONTEXT); - let context = matches.get_one::(options::CONTEXT); - - 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) - { - let _ = std::fs::remove_file(f); - return Err(USimpleError::new(1, e.to_string())); - } - } - } - // Apply SMACK context if requested #[cfg(all(feature = "smack", target_os = "linux"))] { @@ -128,6 +145,7 @@ pub fn uu_app() -> Command { Arg::new(options::FIFO) .hide(true) .action(ArgAction::Append) + .required(true) .value_hint(clap::ValueHint::AnyPath), ) } @@ -136,22 +154,27 @@ pub fn uu_app() -> Command { // libc's path-based `mkfifo` there. Both rely on the caller having cleared // the umask so the requested mode is applied atomically (see issue #10020). #[cfg(not(target_vendor = "apple"))] -fn create_fifo(path: &str, mode: u32) -> Result<(), ()> { - use rustix::fs::{CWD, mkfifoat}; - mkfifoat(CWD, path, Mode::from_bits_truncate(mode)).map_err(|_| ()) +fn create_fifo(path: &str, mode: RawMode) -> std::io::Result<()> { + use rustix::fs; + fs::mkfifoat(fs::CWD, path, Mode::from_bits_truncate(mode)).map_err(Into::into) } #[cfg(target_vendor = "apple")] -fn create_fifo(path: &str, mode: u32) -> Result<(), ()> { +fn create_fifo(path: &str, mode: RawMode) -> std::io::Result<()> { use std::ffi::CString; - let c_path = CString::new(path).map_err(|_| ())?; + let c_path = + CString::new(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; // SAFETY: `c_path` is a valid NUL-terminated C string and `mode` is a // standard mode_t bit pattern. let rc = unsafe { libc::mkfifo(c_path.as_ptr(), mode as libc::mode_t) }; - if rc == 0 { Ok(()) } else { Err(()) } + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } } -fn calculate_mode(mode_option: Option<&String>) -> Result { +fn calculate_mode(mode_option: Option<&String>) -> Result { let umask = uucore::mode::get_umask(); let mode = 0o666; // Default mode for FIFOs diff --git a/src/uu/mknod/Cargo.toml b/src/uu/mknod/Cargo.toml index 03ad05b9d50..f25823b9ec1 100644 --- a/src/uu/mknod/Cargo.toml +++ b/src/uu/mknod/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] name = "uu_mknod" path = "src/mknod.rs" @@ -22,14 +19,18 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["mode", "fs"] } fluent = { workspace = true } -nix = { workspace = true } +nix = { workspace = true, features = ["fs"] } +uucore = { workspace = true, features = ["fs", "mode"] } [features] +diagnostics = ["uucore/diagnostics"] selinux = ["uucore/selinux"] smack = ["uucore/smack"] +[lints] +workspace = true + [[bin]] name = "mknod" path = "src/main.rs" diff --git a/src/uu/mknod/locales/en-US.ftl b/src/uu/mknod/locales/en-US.ftl index 4d655d4c0e9..2198bcf382c 100644 --- a/src/uu/mknod/locales/en-US.ftl +++ b/src/uu/mknod/locales/en-US.ftl @@ -8,9 +8,9 @@ mknod-after-help = Mandatory arguments to long options are mandatory for short o it is interpreted as hexadecimal; otherwise, if it begins with 0, as octal; otherwise, as decimal. TYPE may be: - - b create a block (buffered) special file - - c, u create a character (unbuffered) special file - - p create a FIFO + - b create a block (buffered) special file + - c, u create a character (unbuffered) special file + - p create a FIFO NOTE: your shell may have its own version of mknod, which usually supersedes the version described here. Please refer to your shell's documentation diff --git a/src/uu/mknod/locales/fr-FR.ftl b/src/uu/mknod/locales/fr-FR.ftl index a17ad862e23..c3917aac52c 100644 --- a/src/uu/mknod/locales/fr-FR.ftl +++ b/src/uu/mknod/locales/fr-FR.ftl @@ -8,9 +8,9 @@ mknod-after-help = Les arguments obligatoires pour les options longues le sont a il est interprété comme hexadécimal ; sinon, s'il commence par 0, comme octal ; sinon, comme décimal. TYPE peut être : - - b créer un fichier spécial bloc (mis en mémoire tampon) - - c, u créer un fichier spécial caractère (non mis en mémoire tampon) - - p créer un FIFO + - b créer un fichier spécial bloc (mis en mémoire tampon) + - c, u créer un fichier spécial caractère (non mis en mémoire tampon) + - p créer un FIFO NOTE : votre shell peut avoir sa propre version de mknod, qui remplace généralement la version décrite ici. Veuillez vous référer à la documentation de votre shell diff --git a/src/uu/mknod/src/mknod.rs b/src/uu/mknod/src/mknod.rs index ee1dbb4ca07..2a91f98ba0d 100644 --- a/src/uu/mknod/src/mknod.rs +++ b/src/uu/mknod/src/mknod.rs @@ -7,11 +7,12 @@ use clap::{Arg, ArgAction, Command, value_parser}; use nix::libc::{S_IRGRP, S_IROTH, S_IRUSR, S_IWGRP, S_IWOTH, S_IWUSR, mode_t}; -use nix::sys::stat::{Mode, SFlag, mknod as nix_mknod, umask as nix_umask}; +use nix::sys::stat::{Mode, SFlag, dev_t, mknod as nix_mknod, umask as nix_umask}; +use std::ffi::OsString; use std::io::{self, Write as _}; use uucore::display::Quotable; -use uucore::error::{UResult, USimpleError, UUsageError, set_exit_code}; +use uucore::error::{ExitCode, UResult, USimpleError, UUsageError, set_exit_code}; use uucore::format_usage; use uucore::fs::makedev; use uucore::translate; @@ -55,7 +56,7 @@ struct Config { /// when false, the exact mode bits will be set use_umask: bool, - dev: u64, + dev: dev_t, /// Set security context (SELinux/SMACK). #[cfg(any( @@ -73,6 +74,25 @@ struct Config { } fn mknod(file_name: &str, config: Config) -> i32 { + // Label the node at creation, as GNU does; relabelling after leaves a window. + #[cfg(all(feature = "selinux", any(target_os = "android", target_os = "linux")))] + let _selinux_guard = if config.set_security_context { + let mode = config.file_type.as_sflag().bits() | config.mode.bits(); + match uucore::selinux::FsCreateContext::new( + std::path::Path::new(file_name), + Some(mode), + config.context.as_ref(), + ) { + Ok(guard) => Some(guard), + Err(e) => { + let _ = writeln!(io::stderr(), "mknod: {e}"); + return 1; + } + } + } else { + None + }; + // set umask to 0 and store previous umask let have_prev_umask = if config.use_umask { None @@ -84,7 +104,7 @@ fn mknod(file_name: &str, config: Config) -> i32 { file_name, config.file_type.as_sflag(), config.mode, - config.dev as _, + config.dev, ) .err(); let errno = if mknod_err.is_some() { -1 } else { 0 }; @@ -103,31 +123,16 @@ fn mknod(file_name: &str, config: Config) -> i32 { ); } - // Apply SELinux context if requested - #[cfg(all(feature = "selinux", any(target_os = "android", target_os = "linux")))] - if config.set_security_context { - if let Err(e) = uucore::selinux::set_selinux_security_context( - std::path::Path::new(file_name), - config.context.as_ref(), - ) { - // if it fails, delete the file - let _ = std::fs::remove_file(file_name); - let _ = writeln!(io::stderr(), "mknod: {e}"); - return 1; - } - } - // Apply SMACK context if requested #[cfg(all(feature = "smack", target_os = "linux"))] - if config.set_security_context { - if let Err(e) = + if config.set_security_context + && let Err(e) = uucore::smack::set_smack_label_and_cleanup(file_name, config.context.as_ref(), |p| { std::fs::remove_file(p) }) - { - let _ = writeln!(io::stderr(), "mknod: {e}"); - return 1; - } + { + let _ = writeln!(io::stderr(), "mknod: {e}"); + return 1; } errno @@ -135,6 +140,9 @@ fn mknod(file_name: &str, config: Config) -> i32 { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let args: Vec = args.collect(); + // Kept for the caret in mode diagnostics, which needs the mode as typed. + let diag_args = uucore::diagnostics::operands(&args); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; let file_type = matches.get_one::("type").unwrap(); @@ -144,7 +152,26 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { None => MODE_RW_UGO, Some(str_mode) => { use_umask = false; - parse_mode(str_mode).map_err(|e| USimpleError::new(1, e))? + let mode = + uucore::mode::parse_chmod(MODE_RW_UGO, str_mode, true, uucore::mode::get_umask()) + .map_err(|err| { + let message = + translate!("mknod-error-invalid-mode", "error" => err.to_string()); + if let Some(args) = &diag_args + && err.render_mode_value(args, str_mode, 0, &message) + { + // The diagnostic is already on stderr; exit quietly. + return ExitCode::new(1); + } + USimpleError::new(1, message) + })?; + if mode > 0o777 { + return Err(USimpleError::new( + 1, + translate!("mknod-error-mode-permission-bits-only"), + )); + } + mode } }; let mode = Mode::from_bits_truncate(mode_permissions as mode_t); @@ -177,7 +204,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { translate!("mknod-error-fifo-no-major-minor"), )); } - (_, Some(&major), Some(&minor)) => makedev(major as _, minor as _) as u64, + (_, Some(&major), Some(&minor)) => makedev(major as _, minor as _), _ => { return Err(UUsageError::new( 1, @@ -266,27 +293,10 @@ pub fn uu_app() -> Command { ) } -fn parse_mode(str_mode: &str) -> Result { - let default_mode = MODE_RW_UGO; - uucore::mode::parse_chmod(default_mode, str_mode, true, uucore::mode::get_umask()) - .map_err(|e| { - translate!( - "mknod-error-invalid-mode", - "error" => e - ) - }) - .and_then(|mode| { - if mode > 0o777 { - Err(translate!("mknod-error-mode-permission-bits-only")) - } else { - Ok(mode) - } - }) -} - fn parse_type(tpe: &str) -> Result { - // Only check the first character, to allow mnemonic usage like - // 'mknod /dev/rst0 character 18 0'. + // Dispatch on the leading character alone, so a spelled-out type works + // wherever its initial does: `character` is read like `c` in + // `mknod /dev/ttyS0 character 4 64`. tpe.chars() .next() .ok_or_else(|| translate!("mknod-error-missing-device-type")) diff --git a/src/uu/mktemp/Cargo.toml b/src/uu/mktemp/Cargo.toml index e7ca17a8c68..4af33ebf267 100644 --- a/src/uu/mktemp/Cargo.toml +++ b/src/uu/mktemp/Cargo.toml @@ -11,20 +11,20 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/mktemp.rs" doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } rand = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true } -fluent = { workspace = true } + +[lints] +workspace = true [[bin]] name = "mktemp" diff --git a/src/uu/mktemp/locales/en-US.ftl b/src/uu/mktemp/locales/en-US.ftl index 7f48c4940c3..10c10f03740 100644 --- a/src/uu/mktemp/locales/en-US.ftl +++ b/src/uu/mktemp/locales/en-US.ftl @@ -19,7 +19,6 @@ mktemp-error-suffix-contains-separator = invalid suffix { $suffix }, contains di mktemp-error-invalid-template = invalid template, { $template }; with --tmpdir, it may not be absolute mktemp-error-too-many-templates = too many templates mktemp-error-not-found = failed to create { $template_type } via template { $template }: No such file or directory -mktemp-error-failed-print = failed to print directory name # Template types mktemp-template-type-directory = directory diff --git a/src/uu/mktemp/locales/fr-FR.ftl b/src/uu/mktemp/locales/fr-FR.ftl index 4bfe550a6c4..2a0eff74901 100644 --- a/src/uu/mktemp/locales/fr-FR.ftl +++ b/src/uu/mktemp/locales/fr-FR.ftl @@ -19,7 +19,6 @@ mktemp-error-suffix-contains-separator = suffixe invalide { $suffix }, contient mktemp-error-invalid-template = modèle invalide, { $template } ; avec --tmpdir, il ne peut pas être absolu mktemp-error-too-many-templates = trop de modèles mktemp-error-not-found = échec de la création de { $template_type } via le modèle { $template } : Aucun fichier ou répertoire de ce type -mktemp-error-failed-print = échec de l'affichage du nom de répertoire # Types de modèle mktemp-template-type-directory = répertoire diff --git a/src/uu/mktemp/src/mktemp.rs b/src/uu/mktemp/src/mktemp.rs index 1f872714f09..789a19e4543 100644 --- a/src/uu/mktemp/src/mktemp.rs +++ b/src/uu/mktemp/src/mktemp.rs @@ -14,12 +14,11 @@ use uucore::translate; use std::env; use std::ffi::{OsStr, OsString}; +use std::fs; use std::io::ErrorKind; use std::iter; use std::path::{MAIN_SEPARATOR, Path, PathBuf}; -#[cfg(unix)] -use std::fs; #[cfg(unix)] use std::os::unix::prelude::PermissionsExt; @@ -28,7 +27,6 @@ use rand::{ rngs::{self, SmallRng}, }; use tempfile::Builder; -use thiserror::Error; static DEFAULT_TEMPLATE: &str = "tmp.XXXXXXXXXX"; @@ -49,7 +47,7 @@ const TMPDIR_ENV_VAR: &str = "TMP"; const FALLBACK_TMPDIR: &str = "/tmp"; -#[derive(Error, Debug)] +#[derive(Debug, thiserror::Error)] enum MkTempError { #[error("{}", translate!("mktemp-error-persist-file", "path" => .0.quote()))] PersistError(PathBuf), @@ -72,7 +70,7 @@ enum MkTempError { #[error("{}", translate!("mktemp-error-too-many-templates"))] TooManyTemplates, - #[error("{}", translate!("mktemp-error-not-found", "template_type" => .0.clone(), "template" => .1.quote()))] + #[error("{}", translate!("mktemp-error-not-found", "template_type" => .0, "template" => .1.quote()))] NotFound(String, PathBuf), } @@ -143,7 +141,13 @@ impl Options { } else if matches.get_flag(OPT_T) || matches.contains_id(OPT_TMPDIR) { // If --tmpdir is given without an argument, or -t is given // export in TMPDIR - Some(env::temp_dir()) + #[cfg(target_os = "wasi")] + // WASI's `std::env::temp_dir()` unconditionally panics + let default_tmp_dir = env::var_os(TMPDIR_ENV_VAR) + .map_or_else(|| PathBuf::from(FALLBACK_TMPDIR), PathBuf::from); + #[cfg(not(target_os = "wasi"))] + let default_tmp_dir = env::temp_dir(); + Some(default_tmp_dir) } else { None }; @@ -238,7 +242,7 @@ impl Params { // The template argument must end in 'X' if a suffix option is given. if options.suffix.is_some() && !template_str.ends_with('X') { - return Err(MkTempError::MustEndInX(template_str.clone())); + return Err(MkTempError::MustEndInX(template_str)); } // Get the start and end indices of the randomized part of the template. @@ -251,7 +255,7 @@ impl Params { .chars() .take(template_str.len()) .collect::(), - None => template_str.clone(), + None => template_str, }; return Err(MkTempError::TooFewXs(s)); }; @@ -265,12 +269,10 @@ impl Params { let prefix_from_template = &template_str[..i]; let prefix_path = Path::new(&prefix_from_option).join(prefix_from_template); if options.treat_as_template && prefix_from_template.contains(MAIN_SEPARATOR) { - return Err(MkTempError::PrefixContainsDirSeparator( - template_str.clone(), - )); + return Err(MkTempError::PrefixContainsDirSeparator(template_str)); } if tmpdir.is_some() && Path::new(prefix_from_template).is_absolute() { - return Err(MkTempError::InvalidTemplate(template_str.clone().into())); + return Err(MkTempError::InvalidTemplate(template_str.into())); } // Split the parent directory from the file part of the prefix. @@ -410,32 +412,20 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } } - let dry_run = options.dry_run; - let suppress_file_err = options.quiet; - let make_dir = options.directory; - - // Parse file path parameters from the command-line options. - let Params { - directory: tmpdir, - prefix, - num_rand_chars: rand, - suffix, - } = Params::from(options)?; - - // Create the temporary file or directory, or simulate creating it. - let res = if dry_run { - Ok(dry_exec(&tmpdir, &prefix, rand, &suffix)) - } else { - exec(&tmpdir, &prefix, rand, &suffix, make_dir) - }; - - let res = if suppress_file_err { - // Mapping all UErrors to ExitCodes prevents the errors from being printed - res.map_err(|e| e.code().into()) - } else { - res - }; - println_verbatim(res?).map_err_context(|| translate!("mktemp-error-failed-print")) + let path = mktemp(&options)?; + if let Err(e) = println_verbatim(&path) { + // The caller never learns the name, so leaving the file behind would + // litter the temporary directory with something nothing can clean up. + if !options.dry_run { + let _ = if options.directory { + fs::remove_dir(&path) + } else { + fs::remove_file(&path) + }; + } + return Err(e).map_err_context(|| translate!("common-write-error")); + } + Ok(()) } pub fn uu_app() -> Command { @@ -521,7 +511,7 @@ fn dry_exec(tmpdir: &Path, prefix: &str, rand: usize, suffix: &str) -> PathBuf { SmallRng::try_from_rng(&mut rngs::SysRng) .unwrap_or_else(|_| { //rand::rng panics if getrandom failed - SmallRng::seed_from_u64(bytes.as_ptr() as usize as u64) + SmallRng::seed_from_u64(bytes.as_ptr() as u64) }) .fill(bytes); for byte in bytes { @@ -629,6 +619,13 @@ fn exec(dir: &Path, prefix: &str, rand: usize, suffix: &str, make_dir: bool) -> fn get_tmpdir_env_or_default() -> PathBuf { match env::var_os(TMPDIR_ENV_VAR) { Some(val) if val.is_empty() => PathBuf::from(FALLBACK_TMPDIR), + // WASI's `std::env::temp_dir()` unconditionally panics, + // so read `TMPDIR` directly. + #[cfg(target_os = "wasi")] + Some(val) => PathBuf::from(val), + #[cfg(target_os = "wasi")] + None => PathBuf::from(FALLBACK_TMPDIR), + #[cfg(not(target_os = "wasi"))] _ => env::temp_dir(), } } @@ -636,6 +633,8 @@ fn get_tmpdir_env_or_default() -> PathBuf { /// Create a temporary file or directory /// /// Behavior is determined by the `options` parameter, see [`Options`] for details. +/// +/// The function is public so it can be used by nushell and others. pub fn mktemp(options: &Options) -> UResult { // Parse file path parameters from the command-line options. let Params { @@ -646,10 +645,18 @@ pub fn mktemp(options: &Options) -> UResult { } = Params::from(options.clone())?; // Create the temporary file or directory, or simulate creating it. - if options.dry_run { + let res = if options.dry_run { Ok(dry_exec(&tmpdir, &prefix, rand, &suffix)) } else { exec(&tmpdir, &prefix, rand, &suffix, options.directory) + }; + + if options.quiet { + // Only creation failures are silenced; a bad template is still reported. + // Mapping the UError to an ExitCode prevents the error from being printed. + res.map_err(|e| e.code().into()) + } else { + res } } diff --git a/src/uu/more/Cargo.toml b/src/uu/more/Cargo.toml index 379b1291627..445a78e63ae 100644 --- a/src/uu/more/Cargo.toml +++ b/src/uu/more/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/more.rs" doctest = false @@ -21,13 +18,13 @@ doctest = false [dependencies] clap = { workspace = true } crossterm = { workspace = true, features = ["events"] } -uucore = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true } [target.'cfg(windows)'.dependencies] crossterm = { workspace = true, features = ["windows"] } -[target.'cfg(target_os = "macos")'.dependencies] +[target.'cfg(target_vendor = "apple")'.dependencies] crossterm = { workspace = true, features = ["use-dev-tty"] } [[bin]] @@ -36,3 +33,6 @@ path = "src/main.rs" [dev-dependencies] tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/uu/more/locales/en-US.ftl b/src/uu/more/locales/en-US.ftl index 4743d1ebaf7..75546753280 100644 --- a/src/uu/more/locales/en-US.ftl +++ b/src/uu/more/locales/en-US.ftl @@ -12,9 +12,9 @@ more-error-unknown-key = Unknown key: '{$key}'. Press 'h' for instructions. (uni # Help messages more-help-silent = Display help instead of ringing bell when an illegal key is pressed -more-help-logical = Do not pause after any line containing a ^L (form feed) +more-help-logical = Count logical lines, rather than screen lines more-help-exit-on-eof = Exit on End-Of-File -more-help-no-pause = Count logical lines, rather than screen lines +more-help-no-pause = Do not pause after any line containing a ^L (form feed) more-help-print-over = Do not scroll, clear screen and display text more-help-clean-print = Do not scroll, display text and clean line ends more-help-squeeze = Squeeze multiple blank lines into one diff --git a/src/uu/more/locales/fr-FR.ftl b/src/uu/more/locales/fr-FR.ftl index 69766f2c620..17e1bc9e1d6 100644 --- a/src/uu/more/locales/fr-FR.ftl +++ b/src/uu/more/locales/fr-FR.ftl @@ -12,9 +12,9 @@ more-error-unknown-key = Touche inconnue : '{$key}'. Appuyez sur 'h' pour les in # Messages d'aide more-help-silent = Afficher l'aide au lieu de sonner la cloche lorsqu'une touche illégale est pressée -more-help-logical = Ne pas faire de pause après une ligne contenant un ^L (saut de page) +more-help-logical = Compter les lignes logiques plutôt que les lignes d'écran more-help-exit-on-eof = Quitter à la fin du fichier -more-help-no-pause = Compter les lignes logiques plutôt que les lignes d'écran +more-help-no-pause = Ne pas faire de pause après une ligne contenant un ^L (saut de page) more-help-print-over = Ne pas défiler, effacer l'écran et afficher le texte more-help-clean-print = Ne pas défiler, afficher le texte et nettoyer les fins de ligne more-help-squeeze = Compresser plusieurs lignes vides en une seule diff --git a/src/uu/more/src/more.rs b/src/uu/more/src/more.rs index 32c416a2073..2f8cc41738f 100644 --- a/src/uu/more/src/more.rs +++ b/src/uu/more/src/more.rs @@ -6,7 +6,7 @@ use std::{ ffi::OsString, fs::File, - io::{BufRead, BufReader, Stdin, Stdout, Write, stdin, stdout}, + io::{BufRead, BufReader, IsTerminal as _, Stdin, Stdout, Write, stdin, stdout}, panic::set_hook, path::{Path, PathBuf}, time::Duration, @@ -20,7 +20,6 @@ use crossterm::{ event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, style::Attribute, terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, - tty::IsTty, }; use uucore::error::{UResult, USimpleError, UUsageError}; @@ -123,7 +122,7 @@ impl Options { ) { // We add 1 to the number of lines to display because the last line // is used for the banner - (Some(n), _) | (None, Some(n)) if n > 0 => Some(n + 1), + (Some(n), _) | (None, Some(n)) if n > 0 => Some(n.saturating_add(1)), _ => None, // Use terminal height }; let from_line = match matches.get_one::(options::FROM_LINE).copied() { @@ -195,7 +194,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } } else { let stdin = stdin(); - if stdin.is_tty() { + if stdin.is_terminal() { // stdin is not a pipe return Err(UUsageError::new(1, MoreError::BadUsage.to_string())); } @@ -221,7 +220,7 @@ pub fn uu_app() -> Command { ) .arg( Arg::new(options::LOGICAL) - .short('l') + .short('f') .long(options::LOGICAL) .action(ArgAction::SetTrue) .help(translate!("more-help-logical")), @@ -235,7 +234,7 @@ pub fn uu_app() -> Command { ) .arg( Arg::new(options::NO_PAUSE) - .short('f') + .short('l') .long(options::NO_PAUSE) .action(ArgAction::SetTrue) .help(translate!("more-help-no-pause")), @@ -351,7 +350,7 @@ enum OutputType { Test(Vec), } -impl IsTty for OutputType { +impl OutputType { fn is_tty(&self) -> bool { matches!(self, Self::Tty(_)) } @@ -379,7 +378,7 @@ impl Write for OutputType { fn setup_term() -> UResult { let mut stdout = stdout(); - if stdout.is_tty() { + if stdout.is_terminal() { terminal::enable_raw_mode()?; stdout.execute(EnterAlternateScreen)?.execute(Hide)?; Ok(OutputType::Tty(stdout)) @@ -397,7 +396,7 @@ fn setup_term() -> UResult { fn reset_term() -> UResult<()> { let mut stdout = stdout(); - if stdout.is_tty() { + if stdout.is_terminal() { stdout.queue(Show)?.queue(LeaveAlternateScreen)?; terminal::disable_raw_mode()?; } else { @@ -571,16 +570,15 @@ impl<'a> Pager<'a> { return Ok(()); } loop { - if event::poll(Duration::from_millis(100))? { - if let Event::Key(KeyEvent { + if event::poll(Duration::from_millis(100))? + && let Event::Key(KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, .. }) = event::read()? - { - return Ok(()); - } + { + return Ok(()); } } } @@ -609,10 +607,9 @@ impl<'a> Pager<'a> { let pattern = self.pattern.clone().expect("pattern should be set"); let mut line_num = self.upper_mark; loop { - match self.get_line(line_num) { - Some(line) if line.contains(&pattern) => return Some(line_num), - Some(_) => line_num += 1, - None => return None, + match self.get_line(line_num)? { + line if line.contains(&pattern) => return Some(line_num), + _ => line_num += 1, } } } @@ -1137,6 +1134,14 @@ mod tests { assert!(!stdout.contains("2\n")); } + #[test] + fn test_lines_option_max_u16() { + // The +1 for the banner line used to overflow when -n is u16::MAX. + let matches = uu_app().get_matches_from(vec!["more", "-n", "65535"]); + let options = Options::from(&matches); + assert_eq!(options.lines, Some(u16::MAX)); + } + #[test] fn test_from_line_option() { let content = (0..5).map(|i| i.to_string() + "\n").collect::(); diff --git a/src/uu/mv/Cargo.toml b/src/uu/mv/Cargo.toml index b7b42cee7d2..f446311a32f 100644 --- a/src/uu/mv/Cargo.toml +++ b/src/uu/mv/Cargo.toml @@ -11,31 +11,28 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/mv.rs" doctest = false [dependencies] clap = { workspace = true } -fs_extra = { workspace = true } +fluent = { workspace = true } indicatif = { workspace = true } -libc = { workspace = true } -nix = { workspace = true } +nix = { workspace = true, features = ["fs"] } rustc-hash = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true, features = [ "backup-control", "buf-copy", "fs", - "fsxattr", "perms", "safe-copy", "update-control", ] } -fluent = { workspace = true } + +[target.'cfg(any(target_os = "freebsd", target_os = "hurd", target_os = "linux", target_os = "android", target_os = "netbsd"))'.dependencies] +uucore = { workspace = true, features = ["fsxattr"] } [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = [ @@ -45,13 +42,8 @@ windows-sys = { workspace = true, features = [ ] } [target.'cfg(unix)'.dependencies] -libc = { workspace = true } rustix = { workspace = true, features = ["fs"] } - -[features] -selinux = ["uucore/selinux"] - [[bin]] name = "mv" path = "src/main.rs" @@ -61,6 +53,12 @@ divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[features] +selinux = ["uucore/selinux"] + +[lints] +workspace = true + [[bench]] name = "mv_bench" harness = false diff --git a/src/uu/mv/locales/en-US.ftl b/src/uu/mv/locales/en-US.ftl index 4bb5339fe32..be6462b0b31 100644 --- a/src/uu/mv/locales/en-US.ftl +++ b/src/uu/mv/locales/en-US.ftl @@ -32,11 +32,15 @@ mv-error-backup-might-destroy-source = backing up {$target} might destroy source mv-error-will-not-overwrite-just-created = will not overwrite just-created {$target} with {$source} mv-error-not-replacing = not replacing {$target} mv-error-cannot-move = cannot move {$source} to {$target} +mv-error-cannot-overwrite-non-empty-directory = cannot overwrite {$target} mv-error-directory-not-empty = Directory not empty mv-error-dangling-symlink = can't determine symlink type, since it is dangling mv-error-no-symlink-support = your operating system does not support symlinks mv-error-permission-denied = Permission denied mv-error-inter-device-move-failed = inter-device move failed: {$from} to {$to}; unable to remove target: {$err} +mv-error-setting-attribute = setting attribute {$name}: {$err} +mv-error-exchange-two-operands = --exchange requires exactly two operands +mv-error-exchange-not-supported = --exchange is not supported on this platform # Help messages mv-help-force = do not prompt before overwriting @@ -49,12 +53,14 @@ mv-help-verbose = explain what is being done mv-help-progress = Display a progress bar. Note: this feature is not supported by GNU coreutils. mv-help-debug = explain how a file is copied. Implies -v +mv-help-exchange = exchange source and destination (atomically swap them) mv-help-selinux = set SELinux security context of destination file to default type mv-help-context = like -Z, or if CTX is specified then set the SELinux security context to CTX # Verbose messages mv-verbose-renamed = renamed {$from} -> {$to} mv-verbose-renamed-with-backup = renamed {$from} -> {$to} (backup: {$backup}) +mv-verbose-exchanged = exchanged {$from} <-> {$to} # Debug messages mv-debug-skipped = skipped {$target} @@ -65,3 +71,4 @@ mv-prompt-overwrite-mode = replace {$target}, overriding mode {$mode_info}? # Progress messages mv-progress-moving = moving +mv-error-dest-appeared = destination { $path } was replaced while moving; refusing to follow it diff --git a/src/uu/mv/locales/fr-FR.ftl b/src/uu/mv/locales/fr-FR.ftl index 2c7a279b74b..abf3f552ac9 100644 --- a/src/uu/mv/locales/fr-FR.ftl +++ b/src/uu/mv/locales/fr-FR.ftl @@ -32,11 +32,15 @@ mv-error-backup-might-destroy-source = sauvegarder {$target} pourrait détruire mv-error-will-not-overwrite-just-created = ne va pas écraser le fichier qui vient d'être créé {$target} avec {$source} mv-error-not-replacing = ne remplace pas {$target} mv-error-cannot-move = impossible de déplacer {$source} vers {$target} +mv-error-cannot-overwrite-non-empty-directory = impossible d'écraser {$target} mv-error-directory-not-empty = Répertoire non vide mv-error-dangling-symlink = impossible de déterminer le type de lien symbolique, car il est suspendu mv-error-no-symlink-support = votre système d'exploitation ne prend pas en charge les liens symboliques mv-error-permission-denied = Permission refusée mv-error-inter-device-move-failed = échec du déplacement inter-périphérique : {$from} vers {$to} ; impossible de supprimer la cible : {$err} +mv-error-setting-attribute = définition de l'attribut {$name} : {$err} +mv-error-exchange-two-operands = --exchange nécessite exactement deux opérandes +mv-error-exchange-not-supported = --exchange n'est pas pris en charge sur cette plateforme # Messages d'aide mv-help-force = ne pas demander avant d'écraser @@ -49,10 +53,12 @@ mv-help-verbose = expliquer ce qui est fait mv-help-progress = Afficher une barre de progression. Note : cette fonctionnalité n'est pas prise en charge par GNU coreutils. mv-help-debug = expliquer comment un fichier est copié. Implique -v +mv-help-exchange = échanger la source et la destination (les permuter de façon atomique) # Messages verbeux mv-verbose-renamed = renommé {$from} -> {$to} mv-verbose-renamed-with-backup = renommé {$from} -> {$to} (sauvegarde : {$backup}) +mv-verbose-exchanged = échangé {$from} <-> {$to} # Messages de débogage mv-debug-skipped = ignoré {$target} @@ -62,3 +68,4 @@ mv-prompt-overwrite = écraser {$target} ? # Messages de progression mv-progress-moving = déplacement +mv-error-dest-appeared = la destination { $path } a été remplacée pendant le déplacement ; refus de la suivre diff --git a/src/uu/mv/src/error.rs b/src/uu/mv/src/error.rs index 721cc799cc9..89e3c44590c 100644 --- a/src/uu/mv/src/error.rs +++ b/src/uu/mv/src/error.rs @@ -2,29 +2,29 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use thiserror::Error; + use uucore::error::UError; use uucore::translate; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum MvError { - #[error("{}", translate!("mv-error-no-such-file", "path" => .0.clone()))] + #[error("{}", translate!("mv-error-no-such-file", "path" => .0))] NoSuchFile(String), - #[error("{}", translate!("mv-error-cannot-stat-not-directory", "path" => .0.clone()))] + #[error("{}", translate!("mv-error-cannot-stat-not-directory", "path" => .0))] CannotStatNotADirectory(String), - #[error("{}", translate!("mv-error-same-file", "source" => .0.clone(), "target" => .1.clone()))] + #[error("{}", translate!("mv-error-same-file", "source" => .0, "target" => .1))] SameFile(String, String), - #[error("{}", translate!("mv-error-self-target-subdirectory", "source" => .0.clone(), "target" => .1.clone()))] + #[error("{}", translate!("mv-error-self-target-subdirectory", "source" => .0, "target" => .1))] SelfTargetSubdirectory(String, String), - #[error("{}", translate!("mv-error-directory-to-non-directory", "path" => .0.clone()))] + #[error("{}", translate!("mv-error-directory-to-non-directory", "path" => .0))] DirectoryToNonDirectory(String), - #[error("{}", translate!("mv-error-non-directory-to-directory", "source" => .0.clone(), "target" => .1.clone()))] + #[error("{}", translate!("mv-error-non-directory-to-directory", "source" => .0, "target" => .1))] NonDirectoryToDirectory(String, String), - #[error("{}", translate!("mv-error-not-directory", "path" => .0.clone()))] + #[error("{}", translate!("mv-error-not-directory", "path" => .0))] NotADirectory(String), - #[error("{}", translate!("mv-error-target-not-directory", "path" => .0.clone()))] + #[error("{}", translate!("mv-error-target-not-directory", "path" => .0))] TargetNotADirectory(String), - #[error("{}", translate!("mv-error-failed-access-not-directory", "path" => .0.clone()))] + #[error("{}", translate!("mv-error-failed-access-not-directory", "path" => .0))] FailedToAccessNotADirectory(String), } diff --git a/src/uu/mv/src/hardlink.rs b/src/uu/mv/src/hardlink.rs index 70f0fb8069f..bc3a79753ab 100644 --- a/src/uu/mv/src/hardlink.rs +++ b/src/uu/mv/src/hardlink.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore hardlinked //! Hardlink preservation utilities for mv operations @@ -121,7 +122,7 @@ impl HardlinkTracker { ) -> Option { use std::os::unix::fs::MetadataExt; - let metadata = match source.metadata() { + let metadata = match source.symlink_metadata() { Ok(meta) => meta, Err(e) => { // Gracefully handle metadata errors by logging and continuing without hardlink tracking @@ -181,15 +182,15 @@ impl HardlinkGroupScanner { self.source_files = files.to_vec(); for file in files { - if let Err(e) = self.scan_single_path(file) { - if options.verbose { - // Only show warnings for verbose mode - let _ = writeln!( - io::stderr(), - "warning: failed to scan {}: {e}", - file.quote() - ); - } + if let Err(e) = self.scan_single_path(file) + && options.verbose + { + // Only show warnings for verbose mode + let _ = writeln!( + io::stderr(), + "warning: failed to scan {}: {e}", + file.quote() + ); // 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 @@ -215,18 +216,16 @@ impl HardlinkGroupScanner { fn scan_single_path(&mut self, path: &Path) -> io::Result<()> { use std::os::unix::fs::MetadataExt; - if path.is_dir() { + let metadata = path.symlink_metadata()?; + if metadata.is_dir() { // Recursively scan directory contents self.scan_directory_recursive(path)?; - } else { - let metadata = path.metadata()?; - if metadata.nlink() > 1 { - let key = (metadata.dev(), metadata.ino()); - self.hardlink_groups - .entry(key) - .or_default() - .push(path.to_path_buf()); - } + } else if metadata.is_file() && metadata.nlink() > 1 { + let key = (metadata.dev(), metadata.ino()); + self.hardlink_groups + .entry(key) + .or_default() + .push(path.to_path_buf()); } Ok(()) } @@ -238,15 +237,17 @@ impl HardlinkGroupScanner { let entries = std::fs::read_dir(dir)?; for entry in entries { let entry = entry?; - let path = entry.path(); - if path.is_dir() { - self.scan_directory_recursive(&path)?; + if entry.file_type()?.is_dir() { + self.scan_directory_recursive(&entry.path())?; } else { - let metadata = path.metadata()?; - if metadata.nlink() > 1 { + let metadata = entry.metadata()?; + if metadata.is_file() && metadata.nlink() > 1 { let key = (metadata.dev(), metadata.ino()); - self.hardlink_groups.entry(key).or_default().push(path); + self.hardlink_groups + .entry(key) + .or_default() + .push(entry.path()); } } } diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index fb395237099..68cc9c21887 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -5,6 +5,7 @@ // spell-checker:ignore (ToDO) sourcepath targetpath nushell canonicalized unwriteable // spell-checker:ignore renameat symlinkat unlinkat unguessability RDONLY CLOEXEC +// spell-checker:ignore renamer fsetxattr mod error; #[cfg(unix)] @@ -15,7 +16,13 @@ use clap::error::ErrorKind; use clap::{Arg, ArgAction, ArgMatches, Command}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] +#[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "netbsd" +))] use rustc_hash::FxHashMap; use rustc_hash::FxHashSet; use std::env; @@ -35,16 +42,22 @@ use crate::hardlink::{ HardlinkGroupScanner, HardlinkOptions, HardlinkTracker, create_hardlink_context, with_optional_hardlink_context, }; -use uucore::backup_control::{self, source_is_target_backup}; +use uucore::backup_control::{self, backup_would_destroy_source}; use uucore::display::Quotable; -use uucore::error::{FromIo, UResult, USimpleError, UUsageError, set_exit_code}; +use uucore::error::{FromIo, UError, UResult, USimpleError, UUsageError, set_exit_code}; #[cfg(unix)] use uucore::fs::display_permissions_unix; use uucore::fs::{ MissingHandling, ResolveMode, are_hardlinks_or_one_way_symlink_to_same_file, are_hardlinks_to_same_file, canonicalize, path_ends_with_terminator, }; -#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] +#[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "netbsd" +))] use uucore::fsxattr; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use uucore::selinux::set_selinux_security_context; @@ -56,8 +69,6 @@ use uucore::update_control; pub use uucore::{backup_control::BackupMode, update_control::UpdateMode}; use uucore::{format_usage, prompt_yes, show}; -use fs_extra::dir::get_size as dir_get_size; - use crate::error::MvError; /// Options contains all the possible behaviors and flags for mv. @@ -106,6 +117,9 @@ pub struct Options { /// `-Z, --context` pub context: Option, + + /// `--exchange` atomically exchange source and destination + pub exchange: bool, } impl Default for Options { @@ -122,6 +136,7 @@ impl Default for Options { progress_bar: false, debug: false, context: None, + exchange: false, } } } @@ -152,6 +167,7 @@ static ARG_FILES: &str = "files"; static OPT_DEBUG: &str = "debug"; static OPT_CONTEXT: &str = "context"; static OPT_SELINUX: &str = "selinux"; +static OPT_EXCHANGE: &str = "exchange"; #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { @@ -172,7 +188,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } let overwrite_mode = determine_overwrite_mode(&matches); - let backup_mode = backup_control::determine_backup_mode(&matches)?; + let backup_mode = + backup_control::determine_backup_mode(env::var("VERSION_CONTROL").ok(), &matches)?; let update_mode = update_control::determine_update_mode(&matches); if backup_mode != BackupMode::None @@ -192,10 +209,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .get_one::(OPT_TARGET_DIRECTORY) .map(OsString::from); - if let Some(ref maybe_dir) = target_dir { - if !Path::new(&maybe_dir).is_dir() { - return Err(MvError::TargetNotADirectory(maybe_dir.quote().to_string()).into()); - } + if let Some(ref maybe_dir) = target_dir + && !Path::new(&maybe_dir).is_dir() + { + return Err(MvError::TargetNotADirectory(maybe_dir.quote().to_string()).into()); } // Handle -Z and --context options @@ -219,6 +236,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { progress_bar: matches.get_flag(OPT_PROGRESS), debug: matches.get_flag(OPT_DEBUG), context, + exchange: matches.get_flag(OPT_EXCHANGE), }; mv(&files[..], &opts) @@ -332,6 +350,12 @@ pub fn uu_app() -> Command { .help(translate!("mv-help-debug")) .action(ArgAction::SetTrue), ) + .arg( + Arg::new(OPT_EXCHANGE) + .long(OPT_EXCHANGE) + .help(translate!("mv-help-exchange")) + .action(ArgAction::SetTrue), + ) } fn determine_overwrite_mode(matches: &ArgMatches) -> OverwriteMode { @@ -364,26 +388,29 @@ fn parse_paths(files: &[OsString], opts: &Options) -> Vec { } fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()> { - if opts.backup == BackupMode::Simple && source_is_target_backup(source, target, &opts.suffix) { + // `mv` never follows a symlink source, so the guard must not either. + if backup_would_destroy_source(source, target, &opts.suffix, opts.backup, false) { return Err(io::Error::new( io::ErrorKind::NotFound, translate!("mv-error-backup-might-destroy-source", "target" => target.quote(), "source" => source.quote()), ) .into()); } - if source.symlink_metadata().is_err() { + let Ok(source_metadata) = source.symlink_metadata() else { return Err(if path_ends_with_terminator(source) { MvError::CannotStatNotADirectory(source.quote().to_string()).into() } else { MvError::NoSuchFile(source.quote().to_string()).into() }); - } + }; - let source_is_dir = source.is_dir() && !source.is_symlink(); - let target_is_dir = if target.is_symlink() { - fs::canonicalize(target).is_ok_and(|p| p.is_dir()) - } else { - target.is_dir() + // `symlink_metadata` does not follow symlinks, so this is equivalent to + // `source.is_dir() && !source.is_symlink()` without the extra `stat` calls. + let source_is_dir = source_metadata.is_dir(); + let target_is_dir = match target.symlink_metadata() { + Ok(metadata) if metadata.is_symlink() => fs::canonicalize(target).is_ok_and(|p| p.is_dir()), + Ok(metadata) => metadata.is_dir(), + Err(_) => false, }; if path_ends_with_terminator(target) @@ -414,8 +441,20 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()> hardlink_params.0, hardlink_params.1, ) - .map_err_context(|| { - translate!("mv-error-cannot-move", "source" => source.quote(), "target" => target.quote()) + .map_err(|e| -> Box { + let message = if is_directory_not_empty_error(&e) { + translate!( + "mv-error-cannot-overwrite-non-empty-directory", + "target" => target.quote() + ) + } else { + translate!( + "mv-error-cannot-move", + "source" => source.quote(), + "target" => target.quote() + ) + }; + e.map_err_context(|| message) }) } else { Err(MvError::DirectoryToNonDirectory(target.quote().to_string()).into()) @@ -423,7 +462,7 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()> } else { move_files_into_dir(&[source.to_path_buf()], target, opts) } - } else if target.exists() && source_is_dir { + } else if source_is_dir && target.exists() { match opts.overwrite { OverwriteMode::NoClobber => return Ok(()), OverwriteMode::Interactive => prompt_overwrite(target, None)?, @@ -500,14 +539,15 @@ fn assert_not_same_file( } }; - let same_file = (canonicalized_source.eq(&canonicalized_target) - || are_hardlinks_to_same_file(source, target) - || are_hardlinks_or_one_way_symlink_to_same_file(source, target)) - && opts.backup == BackupMode::None; + let same_file = opts.backup == BackupMode::None + && (canonicalized_source.eq(&canonicalized_target) + || are_hardlinks_to_same_file(source, target) + || are_hardlinks_or_one_way_symlink_to_same_file(source, target)); // get the expected target path to show in errors // this is based on the argument and not canonicalized - let target_display = match source.file_name() { + // only computed when an error is actually reported + let target_display = || match source.file_name() { Some(file_name) if target_is_dir => { // join target_dir/source_file in a platform-independent manner let mut path = target @@ -530,13 +570,13 @@ fn assert_not_same_file( || source.ends_with("/.") || source.is_file()) { - return Err(MvError::SameFile(source.quote().to_string(), target_display).into()); + return Err(MvError::SameFile(source.quote().to_string(), target_display()).into()); } else if (same_file || canonicalized_target.starts_with(canonicalized_source)) // don't error if we're moving a symlink of a directory into itself && !source.is_symlink() { return Err( - MvError::SelfTargetSubdirectory(source.quote().to_string(), target_display).into(), + MvError::SelfTargetSubdirectory(source.quote().to_string(), target_display()).into(), ); } Ok(()) @@ -561,16 +601,83 @@ fn handle_multiple_paths(paths: &[PathBuf], opts: &Options) -> UResult<()> { pub fn mv(files: &[OsString], opts: &Options) -> UResult<()> { let paths = parse_paths(files, opts); + if opts.exchange { + #[cfg(any(target_os = "linux", target_os = "android"))] + return exchange_paths(&paths, opts); + #[cfg(not(any(target_os = "linux", target_os = "android")))] + return Err(USimpleError::new( + 1, + translate!("mv-error-exchange-not-supported"), + )); + } + if let Some(ref name) = opts.target_dir { return move_files_into_dir(&paths, &PathBuf::from(name), opts); } - match paths.len() { - 2 => handle_two_paths(&paths[0], &paths[1], opts), + match paths.as_slice() { + [path0, path1] => handle_two_paths(path0, path1, opts), _ => handle_multiple_paths(&paths, opts), } } +/// Atomically exchange two paths with `--exchange` (renameat2 `RENAME_EXCHANGE`). +/// +/// With more than two operands, the last operand must be a directory, and +/// each of the other operands is exchanged with the file of the same name +/// inside that directory (matching GNU `mv --exchange a b c dir/`). +#[cfg(any(target_os = "linux", target_os = "android"))] +fn exchange_paths(paths: &[PathBuf], opts: &Options) -> UResult<()> { + match paths { + [] | [_] => Err(UUsageError::new( + 1, + translate!("mv-error-exchange-two-operands"), + )), + [from, to] => exchange_two_paths(from, to, opts), + [sources @ .., target_dir] => { + if !target_dir.is_dir() { + return Err(MvError::TargetNotADirectory(target_dir.quote().to_string()).into()); + } + for source in sources { + let name = source + .file_name() + .ok_or_else(|| MvError::NoSuchFile(source.quote().to_string()))?; + exchange_two_paths(source, &target_dir.join(name), opts)?; + } + Ok(()) + } + } +} + +/// Atomically exchange the two given paths (renameat2 `RENAME_EXCHANGE`). +#[cfg(any(target_os = "linux", target_os = "android"))] +fn exchange_two_paths(from: &Path, to: &Path, opts: &Options) -> UResult<()> { + use rustix::fs::{CWD, RenameFlags, renameat_with}; + + let canonicalize_or_absolute = |p: &Path| { + canonicalize(absolute(p)?, MissingHandling::Normal, ResolveMode::Logical) + .or_else(|_| absolute(p)) + }; + if canonicalize_or_absolute(from)?.eq(&canonicalize_or_absolute(to)?) + || are_hardlinks_to_same_file(from, to) + { + return Err(MvError::SameFile(from.quote().to_string(), to.quote().to_string()).into()); + } + + renameat_with(CWD, from, CWD, to, RenameFlags::EXCHANGE) + .map_err(io::Error::from) + .map_err_context( + || translate!("mv-error-cannot-move", "source" => from.quote(), "target" => to.quote()), + )?; + if opts.verbose { + println!( + "{}", + translate!("mv-verbose-exchanged", "from" => from.quote(), "to" => to.quote()) + ); + } + Ok(()) +} + #[allow(clippy::cognitive_complexity)] fn move_files_into_dir(files: &[PathBuf], target_dir: &Path, options: &Options) -> UResult<()> { // remember the moved destinations for further usage @@ -667,21 +774,32 @@ fn move_files_into_dir(files: &[PathBuf], target_dir: &Path, options: &Options) ) { Err(e) if e.to_string().is_empty() => set_exit_code(1), Err(e) => { - let e = e.map_err_context(|| { - translate!("mv-error-cannot-move", "source" => sourcepath.quote(), "target" => targetpath.quote()) - }); + let message = if is_directory_not_empty_error(&e) { + translate!( + "mv-error-cannot-overwrite-non-empty-directory", + "target" => targetpath.quote() + ) + } else { + translate!( + "mv-error-cannot-move", + "source" => sourcepath.quote(), + "target" => targetpath.quote() + ) + }; + let e = e.map_err_context(|| message); if let Some(ref pb) = display_manager { pb.suspend(|| show!(e)); } else { show!(e); } } - Ok(()) => (), + Ok(()) => { + moved_destinations.insert(targetpath.clone()); + } } if let Some(ref pb) = count_progress { pb.inc(1); } - moved_destinations.insert(targetpath.clone()); } Ok(()) } @@ -743,13 +861,22 @@ fn rename( } // "to" may no longer exist if it was backed up - if to.exists() && to.is_dir() && !to.is_symlink() { + // `symlink_metadata` does not follow symlinks, so a symlink to a directory + // is not reported as a directory, as with the previous + // `to.exists() && to.is_dir() && !to.is_symlink()` check + if to + .symlink_metadata() + .is_ok_and(|metadata| metadata.is_dir()) + { // normalize behavior between *nix and windows if from.is_dir() { if is_empty_dir(to) { fs::remove_dir(to)?; } else { - return Err(io::Error::other(translate!("mv-error-directory-not-empty"))); + return Err(io::Error::new( + io::ErrorKind::DirectoryNotEmpty, + translate!("mv-error-directory-not-empty"), + )); } } } @@ -793,6 +920,10 @@ fn rename( Ok(()) } +fn is_directory_not_empty_error(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::DirectoryNotEmpty +} + #[cfg(unix)] fn is_fifo(filetype: fs::FileType) -> bool { filetype.is_fifo() @@ -816,19 +947,12 @@ fn rename_with_fallback( #[cfg(not(unix))] _hardlink_scanner: Option<()>, ) -> io::Result<()> { fs::rename(from, to).or_else(|err| { - #[cfg(windows)] - const EXDEV: i32 = windows_sys::Win32::Foundation::ERROR_NOT_SAME_DEVICE as _; - #[cfg(unix)] - const EXDEV: i32 = libc::EXDEV as _; - #[cfg(target_os = "wasi")] - const EXDEV: i32 = 18; // POSIX EXDEV value - // We will only copy if: - // 1. Files are on different devices (EXDEV error) + // 1. Files are on different devices (CrossesDevices / EXDEV error) // 2. On Windows, if the target file exists and source file is opened by another process // (MoveFileExW fails with "Access Denied" even if the source file has FILE_SHARE_DELETE permission) - let should_fallback = - matches!(err.raw_os_error(), Some(EXDEV)) || (from.is_file() && can_delete_file(from)); + let should_fallback = err.kind() == io::ErrorKind::CrossesDevices + || (from.is_file() && can_delete_file(from)); if !should_fallback { return Err(err); } @@ -910,7 +1034,7 @@ fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { #[cfg(not(target_os = "redox"))] - create_symlink_replace(&path_symlink_points_to, to)?; + uucore::fs::replace_link(&path_symlink_points_to, to, true)?; #[cfg(target_os = "redox")] { fs::remove_file(to)?; @@ -919,78 +1043,21 @@ fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { } Err(e) => return Err(e), } - #[cfg(not(any(target_os = "macos", target_os = "redox")))] + #[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "netbsd" + ))] { - let _ = fsxattr::copy_xattrs_ignore_unsupported(from, to); + if let Ok(failed) = fsxattr::copy_xattrs_ignore_unsupported(from, to) { + show_xattr_failures(failed); + } } let _ = preserve_ownership(from, to); fs::remove_file(from) } - -/// Create a symlink at `to`, atomically replacing any existing entry via -/// a temp-name + `renameat(2)` so observers never see `to` missing. -/// -/// Mirrors GNU's `force_symlinkat` in `force-link.c`: open the parent -/// directory once and operate via `*at` syscalls so a concurrent rename -/// of the parent cannot redirect the operation, and pick the temp name -/// from `/dev/urandom` so it is unguessable to other users in that -/// directory. -#[cfg(all(unix, not(target_os = "redox")))] -fn create_symlink_replace(target: &Path, to: &Path) -> io::Result<()> { - use io::Read; - use rustix::fs::{AtFlags, CWD, Mode, OFlags, openat, renameat, symlinkat, unlinkat}; - use std::ffi::OsStr; - use std::os::unix::ffi::OsStrExt; - - // GNU's template is `CuXXXXXX`: a 2-char prefix plus 6 random chars - // drawn from a 62-char alphabet. Modulo bias on a 256→62 mapping is - // ~3% per slot — irrelevant for an 8-char unguessability budget. - const ALPHABET: &[u8; 62] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - - let parent = to - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - let basename = to - .file_name() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid destination path"))?; - - let dir_fd = openat( - CWD, - parent, - OFlags::DIRECTORY | OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW, - Mode::empty(), - )?; - - let mut urandom = fs::File::open("/dev/urandom")?; - - for _ in 0..32 { - let mut tmp_bytes = *b"Cu------"; - let mut raw = [0u8; 6]; - urandom.read_exact(&mut raw)?; - for (slot, byte) in tmp_bytes[2..].iter_mut().zip(raw) { - *slot = ALPHABET[(byte as usize) % ALPHABET.len()]; - } - let tmp = OsStr::from_bytes(&tmp_bytes); - - match symlinkat(target, &dir_fd, tmp) { - Ok(()) => { - if let Err(e) = renameat(&dir_fd, tmp, &dir_fd, basename) { - let _ = unlinkat(&dir_fd, tmp, AtFlags::empty()); - return Err(io::Error::from(e)); - } - return Ok(()); - } - Err(e) if e == rustix::io::Errno::EXIST => {} - Err(e) => return Err(io::Error::from(e)), - } - } - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "could not allocate a unique temp name in destination directory", - )) -} - #[cfg(windows)] fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { let path_symlink_points_to = fs::read_link(from)?; @@ -1023,8 +1090,7 @@ fn rename_dir_fallback( #[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>, ) -> io::Result<()> { // We remove the destination directory if it exists to match the - // behavior of `fs::rename`. As far as I can tell, `fs_extra`'s - // `move_dir` would otherwise behave differently. + // behavior of `fs::rename`. if to.exists() { fs::remove_dir_all(to)?; } @@ -1034,7 +1100,7 @@ fn rename_dir_fallback( // If finding the total size fails for whatever reason, // the progress bar wont be shown for this file / dir. // (Move will probably fail due to permission error later?) - let total_size = dir_get_size(from).ok(); + let total_size = display_manager.and_then(|_| get_dir_size(from).ok()); let progress_bar = match (display_manager, total_size) { (Some(display_manager), Some(total_size)) => { @@ -1046,8 +1112,21 @@ fn rename_dir_fallback( (_, _) => None, }; - #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] - let xattrs = fsxattr::retrieve_xattrs(from).unwrap_or_else(|_| FxHashMap::default()); + // Retrieve xattrs through a file descriptor so a concurrent renamer cannot + // redirect the list/get calls to a different inode. + #[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "netbsd" + ))] + let xattrs = { + use std::fs::File; + File::open(from) + .and_then(|f| fsxattr::retrieve_xattrs_fd(&f)) + .unwrap_or_else(|_| FxHashMap::default()) + }; // Use directory copying (with or without hardlink support) let result = copy_dir_contents( @@ -1062,8 +1141,24 @@ fn rename_dir_fallback( display_manager, ); - #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] - fsxattr::apply_xattrs(to, xattrs)?; + // Apply xattrs using a file descriptor to avoid TOCTOU races, ignoring + // ENOTSUP/EOPNOTSUPP (filesystem without xattr support, which is expected + // for cross-device moves). + // + // The fd is opened read-only: a directory cannot be opened for writing, and + // fsetxattr checks write permission on the inode, not the open mode. + #[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "netbsd" + ))] + { + use std::fs::File; + let dest = File::open(to)?; + fsxattr::apply_xattrs_fd_ignore_unsupported(&dest, xattrs)?; + } result?; @@ -1074,6 +1169,41 @@ fn rename_dir_fallback( } /// Copy directory recursively, optionally preserving hardlinks +/// Create `path`, refusing to reuse anything already there. +/// +/// `create_dir_all` would accept a symlink planted at `path` after the caller +/// removed the destination, redirecting the move out of the destination tree. +fn create_dir_fail_closed(path: &Path) -> io::Result<()> { + fs::create_dir(path).map_err(|e| { + if e.kind() == io::ErrorKind::AlreadyExists { + io::Error::new( + io::ErrorKind::AlreadyExists, + translate!("mv-error-dest-appeared", "path" => path.quote()), + ) + } else { + e + } + }) +} + +fn get_dir_size(path: &Path) -> io::Result { + let metadata = path.symlink_metadata()?; + + if !metadata.is_dir() { + return Ok(metadata.len()); + } + + let mut size = 0; + for entry in fs::read_dir(path)? { + let entry = entry?; + size += entry.metadata()?.len(); + if entry.file_type()?.is_dir() { + size += get_dir_size(&entry.path())?; + } + } + Ok(size) +} + fn copy_dir_contents( from: &Path, to: &Path, @@ -1084,7 +1214,7 @@ fn copy_dir_contents( display_manager: Option<&MultiProgress>, ) -> io::Result<()> { // Create the destination directory - fs::create_dir_all(to)?; + create_dir_fail_closed(to)?; #[cfg(unix)] { @@ -1166,7 +1296,7 @@ fn copy_dir_contents_recursive( print_verbose(&from_path, &to_path); } else if from_path.is_dir() { // Recursively copy subdirectory (only real directories, not symlinks) - fs::create_dir_all(&to_path)?; + create_dir_fail_closed(&to_path)?; // Preserve ownership (uid/gid) of the subdirectory #[cfg(unix)] @@ -1207,10 +1337,10 @@ fn copy_dir_contents_recursive( print_verbose(&from_path, &to_path); } - if let Some(pb) = progress_bar { - if let Ok(metadata) = from_path.metadata() { - pb.inc(metadata.len()); - } + if let Some(pb) = progress_bar + && let Ok(metadata) = from_path.metadata() + { + pb.inc(metadata.len()); } } @@ -1248,9 +1378,17 @@ fn copy_file_with_hardlinks_helper( // 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"))))] + #[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "netbsd" + ))] { - let _ = fsxattr::copy_xattrs_ignore_unsupported(from, to); + if let Ok(failed) = fsxattr::copy_xattrs_ignore_unsupported(from, to) { + show_xattr_failures(failed); + } } // Preserve ownership (uid/gid) from the source let _ = preserve_ownership(from, to); @@ -1313,15 +1451,33 @@ fn rename_file_fallback( uucore::buf_copy::copy_fast(&mut &src_file, &mut dst_file) .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; - #[cfg(not(any(target_os = "macos", target_os = "redox")))] + #[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "netbsd" + ))] { - let _ = fsxattr::copy_xattrs_fd_ignore_unsupported(&src_file, &dst_file); + if let Ok(failed) = fsxattr::copy_xattrs_fd_ignore_unsupported(&src_file, &dst_file) { + show_xattr_failures(failed); + } } // chown before chmod: chown(2) clears setuid/setgid for non-root, // so the final mode must be applied last to preserve those bits. - let _ = preserve_ownership(from, to); - let _ = dst_file.set_permissions(Permissions::from_mode(src_mode)); + // + // If the chown did not take, the destination belongs to whoever ran + // `mv`, and re-applying setuid/setgid would hand them a binary running + // as themselves that used to run as someone else. GNU strips the bits + // in that case and so do we. + let ownership_preserved = preserve_ownership(from, to).unwrap_or(false); + let dest_mode = if ownership_preserved { + src_mode + } else { + src_mode & !0o6000 + }; + let _ = dst_file.set_permissions(Permissions::from_mode(dest_mode)); } #[cfg(not(unix))] @@ -1335,11 +1491,41 @@ fn rename_file_fallback( Ok(()) } +/// Report each xattr that a cross-device move could not copy. Like GNU, these +/// are only warnings: the move itself still succeeds. +#[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "netbsd" +))] +fn show_xattr_failures(failed: Vec<(OsString, io::Error)>) { + use uucore::error::strip_errno; + use uucore::show_error; + + for (name, err) in failed { + show_error!( + "{}", + translate!( + "mv-error-setting-attribute", + "name" => name.quote(), + "err" => strip_errno(&err) + ) + ); + } +} + /// Preserve ownership (uid/gid) from source to destination. /// Uses lchown so it works on symlinks without following them. /// Errors are silently ignored for non-root users who cannot chown. +/// +/// Returns whether the destination ended up with the source's uid and gid. +/// Callers that re-apply the source mode must strip setuid/setgid when this is +/// false: those bits on a file that changed hands grant the new owner's +/// identity, not the original one's, which is why GNU drops them. #[cfg(unix)] -fn preserve_ownership(from: &Path, to: &Path) -> io::Result<()> { +fn preserve_ownership(from: &Path, to: &Path) -> io::Result { use std::os::unix::fs::MetadataExt; let source_meta = from.symlink_metadata()?; @@ -1356,7 +1542,7 @@ fn preserve_ownership(from: &Path, to: &Path) -> io::Result<()> { // Use follow=false so lchown is used (works on symlinks) // Silently ignore errors: non-root users typically cannot chown to // arbitrary uid, matching GNU mv behavior which also uses best-effort. - let _ = wrap_chown( + if wrap_chown( to, &dest_meta, Some(uid), @@ -1366,10 +1552,14 @@ fn preserve_ownership(from: &Path, to: &Path) -> io::Result<()> { groups_only: false, level: VerbosityLevel::Silent, }, - ); + ) + .is_err() + { + return Ok(false); + } } - Ok(()) + Ok(true) } fn is_empty_dir(path: &Path) -> bool { @@ -1430,44 +1620,11 @@ fn prompt_overwrite(to: &Path, cached_mode: Option) -> io::Result<()> { /// Checks if a file can be deleted by attempting to open it with delete permissions. #[cfg(windows)] fn can_delete_file(path: &Path) -> bool { - use std::{ - os::windows::ffi::OsStrExt as _, - ptr::{null, null_mut}, - }; - - use windows_sys::Win32::{ - Foundation::{CloseHandle, INVALID_HANDLE_VALUE}, - Storage::FileSystem::{ - CreateFileW, DELETE, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_DELETE, FILE_SHARE_READ, - FILE_SHARE_WRITE, OPEN_EXISTING, - }, - }; - - let wide_path = path - .as_os_str() - .encode_wide() - .chain([0]) - .collect::>(); - - let handle = unsafe { - CreateFileW( - wide_path.as_ptr(), - DELETE, - FILE_SHARE_DELETE | FILE_SHARE_READ | FILE_SHARE_WRITE, - null(), - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - null_mut(), - ) - }; - - if handle == INVALID_HANDLE_VALUE { - return false; - } - - unsafe { CloseHandle(handle) }; + use std::fs::OpenOptions; + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::DELETE; - true + OpenOptions::new().access_mode(DELETE).open(path).is_ok() } #[cfg(not(windows))] diff --git a/src/uu/nice/Cargo.toml b/src/uu/nice/Cargo.toml index 4d9ec5563a1..627fc6996cd 100644 --- a/src/uu/nice/Cargo.toml +++ b/src/uu/nice/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/nice.rs" test = false @@ -21,12 +18,18 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true } [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["process"] } +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = ["Win32_System_Threading"] } + +[lints] +workspace = true + [[bin]] name = "nice" path = "src/main.rs" diff --git a/src/uu/nice/src/nice.rs b/src/uu/nice/src/nice.rs index fd4d84cd5d1..db986bda752 100644 --- a/src/uu/nice/src/nice.rs +++ b/src/uu/nice/src/nice.rs @@ -8,13 +8,20 @@ use clap::{Arg, ArgAction, Command}; use std::ffi::OsString; use std::io::{ErrorKind, Write, stdout}; -use std::num::IntErrorKind; -use std::os::unix::process::CommandExt; +#[cfg(unix)] +use std::os::unix::process::CommandExt as _; +#[cfg(windows)] +use std::os::windows::process::CommandExt as _; use std::process; +#[cfg(windows)] +use windows_sys::Win32::System::Threading::{ + ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, HIGH_PRIORITY_CLASS, + IDLE_PRIORITY_CLASS, NORMAL_PRIORITY_CLASS, REALTIME_PRIORITY_CLASS, +}; use uucore::translate; use uucore::{ - error::{UResult, USimpleError, UUsageError, set_exit_code, strip_errno}, + error::{UResult, UUsageError, set_exit_code}, format_usage, show_error, }; @@ -99,8 +106,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result_with_exit_code(uu_app(), args, 125)?; + #[cfg(not(unix))] + let current_niceness = 0i32; // todo: what we can do? + #[cfg(unix)] let current_niceness = rustix::process::getpriority_process(None) - .map_err(|e| USimpleError::new(125, format!("getpriority: {e}")))?; + .map_err(|e| uucore::error::USimpleError::new(125, format!("getpriority: {e}")))?; let Some(mut cmd_iter) = matches.get_many::(options::COMMAND) else { if matches.contains_id(options::ADJUSTMENT) { @@ -114,14 +124,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { return Ok(()); }; + #[cfg(any(unix, windows))] let adjustment = match matches.get_one::(options::ADJUSTMENT) { None => 10, Some(nstr) => match nstr.parse::() { Ok(num) => num, - Err(e) if *e.kind() == IntErrorKind::PosOverflow => i32::MAX, - Err(e) if *e.kind() == IntErrorKind::NegOverflow => i32::MIN, + Err(e) if *e.kind() == std::num::IntErrorKind::PosOverflow => i32::MAX, + Err(e) if *e.kind() == std::num::IntErrorKind::NegOverflow => i32::MIN, Err(e) => { - return Err(USimpleError::new( + return Err(uucore::error::USimpleError::new( 125, translate!("nice-error-invalid-number", "value" => nstr, "error" => e), )); @@ -129,13 +140,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }, }; + #[cfg(any(unix, windows))] let new_niceness = current_niceness.saturating_add(adjustment); // We can't use `show_warning` because that will panic if stderr // isn't writable. The GNU test suite checks specifically that the // exit code when failing to write the advisory is 125, but Rust // will produce an exit code of 101 when it panics. + #[cfg(unix)] if let Err(e) = rustix::process::setpriority_process(None, new_niceness) { - let warning_msg = translate!("nice-warning-setpriority", "util_name" => "nice", "error" => strip_errno(&e.into()) ); + let warning_msg = translate!("nice-warning-setpriority", "util_name" => "nice", "error" => uucore::error::strip_errno(&e.into()) ); if writeln!(std::io::stderr(), "{warning_msg}").is_err() { set_exit_code(125); @@ -143,10 +156,30 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } } + #[cfg(windows)] + let priority_class = match new_niceness { + ..=-20 => REALTIME_PRIORITY_CLASS, + -19 => HIGH_PRIORITY_CLASS, + -18..=-1 => ABOVE_NORMAL_PRIORITY_CLASS, + 0 => NORMAL_PRIORITY_CLASS, + 1..=18 => BELOW_NORMAL_PRIORITY_CLASS, + 19.. => IDLE_PRIORITY_CLASS, + }; + let cmd = cmd_iter.next().unwrap(); let args: Vec<&String> = cmd_iter.collect(); - - let err = process::Command::new(cmd).args(args).exec(); + let mut command = process::Command::new(cmd); + command.args(args); + #[cfg(unix)] + let err = command.exec(); + #[cfg(windows)] + let Err(err) = command.creation_flags(priority_class).spawn() else { + return Ok(()); + }; + #[cfg(not(any(unix, windows)))] + let Err(err) = command.status() else { + return Ok(()); + }; show_error!("{cmd}: {err}"); diff --git a/src/uu/nl/Cargo.toml b/src/uu/nl/Cargo.toml index ed1d1670d0e..2dea9008f2b 100644 --- a/src/uu/nl/Cargo.toml +++ b/src/uu/nl/Cargo.toml @@ -11,25 +11,25 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/nl.rs" doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } itoa = { workspace = true } regex = { workspace = true } uucore = { workspace = true } -fluent = { workspace = true } [dev-dependencies] divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bin]] name = "nl" path = "src/main.rs" diff --git a/src/uu/nl/locales/en-US.ftl b/src/uu/nl/locales/en-US.ftl index b23c5965edd..7e2a73e7336 100644 --- a/src/uu/nl/locales/en-US.ftl +++ b/src/uu/nl/locales/en-US.ftl @@ -2,17 +2,16 @@ nl-about = Number lines of files nl-usage = nl [OPTION]... [FILE]... nl-after-help = STYLE is one of: - - a number all lines - - t number only nonempty lines - - n number no lines - - pBRE number only lines that contain a match for the basic regular - expression, BRE + - a number all lines + - t number only nonempty lines + - n number no lines + - pBRE number only lines that contain a match for the basic regular expression, BRE FORMAT is one of: - - ln left justified, no leading zeros - - rn right justified, no leading zeros - - rz right justified, leading zeros + - ln left justified, no leading zeros + - rn right justified, no leading zeros + - rz right justified, leading zeros # Help messages nl-help-help = Print help information. @@ -33,7 +32,6 @@ nl-error-invalid-arguments = Invalid arguments supplied. nl-error-could-not-read-line = could not read line nl-error-could-not-write = could not write output nl-error-line-number-overflow = line number overflow -nl-error-invalid-line-width = Invalid line number field width: ‘{ $value }’: Numerical result out of range nl-error-invalid-regex = invalid regular expression nl-error-invalid-numbering-style = invalid numbering style: '{ $style }' nl-error-is-directory = { $path }: Is a directory diff --git a/src/uu/nl/locales/fr-FR.ftl b/src/uu/nl/locales/fr-FR.ftl index 487f7b581f8..1a9a9ecdd6c 100644 --- a/src/uu/nl/locales/fr-FR.ftl +++ b/src/uu/nl/locales/fr-FR.ftl @@ -2,17 +2,16 @@ nl-about = Numéroter les lignes des fichiers nl-usage = nl [OPTION]... [FICHIER]... nl-after-help = STYLE est l'un des suivants : - - a numéroter toutes les lignes - - t numéroter seulement les lignes non vides - - n ne numéroter aucune ligne - - pBRE numéroter seulement les lignes qui contiennent une correspondance pour - l'expression régulière de base, BRE + - a numéroter toutes les lignes + - t numéroter seulement les lignes non vides + - n ne numéroter aucune ligne + - pBRE numéroter seulement les lignes qui contiennent une correspondance pour l'expression régulière de base, BRE FORMAT est l'un des suivants : - - ln justifié à gauche, sans zéros en tête - - rn justifié à droite, sans zéros en tête - - rz justifié à droite, avec zéros en tête + - ln justifié à gauche, sans zéros en tête + - rn justifié à droite, sans zéros en tête + - rz justifié à droite, avec zéros en tête # Messages d'aide nl-help-help = Afficher les informations d'aide. @@ -33,7 +32,6 @@ nl-error-invalid-arguments = Arguments fournis invalides. nl-error-could-not-read-line = impossible de lire la ligne nl-error-could-not-write = impossible d'écrire la sortie nl-error-line-number-overflow = débordement du numéro de ligne -nl-error-invalid-line-width = Largeur de champ de numéro de ligne invalide : ‘{ $value }’ : Résultat numérique hors limites nl-error-invalid-regex = expression régulière invalide nl-error-invalid-numbering-style = style de numérotation invalide : '{ $style }' nl-error-is-directory = { $path } : Est un répertoire diff --git a/src/uu/nl/src/helper.rs b/src/uu/nl/src/helper.rs index 6bbec85f444..f4f1e8a9d6b 100644 --- a/src/uu/nl/src/helper.rs +++ b/src/uu/nl/src/helper.rs @@ -2,12 +2,12 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (ToDO) conv use std::ffi::OsString; use crate::options; -use uucore::translate; // parse_options loads the options into the settings, returning an array of // error messages. @@ -16,17 +16,23 @@ pub fn parse_options(settings: &mut crate::Settings, opts: &clap::ArgMatches) -> // This vector holds error messages encountered. let mut errs: Vec = vec![]; settings.renumber = opts.get_flag(options::NO_RENUMBER); - if let Some(delimiter) = opts.get_one::(options::SECTION_DELIMITER) { - // GNU nl determines whether a delimiter is a "single character" based on byte length, not - // character length. A "single character" implies the second character is a ':'. - settings.section_delimiter = if delimiter.len() == 1 { - let mut delimiter = delimiter.clone(); + + if let Some(mut delimiter) = opts + .get_one::(options::SECTION_DELIMITER) + .cloned() + { + let is_single_char = delimiter + .to_str() + .map_or_else(|| delimiter.len() == 1, |s| s.chars().count() == 1); + + // A "single character" implies the second character of the delimiter is ':'. + if is_single_char { delimiter.push(":"); - delimiter - } else { - delimiter.clone() - }; + } + + settings.section_delimiter = delimiter; } + if let Some(val) = opts.get_one::(options::NUMBER_SEPARATOR) { settings.number_separator.clone_from(val); } @@ -61,10 +67,8 @@ pub fn parse_options(settings: &mut crate::Settings, opts: &clap::ArgMatches) -> Some(Ok(style)) => settings.footer_numbering = style, Some(Err(message)) => errs.push(message), } - match opts.get_one::(options::NUMBER_WIDTH) { - None => {} - Some(num) if *num > 0 => settings.number_width = *num, - Some(_) => errs.push(translate!("nl-error-invalid-line-width", "value" => "0")), + if let Some(&num) = opts.get_one::(options::NUMBER_WIDTH) { + settings.number_width = num as usize; } if let Some(num) = opts.get_one::(options::JOIN_BLANK_LINES) { settings.join_blank_lines = *num; diff --git a/src/uu/nl/src/nl.rs b/src/uu/nl/src/nl.rs index 37ec112451a..c94a684c168 100644 --- a/src/uu/nl/src/nl.rs +++ b/src/uu/nl/src/nl.rs @@ -376,7 +376,7 @@ pub fn uu_app() -> Command { .long(options::NUMBER_WIDTH) .help(translate!("nl-help-number-width")) .value_name("NUMBER") - .value_parser(clap::value_parser!(usize)), + .value_parser(clap::value_parser!(u64).range(1..=(i32::MAX as u64))), ) } @@ -391,16 +391,26 @@ fn nl(reader: &mut BufReader, stats: &mut Stats, settings: &Settings let mut writer = BufWriter::new(stdout()); let mut current_numbering_style = &settings.body_numbering; let mut line = Vec::new(); + // Written before every unnumbered line. Capped to avoid OOM on 32-bit targets + // when -w i32::MAX is passed (allocating 2 GiB would panic). + let blank_width = settings.number_width + 1; + let space_buf = vec![b' '; blank_width.min(4096)]; loop { line.clear(); // reads up to and including b'\n'; returns 0 on EOF - let n = reader - .read_until(b'\n', &mut line) - .map_err_context(|| translate!("nl-error-could-not-read-line"))?; - if n == 0 { - break; - } + match reader.read_until(b'\n', &mut line) { + Ok(0) => break, + Ok(bytes_read) => bytes_read, + Err(err) => { + show_error!( + "{}", + err.map_err_context(|| translate!("nl-error-could-not-read-line")) + ); + set_exit_code(1); + break; + } + }; let _ = line.pop_if(|byte| *byte == b'\n'); @@ -456,10 +466,14 @@ fn nl(reader: &mut BufReader, stats: &mut Stats, settings: &Settings .map_err_context(|| translate!("nl-error-could-not-write"))?; stats.line_number = line_number.checked_add(settings.line_increment); } else { - let prefix = " ".repeat(settings.number_width + 1); - writer - .write_all(prefix.as_bytes()) - .map_err_context(|| translate!("nl-error-could-not-write"))?; + let mut remaining = blank_width; + while remaining > 0 { + let chunk = remaining.min(space_buf.len()); + writer + .write_all(&space_buf[..chunk]) + .map_err_context(|| translate!("nl-error-could-not-write"))?; + remaining -= chunk; + } } write_line(&mut writer, &line) .map_err_context(|| translate!("nl-error-could-not-write"))?; diff --git a/src/uu/nohup/Cargo.toml b/src/uu/nohup/Cargo.toml index 8dd08213726..df22e24723f 100644 --- a/src/uu/nohup/Cargo.toml +++ b/src/uu/nohup/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/nohup.rs" test = false @@ -21,11 +18,19 @@ doctest = false [dependencies] clap = { workspace = true } -libc = { workspace = true } -rustix = { workspace = true, features = ["process", "stdio"] } -uucore = { workspace = true, features = ["fs"] } -thiserror = { workspace = true } fluent = { workspace = true } +thiserror = { workspace = true } +uucore = { workspace = true, features = ["fs"] } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } +rustix = { workspace = true, features = ["stdio"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = ["Win32_System_Threading"] } + +[lints] +workspace = true [[bin]] name = "nohup" diff --git a/src/uu/nohup/src/nohup.rs b/src/uu/nohup/src/nohup.rs index 732c0ea844c..f9f61409a23 100644 --- a/src/uu/nohup/src/nohup.rs +++ b/src/uu/nohup/src/nohup.rs @@ -4,25 +4,25 @@ // file that was distributed with this source code. // spell-checker:ignore (ToDO) SIGHUP cproc vprocmgr homeout -#[cfg(not(unix))] -compile_error!("nohup is not supported on the target"); use clap::{Arg, ArgAction, Command}; -use rustix::stdio::{dup2_stderr, dup2_stdin, dup2_stdout, stdout}; use std::env; -use std::fs::{File, OpenOptions}; -use std::io::{Error, ErrorKind, IsTerminal}; -use std::os::unix::fs::OpenOptionsExt; -use std::os::unix::process::CommandExt; -use std::path::{Path, PathBuf}; +use std::fs::File; +use std::io::{Error, ErrorKind}; use std::process; use std::sync::LazyLock; -use thiserror::Error; use uucore::display::Quotable; use uucore::error::{UError, UResult, set_exit_code, strip_errno}; use uucore::translate; use uucore::{format_usage, show_error}; +#[cfg(unix)] +#[path = "platform/unix.rs"] +mod platform; +#[cfg(windows)] +#[path = "platform/windows.rs"] +mod platform; + static NOHUP_OUT: &str = "nohup.out"; // exit codes that match the GNU implementation static EXIT_CANCELED: i32 = 125; @@ -34,15 +34,8 @@ mod options { pub const CMD: &str = "cmd"; } -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum NohupError { - #[cfg(target_vendor = "apple")] - #[error("{}", translate!("nohup-error-cannot-detach"))] - CannotDetach, - - #[error("{}", translate!("nohup-error-cannot-replace", "name" => (*_0), "err" => _1))] - CannotReplace(&'static str, #[source] Error), - #[error("{}", translate!("nohup-error-open-failed", "path" => NOHUP_OUT.quote(), "err" => _1))] OpenFailed(i32, #[source] Error), @@ -52,10 +45,8 @@ enum NohupError { impl UError for NohupError { fn code(&self) -> i32 { - #[allow(clippy::match_wildcard_for_single_variants)] match self { Self::OpenFailed(code, _) | Self::OpenFailed2(code, _, _, _) => *code, - _ => 2, } } } @@ -76,21 +67,19 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { *FAILURE_CODE, )?; - replace_fds()?; - - unsafe { libc::signal(libc::SIGHUP, libc::SIG_IGN) }; + platform::prepare()?; - #[cfg(target_vendor = "apple")] - if unsafe { !_vprocmgr_detach_from_console(0).is_null() } { - return Err(NohupError::CannotDetach.into()); - } #[allow(clippy::unwrap_used, reason = "set as required by clap")] let mut cmd_iter = matches.get_many::(options::CMD).unwrap(); #[allow(clippy::unwrap_used, reason = "set as required by clap")] let cmd = cmd_iter.next().unwrap(); let args: Vec<&String> = cmd_iter.collect(); + let mut command = process::Command::new(cmd); + command.args(args); - let err = process::Command::new(cmd).args(args).exec(); + let Some(err) = platform::run(&mut command)? else { + return Ok(()); + }; show_error!( "{}", @@ -122,33 +111,15 @@ pub fn uu_app() -> Command { .infer_long_args(true) } -fn replace_fds() -> UResult<()> { - if std::io::stdin().is_terminal() { - let new_stdin = File::open(Path::new("/dev/null")) - .map_err(|e| NohupError::CannotReplace("STDIN", e))?; - dup2_stdin(&new_stdin).map_err(|e| NohupError::CannotReplace("STDIN", Error::from(e)))?; - } - - if std::io::stdout().is_terminal() { - let new_stdout = find_stdout()?; - - dup2_stdout(&new_stdout) - .map_err(|e| NohupError::CannotReplace("STDOUT", Error::from(e)))?; - } - - if std::io::stderr().is_terminal() { - dup2_stderr(stdout()).map_err(|e| NohupError::CannotReplace("STDERR", Error::from(e)))?; - } - Ok(()) -} - +/// Open the file the detached command's stdout is appended to: `nohup.out` in +/// the current directory, falling back to `$HOME/nohup.out`. fn find_stdout() -> UResult { try_open_nohup_file(NOHUP_OUT).or_else(|e1| { let Ok(home) = env::var("HOME") else { return Err(NohupError::OpenFailed(*FAILURE_CODE, e1).into()); }; - let home_out = PathBuf::from(home).join(NOHUP_OUT); + let home_out = std::path::PathBuf::from(home).join(NOHUP_OUT); let home_out = home_out.to_str().unwrap(); try_open_nohup_file(home_out).map_err(|e2| { @@ -158,15 +129,10 @@ fn find_stdout() -> UResult { } fn try_open_nohup_file(path: &str) -> std::io::Result { - // POSIX nohup creates the output file with mode 0600 so that other - // users on a shared host can't read whatever the detached job logs. - // Setting `.mode()` here only affects newly-created files; if the - // file already exists its permissions are left alone. - let file = OpenOptions::new() - .create(true) - .append(true) - .mode(0o600) - .open(path)?; + let mut opt = std::fs::OpenOptions::new(); + opt.create(true).append(true); + platform::set_output_file_mode(&mut opt); + let file = opt.open(path)?; show_error!( "{}", @@ -175,8 +141,3 @@ fn try_open_nohup_file(path: &str) -> std::io::Result { Ok(file) } - -#[cfg(target_vendor = "apple")] -unsafe extern "C" { - fn _vprocmgr_detach_from_console(flags: u32) -> *const libc::c_int; -} diff --git a/src/uu/nohup/src/platform/unix.rs b/src/uu/nohup/src/platform/unix.rs new file mode 100644 index 00000000000..d57756b40bf --- /dev/null +++ b/src/uu/nohup/src/platform/unix.rs @@ -0,0 +1,96 @@ +// 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) SIGHUP cproc vprocmgr homeout + +use std::fs::OpenOptions; +use std::io::{Error, IsTerminal as _}; +use std::os::unix::{fs::OpenOptionsExt as _, process::CommandExt as _}; +use std::process::Command; +use uucore::error::{UError, UResult}; +use uucore::translate; + +use crate::find_stdout; + +#[derive(Debug, thiserror::Error)] +enum PlatformError { + #[cfg(target_vendor = "apple")] + #[error("{}", translate!("nohup-error-cannot-detach"))] + CannotDetach, + + #[error("{}", translate!("nohup-error-cannot-replace", "name" => (*_0), "err" => _1))] + CannotReplace(&'static str, #[source] Error), +} + +impl UError for PlatformError { + fn code(&self) -> i32 { + 2 + } +} + +/// Detach from the controlling terminal: redirect the standard streams and +/// ignore `SIGHUP` so the command survives the shell it was started from. +pub(crate) fn prepare() -> UResult<()> { + replace_fds()?; + + unsafe { libc::signal(libc::SIGHUP, libc::SIG_IGN) }; + + #[cfg(target_vendor = "apple")] + if unsafe { !_vprocmgr_detach_from_console(0).is_null() } { + return Err(PlatformError::CannotDetach.into()); + } + + Ok(()) +} + +/// Replace the current process image with `command`. +/// +/// Returns the error that made `execvp` fail; on success it never returns. +#[allow( + clippy::unnecessary_wraps, + reason = "signature shared with the Windows implementation, which can fail" +)] +pub(crate) fn run(command: &mut Command) -> UResult> { + Ok(Some(command.exec())) +} + +/// POSIX nohup creates the output file with mode 0600 so that other users on a +/// shared host can't read whatever the detached job logs. +/// +/// This only affects newly-created files; if the file already exists its +/// permissions are left alone. +pub(crate) fn set_output_file_mode(opt: &mut OpenOptions) { + opt.mode(0o600); +} + +fn replace_fds() -> UResult<()> { + use rustix::stdio::{dup2_stderr, dup2_stdin, dup2_stdout, stdout}; + if std::io::stdin().is_terminal() { + // Open /dev/null write-only so the substitute stdin is unreadable, as + // GNU does: a command that mistakenly reads from it gets an error + // instead of a silent EOF. + let new_stdin = OpenOptions::new() + .write(true) + .open("/dev/null") + .map_err(|e| PlatformError::CannotReplace("STDIN", e))?; + dup2_stdin(&new_stdin).map_err(|e| PlatformError::CannotReplace("STDIN", e.into()))?; + } + + if std::io::stdout().is_terminal() { + let new_stdout = find_stdout()?; + + dup2_stdout(&new_stdout).map_err(|e| PlatformError::CannotReplace("STDOUT", e.into()))?; + } + + if std::io::stderr().is_terminal() { + dup2_stderr(stdout()).map_err(|e| PlatformError::CannotReplace("STDERR", e.into()))?; + } + Ok(()) +} + +#[cfg(target_vendor = "apple")] +unsafe extern "C" { + fn _vprocmgr_detach_from_console(flags: u32) -> *const core::ffi::c_int; +} diff --git a/src/uu/nohup/src/platform/windows.rs b/src/uu/nohup/src/platform/windows.rs new file mode 100644 index 00000000000..72bae764d20 --- /dev/null +++ b/src/uu/nohup/src/platform/windows.rs @@ -0,0 +1,80 @@ +// 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::fs::OpenOptions; +use std::io::{self, Error, IsTerminal as _}; +use std::os::windows::io::{AsHandle as _, OwnedHandle}; +use std::os::windows::process::CommandExt as _; +use std::process::{Command, Stdio}; +use uucore::error::{UError, UResult}; +use uucore::translate; +use windows_sys::Win32::System::Threading::DETACHED_PROCESS; + +use crate::find_stdout; + +#[derive(Debug, thiserror::Error)] +enum PlatformError { + #[error("{}", translate!("nohup-error-cannot-replace", "name" => (*_0), "err" => _1))] + CannotReplace(&'static str, #[source] Error), +} + +impl UError for PlatformError { + fn code(&self) -> i32 { + 2 + } +} + +/// Nothing to do before spawning: Windows has no `SIGHUP`, and the standard +/// streams are redirected on the child in [`run`] instead of on this process. +#[allow( + clippy::unnecessary_wraps, + reason = "signature shared with the Unix implementation, which can fail" +)] +pub(crate) fn prepare() -> UResult<()> { + Ok(()) +} + +/// Spawn `command` detached from the console. +/// +/// Returns `None` once the child is running, or the error that made the spawn +/// fail. +pub(crate) fn run(command: &mut Command) -> UResult> { + if io::stdin().is_terminal() { + command.stdin(Stdio::null()); + } + + let stderr_is_terminal = io::stderr().is_terminal(); + + if io::stdout().is_terminal() { + // `find_stdout` appends to `nohup.out` and reports it on stderr, so it + // must only be called once: give the child a second handle to the very + // same file instead of opening it again. + let nohup_out = find_stdout()?; + if stderr_is_terminal { + let dup = nohup_out + .try_clone() + .map_err(|e| PlatformError::CannotReplace("STDERR", e))?; + command.stderr(dup); + } + command.stdout(nohup_out); + } else if stderr_is_terminal { + // stdout is already redirected, so follow it like the Unix side does + // with `dup2_stderr(stdout())` rather than leaving stderr on the + // console, which a `DETACHED_PROCESS` child no longer has. + let dup: OwnedHandle = io::stdout() + .as_handle() + .try_clone_to_owned() + .map_err(|e| PlatformError::CannotReplace("STDERR", e))?; + command.stderr(Stdio::from(dup)); + } + + match command.creation_flags(DETACHED_PROCESS).spawn() { + Ok(_) => Ok(None), + Err(e) => Ok(Some(e)), + } +} + +/// Windows has no file mode to set on the output file. +pub(crate) fn set_output_file_mode(_opt: &mut OpenOptions) {} diff --git a/src/uu/nproc/Cargo.toml b/src/uu/nproc/Cargo.toml index 853be86fd87..5d3a6e9a675 100644 --- a/src/uu/nproc/Cargo.toml +++ b/src/uu/nproc/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/nproc.rs" test = false @@ -26,6 +23,8 @@ libc = { workspace = true } rustix = { workspace = true, features = ["thread"] } uucore = { workspace = true, features = ["fs"] } +[lints] +workspace = true [[bin]] name = "nproc" diff --git a/src/uu/numfmt/Cargo.toml b/src/uu/numfmt/Cargo.toml index 73030857c11..0b03a8c4562 100644 --- a/src/uu/numfmt/Cargo.toml +++ b/src/uu/numfmt/Cargo.toml @@ -11,27 +11,32 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/numfmt.rs" [dependencies] clap = { workspace = true } +fluent = { workspace = true } +memchr = { workspace = true } +thiserror = { workspace = true } uucore = { workspace = true, features = [ "parser", + "quoting-style", "ranges", "i18n-common", "i18n-decimal", ] } -thiserror = { workspace = true } -fluent = { workspace = true } -memchr = { workspace = true } [dev-dependencies] divan = { workspace = true } -uucore = { workspace = true, features = ["benchmark"] } +rustix = { workspace = true, features = ["stdio", "fs"] } +uucore = { workspace = true, features = ["benchmark", "diagnostics"] } + +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true [[bin]] name = "numfmt" diff --git a/src/uu/numfmt/benches/numfmt_bench.rs b/src/uu/numfmt/benches/numfmt_bench.rs index e51cb5caffb..9056793a3ba 100644 --- a/src/uu/numfmt/benches/numfmt_bench.rs +++ b/src/uu/numfmt/benches/numfmt_bench.rs @@ -3,6 +3,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +//! Benchmarks for `numfmt`, with the numbers as arguments, and on stdin. + use divan::{Bencher, black_box}; use uu_numfmt::uumain; use uucore::benchmark::run_util_function; @@ -126,6 +128,49 @@ fn numfmt_round_modes(bencher: Bencher, (round_mode, count): (&str, usize)) { }); } +/// Run `uumain` with `args`, `data` on stdin and the output thrown away. +#[cfg(unix)] +fn bench_with_stdin(bencher: Bencher, data: &[u8], args: &[&str]) { + use rustix::stdio::{dup2_stdin, dup2_stdout}; + + let file = std::fs::File::open(uucore::benchmark::setup_test_file(data)).unwrap(); + let devnull = std::fs::OpenOptions::new() + .write(true) + .open("/dev/null") + .unwrap(); + let stdin_bak = rustix::io::dup(rustix::stdio::stdin()).unwrap(); + let stdout_bak = rustix::io::dup(rustix::stdio::stdout()).unwrap(); + + bencher.bench_local(|| { + rustix::fs::seek(&file, rustix::fs::SeekFrom::Start(0)).unwrap(); + dup2_stdin(&file).unwrap(); + dup2_stdout(&devnull).unwrap(); + black_box(run_util_function(uumain, args)); + dup2_stdin(&stdin_bak).unwrap(); + dup2_stdout(&stdout_bak).unwrap(); + }); +} + +/// Benchmark SI formatting with the numbers on stdin +#[cfg(unix)] +#[divan::bench] +fn numfmt_stream_to_si(bencher: Bencher) { + let data: Vec = (1..=100_000u64) + .flat_map(|n| format!("{}\n", n * 7919).into_bytes()) + .collect(); + bench_with_stdin(bencher, &data, &["--to=si"]); +} + +/// The same, with a precision: formats through the exact float path +#[cfg(unix)] +#[divan::bench] +fn numfmt_stream_to_si_precision(bencher: Bencher) { + let data: Vec = (1..=100_000u64) + .flat_map(|n| format!("{}\n", n * 7919).into_bytes()) + .collect(); + bench_with_stdin(bencher, &data, &["--to=si", "--format=%.2f"]); +} + fn main() { divan::main(); } diff --git a/src/uu/numfmt/locales/en-US.ftl b/src/uu/numfmt/locales/en-US.ftl index b1a773ac5c4..bc6d6fc3dde 100644 --- a/src/uu/numfmt/locales/en-US.ftl +++ b/src/uu/numfmt/locales/en-US.ftl @@ -3,6 +3,7 @@ numfmt-usage = numfmt [OPTION]... [NUMBER]... numfmt-after-help = UNIT options: - none: no auto-scaling is done; suffixes will trigger an error + - auto: accept optional single/two letter suffix: 1K = 1000, 1Ki = 1024, 1M = 1000000, 1Mi = 1048576, @@ -64,18 +65,19 @@ numfmt-error-delimiter-must-be-single-character = the delimiter must be a single 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-write = write error numfmt-error-invalid-number = invalid number: { $input } numfmt-error-missing-i-suffix = missing 'i' suffix in input: '{ $number }{ $suffix }' (e.g Ki/Mi/Gi) numfmt-error-rejecting-suffix = rejecting suffix in input: '{ $number }{ $suffix }' (consider using --from) numfmt-error-suffix-unsupported-for-unit = This suffix is unsupported for specified unit numfmt-error-invalid-unit-argument = invalid argument '{$arg}' for '{$opt}' numfmt-error-number-too-big = Number is too big and unsupported -numfmt-error-format-no-percent = format '{ $format }' has no % directive -numfmt-error-format-ends-in-percent = format '{ $format }' ends in % -numfmt-error-invalid-format-directive = invalid format '{ $format }', directive must be %[0]['][-][N][.][N]f -numfmt-error-invalid-format-width-overflow = invalid format '{ $format }' (width overflow) -numfmt-error-invalid-precision = invalid precision in format '{ $format }' -numfmt-error-format-too-many-percent = format '{ $format }' has too many % directives +numfmt-error-format-no-percent = format { $format } has no % directive +numfmt-error-format-ends-in-percent = format { $format } ends in % +numfmt-error-invalid-format-directive = invalid format { $format }, directive must be %[0]['][-][N][.][N]f +numfmt-error-invalid-format-width-overflow = invalid format { $format } (width overflow) +numfmt-error-invalid-precision = invalid precision in format { $format } +numfmt-error-format-too-many-percent = format { $format } has too many % directives numfmt-error-unknown-invalid-mode = Unknown invalid mode: { $mode } # Debug messages @@ -83,3 +85,21 @@ numfmt-debug-no-conversion = no conversion option specified numfmt-debug-grouping-no-effect = grouping has no effect in this locale numfmt-debug-failed-to-convert = failed to convert some of the input numbers numfmt-debug-header-ignored = --header ignored with command-line input + +# Diagnostic labels and help lines: what the caret points at, and the advice under it +numfmt-diag-label-number-overflow = this number is too large +numfmt-diag-label-stray-percent = a literal % must be written %% +numfmt-diag-label-bad-conversion = f is the only conversion numfmt has; %d, %e, %g and the other C conversions are not accepted +numfmt-diag-help-format-syntax = a format is [PREFIX]%[0]['][-][WIDTH][.PRECISION]f[SUFFIX], as in "%'-10.2f" +numfmt-diag-label-auto-from-only = auto guesses the unit of the input, so only --from takes it +numfmt-diag-help-unit = --from and --to take none, si, iec or iec-i, and --from also takes auto +numfmt-diag-label-zero-unit-size = a unit size must be at least 1 +numfmt-diag-help-unit-size = a unit size is a number, a K, M, G, T, P or E multiplier, or both, as in 512, K or 2Ki +numfmt-diag-label-zero-padding = a padding is a width in characters, so 0 asks for nothing +numfmt-diag-help-padding = --padding takes a non-zero whole number; a negative one left-aligns, as in --padding=-10 +numfmt-diag-label-zero-header = leave --header out to convert every line +numfmt-diag-help-header = --header takes the number of leading lines to pass through unchanged, at least 1 +numfmt-diag-help-input-no-from = without --from a number must be plain; --from=auto reads a K, M or Gi suffix +numfmt-diag-help-input-suffixes = the suffixes are K, M, G, T, P, E, Z, Y, R and Q, with an optional i under --from=auto or iec-i +numfmt-diag-label-zero-field = fields are numbered from 1 +numfmt-diag-help-field-syntax = --field takes N, N-M, N- or -M, separated by commas, as in --field=1,3-5 diff --git a/src/uu/numfmt/locales/fr-FR.ftl b/src/uu/numfmt/locales/fr-FR.ftl index 71f0ab54063..a7c55d6d981 100644 --- a/src/uu/numfmt/locales/fr-FR.ftl +++ b/src/uu/numfmt/locales/fr-FR.ftl @@ -3,6 +3,7 @@ numfmt-usage = numfmt [OPTION]... [NOMBRE]... numfmt-after-help = Options d'UNITÉ : - none : aucune mise à l'échelle automatique n'est effectuée ; les suffixes déclencheront une erreur + - auto : accepter un suffixe optionnel d'une/deux lettres : 1K = 1000, 1Ki = 1024, 1M = 1000000, 1Mi = 1048576, @@ -63,18 +64,19 @@ numfmt-error-delimiter-must-be-single-character = le délimiteur doit être un s 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-write = erreur d'écriture 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) numfmt-error-suffix-unsupported-for-unit = Ce suffixe n'est pas pris en charge pour l'unité spécifiée numfmt-error-unit-auto-not-supported-with-to = L'unité 'auto' n'est pas prise en charge avec les options --to numfmt-error-number-too-big = Le nombre est trop grand et non pris en charge -numfmt-error-format-no-percent = le format '{ $format }' n'a pas de directive % -numfmt-error-format-ends-in-percent = le format '{ $format }' se termine par % -numfmt-error-invalid-format-directive = format invalide '{ $format }', la directive doit être %[0]['][-][N][.][N]f -numfmt-error-invalid-format-width-overflow = format invalide '{ $format }' (débordement de largeur) -numfmt-error-invalid-precision = précision invalide dans le format '{ $format }' -numfmt-error-format-too-many-percent = le format '{ $format }' a trop de directives % +numfmt-error-format-no-percent = le format { $format } n'a pas de directive % +numfmt-error-format-ends-in-percent = le format { $format } se termine par % +numfmt-error-invalid-format-directive = format invalide { $format }, la directive doit être %[0]['][-][N][.][N]f +numfmt-error-invalid-format-width-overflow = format invalide { $format } (débordement de largeur) +numfmt-error-invalid-precision = précision invalide dans le format { $format } +numfmt-error-format-too-many-percent = le format { $format } a trop de directives % numfmt-error-unknown-invalid-mode = Mode invalide inconnu : { $mode } # Messages de débogage @@ -82,3 +84,21 @@ numfmt-debug-no-conversion = aucune option de conversion spécifiée numfmt-debug-grouping-no-effect = le groupement n'a aucun effet dans cette locale numfmt-debug-failed-to-convert = échec de conversion d'une partie des nombres en entrée numfmt-debug-header-ignored = --header ignoré avec une entrée en ligne de commande + +# Étiquettes de diagnostic et aides : ce que le caret désigne, et le conseil en dessous +numfmt-diag-label-number-overflow = ce nombre est trop grand +numfmt-diag-label-stray-percent = un % littéral doit s'écrire %% +numfmt-diag-label-bad-conversion = f est la seule conversion de numfmt ; %d, %e, %g et les autres conversions C ne sont pas acceptées +numfmt-diag-help-format-syntax = un format s'écrit [PRÉFIXE]%[0]['][-][LARGEUR][.PRÉCISION]f[SUFFIXE], comme "%'-10.2f" +numfmt-diag-label-auto-from-only = auto devine l'unité de l'entrée, seul --from le prend donc +numfmt-diag-help-unit = --from et --to prennent none, si, iec ou iec-i, et --from prend aussi auto +numfmt-diag-label-zero-unit-size = une taille d'unité doit valoir au moins 1 +numfmt-diag-help-unit-size = une taille d'unité est un nombre, un multiplicateur K, M, G, T, P ou E, ou les deux, comme 512, K ou 2Ki +numfmt-diag-label-zero-padding = un remplissage est une largeur en caractères, 0 ne demande donc rien +numfmt-diag-help-padding = --padding prend un entier non nul ; un entier négatif aligne à gauche, comme --padding=-10 +numfmt-diag-label-zero-header = omettez --header pour convertir toutes les lignes +numfmt-diag-help-header = --header prend le nombre de lignes de tête à laisser telles quelles, au moins 1 +numfmt-diag-help-input-no-from = sans --from un nombre doit être simple ; --from=auto lit un suffixe K, M ou Gi +numfmt-diag-help-input-suffixes = les suffixes sont K, M, G, T, P, E, Z, Y, R et Q, avec un i optionnel sous --from=auto ou iec-i +numfmt-diag-label-zero-field = les champs sont numérotés à partir de 1 +numfmt-diag-help-field-syntax = --field prend N, N-M, N- ou -M, séparés par des virgules, comme --field=1,3-5 diff --git a/src/uu/numfmt/src/diagnostics.rs b/src/uu/numfmt/src/diagnostics.rs new file mode 100644 index 00000000000..d73dad4aa70 --- /dev/null +++ b/src/uu/numfmt/src/diagnostics.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. + +//! Maps an error onto the part of the command line it came from, so that +//! [`uucore::diagnostics`] can render it with a caret: a [`FormatError`], an +//! [`OptionValueError`], or a number that does not convert. A bad `--field` +//! list goes through [`RangeError`], which knows how to render itself. + +use std::ffi::{OsStr, OsString}; + +use uucore::diagnostics::{Snapshot, ValueOptions}; +use uucore::ranges::RangeError; +use uucore::translate; + +use crate::format::{holds_number, invalid_span}; +use crate::options::{FormatError, FormatErrorKind, NumfmtOptions, OptionValueError}; +use crate::units::Unit; + +/// Render `err`, raised while parsing `format`, against `args` — the whole +/// argument list, program name included. +/// +/// Returns `false` when the format cannot be found among the arguments, in +/// which case the caller should fall back to the plain one-line message. +pub fn render(args: &[OsString], format: &str, err: &FormatError) -> bool { + // Labelled only where a label would add to the message, per the convention + // in `uucore::diagnostics`. + let label = match err.kind { + FormatErrorKind::MissingDirective | FormatErrorKind::UnexpectedCharacter => None, + FormatErrorKind::UnexpectedConversion => Some("numfmt-diag-label-bad-conversion"), + FormatErrorKind::NumberOverflow => Some("numfmt-diag-label-number-overflow"), + FormatErrorKind::StrayPercent => Some("numfmt-diag-label-stray-percent"), + }; + + let snapshot = Snapshot::with_program(args); + let Some(index) = snapshot.index_of_value(format, None, Some("format")) else { + return false; + }; + snapshot.render_inside_at( + index, + format, + err.span.clone(), + &err.message, + label.map(|label| translate!(label)).as_deref(), + Some(&translate!("numfmt-diag-help-format-syntax")), + ) +} + +/// The options whose value can be a separate argument. `--header` requires an +/// `=`, so its value never is one. +const VALUE_OPTIONS: ValueOptions = ValueOptions { + shorts: &['d'], + longs: &[ + "delimiter", + "field", + "format", + "from", + "from-unit", + "invalid", + "padding", + "round", + "suffix", + "to", + "to-unit", + "unit-separator", + ], +}; + +/// Render `message`, raised while converting `input` — the `n`-th number given +/// on the command line — against `args`. +/// +/// Returns `false` when the number cannot be pointed at, in which case the +/// caller should fall back to the plain one-line message. +pub fn render_input( + args: &[OsString], + input: &[u8], + n: usize, + message: &str, + options: &NumfmtOptions, +) -> bool { + // With fields, the offsets below are of the whole line, not the number. + if options.delimiter.is_some() { + return false; + } + let Ok(input) = std::str::from_utf8(input) else { + return false; + }; + if input.split_whitespace().count() > 1 { + return false; + } + + let snapshot = Snapshot::with_program(args); + let Some(index) = snapshot + .index_of_operand(n, &VALUE_OPTIONS) + .or_else(|| snapshot.index_of(OsStr::new(input))) + else { + return false; + }; + let span = invalid_span(input, options); + // Without --from there is no suffix to spell out, only the option to reach + // for; an input with no number in it at all is not a suffix question, and + // neither is one whose leading part only looks like the start of one. + let help = match options.transform.from { + _ if !holds_number(input) => None, + Unit::None => Some("numfmt-diag-help-input-no-from"), + _ => Some("numfmt-diag-help-input-suffixes"), + }; + snapshot.render_inside_at( + index, + input, + span, + message, + None, + help.map(|help| translate!(help)).as_deref(), + ) +} + +/// Render `err`, an option value that is wrong from end to end, against `args`. +/// +/// Returns `false` when no argument carries the value, in which case the caller +/// should fall back to the plain one-line message. +pub fn render_value(args: &[OsString], err: &OptionValueError) -> bool { + Snapshot::with_program(args).render_option_value( + &err.value, + None, + Some(err.option), + 0..err.value.len(), + &err.message, + err.label.map(|label| translate!(label)).as_deref(), + Some(&translate!(err.help)), + ) +} + +/// Render `err`, raised while parsing `fields` — the value of `--field` — +/// against `args`. +/// +/// # Returns +/// +/// `false` when the list cannot be found among the arguments, in which case +/// the caller should fall back to the plain one-line message. +pub fn render_field(args: &[OsString], fields: &str, err: &RangeError) -> bool { + err.render_option_value( + args, + fields, + None, + Some("field"), + &translate!("numfmt-diag-label-zero-field"), + &translate!("numfmt-diag-help-field-syntax"), + ) +} + +#[cfg(test)] +mod tests { + use super::VALUE_OPTIONS; + use std::ffi::OsString; + use uucore::diagnostics::Snapshot; + + fn args(args: &[&str]) -> Vec { + args.iter().map(OsString::from).collect() + } + + /// The walk over `args` with numfmt's own option table. + fn index_of_operand(args: &[OsString], n: usize) -> Option { + Snapshot::with_program(args).index_of_operand(n, &VALUE_OPTIONS) + } + + #[test] + fn operand_is_found_past_a_detached_option_value() { + let args = args(&["numfmt", "--suffix", "q", "--from", "si", "q"]); + assert_eq!(index_of_operand(&args, 0), Some(5)); + assert_eq!(index_of_operand(&args, 1), None); + } + + #[test] + fn operands_are_counted_in_order() { + let args = args(&["numfmt", "1", "-d", ",", "--grouping", "2", "-z", "3"]); + for (n, index) in [(0, 1), (1, 5), (2, 7)] { + assert_eq!(index_of_operand(&args, n), Some(index), "operand {n}"); + } + } + + #[test] + fn a_dash_and_everything_past_a_double_dash_is_an_operand() { + let args = args(&["numfmt", "-", "--", "-1", "--from=si"]); + for (n, index) in [(0, 1), (1, 3), (2, 4)] { + assert_eq!(index_of_operand(&args, n), Some(index), "operand {n}"); + } + } + + #[test] + fn an_attached_value_does_not_swallow_the_operand() { + for args in [ + args(&["numfmt", "--suffix=q", "q"]), + args(&["numfmt", "-d,", "q"]), + args(&["numfmt", "--header=2", "q"]), + ] { + assert_eq!(index_of_operand(&args, 0), Some(2), "in {args:?}"); + } + } + + #[test] + fn an_abbreviated_long_option_still_takes_its_value() { + let args = args(&["numfmt", "--suf", "q", "q"]); + assert_eq!(index_of_operand(&args, 0), Some(3)); + } +} diff --git a/src/uu/numfmt/src/errors.rs b/src/uu/numfmt/src/errors.rs index d3dcc48732c..d5954537aa5 100644 --- a/src/uu/numfmt/src/errors.rs +++ b/src/uu/numfmt/src/errors.rs @@ -4,10 +4,9 @@ // file that was distributed with this source code. use std::fmt::Debug; -use thiserror::Error; use uucore::error::UError; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] #[error("{0}")] pub enum NumfmtError { IoError(String), @@ -18,8 +17,7 @@ pub enum NumfmtError { impl UError for NumfmtError { fn code(&self) -> i32 { match self { - Self::IoError(_) => 1, - Self::IllegalArgument(_) => 1, + Self::IoError(_) | Self::IllegalArgument(_) => 1, Self::FormattingError(_) => 2, } } diff --git a/src/uu/numfmt/src/format.rs b/src/uu/numfmt/src/format.rs index f0cb16d253a..3c0e005d8b0 100644 --- a/src/uu/numfmt/src/format.rs +++ b/src/uu/numfmt/src/format.rs @@ -5,6 +5,8 @@ // spell-checker:ignore powf seps replacen +use std::fmt::Write as _; + use uucore::display::Quotable; use uucore::i18n::decimal::{locale_decimal_separator, locale_grouping_separator}; use uucore::translate; @@ -15,6 +17,26 @@ use crate::units::{ DisplayableSuffix, RawSuffix, Result, Suffix, Unit, iec_bases_f64, si_bases_f64, }; +/// What can go wrong while writing a formatted line: either the line itself is +/// not convertible (which `--invalid` decides what to do with), or the output +/// could not be written at all (which is always fatal). +pub enum WriteError { + Io(std::io::Error), + Invalid(String), +} + +impl From for WriteError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for WriteError { + fn from(message: String) -> Self { + Self::Invalid(message) + } +} + fn find_numeric_beginning(s: &str) -> Option<&str> { let dec_sep = locale_decimal_separator(); let mut seen_dec = false; @@ -43,9 +65,7 @@ fn find_numeric_beginning(s: &str) -> Option<&str> { continue; } let num_str = s[..i].replace(dec_sep, "."); - if num_str.parse::().is_err() { - return None; - } + num_str.parse::().ok()?; return Some(&s[..i]); } @@ -102,6 +122,65 @@ fn valid_end_with_unit_separator( Some(valid_part.len() + unit_separator.len() + suffix_len) } +/// Length of the leading part of `s` that reads as a number, suffix included. +fn valid_prefix_len(s: &str, unit: Unit, unit_separator: &str) -> usize { + let Some(number_prefix) = find_valid_number_with_suffix(s, unit) else { + return 0; + }; + + // When a unit separator is in use, the valid part may extend beyond the + // contiguous number+suffix found by find_valid_number_with_suffix. + // For example "5 K Field2" with unit_separator=" " has number_prefix="5" but + // the real valid prefix is "5 K"; the trailing " Field2" is the garbage. + if !unit_separator.is_empty() && number_prefix == find_numeric_beginning(s).unwrap_or("") { + valid_end_with_unit_separator(s, number_prefix, unit, unit_separator) + .unwrap_or(number_prefix.len()) + } else { + number_prefix.len() + } +} + +/// Byte range of the part of a refused `input` a caret should point at: what +/// follows the number that could be read, or the whole of it when there is +/// none. Surrounding whitespace, reproduced rather than converted, is left out. +pub fn invalid_span(input: &str, options: &NumfmtOptions) -> std::ops::Range { + let start = input.len() - input.trim_start().len(); + let mut end = input.trim_end().len(); + + // Whitespace only: there is no number to point past, and the two offsets + // above have crossed — `start` is the whole length, `end` is zero. + if start >= end { + return 0..input.len(); + } + + // A declared --suffix is stripped before parsing, so it is not at fault. + if let Some(suffix) = &options.suffix + && !suffix.is_empty() + && let Some(stripped) = input[start..end].strip_suffix(suffix.as_str()) + { + end = start + stripped.len(); + } + + let valid = valid_prefix_len( + &input[start..end], + options.transform.from, + &options.unit_separator, + ); + start + valid..end.max(start + valid) +} + +/// Whether `input` begins with something that reads as a number, so that advice +/// about what may follow one is only given where there is one. A lone sign or +/// decimal separator is the leading part of a number, but not yet a number. +pub fn holds_number(input: &str) -> bool { + find_numeric_beginning(input.trim_start()).is_some_and(|number| { + number + .replace(locale_decimal_separator(), ".") + .parse::() + .is_ok() + }) +} + fn detailed_error_message(s: &str, unit: Unit, unit_separator: &str) -> Option { if s.is_empty() { return Some(translate!("numfmt-error-invalid-number-empty")); @@ -119,19 +198,7 @@ fn detailed_error_message(s: &str, unit: Unit, unit_separator: &str) -> Option s.quote())); } - // When a unit separator is in use, the valid part may extend beyond the - // contiguous number+suffix found by find_valid_number_with_suffix. - // For example "5 K Field2" with unit_separator=" " has number_prefix="5" but - // the real valid prefix is "5 K"; the trailing " Field2" is the garbage. - let valid_end = - if !unit_separator.is_empty() && number_prefix == find_numeric_beginning(s).unwrap_or("") { - valid_end_with_unit_separator(s, number_prefix, unit, unit_separator) - .unwrap_or(number_prefix.len()) - } else { - number_prefix.len() - }; - - let valid_part = &s[..valid_end]; + let valid_part = &s[..valid_prefix_len(s, unit, unit_separator)]; if valid_part != s && valid_part.parse::().is_ok() { return match s.chars().nth(valid_part.len()) { @@ -161,6 +228,14 @@ fn parse_number_part(s: &str, input: &str) -> Result { return Err(translate!("numfmt-error-invalid-number", "input" => input.quote())); } + // GNU rejects a leading '+' and scientific notation, which Rust's parsers accept. + if s.starts_with('+') { + return Err(translate!("numfmt-error-invalid-number", "input" => input.quote())); + } + if s.bytes().any(|b| b == b'e' || b == b'E') { + return Err(translate!("numfmt-error-invalid-suffix", "input" => input.quote())); + } + if let Ok(n) = s.parse::() { return Ok(ParsedNumber::ExactInt(n)); } @@ -418,10 +493,8 @@ fn transform_from( })?; let had_no_suffix = suffix.is_none(); - if had_no_suffix { - if let Some(scaled) = try_scale_exact_int_with_from_unit(i, opts.from_unit) { - return Ok(scaled); - } + if had_no_suffix && let Some(scaled) = try_scale_exact_int_with_from_unit(i, opts.from_unit) { + return Ok(scaled); } let i = i.to_f64() * (opts.from_unit as f64); @@ -479,6 +552,12 @@ pub fn div_round(n: f64, d: f64, method: RoundMethod) -> f64 { fn round_with_precision(n: f64, method: RoundMethod, precision: usize) -> f64 { let p = 10.0_f64.powf(precision as f64); + // rounding is a no-op once the scale factor overflows f64; + // dividing by it would turn the value into NaN + if !p.is_finite() { + return n; + } + method.round(p * n) / p } @@ -487,6 +566,7 @@ fn consider_suffix( u: Unit, round_method: RoundMethod, precision: usize, + is_precision_specified: bool, ) -> Result<(f64, Option)> { use crate::units::RawSuffix::{E, G, K, M, P, Q, R, T, Y, Z}; @@ -525,7 +605,10 @@ fn consider_suffix( } else { precision }; - let v = if precision > 0 { + let v = if is_precision_specified { + // An explicit `--format` precision, `%.0f` included, rounds the scaled + // value to exactly that many decimals. `div_round` is for the default + // presentation instead, which keeps one decimal below the next base. round_with_precision(n / bases[i], round_method, effective_precision) } else { div_round(n, bases[i], round_method) @@ -604,6 +687,30 @@ fn format_gnu_scientific(v: f64) -> String { } } +/// Format `value` with `precision` decimals, exactly as `{:.precision$}` would. +/// +/// Most of what numfmt prints is a whole number — `348M`, `1024` — and the +/// general float formatter reaches for a big-integer expansion of the value to +/// get its last digit right. When the value is an integer the answer needs no +/// expansion, so take the short way and let the rest fall through. +fn format_float(value: f64, precision: usize) -> String { + // Past 2^53 an f64 no longer holds every integer, and the two routes would + // not agree on what to print. + const EXACT_INTEGERS_UP_TO: f64 = 9_007_199_254_740_992.0; + + if precision == 0 && value.is_finite() && value.abs() < EXACT_INTEGERS_UP_TO { + // Both routes round halves to even; they part only on the sign of a + // negative value that rounds to zero, which `{:.0}` keeps. + let rounded = value.round_ties_even(); + if rounded == 0.0 && value.is_sign_negative() { + return "-0".to_string(); + } + return (rounded as i64).to_string(); + } + + format!("{value:.precision$}") +} + fn transform_to( s: ParsedNumber, opts: &TransformOptions, @@ -618,7 +725,7 @@ fn transform_to( let s = s.to_f64(); let i2 = s / (opts.to_unit as f64); - let (i2, s) = consider_suffix(i2, opts.to, round_method, precision)?; + let (i2, s) = consider_suffix(i2, opts.to, round_method, precision, is_precision_specified)?; let dec_sep = locale_decimal_separator(); let localize = |s: String| -> String { if dec_sep == "." { @@ -628,22 +735,24 @@ fn transform_to( } }; Ok(match s { - None if opts.to == Unit::None => localize(format!( - "{:.precision$}", + None if opts.to == Unit::None && precision <= u16::MAX.into() => localize(format_float( round_with_precision(i2, round_method, precision), + precision, )), None if is_precision_specified && precision <= u16::MAX.into() => { let i2 = round_with_precision(i2, round_method, 0); - localize(format!("{i2:.precision$}")) + localize(format_float(i2, precision)) } - None => localize(format!("{i2:.0}")), - Some(s) if precision > 0 => localize(format!( + None => localize(format_float(i2, 0)), + Some(s) if precision > 0 && precision <= u16::MAX.into() => localize(format!( "{i2:.precision$}{unit_separator}{}", DisplayableSuffix(s, opts.to), )), - Some(s) if is_precision_specified => { - format!("{i2:.0}{unit_separator}{}", DisplayableSuffix(s, opts.to)) - } + Some(s) if is_precision_specified => format!( + "{}{unit_separator}{}", + format_float(i2, 0), + DisplayableSuffix(s, opts.to) + ), Some(s) if i2.abs() < 10.0 => { // single digit before the decimal, like 1.5K localize(format!( @@ -651,9 +760,11 @@ fn transform_to( DisplayableSuffix(s, opts.to) )) } - Some(s) => { - format!("{i2:.0}{unit_separator}{}", DisplayableSuffix(s, opts.to)) - } + Some(s) => format!( + "{}{unit_separator}{}", + format_float(i2, 0), + DisplayableSuffix(s, opts.to) + ), }) } @@ -677,6 +788,18 @@ fn pad_string(s: &str, width: usize, fill: char, right_align: bool) -> String { result } +/// Split a scaled value into its numeric part and the unit suffix that trails +/// it (`k`, `Mi`, ...). +/// +/// A value with no digits at all, such as `inf`, has no numeric part to pad, so +/// it is returned whole and is padded as before. +fn split_number_and_units(s: &str) -> (&str, &str) { + match s.rfind(|c: char| c.is_ascii_digit()) { + Some(last_digit) => s.split_at(last_digit + 1), + None => (s, ""), + } +} + fn format_string( source: &str, options: &NumfmtOptions, @@ -731,11 +854,30 @@ fn format_string( let padded_number = match padding { 0 => number_with_suffix, p if p > 0 && options.format.zero_padding => { - let zero_padded = if let Some(unsigned) = number_with_suffix.strip_prefix(['-', '+']) { - let sign = &number_with_suffix[..1]; - format!("{sign}{}", pad_string(unsigned, p as usize - 1, '0', true)) + // GNU zero-pads the number itself: "Optional zero (%010f) width + // will zero pad the number". A unit suffix from --to and the + // --suffix text sit outside the width, unlike space padding, + // which applies to the whole output. + // The --suffix text is user-supplied and may itself contain + // digits, so peel it off before locating the number. + let (scaled, user_suffix) = match &options.suffix { + Some(suffix) => number_with_suffix + .strip_suffix(suffix.as_str()) + .map_or((number_with_suffix.as_str(), ""), |rest| { + (rest, suffix.as_str()) + }), + None => (number_with_suffix.as_str(), ""), + }; + let (number, unit) = split_number_and_units(scaled); + let trailing = format!("{unit}{user_suffix}"); + let zero_padded = if let Some(unsigned) = number.strip_prefix(['-', '+']) { + let sign = &number[..1]; + format!( + "{sign}{}{trailing}", + pad_string(unsigned, (p as usize).saturating_sub(1), '0', true) + ) } else { - pad_string(&number_with_suffix, p as usize, '0', true) + format!("{}{trailing}", pad_string(number, p as usize, '0', true)) }; match implicit_padding.unwrap_or(options.padding) { @@ -762,13 +904,13 @@ pub(crate) fn escape_line(line: &[u8]) -> String { for chunk in line.utf8_chunks() { for c in chunk.valid().chars() { if c.is_ascii() && !c.is_ascii_graphic() && !c.is_ascii_whitespace() { - result.push_str(&format!("\\{:03o}", c as u8)); + let _ = write!(result, "\\{:03o}", c as u8); } else { result.push(c); } } for &b in chunk.invalid() { - result.push_str(&format!("\\{b:03o}")); + let _ = write!(result, "\\{b:03o}"); } } result @@ -796,7 +938,7 @@ pub fn write_formatted_with_delimiter( input: &[u8], options: &NumfmtOptions, eol: Option, -) -> Result<()> { +) -> std::result::Result<(), WriteError> { let delimiter = options.delimiter.as_deref().unwrap(); for (n, field) in (1..).zip(split_bytes(input, delimiter)) { @@ -804,7 +946,7 @@ pub fn write_formatted_with_delimiter( // add delimiter before second and subsequent fields if n > 1 { - writer.write_all(delimiter).unwrap(); + writer.write_all(delimiter)?; } if field_selected { @@ -813,15 +955,15 @@ pub fn write_formatted_with_delimiter( .map_err(|_| translate!("numfmt-error-invalid-number", "input" => escape_line(field).quote()))? .trim_start(); let formatted = format_string(field_str, options, None)?; - writer.write_all(formatted.as_bytes()).unwrap(); + writer.write_all(formatted.as_bytes())?; } else { // add unselected field without conversion - writer.write_all(field).unwrap(); + writer.write_all(field)?; } } if let Some(eol) = eol { - writer.write_all(&[eol]).unwrap(); + writer.write_all(&[eol])?; } Ok(()) @@ -832,7 +974,7 @@ pub fn write_formatted_with_whitespace( s: &str, options: &NumfmtOptions, eol: Option, -) -> Result<()> { +) -> std::result::Result<(), WriteError> { for (n, (prefix, field)) in (1..).zip(WhitespaceSplitter { s: Some(s), options, @@ -844,8 +986,8 @@ pub fn write_formatted_with_whitespace( // add delimiter before second and subsequent fields let prefix = if n > 1 { - writer.write_all(b" ").unwrap(); - &prefix[1..] + writer.write_all(b" ")?; + &prefix[prefix.chars().next().map_or(0, char::len_utf8)..] } else { prefix }; @@ -857,23 +999,23 @@ pub fn write_formatted_with_whitespace( }; let formatted = format_string(field, options, implicit_padding)?; - writer.write_all(formatted.as_bytes()).unwrap(); + writer.write_all(formatted.as_bytes())?; } else { // the -z option converts an initial \n into a space let prefix = if options.zero_terminated && prefix.starts_with('\n') { - writer.write_all(b" ").unwrap(); + writer.write_all(b" ")?; &prefix[1..] } else { prefix }; // add unselected field without conversion - writer.write_all(prefix.as_bytes()).unwrap(); - writer.write_all(field.as_bytes()).unwrap(); + writer.write_all(prefix.as_bytes())?; + writer.write_all(field.as_bytes())?; } } if let Some(eol) = eol { - writer.write_all(&[eol]).unwrap(); + writer.write_all(&[eol])?; } Ok(()) @@ -883,6 +1025,41 @@ pub fn write_formatted_with_whitespace( mod tests { use super::*; + #[test] + fn test_format_float_matches_the_general_formatter() { + // The short route must be indistinguishable from `{:.precision$}`, + // including the halves, the signed zero and the values that leave the + // range where an f64 holds every integer. + for value in [ + 0.0, + -0.0, + -0.4, + 0.5, + 1.5, + 2.5, + -1.5, + -2.5, + -0.5, + 348.123_456, + 1023.999, + 9_007_199_254_740_992.0, + 9_007_199_254_740_994.0, + -9_007_199_254_740_994.0, + 1e300, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NAN, + ] { + for precision in [0, 1, 2, 6] { + assert_eq!( + format_float(value, precision), + format!("{value:.precision$}"), + "value {value}, precision {precision}" + ); + } + } + } + #[test] #[allow(clippy::cognitive_complexity)] fn test_round_with_precision() { @@ -1096,7 +1273,7 @@ mod tests { use crate::options::RoundMethod; use crate::units::Unit; - let result = consider_suffix(1e27, Unit::Si, RoundMethod::FromZero, 0); + let result = consider_suffix(1e27, Unit::Si, RoundMethod::FromZero, 0, false); assert!(result.is_ok()); let (value, suffix) = result.unwrap(); assert!(suffix.is_some()); @@ -1104,7 +1281,7 @@ mod tests { assert_eq!(raw_suffix as i32, RawSuffix::R as i32); assert_eq!(value, 1.0); - let result = consider_suffix(1e30, Unit::Si, RoundMethod::FromZero, 0); + let result = consider_suffix(1e30, Unit::Si, RoundMethod::FromZero, 0, false); assert!(result.is_ok()); let (value, suffix) = result.unwrap(); assert!(suffix.is_some()); @@ -1112,7 +1289,7 @@ mod tests { assert_eq!(raw_suffix as i32, RawSuffix::Q as i32); assert_eq!(value, 1.0); - let result = consider_suffix(5e30, Unit::Si, RoundMethod::FromZero, 0); + let result = consider_suffix(5e30, Unit::Si, RoundMethod::FromZero, 0, false); assert!(result.is_ok()); let (value, suffix) = result.unwrap(); assert!(suffix.is_some()); diff --git a/src/uu/numfmt/src/numfmt.rs b/src/uu/numfmt/src/numfmt.rs index 92d7205bcb4..5b9a6ea04a1 100644 --- a/src/uu/numfmt/src/numfmt.rs +++ b/src/uu/numfmt/src/numfmt.rs @@ -2,24 +2,27 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore behavior use crate::errors::NumfmtError; -use crate::format::{escape_line, write_formatted_with_delimiter, write_formatted_with_whitespace}; +use crate::format::{ + WriteError, escape_line, write_formatted_with_delimiter, write_formatted_with_whitespace, +}; use crate::options::{ DEBUG, DELIMITER, FIELD, FIELD_DEFAULT, FORMAT, FROM, FROM_DEFAULT, FROM_UNIT, FROM_UNIT_DEFAULT, FormatOptions, GROUPING, HEADER, HEADER_DEFAULT, INVALID, InvalidModes, - NUMBER, NumfmtOptions, PADDING, ROUND, RoundMethod, SUFFIX, TO, TO_DEFAULT, TO_UNIT, - TO_UNIT_DEFAULT, TransformOptions, UNIT_SEPARATOR, ZERO_TERMINATED, + NUMBER, NumfmtOptions, OptionValueError, PADDING, ParseError, ROUND, RoundMethod, SUFFIX, TO, + TO_DEFAULT, TO_UNIT, TO_UNIT_DEFAULT, TransformOptions, UNIT_SEPARATOR, ZERO_TERMINATED, }; use crate::units::{Result, Unit}; use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, parser::ValueSource}; use std::ffi::OsString; -use std::io::{BufRead, Write as _, stderr}; +use std::io::{BufRead, BufWriter, IsTerminal, Write, stderr}; use std::str::FromStr; use uucore::display::Quotable; -use uucore::error::UResult; +use uucore::error::{FromIo, UResult}; use uucore::i18n::decimal::locale_grouping_separator; use uucore::parser::parse_size::{IEC_BASES, SI_BASES}; use uucore::parser::shortcut_value_parser::ShortcutValueParser; @@ -30,28 +33,24 @@ pub mod errors; pub mod format; pub mod options; +mod diagnostics; mod numeric; mod units; // Returns `true` if the input is in scientific notation fn is_scientific(input: &[u8]) -> bool { - if let Some(pos) = input.iter().position(|&b| b == b'E' || b == b'e') { - if pos < input.len() - 1 { - if input[pos + 1].is_ascii_digit() { - return true; - } - } - } - - false + input + .iter() + .position(|b| b"Ee".contains(b)) + .is_some_and(|pos| input.get(pos + 1).is_some_and(u8::is_ascii_digit)) } /// Format a single line and write it, handling `--invalid` error modes. /// /// Returns `true` if the line contained invalid input (only possible in /// non-abort modes). -fn format_and_write( - writer: &mut W, +fn format_and_write( + writer: &mut dyn Write, input_line: &[u8], options: &NumfmtOptions, eol: Option, @@ -66,7 +65,7 @@ fn format_and_write( // can emit the original line instead. let buffer_output = !matches!(options.invalid, InvalidModes::Abort); let mut buf = Vec::new(); - let dest: &mut dyn std::io::Write = if buffer_output { &mut buf } else { writer }; + let dest: &mut dyn Write = if buffer_output { &mut buf } else { writer }; let result = if options.delimiter.is_some() { write_formatted_with_delimiter(dest, line, options, eol) @@ -74,10 +73,11 @@ fn format_and_write( match std::str::from_utf8(line) { Ok(s) => { if is_scientific(s.as_bytes()) { - Err(format!( - "invalid suffix in input: '{}'", - String::from_utf8_lossy(line) - )) + Err(translate!( + "numfmt-error-invalid-suffix", + "input" => String::from_utf8_lossy(line).quote() + ) + .into()) } else { write_formatted_with_whitespace(dest, s, options, eol) } @@ -85,10 +85,20 @@ fn format_and_write( Err(_) => Err(translate!( "numfmt-error-invalid-number", "input" => escape_line(line).quote() - )), + ) + .into()), } }; + // A failure to write is fatal, whatever --invalid says. + let result = match result { + Ok(()) => Ok(()), + Err(WriteError::Io(error)) => { + return Err::(error).map_err_context(|| translate!("numfmt-error-write")); + } + Err(WriteError::Invalid(message)) => Err(message), + }; + if let Err(msg) = result { match options.invalid { InvalidModes::Abort => { @@ -103,38 +113,99 @@ fn format_and_write( InvalidModes::Ignore => {} } // On error, echo the original line unchanged. - writer.write_all(input_line)?; + writer + .write_all(input_line) + .map_err_context(|| translate!("numfmt-error-write"))?; if let Some(eol) = eol { - writer.write_all(&[eol])?; + writer + .write_all(&[eol]) + .map_err_context(|| translate!("numfmt-error-write"))?; } return Ok(true); } if buffer_output { - writer.write_all(&buf)?; + writer + .write_all(&buf) + .map_err_context(|| translate!("numfmt-error-write"))?; } Ok(false) } +/// Run `body` with stdout buffered the way stdio buffers it: a block at a time +/// into a file or a pipe, where only the total number of writes matters, and a +/// line at a time onto a terminal, where output is read as it is produced. +/// +/// Whatever `body` wrote is flushed before this returns, so an error still +/// leaves the lines that came before it on stdout. +fn with_stdout(body: impl FnOnce(&mut dyn Write) -> UResult) -> UResult { + let stdout = std::io::stdout(); + if stdout.is_terminal() { + let mut writer = stdout.lock(); + finish(body(&mut writer), &mut writer) + } else { + let mut writer = BufWriter::new(stdout.lock()); + finish(body(&mut writer), &mut writer) + } +} + +/// Flush `writer`, keeping the failure `result` already carries, if any. +fn finish(result: UResult, writer: &mut impl Write) -> UResult { + let flushed = writer + .flush() + .map_err_context(|| translate!("numfmt-error-write")); + let value = result?; + flushed?; + Ok(value) +} + /// Process command-line number arguments. /// +/// `snapshot` is the command line as typed, for the caret under a number that +/// does not convert, and `None` when diagnostics are off. +/// /// Returns `true` if any line contained invalid input. -fn handle_args<'a>(args: impl Iterator, options: &NumfmtOptions) -> UResult { - let mut stdout = std::io::stdout().lock(); - let terminator = if options.zero_terminated { 0u8 } else { b'\n' }; - let mut saw_invalid = false; - for l in args { - saw_invalid |= format_and_write(&mut stdout, l, options, Some(terminator))?; - } - Ok(saw_invalid) +fn handle_args<'a>( + args: impl Iterator, + options: &NumfmtOptions, + snapshot: Option<&[OsString]>, +) -> UResult { + with_stdout(|stdout| { + let terminator = if options.zero_terminated { 0u8 } else { b'\n' }; + let mut saw_invalid = false; + for (n, l) in args.enumerate() { + match format_and_write(stdout, l, options, Some(terminator)) { + Ok(invalid) => saw_invalid |= invalid, + // Only this mode stops on the first bad number; the others carry + // on, where a report per line would bury the output. + Err(error) => { + return Err(uucore::diagnostics::error_after_report( + snapshot, + error, + |args, error| { + diagnostics::render_input(args, l, n, &error.to_string(), options) + }, + )); + } + } + } + Ok(saw_invalid) + }) } /// Process lines read from stdin. /// /// Returns `true` if any line contained invalid input. -fn handle_buffer(mut input: R, options: &NumfmtOptions) -> UResult { +fn handle_buffer(input: R, options: &NumfmtOptions) -> UResult { + with_stdout(|stdout| handle_buffer_to(input, options, stdout)) +} + +fn handle_buffer_to( + mut input: R, + options: &NumfmtOptions, + stdout: &mut dyn Write, +) -> UResult { let terminator = if options.zero_terminated { 0u8 } else { b'\n' }; - let mut stdout = std::io::stdout().lock(); let mut buf = Vec::new(); let mut line_idx = 0; let mut saw_invalid = false; @@ -165,7 +236,7 @@ fn handle_buffer(mut input: R, options: &NumfmtOptions) -> UResult(mut input: R, options: &NumfmtOptions) -> UResult Result { +fn parse_unit(s: &str, opt: &'static str) -> std::result::Result { match s { "auto" if opt != TO => Ok(Unit::Auto), "si" => Ok(Unit::Si), "iec" => Ok(Unit::Iec(false)), "iec-i" => Ok(Unit::Iec(true)), "none" => Ok(Unit::None), - value => Err( - translate!("numfmt-error-invalid-unit-argument", "arg" => value, "opt" => format!("--{opt}")), - ), + value => Err(OptionValueError { + message: translate!("numfmt-error-invalid-unit-argument", "arg" => value, "opt" => format!("--{opt}")), + option: opt, + value: value.to_string(), + // `auto` is a real unit, just not one --to can scale to. + label: (value == "auto").then_some("numfmt-diag-label-auto-from-only"), + help: "numfmt-diag-help-unit", + } + .into()), } } /// Parses a unit size. Suffixes are turned into their integer representations. For example, 'K' /// will return `Ok(1000)`, and '2K' will return `Ok(2000)`. -fn parse_unit_size(s: &str) -> Result { +fn parse_unit_size(s: &str, opt: &'static str) -> std::result::Result { let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); let (number, suffix) = s.split_at(split); // Reject all-zero numeric parts like "0" or "00K". let all_zero = !number.is_empty() && number.bytes().all(|b| b == b'0'); - if !all_zero { - if let Some(multiplier) = parse_unit_size_suffix(suffix) { - if number.is_empty() { - return Ok(multiplier); - } - if let Ok(n) = number.parse::() { - return Ok(n * multiplier); - } + if !all_zero && let Some(multiplier) = parse_unit_size_suffix(suffix) { + if number.is_empty() { + return Ok(multiplier); + } + if let Some(size) = number + .parse::() + .ok() + .and_then(|n| n.checked_mul(multiplier)) + { + return Ok(size); } } - Err(translate!("numfmt-error-invalid-unit-size", "size" => s.quote())) + Err(OptionValueError { + message: translate!("numfmt-error-invalid-unit-size", "size" => s.quote()), + option: opt, + value: s.to_string(), + // A zero size is spelled like a valid one; the rest the help covers. + // An unknown suffix is what is wrong about "0x", not the zero. + label: (all_zero && parse_unit_size_suffix(suffix).is_some()) + .then_some("numfmt-diag-label-zero-unit-size"), + help: "numfmt-diag-help-unit-size", + } + .into()) } /// Parses a suffix of a unit size and returns the corresponding multiplier. For example, @@ -247,11 +336,11 @@ fn parse_delimiter(arg: &OsString) -> Result> { } } -fn parse_options(args: &ArgMatches) -> Result { +fn parse_options(args: &ArgMatches) -> std::result::Result { let from = parse_unit(args.get_one::(FROM).unwrap(), FROM)?; let to = parse_unit(args.get_one::(TO).unwrap(), TO)?; - let from_unit = parse_unit_size(args.get_one::(FROM_UNIT).unwrap())?; - let to_unit = parse_unit_size(args.get_one::(TO_UNIT).unwrap())?; + let from_unit = parse_unit_size(args.get_one::(FROM_UNIT).unwrap(), FROM_UNIT)?; + let to_unit = parse_unit_size(args.get_one::(TO_UNIT).unwrap(), TO_UNIT)?; let transform = TransformOptions { from, @@ -268,7 +357,14 @@ fn parse_options(args: &ArgMatches) -> Result { 0 => Err(s), _ => Ok(n), }) - .map_err(|s| translate!("numfmt-error-invalid-padding", "value" => s.quote())), + .map_err(|s| OptionValueError { + message: translate!("numfmt-error-invalid-padding", "value" => s.quote()), + option: PADDING, + value: s.clone(), + // A zero parses fine and only fails on meaning. + label: (s == "0").then_some("numfmt-diag-label-zero-padding"), + help: "numfmt-diag-help-padding", + }), None => Ok(0), }?; @@ -282,7 +378,13 @@ fn parse_options(args: &ArgMatches) -> Result { 0 => Err(value), _ => Ok(n), }) - .map_err(|value| translate!("numfmt-error-invalid-header", "value" => value.quote())) + .map_err(|value| OptionValueError { + message: translate!("numfmt-error-invalid-header", "value" => value.quote()), + option: HEADER, + value: value.clone(), + label: (value == "0").then_some("numfmt-diag-label-zero-header"), + help: "numfmt-diag-help-header", + }) } else { Ok(0) }?; @@ -305,17 +407,13 @@ fn parse_options(args: &ArgMatches) -> Result { }; if grouping && args.contains_id(FORMAT) { - return Err(translate!( - "numfmt-error-grouping-cannot-be-combined-with-format" - )); + return Err(translate!("numfmt-error-grouping-cannot-be-combined-with-format").into()); } let grouping = grouping || format.grouping; if grouping && to != Unit::None { - return Err(translate!( - "numfmt-error-grouping-cannot-be-combined-with-to" - )); + return Err(translate!("numfmt-error-grouping-cannot-be-combined-with-to").into()); } let delimiter = args @@ -394,8 +492,49 @@ fn print_debug_warnings(options: &NumfmtOptions, matches: &ArgMatches) { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let args: Vec = args.collect(); + // Kept for the caret in --format diagnostics. + let format_args = uucore::diagnostics::capture(&args); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - let options = parse_options(&matches).map_err(NumfmtError::IllegalArgument)?; + let options = match parse_options(&matches) { + Ok(options) => options, + // A format error still knows where in the format string it happened, + // so it is the one error worth a caret. + Err(ParseError::Format(error)) => { + return Err(uucore::diagnostics::error_after_report( + format_args.as_deref(), + NumfmtError::IllegalArgument(error.message.clone()), + |args, _| { + matches + .get_one::(FORMAT) + .is_some_and(|format| diagnostics::render(args, format, &error)) + }, + )); + } + // As for a format, a field list knows which of its ranges is at fault. + Err(ParseError::Field(error)) => { + return Err(uucore::diagnostics::error_after_report( + format_args.as_deref(), + NumfmtError::IllegalArgument(error.message.clone()), + |args, _| { + matches + .get_one::(FIELD) + .is_some_and(|fields| diagnostics::render_field(args, fields, &error)) + }, + )); + } + // An option value that is wrong as a whole: underline it where typed. + Err(ParseError::Value(error)) => { + return Err(uucore::diagnostics::error_after_report( + format_args.as_deref(), + NumfmtError::IllegalArgument(error.message.clone()), + |args, _| diagnostics::render_value(args, &error), + )); + } + Err(ParseError::Other(message)) => { + return Err(NumfmtError::IllegalArgument(message).into()); + } + }; if options.debug { print_debug_warnings(&options, &matches); @@ -406,7 +545,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .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) + handle_args(byte_args.into_iter(), &options, format_args.as_deref()) } else { let stdin = std::io::stdin(); handle_buffer(stdin.lock(), &options) @@ -458,6 +597,7 @@ pub fn uu_app() -> Command { .long(DELIMITER) .value_name("X") .value_parser(ValueParser::os_string()) + .allow_hyphen_values(true) .help(translate!("numfmt-help-delimiter")), ) .arg( @@ -480,34 +620,39 @@ pub fn uu_app() -> Command { .long(FROM) .help(translate!("numfmt-help-from")) .value_name("UNIT") - .default_value(FROM_DEFAULT), + .default_value(FROM_DEFAULT) + .allow_hyphen_values(true), ) .arg( Arg::new(FROM_UNIT) .long(FROM_UNIT) .help(translate!("numfmt-help-from-unit")) .value_name("N") - .default_value(FROM_UNIT_DEFAULT), + .default_value(FROM_UNIT_DEFAULT) + .allow_hyphen_values(true), ) .arg( Arg::new(TO) .long(TO) .help(translate!("numfmt-help-to")) .value_name("UNIT") - .default_value(TO_DEFAULT), + .default_value(TO_DEFAULT) + .allow_hyphen_values(true), ) .arg( Arg::new(TO_UNIT) .long(TO_UNIT) .help(translate!("numfmt-help-to-unit")) .value_name("N") - .default_value(TO_UNIT_DEFAULT), + .default_value(TO_UNIT_DEFAULT) + .allow_hyphen_values(true), ) .arg( Arg::new(PADDING) .long(PADDING) .help(translate!("numfmt-help-padding")) - .value_name("N"), + .value_name("N") + .allow_hyphen_values(true), ) .arg( Arg::new(HEADER) @@ -531,19 +676,22 @@ pub fn uu_app() -> Command { "from-zero", "towards-zero", "nearest", - ])), + ])) + .allow_hyphen_values(true), ) .arg( Arg::new(SUFFIX) .long(SUFFIX) .help(translate!("numfmt-help-suffix")) - .value_name("SUFFIX"), + .value_name("SUFFIX") + .allow_hyphen_values(true), ) .arg( Arg::new(UNIT_SEPARATOR) .long(UNIT_SEPARATOR) .help(translate!("numfmt-help-unit-separator")) - .value_name("STRING"), + .value_name("STRING") + .allow_hyphen_values(true), ) .arg( Arg::new(INVALID) @@ -551,7 +699,8 @@ pub fn uu_app() -> Command { .help(translate!("numfmt-help-invalid")) .default_value("abort") .value_parser(["abort", "fail", "warn", "ignore"]) - .value_name("INVALID"), + .value_name("INVALID") + .allow_hyphen_values(true), ) .arg( Arg::new(ZERO_TERMINATED) @@ -573,8 +722,9 @@ mod tests { use uucore::error::get_exit_code; use super::{ - FormatOptions, InvalidModes, NumfmtOptions, Range, RoundMethod, TransformOptions, Unit, - handle_args, handle_buffer, parse_unit_size, parse_unit_size_suffix, + FROM_UNIT, FormatOptions, InvalidModes, NumfmtOptions, Range, RoundMethod, + TransformOptions, Unit, handle_args, handle_buffer, parse_unit_size, + parse_unit_size_suffix, }; use std::io::{BufReader, Error, ErrorKind, Read}; struct MockBuffer {} @@ -702,7 +852,7 @@ mod tests { 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(); + handle_args(input_value, &options, None).unwrap(); assert_eq!( get_exit_code(), 2, @@ -715,28 +865,28 @@ mod tests { 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); + let result = handle_args(input_value, &options, None); assert!(result.is_ok(), "did not return ok for invalid input"); } #[test] fn test_parse_unit_size() { - assert_eq!(1, parse_unit_size("1").unwrap()); - assert_eq!(1, parse_unit_size("01").unwrap()); - assert!(parse_unit_size("1.1").is_err()); - assert!(parse_unit_size("0").is_err()); - assert!(parse_unit_size("-1").is_err()); - assert!(parse_unit_size("A").is_err()); - assert!(parse_unit_size("18446744073709551616").is_err()); + assert_eq!(1, parse_unit_size("1", FROM_UNIT).unwrap()); + assert_eq!(1, parse_unit_size("01", FROM_UNIT).unwrap()); + assert!(parse_unit_size("1.1", FROM_UNIT).is_err()); + assert!(parse_unit_size("0", FROM_UNIT).is_err()); + assert!(parse_unit_size("-1", FROM_UNIT).is_err()); + assert!(parse_unit_size("A", FROM_UNIT).is_err()); + assert!(parse_unit_size("18446744073709551616", FROM_UNIT).is_err()); } #[test] fn test_parse_unit_size_with_suffix() { - assert_eq!(1000, parse_unit_size("K").unwrap()); - assert_eq!(1024, parse_unit_size("Ki").unwrap()); - assert_eq!(2000, parse_unit_size("2K").unwrap()); - assert_eq!(2048, parse_unit_size("2Ki").unwrap()); - assert!(parse_unit_size("0K").is_err()); + assert_eq!(1000, parse_unit_size("K", FROM_UNIT).unwrap()); + assert_eq!(1024, parse_unit_size("Ki", FROM_UNIT).unwrap()); + assert_eq!(2000, parse_unit_size("2K", FROM_UNIT).unwrap()); + assert_eq!(2048, parse_unit_size("2Ki", FROM_UNIT).unwrap()); + assert!(parse_unit_size("0K", FROM_UNIT).is_err()); } #[test] diff --git a/src/uu/numfmt/src/options.rs b/src/uu/numfmt/src/options.rs index ced2d4b6493..009d1492351 100644 --- a/src/uu/numfmt/src/options.rs +++ b/src/uu/numfmt/src/options.rs @@ -2,12 +2,29 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use std::str::FromStr; + +use std::fmt::Display; +use std::iter::Peekable; +// `Range` alone is the field range from uucore, so byte ranges are named apart. +use std::ops::Range as ByteRange; +use std::str::{CharIndices, FromStr}; use crate::units::Unit; +use uucore::quoting_style::locale_aware_c_single_escape; use uucore::ranges::Range; use uucore::translate; +/// Byte offset the parser stopped at, the end of input when nothing is left. +fn offset(iter: &mut Peekable>, s: &str) -> usize { + iter.peek().map_or(s.len(), |&(i, _)| i) +} + +/// Byte range of the character the parser stopped at, empty at end of input. +fn at(iter: &mut Peekable>, s: &str) -> ByteRange { + iter.peek() + .map_or(s.len()..s.len(), |&(i, c)| i..i + c.len_utf8()) +} + pub const DEBUG: &str = "debug"; pub const DELIMITER: &str = "delimiter"; pub const FIELD: &str = "field"; @@ -108,8 +125,90 @@ pub struct FormatOptions { pub zero_padding: bool, } +/// A `--format` string that does not parse, and where the parse stopped. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FormatError { + pub message: String, + /// Byte range inside the format string. + pub span: ByteRange, + pub kind: FormatErrorKind, +} + +/// What went wrong, so a caller can label the caret. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormatErrorKind { + /// No `%f` directive at all, or one that never ends. + MissingDirective, + /// A character that has no place in a directive. + UnexpectedCharacter, + /// A directive ending on something other than `f`, such as `%d` or `%e`. + UnexpectedConversion, + /// A width or precision that does not fit. + NumberOverflow, + /// A `%` in the suffix that is not part of a `%%` pair. + StrayPercent, +} + +impl Display for FormatError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +/// An option value that does not parse as a whole — a unit name, a unit size, +/// a padding — so that the caret can underline it where it was typed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OptionValueError { + pub message: String, + /// Long name of the option the value came from, as in `from-unit`. + pub option: &'static str, + /// The value as given, which is how it is found among the arguments. + pub value: String, + /// Fluent identifier of the label under the caret, when one would add to + /// the message. + pub label: Option<&'static str>, + /// Fluent identifier of the line of advice under the report. + pub help: &'static str, +} + +/// Why option parsing failed: a format error that still knows where in the +/// format string it happened, or a plain message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseError { + Format(FormatError), + /// A `--field` list that does not parse, knowing where in the list. + Field(uucore::ranges::RangeError), + /// An option value that is wrong from end to end. + Value(Box), + Other(String), +} + +impl From for ParseError { + fn from(error: OptionValueError) -> Self { + Self::Value(Box::new(error)) + } +} + +impl From for ParseError { + fn from(error: FormatError) -> Self { + Self::Format(error) + } +} + +impl From for ParseError { + fn from(message: String) -> Self { + Self::Other(message) + } +} + +impl From for ParseError { + fn from(error: uucore::ranges::RangeError) -> Self { + Self::Field(error) + } +} + impl FromStr for FormatOptions { - type Err = String; + type Err = FormatError; // The recognized format is: [PREFIX]%[0]['][-][N][.][N]f[SUFFIX] // @@ -121,8 +220,16 @@ impl FromStr for FormatOptions { // An optional precision (%.1f) determines the precision of the number. #[allow(clippy::cognitive_complexity)] fn from_str(s: &str) -> Result { - let mut iter = s.chars().peekable(); + // Byte offsets are tracked alongside the characters so that a failure + // can say where in the format it gave up. + let mut iter = s.char_indices().peekable(); let mut options = Self::default(); + let quoted = locale_aware_c_single_escape(s); + let error = |message: String, span: ByteRange, kind: FormatErrorKind| FormatError { + message, + span, + kind, + }; let mut padding = String::new(); let mut precision = String::new(); @@ -131,9 +238,9 @@ impl FromStr for FormatOptions { // '%' chars in the prefix, if any, must appear in blocks of even length, for example: "%%%%" and // "%% %%" are ok, "%%% %" is not ok. A single '%' is treated as the beginning of the // floating point argument. - while let Some(c) = iter.next() { + while let Some((_, c)) = iter.next() { match c { - '%' if iter.peek() == Some(&'%') => { + '%' if matches!(iter.peek(), Some((_, '%'))) => { iter.next(); double_percentage_counter += 1; @@ -152,16 +259,26 @@ impl FromStr for FormatOptions { } if iter.peek().is_none() { - return if options.prefix == s { - Err(translate!("numfmt-error-format-no-percent", "format" => s)) + // Nothing to point inside: the directive is missing from the whole + // format, or it is the trailing '%' that never became one. + return Err(if options.prefix == s { + error( + translate!("numfmt-error-format-no-percent", "format" => quoted.as_str()), + 0..s.len(), + FormatErrorKind::MissingDirective, + ) } else { - Err(translate!("numfmt-error-format-ends-in-percent", "format" => s)) - }; + error( + translate!("numfmt-error-format-ends-in-percent", "format" => quoted.as_str()), + s.len().saturating_sub(1)..s.len(), + FormatErrorKind::MissingDirective, + ) + }); } // GNU numfmt allows to mix the characters " ", "'", and "0" in any way, so we do the same - while matches!(iter.peek(), Some(' ' | '\'' | '0')) { - match iter.next().unwrap() { + while matches!(iter.peek(), Some((_, ' ' | '\'' | '0'))) { + match iter.next().unwrap().1 { ' ' => (), '\'' => options.grouping = true, '0' => options.zero_padding = true, @@ -169,18 +286,23 @@ impl FromStr for FormatOptions { } } - if let Some('-') = iter.peek() { + if let Some((_, '-')) = iter.peek() { iter.next(); match iter.peek() { - Some(c) if c.is_ascii_digit() => padding.push('-'), + Some((_, c)) if c.is_ascii_digit() => padding.push('-'), _ => { - return Err(translate!("numfmt-error-invalid-format-directive", "format" => s)); + return Err(error( + translate!("numfmt-error-invalid-format-directive", "format" => quoted.as_str()), + at(&mut iter, s), + FormatErrorKind::UnexpectedCharacter, + )); } } } - while let Some(c) = iter.peek() { + let padding_start = offset(&mut iter, s); + while let Some((_, c)) = iter.peek() { if c.is_ascii_digit() { padding.push(*c); iter.next(); @@ -193,20 +315,27 @@ impl FromStr for FormatOptions { if let Ok(p) = padding.parse() { options.padding = Some(p); } else { - return Err( - translate!("numfmt-error-invalid-format-width-overflow", "format" => s), - ); + return Err(error( + translate!("numfmt-error-invalid-format-width-overflow", "format" => quoted.as_str()), + padding_start..offset(&mut iter, s), + FormatErrorKind::NumberOverflow, + )); } } - if let Some('.') = iter.peek() { + if let Some((_, '.')) = iter.peek() { iter.next(); - if matches!(iter.peek(), Some(' ' | '+' | '-')) { - return Err(translate!("numfmt-error-invalid-precision", "format" => s)); + if matches!(iter.peek(), Some((_, ' ' | '+' | '-'))) { + return Err(error( + translate!("numfmt-error-invalid-precision", "format" => quoted.as_str()), + at(&mut iter, s), + FormatErrorKind::UnexpectedCharacter, + )); } - while let Some(c) = iter.peek() { + let precision_start = offset(&mut iter, s); + while let Some((_, c)) = iter.peek() { if c.is_ascii_digit() { precision.push(*c); iter.next(); @@ -220,28 +349,48 @@ impl FromStr for FormatOptions { } else if let Ok(p) = precision.parse() { options.precision = Some(p); } else { - return Err(translate!("numfmt-error-invalid-precision", "format" => s)); + return Err(error( + translate!("numfmt-error-invalid-precision", "format" => quoted.as_str()), + precision_start..offset(&mut iter, s), + FormatErrorKind::NumberOverflow, + )); } } - if let Some('f') = iter.peek() { + if let Some((_, 'f')) = iter.peek() { iter.next(); } else { - return Err(translate!("numfmt-error-invalid-format-directive", "format" => s)); + // Only the character standing where the conversion belongs is at + // fault: whatever follows it would have been a valid suffix. At end + // of input there is no conversion to name, only one missing. + let kind = if iter.peek().is_none() { + FormatErrorKind::MissingDirective + } else { + FormatErrorKind::UnexpectedConversion + }; + return Err(error( + translate!("numfmt-error-invalid-format-directive", "format" => quoted.as_str()), + at(&mut iter, s), + kind, + )); } // '%' chars in the suffix, if any, must appear in blocks of even length, otherwise // it is an error. For example: "%%%%" and "%% %%" are ok, "%%% %" is not ok. - while let Some(c) = iter.next() { + while let Some((i, c)) = iter.next() { if c != '%' { options.suffix.push(c); - } else if iter.peek() == Some(&'%') { + } else if matches!(iter.peek(), Some((_, '%'))) { for _ in 0..2 { options.suffix.push('%'); } iter.next(); } else { - return Err(translate!("numfmt-error-format-too-many-percent", "format" => s)); + return Err(error( + translate!("numfmt-error-format-too-many-percent", "format" => quoted.as_str()), + i..i + 1, + FormatErrorKind::StrayPercent, + )); } } diff --git a/src/uu/numfmt/src/units.rs b/src/uu/numfmt/src/units.rs index 45cacffb703..068c284ba6b 100644 --- a/src/uu/numfmt/src/units.rs +++ b/src/uu/numfmt/src/units.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use std::fmt; use uucore::parser::parse_size::{IEC_BASES, SI_BASES}; diff --git a/src/uu/od/Cargo.toml b/src/uu/od/Cargo.toml index 1804630d43f..237ab944ad5 100644 --- a/src/uu/od/Cargo.toml +++ b/src/uu/od/Cargo.toml @@ -11,21 +11,31 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/od.rs" [dependencies] -byteorder = { workspace = true } clap = { workspace = true } -half = { workspace = true } -rustix = { workspace = true, features = ["stdio"] } -uucore = { workspace = true, features = ["fs", "parser-size"] } fluent = { workspace = true } +half = { workspace = true } libc.workspace = true +uucore = { workspace = true, features = ["fs", "parser-size", "pipes"] } + +[target.'cfg(any(unix, target_os = "wasi"))'.dependencies] +rustix = { workspace = true, features = ["stdio"] } + +[dev-dependencies] +divan = { workspace = true } +tempfile = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[lints] +workspace = true [[bin]] name = "od" path = "src/main.rs" + +[[bench]] +name = "od_bench" +harness = false diff --git a/src/uu/od/benches/od_bench.rs b/src/uu/od/benches/od_bench.rs new file mode 100644 index 00000000000..164272fc3ed --- /dev/null +++ b/src/uu/od/benches/od_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 uu_od::uumain; +use uucore::benchmark::{get_bench_args, setup_test_file}; + +/// Size of the input dumped by each benchmark. od writes several bytes of +/// output per input byte, so this stays modest to keep the run times sane. +const INPUT_SIZE: usize = 1024 * 1024; + +/// Generate binary input covering the whole byte range, so that the printable +/// character lookups and the number formatting both see every possible value. +fn binary_data() -> Vec { + (0..INPUT_SIZE).map(|i| (i % 256) as u8).collect() +} + +/// Benchmark the default output format (octal 2-byte words). +#[divan::bench] +fn od_default(bencher: Bencher) { + let file_path = setup_test_file(&binary_data()); + + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); +} + +/// Benchmark single byte hexadecimal output, the most common invocation. +#[divan::bench] +fn od_hex_bytes(bencher: Bencher) { + let file_path = setup_test_file(&binary_data()); + + bencher + .with_inputs(|| get_bench_args(&[&"-t", &"x1", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); +} + +/// Benchmark named character output, which escapes control characters. +#[divan::bench] +fn od_chars(bencher: Bencher) { + let file_path = setup_test_file(&binary_data()); + + bencher + .with_inputs(|| get_bench_args(&[&"-c", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); +} + +fn main() { + divan::main(); +} diff --git a/src/uu/od/locales/en-US.ftl b/src/uu/od/locales/en-US.ftl index 2c0d8baed49..e123ca9bbc9 100644 --- a/src/uu/od/locales/en-US.ftl +++ b/src/uu/od/locales/en-US.ftl @@ -59,7 +59,7 @@ od-error-overflow = Numerical result out of range od-error-invalid-suffix = invalid suffix in {$option} argument {$value} od-error-invalid-argument = invalid {$option} argument {$value} od-error-argument-too-large = {$option} argument {$value} too large -od-error-skip-past-end = tried to skip past end of input +od-error-skip-past-end = cannot skip past end of combined input # Help messages od-help-help = Print help information. diff --git a/src/uu/od/locales/fr-FR.ftl b/src/uu/od/locales/fr-FR.ftl index ada0c2d796f..c12d085c16a 100644 --- a/src/uu/od/locales/fr-FR.ftl +++ b/src/uu/od/locales/fr-FR.ftl @@ -59,7 +59,7 @@ od-error-parse-failed = échec de l'analyse od-error-invalid-suffix = suffixe invalide dans l'argument {$option} {$value} od-error-invalid-argument = argument {$option} invalide {$value} od-error-argument-too-large = argument {$option} {$value} trop grand -od-error-skip-past-end = tentative d'ignorer au-delà de la fin de l'entrée +od-error-skip-past-end = impossible d'ignorer au-delà de la fin de l'entrée combinée # Messages d'aide od-help-help = Afficher les informations d'aide. diff --git a/src/uu/od/src/byteorder_io.rs b/src/uu/od/src/byteorder_io.rs index 8cc7a8bac05..40eb01c7cc3 100644 --- a/src/uu/od/src/byteorder_io.rs +++ b/src/uu/od/src/byteorder_io.rs @@ -2,13 +2,6 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) byteorder - -// workaround until https://github.com/BurntSushi/byteorder/issues/41 has been fixed -// based on: https://github.com/netvl/immeta/blob/4460ee/src/utils.rs#L76 - -use byteorder::ByteOrder as ByteOrderTrait; -use byteorder::{BigEndian, LittleEndian, NativeEndian}; #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum ByteOrder { @@ -18,25 +11,16 @@ pub enum ByteOrder { } macro_rules! gen_byte_order_ops { - ($($read_name:ident, $write_name:ident -> $tpe:ty),+) => { + ($($read_name:ident -> $tpe:ty),+) => { impl ByteOrder { $( - #[allow(dead_code)] #[inline] pub fn $read_name(self, source: &[u8]) -> $tpe { + let bytes = source[..std::mem::size_of::<$tpe>()].try_into().unwrap(); match self { - ByteOrder::Little => LittleEndian::$read_name(source), - ByteOrder::Big => BigEndian::$read_name(source), - ByteOrder::Native => NativeEndian::$read_name(source), - } - } - - #[allow(dead_code)] - pub fn $write_name(self, target: &mut [u8], n: $tpe) { - match self { - ByteOrder::Little => LittleEndian::$write_name(target, n), - ByteOrder::Big => BigEndian::$write_name(target, n), - ByteOrder::Native => NativeEndian::$write_name(target, n), + Self::Little => <$tpe>::from_le_bytes(bytes), + Self::Big => <$tpe>::from_be_bytes(bytes), + Self::Native => <$tpe>::from_ne_bytes(bytes), } } )+ @@ -45,13 +29,10 @@ macro_rules! gen_byte_order_ops { } gen_byte_order_ops! { - read_u16, write_u16 -> u16, - read_u32, write_u32 -> u32, - read_u64, write_u64 -> u64, - read_i16, write_i16 -> i16, - read_i32, write_i32 -> i32, - read_i64, write_i64 -> i64, - read_f32, write_f32 -> f32, - read_f64, write_f64 -> f64, - read_u128, write_u128 -> u128 + read_u16 -> u16, + read_u32 -> u32, + read_u64 -> u64, + read_f32 -> f32, + read_f64 -> f64, + read_u128 -> u128 } diff --git a/src/uu/od/src/formatter_item_info.rs b/src/uu/od/src/formatter_item_info.rs index 472c9fc4ee2..795c7a819be 100644 --- a/src/uu/od/src/formatter_item_info.rs +++ b/src/uu/od/src/formatter_item_info.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (ToDO) formatteriteminfo use std::fmt; diff --git a/src/uu/od/src/input_decoder.rs b/src/uu/od/src/input_decoder.rs index 416badb444b..902de5add24 100644 --- a/src/uu/od/src/input_decoder.rs +++ b/src/uu/od/src/input_decoder.rs @@ -27,11 +27,6 @@ where /// The number of bytes in the buffer reserved for the peek data from `PeekRead`. reserved_peek_length: usize, - /// The number of (valid) bytes in the buffer. - used_normal_length: usize, - /// The number of peek bytes in the buffer. - used_peek_length: usize, - /// Byte order used to read data from the buffer. byte_order: ByteOrder, } @@ -51,8 +46,6 @@ impl InputDecoder<'_, I> { input, data: bytes, reserved_peek_length: peek_length, - used_normal_length: 0, - used_peek_length: 0, byte_order, } } @@ -65,22 +58,14 @@ where /// calls `peek_read` on the internal stream to (re)fill the buffer. Returns a /// `MemoryDecoder` providing access to the result or returns an i/o error. pub fn peek_read(&mut self) -> io::Result> { - match self - .input + self.input .peek_read(self.data.as_mut_slice(), self.reserved_peek_length) - { - Ok((n, p)) => { - self.used_normal_length = n; - self.used_peek_length = p; - Ok(MemoryDecoder { - data: &mut self.data, - used_normal_length: self.used_normal_length, - used_peek_length: self.used_peek_length, - byte_order: self.byte_order, - }) - } - Err(e) => Err(e), - } + .map(|(n, p)| MemoryDecoder { + data: &mut self.data, + used_normal_length: n, + used_peek_length: p, + byte_order: self.byte_order, + }) } } @@ -110,9 +95,7 @@ impl MemoryDecoder<'_> { /// Set a part of the internal buffer to zero. /// access to the whole buffer is possible, not just to the valid data. pub fn zero_out_buffer(&mut self, start: usize, end: usize) { - for i in start..end { - self.data[i] = 0; - } + self.data[start..end].fill(0); } /// Returns the current length of the buffer. (ie. how much valid data it contains.) diff --git a/src/uu/od/src/input_offset.rs b/src/uu/od/src/input_offset.rs index fa8e66c1a05..6411b9d678b 100644 --- a/src/uu/od/src/input_offset.rs +++ b/src/uu/od/src/input_offset.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use std::io::{self, Write}; #[derive(Copy, Clone, Debug, Eq, PartialEq)] diff --git a/src/uu/od/src/mockstream.rs b/src/uu/od/src/mockstream.rs deleted file mode 100644 index 8cfb26bc9c7..00000000000 --- a/src/uu/od/src/mockstream.rs +++ /dev/null @@ -1,112 +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. -// https://github.com/lazy-bitfield/rust-mockstream/pull/2 - -use std::io::{Cursor, Error, ErrorKind, Read, Result}; - -/// `FailingMockStream` mocks a stream which will fail upon read or write -/// -/// # Examples -/// -/// ```no_run -/// use std::io::{Cursor, Read}; -/// -/// struct CountIo {} -/// -/// impl CountIo { -/// fn read_data(&self, r: &mut Read) -> usize { -/// let mut count: usize = 0; -/// let mut retries = 3; -/// -/// loop { -/// let mut buffer = [0; 5]; -/// match r.read(&mut buffer) { -/// Err(_) => { -/// if retries == 0 { break; } -/// retries -= 1; -/// }, -/// Ok(0) => break, -/// Ok(n) => count += n, -/// } -/// } -/// count -/// } -/// } -/// -/// #[test] -/// fn test_io_retries() { -/// let mut c = Cursor::new(&b"1234"[..]) -/// .chain(FailingMockStream::new(ErrorKind::Other, "Failing", 3)) -/// .chain(Cursor::new(&b"5678"[..])); -/// -/// let sut = CountIo {}; -/// // this will fail unless read_data performs at least 3 retries on I/O errors -/// assert_eq!(8, sut.read_data(&mut c)); -/// } -/// ``` -#[derive(Clone)] -pub struct FailingMockStream { - kind: ErrorKind, - message: &'static str, - repeat_count: i32, -} - -impl FailingMockStream { - /// Creates a [`FailingMockStream`] - /// - /// When `read` or `write` is called, it will return an error `repeat_count` times. - /// `kind` and `message` can be specified to define the exact error. - pub fn new(kind: ErrorKind, message: &'static str, repeat_count: i32) -> Self { - Self { - kind, - message, - repeat_count, - } - } - - fn error(&mut self) -> Result { - if self.repeat_count == 0 { - Ok(0) - } else { - if self.repeat_count > 0 { - self.repeat_count -= 1; - } - Err(Error::new(self.kind, self.message)) - } - } -} - -impl Read for FailingMockStream { - fn read(&mut self, _: &mut [u8]) -> Result { - self.error() - } -} - -#[test] -fn test_failing_mock_stream_read() { - let mut s = FailingMockStream::new(ErrorKind::BrokenPipe, "The dog ate the ethernet cable", 1); - let mut v = [0; 4]; - let error = s.read(v.as_mut()).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::BrokenPipe); - assert_eq!(error.to_string(), "The dog ate the ethernet cable"); - // after a single error, it will return Ok(0) - assert_eq!(s.read(v.as_mut()).unwrap(), 0); -} - -#[test] -fn test_failing_mock_stream_chain_interrupted() { - let mut c = Cursor::new(&b"abcd"[..]) - .chain(FailingMockStream::new( - ErrorKind::Interrupted, - "Interrupted", - 5, - )) - .chain(Cursor::new(&b"ABCD"[..])); - - let mut v = [0; 8]; - c.read_exact(v.as_mut()).unwrap(); - assert_eq!(v, [0x61, 0x62, 0x63, 0x64, 0x41, 0x42, 0x43, 0x44]); - assert_eq!(c.read(v.as_mut()).unwrap(), 0); -} diff --git a/src/uu/od/src/multifile_reader.rs b/src/uu/od/src/multifile_reader.rs index 3a73f25c4f1..7eb10df1548 100644 --- a/src/uu/od/src/multifile_reader.rs +++ b/src/uu/od/src/multifile_reader.rs @@ -2,25 +2,58 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (ToDO) multifile curr fnames fname xfrd fillloop mockstream use std::fs::File; use std::io; +#[cfg(unix)] +use std::io::{Seek, SeekFrom}; + +use uucore::{display::Quotable, error::strip_errno, show_error, translate}; -use uucore::display::Quotable; -use uucore::show_error; +/// Buffer size used when skipping bytes by reading and discarding them. +const SKIP_BUFFER_SIZE: usize = 16 * 1024; pub enum InputSource<'a> { FileName(&'a str), Stdin, - #[allow(dead_code)] - Stream(Box), +} + +/// The file currently being read. A real `File` is kept as a concrete handle so +/// that `skip` can `fstat`/`seek` it; anything else (stdin, an in-memory stream) +/// can only be advanced by reading. +enum CurrentReader { + File(File), + #[cfg(any(unix, target_os = "wasi"))] + Stdin(uucore::io::RawReader>), + #[cfg(not(any(unix, target_os = "wasi")))] + Stdin(io::Stdin), +} + +impl io::Read for CurrentReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self { + Self::File(f) => f.read(buf), + Self::Stdin(r) => r.read(buf), + } + } +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +impl rustix::fd::AsFd for CurrentReader { + fn as_fd(&self) -> rustix::fd::BorrowedFd<'_> { + match self { + Self::File(f) => f.as_fd(), + Self::Stdin(s) => s.0, + } + } } // MultifileReader - concatenate all our input, file or stdin. pub struct MultifileReader<'a> { ni: Vec>, - curr_file: Option>, + curr_file: Option, any_err: bool, file_name: Option<&'a str>, } @@ -59,7 +92,7 @@ impl MultifileReader<'_> { #[cfg(any(unix, target_os = "wasi"))] { let stdin = uucore::io::RawReader(rustix::stdio::stdin()); - self.curr_file = Some(Box::new(stdin)); + self.curr_file = Some(CurrentReader::Stdin(stdin)); } // For non-unix platforms we don't have GNU compatibility requirements, so @@ -68,8 +101,7 @@ impl MultifileReader<'_> { // doesn't seem worth worrying about at this time. #[cfg(not(any(unix, target_os = "wasi")))] { - let stdin = io::stdin(); - self.curr_file = Some(Box::new(stdin)); + self.curr_file = Some(CurrentReader::Stdin(io::stdin())); } break; } @@ -79,32 +111,110 @@ impl MultifileReader<'_> { self.file_name = Some(fname); // No need to wrap `f` in a BufReader - buffered reading is taken care // of elsewhere. - self.curr_file = Some(Box::new(f)); + self.curr_file = Some(CurrentReader::File(f)); break; } Err(e) => { // If any file can't be opened, // print an error at the time that the file is needed, - // then move to the next file. - // This matches the behavior of the original `od` - // Format error without OS error code to match GNU od + // then move to the next file let error_msg = match e.kind() { + #[cfg(windows)] io::ErrorKind::NotFound => "No such file or directory", - io::ErrorKind::PermissionDenied => "Permission denied", - _ => "I/O error", + _ => &strip_errno(&e), }; - show_error!("{}: {error_msg}", fname.maybe_quote().external(true)); + show_error!("{}: {error_msg}", fname.maybe_quote().external(true),); self.any_err = true; } } } - InputSource::Stream(s) => { - self.curr_file = Some(s); - break; - } } } } + + /// Skip `n_skip` bytes from the start of the combined input. + /// + /// A real file is positioned by `seek` whenever that is safe: a regular + /// file large enough that its reported size is trustworthy, or any seekable + /// special file (e.g. `/dev/null`, which can be skipped past its empty end). + /// Everything else - proc/sys files that report a bogus size, pipes, stdin - + /// is advanced by reading and discarding. Skipping past the end of the whole + /// input is an error, matching GNU `od`. + pub fn skip(&mut self, mut n_skip: u64) -> io::Result<()> { + while n_skip > 0 { + let Some(curr) = self.curr_file.as_mut() else { + break; + }; + n_skip = skip_in_file(curr, n_skip)?; + if n_skip == 0 { + break; + } + // Current file is exhausted; continue skipping in the next one. + self.next_file(); + } + + if n_skip > 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + translate!("od-error-skip-past-end"), + )); + } + Ok(()) + } +} + +/// Skip up to `n_skip` bytes within a single file. Returns the number of bytes +/// that still need to be skipped (0 if the skip landed inside this file, or +/// the remainder if the file ended first). +fn skip_in_file(curr: &mut CurrentReader, n_skip: u64) -> io::Result { + #[cfg(unix)] + if let CurrentReader::File(f) = curr + && let Ok(meta) = f.metadata() + { + let size = meta.len(); + let blksize = uucore::fs::sane_blksize::sane_blksize_from_metadata(&meta); + + // A regular file larger than a block reports a reliable size, so we + // can either drop the whole file or seek within it. Small or + // proc-like files lie about their size and fall through to reading. + let is_file = meta.is_file(); + if is_file && blksize < size { + if size < n_skip { + return Ok(n_skip - size); + } + if seek_forward(f, n_skip)? { + return Ok(0); + } + } else if !is_file { + // Seekable special files (character/block devices) can be + // skipped past their end without error. + if seek_forward(f, n_skip).unwrap_or(false) { + return Ok(0); + } + } + } + #[cfg(any(target_os = "linux", target_os = "android"))] + match uucore::pipes::discard_n_bytes(&curr, n_skip as usize) { + Ok(spliced) => Ok(n_skip - spliced as u64), + Err(spliced) => { + let read = + uucore::io::read_and_discard(curr, n_skip - spliced as u64, SKIP_BUFFER_SIZE)?; + Ok(n_skip - spliced as u64 - read) + } + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + Ok(n_skip - uucore::io::read_and_discard(curr, n_skip, SKIP_BUFFER_SIZE)?) +} + +/// Seek `f` forward by `n` bytes. Returns `Ok(true)` if the seek happened, or +/// `Ok(false)` if `n` is too large to express as a seek offset (the caller +/// should fall back to reading and discarding). +#[cfg(unix)] +fn seek_forward(f: &mut File, n: u64) -> io::Result { + match i64::try_from(n) { + Ok(off) => f.seek(SeekFrom::Current(off)).map(|_| true), + Err(_) => Ok(false), + } } impl io::Read for MultifileReader<'_> { @@ -129,7 +239,7 @@ impl io::Read for MultifileReader<'_> { show_error!( "{}: {}", self.file_name.unwrap_or("I/O"), - uucore::error::strip_errno(&e) + strip_errno(&e) ); self.any_err = true; break; @@ -155,17 +265,23 @@ impl HasError for MultifileReader<'_> { } #[cfg(test)] +#[cfg(not(target_os = "wasi"))] mod tests { use super::*; - use crate::mockstream::*; - use std::io::{Cursor, ErrorKind, Read}; + use std::io::{Read, Write as _}; + use tempfile::NamedTempFile; #[test] fn test_multi_file_reader_one_read() { - let inputs = vec![ - InputSource::Stream(Box::new(Cursor::new(&b"abcd"[..]))), - InputSource::Stream(Box::new(Cursor::new(&b"ABCD"[..]))), - ]; + let mut file1 = NamedTempFile::new().unwrap(); + file1.write_all(b"abcd").unwrap(); + let path1 = file1.path().to_str().unwrap(); + + let mut file2 = NamedTempFile::new().unwrap(); + file2.write_all(b"ABCD").unwrap(); + let path2 = file2.path().to_str().unwrap(); + + let inputs = vec![InputSource::FileName(path1), InputSource::FileName(path2)]; let mut v = [0; 10]; let mut sut = MultifileReader::new(inputs); @@ -177,62 +293,15 @@ mod tests { #[test] fn test_multi_file_reader_two_reads() { - let inputs = vec![ - InputSource::Stream(Box::new(Cursor::new(&b"abcd"[..]))), - InputSource::Stream(Box::new(Cursor::new(&b"ABCD"[..]))), - ]; - let mut v = [0; 5]; + let mut file1 = NamedTempFile::new().unwrap(); + file1.write_all(b"abcd").unwrap(); + let path1 = file1.path().to_str().unwrap(); - let mut sut = MultifileReader::new(inputs); + let mut file2 = NamedTempFile::new().unwrap(); + file2.write_all(b"ABCD").unwrap(); + let path2 = file2.path().to_str().unwrap(); - assert_eq!(sut.read(v.as_mut()).unwrap(), 5); - assert_eq!(v, [0x61, 0x62, 0x63, 0x64, 0x41]); - assert_eq!(sut.read(v.as_mut()).unwrap(), 3); - assert_eq!(v, [0x42, 0x43, 0x44, 0x64, 0x41]); // last two bytes are not overwritten - } - - #[test] - fn test_multi_file_reader_read_error() { - let c = Cursor::new(&b"1234"[..]) - .chain(FailingMockStream::new(ErrorKind::Other, "Failing", 1)) - .chain(Cursor::new(&b"5678"[..])); - let inputs = vec![ - InputSource::Stream(Box::new(c)), - InputSource::Stream(Box::new(Cursor::new(&b"ABCD"[..]))), - ]; - let mut v = [0; 5]; - - let mut sut = MultifileReader::new(inputs); - - assert_eq!(sut.read(v.as_mut()).unwrap(), 5); - assert_eq!(v, [49, 50, 51, 52, 65]); - assert_eq!(sut.read(v.as_mut()).unwrap(), 3); - assert_eq!(v, [66, 67, 68, 52, 65]); // last two bytes are not overwritten - - // note: no retry on i/o error, so 5678 is missing - } - - #[test] - fn test_multi_file_reader_read_error_at_start() { - let inputs = vec![ - InputSource::Stream(Box::new(FailingMockStream::new( - ErrorKind::Other, - "Failing", - 1, - ))), - InputSource::Stream(Box::new(Cursor::new(&b"abcd"[..]))), - InputSource::Stream(Box::new(FailingMockStream::new( - ErrorKind::Other, - "Failing", - 1, - ))), - InputSource::Stream(Box::new(Cursor::new(&b"ABCD"[..]))), - InputSource::Stream(Box::new(FailingMockStream::new( - ErrorKind::Other, - "Failing", - 1, - ))), - ]; + let inputs = vec![InputSource::FileName(path1), InputSource::FileName(path2)]; let mut v = [0; 5]; let mut sut = MultifileReader::new(inputs); diff --git a/src/uu/od/src/od.rs b/src/uu/od/src/od.rs index 0db92ff7bc6..a8d2defb1a6 100644 --- a/src/uu/od/src/od.rs +++ b/src/uu/od/src/od.rs @@ -4,15 +4,13 @@ // file that was distributed with this source code. // spell-checker:ignore (clap) dont -// spell-checker:ignore (ToDO) formatteriteminfo inputdecoder inputoffset mockstream nrofbytes partialreader odfunc multifile exitcode +// spell-checker:ignore (ToDO) formatteriteminfo inputdecoder inputoffset nrofbytes partialreader odfunc multifile exitcode // spell-checker:ignore Anone bfloat mod byteorder_io; mod formatter_item_info; mod input_decoder; mod input_offset; -#[cfg(test)] -mod mockstream; mod multifile_reader; mod output_info; mod parse_formats; @@ -25,7 +23,6 @@ mod prn_float; mod prn_int; use std::cmp; -use std::fmt::Write; use std::io::{BufReader, Read}; use crate::byteorder_io::ByteOrder; @@ -42,6 +39,8 @@ use crate::peek_reader::{PeekRead, PeekReader}; use crate::prn_char::format_ascii_dump; use clap::ArgAction; use clap::{Arg, ArgMatches, Command, parser::ValueSource}; +use std::ffi::OsString; +use uucore::diagnostics::OptionValue; use uucore::display::Quotable; use uucore::error::{UResult, USimpleError}; use uucore::translate; @@ -83,23 +82,32 @@ struct OdOptions { fn parse_bytes_option( matches: &ArgMatches, args: &[String], - option_name: &str, - short: Option, + diag_args: Option<&[OsString]>, + option_name: &'static str, + short: char, ) -> UResult> { match matches.get_one::(option_name) { None => Ok(None), Some(s) => match parse_number_of_bytes(s) { Ok(n) => Ok(Some(n)), - Err(e) => Err(USimpleError::new( - 1, - format_error_message(&e, s, &option_display_name(args, option_name, short)), - )), + Err(e) => { + let message = + format_error_message(&e, s, &option_display_name(args, option_name, short)); + let option = OptionValue::with_names(s, Some(short), Some(option_name)); + Err(e.size_value_error( + diag_args, + &option, + 0, + &message, + USimpleError::new(1, message.clone()), + )) + } }, } } impl OdOptions { - fn new(matches: &ArgMatches, args: &[String]) -> UResult { + fn new(matches: &ArgMatches, args: &[String], diag_args: Option<&[OsString]>) -> UResult { let byte_order = if let Some(s) = matches.get_one::(options::ENDIAN) { match s.as_str() { "little" => ByteOrder::Little, @@ -116,7 +124,7 @@ impl OdOptions { }; let mut skip_bytes = - parse_bytes_option(matches, args, options::SKIP_BYTES, Some('j'))?.unwrap_or(0); + parse_bytes_option(matches, args, diag_args, options::SKIP_BYTES, 'j')?.unwrap_or(0); let mut label: Option = None; @@ -136,15 +144,23 @@ impl OdOptions { matches.get_one::(options::WIDTH), matches.value_source(options::WIDTH), ) { - let width_display = option_display_name(args, options::WIDTH, Some('w')); - let parsed = parse_number_of_bytes(s) - .map_err(|e| USimpleError::new(1, format_error_message(&e, s, &width_display)))?; + let width_display = option_display_name(args, options::WIDTH, 'w'); + let parsed = parse_number_of_bytes(s).map_err(|e| { + let message = format_error_message(&e, s, &width_display); + e.size_value_error( + diag_args, + &OptionValue::new(s, 'w', options::WIDTH), + 0, + &message, + USimpleError::new(1, message.clone()), + ) + })?; if parsed == 0 { return Err(USimpleError::new( 1, translate!( "od-error-invalid-argument", - "option" => width_display.clone(), + "option" => width_display, "value" => s.quote() ), )); @@ -176,9 +192,10 @@ impl OdOptions { let output_duplicates = matches.get_flag(options::OUTPUT_DUPLICATES); - let read_bytes = parse_bytes_option(matches, args, options::READ_BYTES, Some('N'))?; + let read_bytes = parse_bytes_option(matches, args, diag_args, options::READ_BYTES, 'N')?; - let string_min_length = match parse_bytes_option(matches, args, options::STRINGS, Some('S'))? { + let strings = parse_bytes_option(matches, args, diag_args, options::STRINGS, 'S')?; + let string_min_length = match strings { None => None, Some(n) => Some(usize::try_from(n).map_err(|_| { USimpleError::new( @@ -237,12 +254,15 @@ impl OdOptions { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { let args = args.collect_ignore(); + let raw_args: Vec = args.iter().map(OsString::from).collect(); let clap_opts = uu_app(); let clap_matches = uucore::clap_localization::handle_clap_result(clap_opts, &args)?; - let od_options = OdOptions::new(&clap_matches, &args)?; + // Kept for the caret in SIZE diagnostics, which echoes the command line. + let diag_args = uucore::diagnostics::capture(&raw_args); + let od_options = OdOptions::new(&clap_matches, &args, diag_args.as_deref())?; let mut out = std::io::stdout().lock(); // Check if we're in strings mode @@ -263,7 +283,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { &od_options.input_strings, od_options.skip_bytes, od_options.read_bytes, - ); + )?; let mut input_decoder = InputDecoder::new( &mut input, od_options.line_bytes, @@ -315,14 +335,16 @@ pub fn uu_app() -> Command { .short('j') .long(options::SKIP_BYTES) .help(translate!("od-help-skip-bytes")) - .value_name("BYTES"), + .value_name("BYTES") + .allow_hyphen_values(true), ) .arg( Arg::new(options::READ_BYTES) .short('N') .long(options::READ_BYTES) .help(translate!("od-help-read-bytes")) - .value_name("BYTES"), + .value_name("BYTES") + .allow_hyphen_values(true), ) .arg( Arg::new(options::ENDIAN) @@ -591,9 +613,8 @@ fn extract_strings_from_input( let to_skip = cmp::min(8192, skip_bytes - skipped); let mut skip_buf = vec![0u8; to_skip as usize]; match mf.read(&mut skip_buf) { - Ok(0) => break, // EOF reached + Ok(0) | Err(_) => break, // 0 is EOF Ok(n) => skipped += n as u64, - Err(_) => break, } } @@ -671,6 +692,33 @@ fn extract_strings_from_input( } } +/// Writes `n` spaces to `writer` without allocating a string for them. +/// +/// The padding in front of an ascii dump grows with `-w`, which accepts huge +/// values, so it has to be written in chunks rather than materialized. +fn write_spaces(writer: &mut impl std::io::Write, n: usize) -> std::io::Result<()> { + const SPACES: [u8; 512] = [b' '; 512]; + + let mut remaining = n; + while remaining != 0 { + let chunk = cmp::min(remaining, SPACES.len()); + writer.write_all(&SPACES[..chunk])?; + remaining -= chunk; + } + Ok(()) +} + +/// Writes `s` to `writer`, adding its width in characters to `line_width`. +fn write_field( + writer: &mut impl std::io::Write, + s: &str, + line_width: &mut usize, +) -> std::io::Result<()> { + writer.write_all(s.as_bytes())?; + *line_width += s.chars().count(); + Ok(()) +} + /// Outputs a single line of input, into one or more lines human readable output. fn write_bytes( writer: &mut impl std::io::Write, @@ -680,37 +728,49 @@ fn write_bytes( ) -> std::io::Result<()> { let mut first = true; // First line of a multi-format raster. for f in output_info.spaced_formatters_iter() { - let mut output_text = String::new(); + if first { + write!(writer, "{prefix}")?; // print offset + // if printing in multiple formats offset is printed only once + first = false; + } else { + // this takes the space of the file offset on subsequent + // lines of multi-format rasters. + write_spaces(writer, prefix.chars().count())?; + } + // The formatted fields are written out as they are produced: a line + // holds up to `-w` bytes of input, so buffering it would allocate + // several times the width, which can be huge. + let mut line_width = 0; let mut b = 0; while b < input_decoder.length() { - write!( - output_text, - "{:>width$}", - "", - width = f.spacing[b % output_info.byte_size_block] - ) - .unwrap(); + let spacing = f.spacing[b % output_info.byte_size_block]; + write_spaces(writer, spacing)?; + line_width += spacing; match f.formatter_item_info.formatter { FormatWriter::IntWriter(func) => { let p = input_decoder.read_uint(b, f.formatter_item_info.byte_size); - output_text.push_str(&func(p)); + write_field(writer, &func(p), &mut line_width)?; } FormatWriter::FloatWriter(func) => { let p = input_decoder.read_float(b, f.formatter_item_info.byte_size); - output_text.push_str(&func(p)); + write_field(writer, &func(p), &mut line_width)?; } FormatWriter::LongDoubleWriter(func) => { let p = input_decoder.read_long_double(b); - output_text.push_str(&func(p)); + write_field(writer, &func(p), &mut line_width)?; } FormatWriter::BFloatWriter(func) => { let p = input_decoder.read_bfloat(b); - output_text.push_str(&func(p)); + write_field(writer, &func(p), &mut line_width)?; } FormatWriter::MultibyteWriter(func) => { - output_text.push_str(&func(input_decoder.get_full_buffer(b))); + write_field( + writer, + &func(input_decoder.get_full_buffer(b)), + &mut line_width, + )?; } } @@ -718,28 +778,11 @@ fn write_bytes( } if f.add_ascii_dump { - let missing_spacing = output_info - .print_width_line - .saturating_sub(output_text.chars().count()); - write!( - output_text, - "{:>missing_spacing$} {}", - "", - format_ascii_dump(input_decoder.get_buffer(0)), - ) - .unwrap(); + let missing_spacing = output_info.print_width_line.saturating_sub(line_width); + write_spaces(writer, missing_spacing + 2)?; + write!(writer, "{}", format_ascii_dump(input_decoder.get_buffer(0)))?; } - - if first { - write!(writer, "{prefix}")?; // print offset - // if printing in multiple formats offset is printed only once - first = false; - } else { - // this takes the space of the file offset on subsequent - // lines of multi-format rasters. - write!(writer, "{:>width$}", "", width = prefix.chars().count())?; - } - writeln!(writer, "{output_text}")?; + writeln!(writer)?; } Ok(()) } @@ -763,17 +806,19 @@ fn open_input_peek_reader( input_strings: &[String], skip_bytes: u64, read_bytes: Option, -) -> PeekReader>>> { +) -> UResult>>>> { // should return "impl PeekRead + Read + HasError" when supported in (stable) rust let inputs = map_input_strings(input_strings); - let mf = MultifileReader::new(inputs); - let pr = PartialReader::new(mf, skip_bytes, read_bytes); + let mut mf = MultifileReader::new(inputs); + mf.skip(skip_bytes) + .map_err(|e| USimpleError::new(1, e.to_string()))?; + let pr = PartialReader::new(mf, read_bytes); // Add a BufReader over the top of the PartialReader. This will have the // effect of generating buffered reads to files/stdin, but since these reads // go through MultifileReader (which limits the maximum number of bytes read) // we won't ever read more bytes than were specified with the `-N` flag. let buf_pr = BufReader::new(pr); - PeekReader::new(buf_pr) + Ok(PeekReader::new(buf_pr)) } impl HasError for BufReader { @@ -782,25 +827,20 @@ impl HasError for BufReader { } } -fn option_display_name(args: &[String], option_name: &str, short: Option) -> String { - let long_form = format!("--{option_name}"); - let long_form_with_eq = format!("{long_form}="); - if let Some(short_char) = short { - let short_form = format!("-{short_char}"); - for arg in args.iter().skip(1) { - if !arg.starts_with("--") && arg.starts_with(&short_form) { - return short_form; - } - } - for arg in args.iter().skip(1) { - if arg == &long_form || arg.starts_with(&long_form_with_eq) { +fn option_display_name(args: &[String], option_name: &str, short: char) -> String { + if let Some(args) = args.get(1..) { + let long_form = format!("--{option_name}"); + for arg in args { + if arg == &long_form + || arg + .strip_prefix(&long_form) + .is_some_and(|rest| rest.starts_with('=')) + { return long_form; } } - short_form - } else { - long_form } + format!("-{short}") } fn format_error_message(error: &ParseSizeError, s: &str, option: &str) -> String { diff --git a/src/uu/od/src/output_info.rs b/src/uu/od/src/output_info.rs index 0df17f5f9c9..d92c2ea6643 100644 --- a/src/uu/od/src/output_info.rs +++ b/src/uu/od/src/output_info.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore formatteriteminfo blocksize thisblock use std::cmp; diff --git a/src/uu/od/src/parse_formats.rs b/src/uu/od/src/parse_formats.rs index b6cd91b4d0d..8e0f28c07cf 100644 --- a/src/uu/od/src/parse_formats.rs +++ b/src/uu/od/src/parse_formats.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore formatteriteminfo docopt fvox fvoxw vals acdx use uucore::display::Quotable; @@ -37,25 +38,19 @@ impl ParsedFormatterItemInfo { fn od_argument_traditional_format(ch: char) -> Option { match ch { 'a' => Some(FORMAT_ITEM_A), - 'B' => Some(FORMAT_ITEM_OCT16), + 'B' | 'o' => Some(FORMAT_ITEM_OCT16), 'b' => Some(FORMAT_ITEM_OCT8), 'c' => Some(FORMAT_ITEM_C), 'D' => Some(FORMAT_ITEM_DEC32U), 'd' => Some(FORMAT_ITEM_DEC16U), - 'e' => Some(FORMAT_ITEM_F64), - 'F' => Some(FORMAT_ITEM_F64), + 'e' | 'F' => Some(FORMAT_ITEM_F64), 'f' => Some(FORMAT_ITEM_F32), - 'H' => Some(FORMAT_ITEM_HEX32), - 'h' => Some(FORMAT_ITEM_HEX16), + 'H' | 'X' => Some(FORMAT_ITEM_HEX32), + 'h' | 'x' => Some(FORMAT_ITEM_HEX16), 'i' => Some(FORMAT_ITEM_DEC32S), - 'I' => Some(FORMAT_ITEM_DEC64S), - 'L' => Some(FORMAT_ITEM_DEC64S), - 'l' => Some(FORMAT_ITEM_DEC64S), + 'I' | 'L' | 'l' => Some(FORMAT_ITEM_DEC64S), 'O' => Some(FORMAT_ITEM_OCT32), - 'o' => Some(FORMAT_ITEM_OCT16), 's' => Some(FORMAT_ITEM_DEC16S), - 'X' => Some(FORMAT_ITEM_HEX32), - 'x' => Some(FORMAT_ITEM_HEX16), _ => None, } } @@ -87,8 +82,7 @@ fn od_format_type(type_char: FormatType, byte_size: u8) -> Option Some(FORMAT_ITEM_F16), (FormatType::Float, 4) => Some(FORMAT_ITEM_F32), - (FormatType::Float, 0) => Some(FORMAT_ITEM_F64), - (FormatType::Float, 8) => Some(FORMAT_ITEM_F64), + (FormatType::Float, 0) | (FormatType::Float, 8) => Some(FORMAT_ITEM_F64), (FormatType::Float, 16) => Some(FORMAT_ITEM_LONG_DOUBLE), _ => None, @@ -222,31 +216,18 @@ fn is_format_size_char( *byte_size = 1; true } - (FormatTypeCategory::Integer, Some('S')) => { + (FormatTypeCategory::Integer, Some('S')) | (FormatTypeCategory::Float, Some('H' | 'B')) => { *byte_size = 2; true } - (FormatTypeCategory::Integer, Some('I')) => { + (FormatTypeCategory::Integer, Some('I')) | (FormatTypeCategory::Float, Some('F')) => { *byte_size = 4; true } - (FormatTypeCategory::Integer, Some('L')) => { + (FormatTypeCategory::Integer, Some('L')) | (FormatTypeCategory::Float, Some('D')) => { *byte_size = 8; true } - - (FormatTypeCategory::Float, Some('F')) => { - *byte_size = 4; - true - } - (FormatTypeCategory::Float, Some('D')) => { - *byte_size = 8; - true - } - (FormatTypeCategory::Float, Some('H' | 'B')) => { - *byte_size = 2; - true - } (FormatTypeCategory::Float, Some('L')) => { *byte_size = 16; true diff --git a/src/uu/od/src/parse_inputs.rs b/src/uu/od/src/parse_inputs.rs index 414e11768ab..99e633c2f29 100644 --- a/src/uu/od/src/parse_inputs.rs +++ b/src/uu/od/src/parse_inputs.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use super::options; use clap::ArgMatches; use uucore::translate; @@ -71,13 +72,14 @@ pub fn parse_inputs(matches: &dyn CommandLineOpts) -> Result { - // if there is just 1 input (stdin), an offset must start with '+' - if input_strings.len() == 1 && input_strings[0].starts_with('+') { + if let [first] = input_strings[..] + && first.starts_with('+') + { return Ok(CommandLineInputs::FileAndOffset(("-".to_string(), n, None))); } - if input_strings.len() == 2 { + if let [first, _] = input_strings[..] { return Ok(CommandLineInputs::FileAndOffset(( - input_strings[0].to_string(), + first.to_string(), n, None, ))); diff --git a/src/uu/od/src/parse_nrofbytes.rs b/src/uu/od/src/parse_nrofbytes.rs index 241e1c6e7ca..a7b10950985 100644 --- a/src/uu/od/src/parse_nrofbytes.rs +++ b/src/uu/od/src/parse_nrofbytes.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use uucore::parser::parse_size::{ParseSizeError, parse_size_u64}; pub fn parse_number_of_bytes(s: &str) -> Result { @@ -69,10 +70,8 @@ pub fn parse_number_of_bytes(s: &str) -> Result { _ => {} } - let factor = match u64::from_str_radix(&s[start..len], radix) { - Ok(f) => f, - Err(e) => return Err(ParseSizeError::ParseFailure(e.to_string())), - }; + let factor = u64::from_str_radix(&s[start..len], radix) + .map_err(|e| ParseSizeError::ParseFailure(e.to_string()))?; factor .checked_mul(multiply) .ok_or_else(|| ParseSizeError::SizeTooBig(s.to_string())) diff --git a/src/uu/od/src/partial_reader.rs b/src/uu/od/src/partial_reader.rs index 31367181a85..4a182c8aa2c 100644 --- a/src/uu/od/src/partial_reader.rs +++ b/src/uu/od/src/partial_reader.rs @@ -3,65 +3,31 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore mockstream abcdefgh bcdefgh multifile +// spell-checker:ignore abcdefgh bcdefgh multifile -use std::cmp; use std::io; use std::io::Read; use crate::multifile_reader::HasError; -use uucore::translate; -/// When a large number of bytes must be skipped, it will be read into a -/// dynamically allocated buffer. The buffer will be limited to this size. -const MAX_SKIP_BUFFER: usize = 16 * 1024; - -/// Wrapper for `std::io::Read` which can skip bytes at the beginning -/// of the input, and it can limit the returned bytes to a particular -/// number of bytes. +/// Wrapper for `std::io::Read` which limits the returned bytes to a particular +/// number of bytes. Skipping leading bytes is handled upstream by +/// `MultifileReader::skip`, which can seek seekable inputs. pub struct PartialReader { inner: R, - skip: u64, limit: Option, } impl PartialReader { - /// Create a new `PartialReader` wrapping `inner`, which will skip - /// `skip` bytes, and limits the output to `limit` bytes. Set `limit` - /// to `None` if there should be no limit. - pub fn new(inner: R, skip: u64, limit: Option) -> Self { - Self { inner, skip, limit } + /// Create a new `PartialReader` wrapping `inner`, limiting the output to + /// `limit` bytes. Set `limit` to `None` if there should be no limit. + pub fn new(inner: R, limit: Option) -> Self { + Self { inner, limit } } } impl Read for PartialReader { fn read(&mut self, out: &mut [u8]) -> io::Result { - if self.skip > 0 { - let mut bytes = [0; MAX_SKIP_BUFFER]; - - while self.skip > 0 { - let skip_count: usize = cmp::min(self.skip as usize, MAX_SKIP_BUFFER); - - loop { - match self.inner.read(&mut bytes[..skip_count]) { - Ok(0) => { - // this is an error as we still have more to skip - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - translate!("od-error-skip-past-end"), - )); - } - Ok(n) => { - self.skip -= n as u64; - break; - } - Err(e) if e.kind() == io::ErrorKind::Interrupted => {} - Err(e) => return Err(e), - } - } - } - } - match self.limit { None => loop { match self.inner.read(out) { @@ -100,81 +66,21 @@ impl HasError for PartialReader { #[cfg(test)] mod tests { use super::*; - use crate::mockstream::*; - use std::io::{Cursor, ErrorKind, Read}; + use std::io::Cursor; #[test] fn test_read_without_limits() { let mut v = [0; 10]; - let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), 0, None); + let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), None); assert_eq!(sut.read(v.as_mut()).unwrap(), 8); assert_eq!(v, [0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0, 0]); } - #[test] - fn test_read_without_limits_with_error() { - let mut v = [0; 10]; - let f = FailingMockStream::new(ErrorKind::PermissionDenied, "No access", 3); - let mut sut = PartialReader::new(f, 0, None); - - let error = sut.read(v.as_mut()).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::PermissionDenied); - assert_eq!(error.to_string(), "No access"); - } - - #[test] - fn test_read_skipping_bytes() { - let mut v = [0; 10]; - let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), 2, None); - - assert_eq!(sut.read(v.as_mut()).unwrap(), 6); - assert_eq!(v, [0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0, 0, 0, 0]); - } - - #[test] - fn test_read_skipping_all() { - let mut v = [0; 10]; - let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), 20, None); - - let error = sut.read(v.as_mut()).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::UnexpectedEof); - } - - #[test] - fn test_read_skipping_with_error() { - let mut v = [0; 10]; - let f = FailingMockStream::new(ErrorKind::PermissionDenied, "No access", 3); - let mut sut = PartialReader::new(f, 2, None); - - let error = sut.read(v.as_mut()).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::PermissionDenied); - assert_eq!(error.to_string(), "No access"); - } - - #[test] - fn test_read_skipping_with_two_reads_during_skip() { - let mut v = [0; 10]; - let c = Cursor::new(&b"a"[..]).chain(Cursor::new(&b"bcdefgh"[..])); - let mut sut = PartialReader::new(c, 2, None); - - assert_eq!(sut.read(v.as_mut()).unwrap(), 6); - assert_eq!(v, [0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0, 0, 0, 0]); - } - - #[test] - fn test_read_skipping_huge_number() { - let mut v = [0; 10]; - // test if it does not eat all memory.... - let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), u64::MAX, None); - - sut.read(v.as_mut()).unwrap_err(); - } - #[test] fn test_read_limiting_all() { let mut v = [0; 10]; - let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), 0, Some(0)); + let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), Some(0)); assert_eq!(sut.read(v.as_mut()).unwrap(), 0); } @@ -182,36 +88,16 @@ mod tests { #[test] fn test_read_limiting() { let mut v = [0; 10]; - let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), 0, Some(6)); + let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), Some(6)); assert_eq!(sut.read(v.as_mut()).unwrap(), 6); assert_eq!(v, [0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0, 0, 0, 0]); } - #[test] - fn test_read_limiting_with_error() { - let mut v = [0; 10]; - let f = FailingMockStream::new(ErrorKind::PermissionDenied, "No access", 3); - let mut sut = PartialReader::new(f, 0, Some(6)); - - let error = sut.read(v.as_mut()).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::PermissionDenied); - assert_eq!(error.to_string(), "No access"); - } - - #[test] - fn test_read_limiting_with_large_limit() { - let mut v = [0; 10]; - let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), 0, Some(20)); - - assert_eq!(sut.read(v.as_mut()).unwrap(), 8); - assert_eq!(v, [0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0, 0]); - } - #[test] fn test_read_limiting_with_multiple_reads() { let mut v = [0; 3]; - let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), 0, Some(6)); + let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), Some(6)); assert_eq!(sut.read(v.as_mut()).unwrap(), 3); assert_eq!(v, [0x61, 0x62, 0x63]); @@ -219,13 +105,4 @@ mod tests { assert_eq!(v, [0x64, 0x65, 0x66]); assert_eq!(sut.read(v.as_mut()).unwrap(), 0); } - - #[test] - fn test_read_skipping_and_limiting() { - let mut v = [0; 10]; - let mut sut = PartialReader::new(Cursor::new(&b"abcdefgh"[..]), 2, Some(4)); - - assert_eq!(sut.read(v.as_mut()).unwrap(), 4); - assert_eq!(v, [0x63, 0x64, 0x65, 0x66, 0, 0, 0, 0, 0, 0]); - } } diff --git a/src/uu/od/src/peek_reader.rs b/src/uu/od/src/peek_reader.rs index 6287306ef76..45e3a70af7a 100644 --- a/src/uu/od/src/peek_reader.rs +++ b/src/uu/od/src/peek_reader.rs @@ -68,7 +68,7 @@ impl PeekReader { fn write_to_tempbuffer(&mut self, bytes: &[u8]) { // if temp_buffer is not empty, data has to be inserted in front - let org_buffer: Vec<_> = self.temp_buffer.drain(..).collect(); + let org_buffer = std::mem::take(&mut self.temp_buffer); self.temp_buffer.write_all(bytes).unwrap(); self.temp_buffer.extend(org_buffer); } diff --git a/src/uu/od/src/prn_float.rs b/src/uu/od/src/prn_float.rs index c93b02d25cb..c26e02ba925 100644 --- a/src/uu/od/src/prn_float.rs +++ b/src/uu/od/src/prn_float.rs @@ -2,6 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + +// spell-checker:ignore dtoastr ftoastr + use half::{bf16, f16}; use std::num::FpCategory; @@ -166,19 +169,92 @@ fn is_subnormal_bf16(value: bf16) -> bool { /// formats float with 8 significant digits, eg 12345678 or -1.2345678e+12 /// always returns a string of 14 characters fn format_f32(f: f32) -> String { - let width: usize = 15; - let precision: usize = 8; - + // Use shortest round-trip decimal representation, matching GNU od's ftoastr. + // Right-padded to 15 chars (format_item_f32 prepends one space -> 16 total). + if f.is_nan() { + return format!("{:>15}", "NaN"); + } + if f.is_infinite() { + return if f.is_sign_negative() { + format!("{:>15}", "-inf") + } else { + format!("{:>15}", "inf") + }; + } + if f == 0.0 { + return if f.is_sign_negative() { + format!("{:>15}", "-0") + } else { + format!("{:>15}", "0") + }; + } if f.classify() == FpCategory::Subnormal { - // subnormal numbers will be normal as f64, so will print with a wrong precision - format_f32_exp(f, width) // subnormal numbers + return format_f32_exp(f, 15); + } + let s = shortest_float_str_f32(f); + format!("{s:>15}") +} + +/// Return the shortest decimal string that round-trips back to `f`. +/// Uses Display for short values, Debug (scientific) for large/small ones. +fn shortest_float_str_f32(f: f32) -> String { + let display = format!("{f}"); + let debug = format!("{f:?}"); + let candidate = if display.len() <= debug.len() { + display + } else { + debug + }; + // GNU ftoastr uses 'e+' for positive exponents, not bare 'e' + if candidate.contains('e') && !candidate.contains("e-") { + candidate.replace('e', "e+") } else { - format_float(f64::from(f), width, precision) + candidate } } fn format_f64(f: f64) -> String { - format_float(f, 24, 17) + // Use shortest round-trip decimal representation, matching GNU od's dtoastr. + // Right-padded to 24 chars (format_item_f64 prepends one space -> 25 total). + if f.is_nan() { + return format!("{:>24}", "NaN"); + } + if f.is_infinite() { + return if f.is_sign_negative() { + format!("{:>24}", "-inf") + } else { + format!("{:>24}", "inf") + }; + } + if f == 0.0 { + return if f.is_sign_negative() { + format!("{:>24}", "-0") + } else { + format!("{:>24}", "0") + }; + } + if f.classify() == FpCategory::Subnormal { + let s = format!("{f:e}"); + let s = if s.contains('e') && !s.contains("e-") { + s.replace('e', "e+") + } else { + s + }; + return format!("{s:>24}"); + } + let display = format!("{f}"); + let debug = format!("{f:?}"); + let candidate = if display.len() <= debug.len() { + display + } else { + debug + }; + let candidate = if candidate.contains('e') && !candidate.contains("e-") { + candidate.replace('e', "e+") + } else { + candidate + }; + format!("{candidate:>24}") } fn format_float(f: f64, width: usize, precision: usize) -> String { @@ -239,75 +315,35 @@ fn format_long_double(f: f64) -> String { #[test] #[allow(clippy::excessive_precision)] -#[allow(clippy::cognitive_complexity)] fn test_format_f32() { - assert_eq!(format_f32(1.0), " 1.0000000"); - assert_eq!(format_f32(9.999_999_0), " 9.9999990"); - assert_eq!(format_f32(10.0), " 10.000000"); - assert_eq!(format_f32(99.999_977), " 99.999977"); - assert_eq!(format_f32(99.999_992), " 99.999992"); - assert_eq!(format_f32(100.0), " 100.00000"); - assert_eq!(format_f32(999.99994), " 999.99994"); - assert_eq!(format_f32(1000.0), " 1000.0000"); - assert_eq!(format_f32(9999.9990), " 9999.9990"); - assert_eq!(format_f32(10000.0), " 10000.000"); - assert_eq!(format_f32(99999.992), " 99999.992"); - assert_eq!(format_f32(100_000.0), " 100000.00"); - assert_eq!(format_f32(999_999.94), " 999999.94"); - assert_eq!(format_f32(1_000_000.0), " 1000000.0"); - assert_eq!(format_f32(9_999_999.0), " 9999999.0"); + // Shortest round-trip representation, right-padded to 15 chars. + // Values are rendered without trailing zeros, matching GNU od's ftoastr. + assert_eq!(format_f32(1.0), " 1"); + assert_eq!(format_f32(10.0), " 10"); + assert_eq!(format_f32(100.0), " 100"); + assert_eq!(format_f32(1000.0), " 1000"); + assert_eq!(format_f32(10000.0), " 10000"); + assert_eq!(format_f32(100_000.0), " 100000"); + assert_eq!(format_f32(1_000_000.0), " 1000000"); + assert_eq!(format_f32(9_999_999.0), " 9999999"); assert_eq!(format_f32(10_000_000.0), " 10000000"); - assert_eq!(format_f32(99_999_992.0), " 99999992"); - assert_eq!(format_f32(100_000_000.0), " 1.0000000e+8"); - assert_eq!(format_f32(9.999_999_4e8), " 9.9999994e+8"); - assert_eq!(format_f32(1.0e9), " 1.0000000e+9"); - assert_eq!(format_f32(9.999_999_0e9), " 9.9999990e+9"); - assert_eq!(format_f32(1.0e10), " 1.0000000e+10"); - - assert_eq!(format_f32(0.1), " 0.10000000"); - assert_eq!(format_f32(0.999_999_94), " 0.99999994"); - assert_eq!(format_f32(0.010_000_001), " 1.0000001e-2"); - assert_eq!(format_f32(0.099_999_994), " 9.9999994e-2"); - assert_eq!(format_f32(0.001), " 1.0000000e-3"); - assert_eq!(format_f32(0.009_999_999_8), " 9.9999998e-3"); - - assert_eq!(format_f32(-1.0), " -1.0000000"); - assert_eq!(format_f32(-9.999_999_0), " -9.9999990"); - assert_eq!(format_f32(-10.0), " -10.000000"); - assert_eq!(format_f32(-99.999_977), " -99.999977"); - assert_eq!(format_f32(-99.999_992), " -99.999992"); - assert_eq!(format_f32(-100.0), " -100.00000"); - assert_eq!(format_f32(-999.99994), " -999.99994"); - assert_eq!(format_f32(-1000.0), " -1000.0000"); - assert_eq!(format_f32(-9999.9990), " -9999.9990"); - assert_eq!(format_f32(-10000.0), " -10000.000"); - assert_eq!(format_f32(-99999.992), " -99999.992"); - assert_eq!(format_f32(-100_000.0), " -100000.00"); - assert_eq!(format_f32(-999_999.94), " -999999.94"); - assert_eq!(format_f32(-1_000_000.0), " -1000000.0"); - assert_eq!(format_f32(-9_999_999.0), " -9999999.0"); - assert_eq!(format_f32(-10_000_000.0), " -10000000"); - assert_eq!(format_f32(-99_999_992.0), " -99999992"); - assert_eq!(format_f32(-100_000_000.0), " -1.0000000e+8"); - assert_eq!(format_f32(-9.999_999_4e8), " -9.9999994e+8"); - assert_eq!(format_f32(-1.0e9), " -1.0000000e+9"); - assert_eq!(format_f32(-9.999_999_0e9), " -9.9999990e+9"); - assert_eq!(format_f32(-1.0e10), " -1.0000000e+10"); - - assert_eq!(format_f32(-0.1), " -0.10000000"); - assert_eq!(format_f32(-0.999_999_94), " -0.99999994"); - assert_eq!(format_f32(-0.010_000_001), " -1.0000001e-2"); - assert_eq!(format_f32(-0.099_999_994), " -9.9999994e-2"); - assert_eq!(format_f32(-0.001), " -1.0000000e-3"); - assert_eq!(format_f32(-0.009_999_999_8), " -9.9999998e-3"); - + assert_eq!(format_f32(99_999_992.0), " 99999990"); + assert_eq!(format_f32(100_000_000.0), " 100000000"); + assert_eq!(format_f32(1.0e9), " 1000000000"); + assert_eq!(format_f32(1.0e10), " 10000000000"); + + assert_eq!(format_f32(0.1), " 0.1"); + assert_eq!(format_f32(0.001), " 0.001"); + assert_eq!(format_f32(1e-4_f32), " 0.0001"); + assert_eq!(format_f32(1e-5_f32), " 1e-5"); + + assert_eq!(format_f32(-1.0), " -1"); + assert_eq!(format_f32(-10.0), " -10"); + assert_eq!(format_f32(-0.1), " -0.1"); assert_eq!(format_f32(3.402_823_3e38), " 3.4028233e+38"); assert_eq!(format_f32(-3.402_823_3e38), " -3.4028233e+38"); - assert_eq!(format_f32(-1.166_310_8e-38), " -1.1663108e-38"); - assert_eq!(format_f32(-4.701_977_1e-38), " -4.7019771e-38"); assert_eq!(format_f32(1e-45), " 1e-45"); - assert_eq!(format_f32(-3.402_823_466e+38), " -3.4028235e+38"); assert_eq!(format_f32(f32::NAN), " NaN"); assert_eq!(format_f32(f32::INFINITY), " inf"); assert_eq!(format_f32(f32::NEG_INFINITY), " -inf"); @@ -316,25 +352,32 @@ fn test_format_f32() { } #[test] -#[allow(clippy::cognitive_complexity)] fn test_format_f64() { - assert_eq!(format_f64(1.0), " 1.0000000000000000"); - assert_eq!(format_f64(10.0), " 10.000000000000000"); + // Shortest round-trip representation, right-padded to 24 chars. + // Matches GNU od 9.11 output which uses dtoastr (shortest decimal). + assert_eq!(format_f64(1.0), " 1"); + assert_eq!(format_f64(10.0), " 10"); assert_eq!( format_f64(1_000_000_000_000_000.0), - " 1000000000000000.0" + " 1000000000000000" ); assert_eq!( format_f64(10_000_000_000_000_000.0), - " 10000000000000000" + " 1e+16" ); assert_eq!( format_f64(100_000_000_000_000_000.0), - " 1.0000000000000000e+17" + " 1e+17" ); - assert_eq!(format_f64(-0.1), " -0.10000000000000001"); - assert_eq!(format_f64(-0.01), " -1.0000000000000000e-2"); + assert_eq!(format_f64(-0.1), " -0.1"); + assert_eq!(format_f64(-0.01), " -0.01"); + + // od-float.sh test 7: -t f / -t fD with 8-byte little-endian input + let d = f64::from_bits(u64::from_le_bytes([ + 0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x40, + ])); + assert_eq!(format_f64(d), " 2.000000473111868"); assert_eq!( format_f64(-2.225_073_858_507_201_4e-308), diff --git a/src/uu/od/src/prn_int.rs b/src/uu/od/src/prn_int.rs index 8e59f57b7b5..9f358e8cb4a 100644 --- a/src/uu/od/src/prn_int.rs +++ b/src/uu/od/src/prn_int.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use crate::formatter_item_info::{FormatWriter, FormatterItemInfo}; /// format string to print octal using `int_writer_unsigned` diff --git a/src/uu/paste/Cargo.toml b/src/uu/paste/Cargo.toml index dbb1bfdf577..a984c0ee0a3 100644 --- a/src/uu/paste/Cargo.toml +++ b/src/uu/paste/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/paste.rs" test = false @@ -21,8 +18,11 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["i18n-charmap"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["i18n-charmap"] } + +[lints] +workspace = true [[bin]] name = "paste" diff --git a/src/uu/paste/src/paste.rs b/src/uu/paste/src/paste.rs index 89408f9218c..00e62cab2e8 100644 --- a/src/uu/paste/src/paste.rs +++ b/src/uu/paste/src/paste.rs @@ -48,6 +48,8 @@ pub fn uu_app() -> Command { .about(translate!("paste-about")) .override_usage(format_usage(&translate!("paste-usage"))) .infer_long_args(true) + // GNU lets a later -d override an earlier one. + .args_override_self(true) .arg( Arg::new(options::SERIAL) .long(options::SERIAL) @@ -63,6 +65,7 @@ pub fn uu_app() -> Command { .value_name("LIST") .default_value("\t") .hide_default_value(true) + .allow_hyphen_values(true) .value_parser(clap::value_parser!(OsString)), ) .arg( @@ -138,12 +141,8 @@ fn paste( for input_source in &mut input_source_vec { output.clear(); - loop { - if input_source.read_until(line_ending_byte, &mut output)? == 0 { - break; - } - remove_trailing_line_ending_byte(line_ending_byte, &mut output); - + while let read @ 1.. = input_source.read_until(line_ending_byte, &mut output)? { + remove_trailing_line_ending_byte(line_ending_byte, read, &mut output); delimiter_state.write_delimiter(&mut output); } @@ -174,8 +173,8 @@ fn paste( eof[i] = true; eof_count += 1; } - _ => { - remove_trailing_line_ending_byte(line_ending_byte, &mut output); + read => { + remove_trailing_line_ending_byte(line_ending_byte, read, &mut output); } } } @@ -213,13 +212,7 @@ fn write_single_input_source( let mut has_data = false; let mut last_byte = line_ending_byte; - loop { - let bytes_read = input_source.read(&mut buffer)?; - - if bytes_read == 0 { - break; - } - + while let bytes_read @ 1.. = input_source.read(&mut buffer)? { has_data = true; last_byte = buffer[bytes_read - 1]; @@ -259,7 +252,7 @@ fn parse_delimiters(delimiters: &OsString) -> UResult]>> { _ => { // Unknown escape: strip backslash, use the following character(s) let remaining = &bytes[i..]; - let len = mb_char_len(remaining).min(remaining.len()); + let len = mb_char_len(remaining); vec.push(Box::from(&bytes[i..i + len])); i += len; continue; @@ -268,7 +261,7 @@ fn parse_delimiters(delimiters: &OsString) -> UResult]>> { i += 1; } else { let remaining = &bytes[i..]; - let len = mb_char_len(remaining).min(remaining.len()); + let len = mb_char_len(remaining); vec.push(Box::from(&bytes[i..i + len])); i += len; } @@ -277,8 +270,14 @@ fn parse_delimiters(delimiters: &OsString) -> UResult]>> { Ok(vec.into_boxed_slice()) } -fn remove_trailing_line_ending_byte(line_ending_byte: u8, output: &mut Vec) { - let _ = output.pop_if(|byte| *byte == line_ending_byte); +fn remove_trailing_line_ending_byte(line_ending_byte: u8, read: usize, output: &mut Vec) { + if output.pop_if(|byte| *byte == line_ending_byte).is_none() { + return; + } + + if cfg!(windows) && line_ending_byte == b'\n' && read > 1 { + let _ = output.pop_if(|byte| *byte == b'\r'); + } } enum DelimiterState<'a> { diff --git a/src/uu/pathchk/Cargo.toml b/src/uu/pathchk/Cargo.toml index 528594e2924..dde778695c3 100644 --- a/src/uu/pathchk/Cargo.toml +++ b/src/uu/pathchk/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/pathchk.rs" test = false @@ -21,9 +18,12 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } libc = { workspace = true } uucore = { workspace = true } -fluent = { workspace = true } + +[lints] +workspace = true [[bin]] name = "pathchk" diff --git a/src/uu/pathchk/locales/en-US.ftl b/src/uu/pathchk/locales/en-US.ftl index ee5dad5c0c4..681ef1878dd 100644 --- a/src/uu/pathchk/locales/en-US.ftl +++ b/src/uu/pathchk/locales/en-US.ftl @@ -7,12 +7,11 @@ pathchk-help-posix-special = check for empty names and leading "-" pathchk-help-portability = check for all POSIX systems (equivalent to -p -P) # Error messages -pathchk-error-missing-operand = missing operand pathchk-error-empty-file-name = empty file name pathchk-error-posix-path-length-exceeded = limit { $limit } exceeded by length { $length } of file name { $path } pathchk-error-posix-name-length-exceeded = limit { $limit } exceeded by length { $length } of file name component { $component } pathchk-error-leading-hyphen = leading hyphen in file name component { $component } pathchk-error-path-length-exceeded = limit { $limit } exceeded by length { $length } of file name { $path } pathchk-error-name-length-exceeded = limit { $limit } exceeded by length { $length } of file name component { $component } -pathchk-error-empty-path-not-found = pathchk: '': No such file or directory +pathchk-error-empty-path-not-found = '': No such file or directory pathchk-error-nonportable-character = nonportable character '{ $character }' in file name component { $component } diff --git a/src/uu/pathchk/locales/fr-FR.ftl b/src/uu/pathchk/locales/fr-FR.ftl index 31eaaa6565d..19bf9a9e1bb 100644 --- a/src/uu/pathchk/locales/fr-FR.ftl +++ b/src/uu/pathchk/locales/fr-FR.ftl @@ -7,12 +7,11 @@ pathchk-help-posix-special = vérifier les noms vides et les "-" en début pathchk-help-portability = vérifier pour tous les systèmes POSIX (équivalent à -p -P) # Messages d'erreur -pathchk-error-missing-operand = opérande manquant pathchk-error-empty-file-name = nom de fichier vide pathchk-error-posix-path-length-exceeded = limite { $limit } dépassée par la longueur { $length } du nom de fichier { $path } pathchk-error-posix-name-length-exceeded = limite { $limit } dépassée par la longueur { $length } du composant de nom de fichier { $component } pathchk-error-leading-hyphen = tiret en début dans le composant de nom de fichier { $component } pathchk-error-path-length-exceeded = limite { $limit } dépassée par la longueur { $length } du nom de fichier { $path } pathchk-error-name-length-exceeded = limite { $limit } dépassée par la longueur { $length } du composant de nom de fichier { $component } -pathchk-error-empty-path-not-found = pathchk: '' : Aucun fichier ou répertoire de ce type +pathchk-error-empty-path-not-found = '' : Aucun fichier ou répertoire de ce type pathchk-error-nonportable-character = caractère non portable '{ $character }' dans le composant de nom de fichier { $component } diff --git a/src/uu/pathchk/src/pathchk.rs b/src/uu/pathchk/src/pathchk.rs index 635ae6e9820..c7813c08c71 100644 --- a/src/uu/pathchk/src/pathchk.rs +++ b/src/uu/pathchk/src/pathchk.rs @@ -2,16 +2,18 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -#![allow(unused_must_use)] // because we of writeln! // spell-checker:ignore (ToDO) lstat + use clap::{Arg, ArgAction, Command}; use std::ffi::OsString; use std::fs; -use std::io::{ErrorKind, Write}; +use std::io::ErrorKind; use uucore::display::Quotable; -use uucore::error::{UResult, UUsageError, set_exit_code}; +use uucore::error::strip_errno; +use uucore::error::{UResult, set_exit_code}; use uucore::format_usage; +use uucore::show_error; use uucore::translate; // operating mode @@ -68,12 +70,6 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // take necessary actions let paths = matches.get_many::(options::PATH); - if paths.is_none() { - return Err(UUsageError::new( - 1, - translate!("pathchk-error-missing-operand"), - )); - } // free strings are path operands // FIXME: TCS, seems inefficient and overly verbose (?) @@ -124,12 +120,19 @@ pub fn uu_app() -> Command { .hide(true) .action(ArgAction::Append) .value_hint(clap::ValueHint::AnyPath) + .required(true) .value_parser(clap::value_parser!(OsString)), ) } /// check a path, given as a slice of it's components and an operating mode fn check_path(mode: &Mode, path: &[String]) -> bool { + // GNU rejects an empty file name in any portability mode before touching the filesystem. + if !matches!(mode, Mode::Default) && path.join("/").is_empty() { + show_error!("{}", translate!("pathchk-error-empty-file-name")); + return false; + } + match *mode { Mode::Basic => check_basic(path), Mode::Extra => check_default(path) && check_extra(path), @@ -144,26 +147,18 @@ fn check_basic(path: &[String]) -> bool { let total_len = joined_path.len(); // path length if total_len > POSIX_PATH_MAX { - writeln!( - std::io::stderr(), + show_error!( "{}", translate!("pathchk-error-posix-path-length-exceeded", "limit" => POSIX_PATH_MAX, "length" => total_len, "path" => joined_path) ); return false; - } else if total_len == 0 { - writeln!( - std::io::stderr(), - "{}", - translate!("pathchk-error-empty-file-name") - ); - return false; } + // components: character portability and length for p in path { let component_len = p.len(); if component_len > POSIX_NAME_MAX { - writeln!( - std::io::stderr(), + show_error!( "{}", translate!("pathchk-error-posix-name-length-exceeded", "limit" => POSIX_NAME_MAX, "length" => component_len, "component" => p.quote()) ); @@ -180,22 +175,10 @@ fn check_basic(path: &[String]) -> bool { /// check a path in extra compatibility mode fn check_extra(path: &[String]) -> bool { // components: leading hyphens - for p in path { - if p.starts_with('-') { - writeln!( - std::io::stderr(), - "{}", - translate!("pathchk-error-leading-hyphen", "component" => p.quote()) - ); - return false; - } - } - // path length - if path.join("/").is_empty() { - writeln!( - std::io::stderr(), + if let Some(p) = path.iter().find(|p| p.starts_with('-')) { + show_error!( "{}", - translate!("pathchk-error-empty-file-name") + translate!("pathchk-error-leading-hyphen", "component" => p.quote()) ); return false; } @@ -208,24 +191,19 @@ fn check_default(path: &[String]) -> bool { let total_len = joined_path.len(); // path length if total_len > PATH_MAX { - writeln!( - std::io::stderr(), + show_error!( "{}", translate!("pathchk-error-path-length-exceeded", "limit" => PATH_MAX, "length" => total_len, "path" => joined_path.quote()) ); return false; } if total_len == 0 { - // Check whether a file name component is in a directory that is not searchable, - // or has some other serious problem. POSIX does not allow "" as a file name, - // but some non-POSIX hosts do (as an alias for "."), - // so allow "" if `symlink_metadata` (corresponds to `lstat`) does. + // POSIX has no empty file name, yet some systems accept one as a way + // of writing the current directory. Rather than decide that here, ask + // the platform: keep the operand when `symlink_metadata` (`lstat`) + // resolves it, reject it when that fails. if fs::symlink_metadata(&joined_path).is_err() { - writeln!( - std::io::stderr(), - "{}", - translate!("pathchk-error-empty-path-not-found") - ); + show_error!("{}", translate!("pathchk-error-empty-path-not-found")); return false; } } @@ -234,8 +212,7 @@ fn check_default(path: &[String]) -> bool { for p in path { let component_len = p.len(); if component_len > FILENAME_MAX { - writeln!( - std::io::stderr(), + show_error!( "{}", translate!("pathchk-error-name-length-exceeded", "limit" => FILENAME_MAX, "length" => component_len, "component" => p.quote()) ); @@ -251,13 +228,10 @@ fn check_searchable(path: &str) -> bool { // we use lstat, just like the original implementation match fs::symlink_metadata(path) { Ok(_) => true, + Err(e) if e.kind() == ErrorKind::NotFound => true, Err(e) => { - if e.kind() == ErrorKind::NotFound { - true - } else { - writeln!(std::io::stderr(), "{e}"); - false - } + show_error!("{}: {}", path, strip_errno(&e)); + false } } } @@ -268,8 +242,7 @@ fn check_portable_chars(path_segment: &str) -> bool { for (i, ch) in path_segment.as_bytes().iter().enumerate() { if !VALID_CHARS.contains(ch) { let invalid = path_segment[i..].chars().next().unwrap(); - writeln!( - std::io::stderr(), + show_error!( "{}", translate!("pathchk-error-nonportable-character", "character" => invalid, "component" => path_segment.quote()) ); diff --git a/src/uu/pinky/Cargo.toml b/src/uu/pinky/Cargo.toml index 5a6a7c4b942..73c8470cec1 100644 --- a/src/uu/pinky/Cargo.toml +++ b/src/uu/pinky/Cargo.toml @@ -13,20 +13,20 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - -[features] -feat_systemd_logind = ["uucore/feat_systemd_logind"] - [lib] path = "src/pinky.rs" doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["utmpx", "entries"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["utmpx", "entries"] } + +[features] +feat_systemd_logind = ["uucore/feat_systemd_logind"] + +[lints] +workspace = true [[bin]] name = "pinky" diff --git a/src/uu/pinky/src/pinky.rs b/src/uu/pinky/src/pinky.rs index a0779799396..9dea772e479 100644 --- a/src/uu/pinky/src/pinky.rs +++ b/src/uu/pinky/src/pinky.rs @@ -3,8 +3,6 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) BUFSIZE gecos fullname, mesg iobuf - use clap::{Arg, ArgAction, Command}; use uucore::format_usage; use uucore::translate; @@ -68,6 +66,8 @@ pub fn uu_app() -> Command { .action(ArgAction::SetTrue), ) .arg( + // Accepted and ignored: the short format is what pinky prints + // unless -l asks for the long one. Arg::new(options::SHORT_FORMAT) .short('s') .help(translate!("pinky-help-short-format")) @@ -118,22 +118,19 @@ pub fn uu_app() -> Command { ) } -pub trait Capitalize { - fn capitalize(&self) -> String; -} - -impl Capitalize for str { - fn capitalize(&self) -> String { - self.char_indices() - .fold(String::with_capacity(self.len()), |mut acc, x| { - if x.0 == 0 { - acc.push(x.1.to_ascii_uppercase()); - } else { - acc.push(x.1); - } - acc - }) +/// Upper-case the first character if it is ASCII, leaving the rest of the +/// string alone. This matches what GNU does to a login name. +/// +/// Public because `tests/by-util/test_pinky.rs` builds the expected long-format +/// output with it, so that the test spells the rule out the same way. +pub fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + let mut capitalized = String::with_capacity(s.len()); + if let Some(first) = chars.next() { + capitalized.push(first.to_ascii_uppercase()); + capitalized.push_str(chars.as_str()); } + capitalized } #[cfg(test)] @@ -142,9 +139,9 @@ mod test { #[test] fn test_capitalize() { - assert_eq!("Zbnmasd", "zbnmasd".capitalize()); // spell-checker:disable-line - assert_eq!("Abnmasd", "Abnmasd".capitalize()); // spell-checker:disable-line - assert_eq!("1masd", "1masd".capitalize()); // spell-checker:disable-line - assert_eq!("", "".capitalize()); + assert_eq!("Zbnmasd", capitalize("zbnmasd")); // spell-checker:disable-line + assert_eq!("Abnmasd", capitalize("Abnmasd")); // spell-checker:disable-line + assert_eq!("1masd", capitalize("1masd")); // spell-checker:disable-line + assert_eq!("", capitalize("")); } } diff --git a/src/uu/pinky/src/platform/openbsd.rs b/src/uu/pinky/src/platform/openbsd.rs index d24db248e5f..ad3a4a9bf1d 100644 --- a/src/uu/pinky/src/platform/openbsd.rs +++ b/src/uu/pinky/src/platform/openbsd.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // Specific implementation for OpenBSD: tool unsupported (utmpx not supported) use crate::uu_app; diff --git a/src/uu/pinky/src/platform/unix.rs b/src/uu/pinky/src/platform/unix.rs index e0831da05e2..b8adb543a43 100644 --- a/src/uu/pinky/src/platform/unix.rs +++ b/src/uu/pinky/src/platform/unix.rs @@ -3,9 +3,9 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) BUFSIZE gecos fullname, mesg iobuf +// spell-checker:ignore mesg -use crate::Capitalize; +use crate::capitalize; use crate::options; use crate::uu_app; @@ -15,6 +15,7 @@ use uucore::libc::S_IWGRP; use uucore::translate; use uucore::utmpx::{self, Utmpx, UtmpxRecord, time}; +use std::fmt::{self, Write as _}; use std::fs::File; use std::io; use std::io::prelude::*; @@ -29,290 +30,389 @@ fn get_long_usage() -> String { ) } -pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = - uucore::clap_localization::handle_clap_result(uu_app().after_help(get_long_usage()), args)?; - - let users: Vec = matches - .get_many::(options::USER) - .map(|v| v.map(ToString::to_string).collect()) - .unwrap_or_default(); - - // If true, display the hours:minutes since each user has touched - // the keyboard, or blank if within the last minute, or days followed - // by a 'd' if not within the last day. - let mut include_idle = true; - - // If true, display a line at the top describing each field. - let include_heading = !matches.get_flag(options::OMIT_HEADINGS); +/// Which columns the short listing carries. +struct Layout { + /// Prepend a header row naming the columns. + header: bool, + /// The account holder's real name, taken from the password file. + real_name: bool, + /// How long the terminal has been quiet. + idle: bool, + /// Where the session came from: the host recorded alongside it. + origin: bool, +} - // if true, display the user's full name from pw_gecos. - let mut include_fullname = true; +/// How much of each account the long report spells out. +struct Details { + /// The home directory and the login shell. + home_and_shell: bool, + /// The contents of `~/.project`. + project: bool, + /// The contents of `~/.plan`. + plan: bool, +} - // if true, display the user's ~/.project file when doing long format. - let include_project = !matches.get_flag(options::OMIT_PROJECT_FILE); +/// Whether the terminal would let another user write to it, which is what +/// the flag ahead of the device name in the short listing reports. +enum Messages { + /// The device is group-writable, so `write` and `wall` can reach it. + Accepted, + /// The device is not group-writable; `mesg n` leaves a terminal here. + Refused, + /// The device could not be stat'd, so there is nothing to report. + Unknown, +} - // if true, display the user's ~/.plan file when doing long format. - let include_plan = !matches.get_flag(options::OMIT_PLAN_FILE); +impl fmt::Display for Messages { + /// The listing spends one column on this, the same character GNU prints. + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(match self { + Self::Accepted => " ", + Self::Refused => "*", + Self::Unknown => "?", + }) + } +} - // if true, display the user's home directory and shell - // when doing long format. - let include_home_and_shell = !matches.get_flag(options::OMIT_HOME_DIR); +/// What an entry's terminal has to say about the session. +struct Terminal { + messages: Messages, + /// When the terminal was last read from, if that is known. + read_at: Option, +} - // if true, use the "short" output format. - let do_short_format = !matches.get_flag(options::LONG_FORMAT); +impl Terminal { + /// Query the device the entry names, relative to `/dev`. + fn query(device: &str) -> Self { + let mut path = PathBuf::from("/dev"); + path.push(device); - // If true, attempt to canonicalize hostname via a DNS lookup. - let do_lookup = matches.get_flag(options::LOOKUP); + #[allow(clippy::unnecessary_cast)] + match path.metadata() { + Ok(meta) => Self { + messages: if meta.mode() & (S_IWGRP as u32) == 0 { + Messages::Refused + } else { + Messages::Accepted + }, + read_at: Some(meta.atime()).filter(|at| *at != 0), + }, + Err(_) => Self { + messages: Messages::Unknown, + read_at: None, + }, + } + } +} - /* if true, display the ut_host field. */ - let mut include_where = true; +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let matches = + uucore::clap_localization::handle_clap_result(uu_app().after_help(get_long_usage()), args)?; - if matches.get_flag(options::OMIT_NAME) { - include_fullname = false; - } - if matches.get_flag(options::OMIT_NAME_HOST) { - include_fullname = false; - include_where = false; - } - if matches.get_flag(options::OMIT_NAME_HOST_TIME) { - include_fullname = false; - include_idle = false; - include_where = false; - } + let users: Vec = matches + .get_many::(options::USER) + .map(|v| v.map(ToString::to_string).collect()) + .unwrap_or_default(); - let pk = Pinky { - do_lookup, - include_idle, - include_heading, - include_fullname, - include_project, - include_plan, - include_home_and_shell, - include_where, + // The three narrowing flags stack: `-w` omits the real name, `-i` omits + // the origin as well, and `-q` keeps neither of those nor the idle time. + let omit_name = matches.get_flag(options::OMIT_NAME); + let omit_name_host = matches.get_flag(options::OMIT_NAME_HOST); + let omit_name_host_time = matches.get_flag(options::OMIT_NAME_HOST_TIME); + + let pinky = Pinky { + // Resolve each recorded host to its canonical name before printing it. + resolve_hosts: matches.get_flag(options::LOOKUP), + layout: Layout { + header: !matches.get_flag(options::OMIT_HEADINGS), + real_name: !(omit_name || omit_name_host || omit_name_host_time), + idle: !omit_name_host_time, + origin: !(omit_name_host || omit_name_host_time), + }, + details: Details { + home_and_shell: !matches.get_flag(options::OMIT_HOME_DIR), + project: !matches.get_flag(options::OMIT_PROJECT_FILE), + plan: !matches.get_flag(options::OMIT_PLAN_FILE), + }, names: users, }; - if do_short_format { - pk.write_short(&mut io::stdout().lock())?; + let mut stdout = io::stdout().lock(); + if matches.get_flag(options::LONG_FORMAT) { + pinky.describe_users(&mut stdout)?; } else { - pk.write_long(&mut io::stdout().lock())?; + pinky.list_sessions(&mut stdout)?; } Ok(()) } -struct Pinky { - do_lookup: bool, - include_idle: bool, - include_heading: bool, - include_fullname: bool, - include_project: bool, - include_plan: bool, - include_where: bool, - include_home_and_shell: bool, - names: Vec, -} - -fn idle_string(when: i64) -> String { +/// Render how long the terminal last read at `read_at` has been quiet. +fn format_idle(read_at: i64) -> String { thread_local! { static NOW: time::OffsetDateTime = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc()); } - NOW.with(|n| { - let duration = n.unix_timestamp() - when; - if duration < 60 { - // less than 1min - " ".to_owned() - } else if duration < 24 * 3600 { - // less than 1day - let hours = duration / (60 * 60); - let minutes = (duration % (60 * 60)) / 60; - format!("{hours:02}:{minutes:02}") - } else { - // more than 1day - let days = duration / (24 * 3600); - format!("{days}d") - } - }) -} - -fn time_string(ut: &UtmpxRecord) -> String { - const FORMAT_DESCRIPTION_VERSION: usize = 2; - - let time_format: Vec = if ["LC_ALL", "LC_TIME", "LANG"] - .into_iter() - .find_map(std::env::var_os) - .as_deref() - == Some(std::ffi::OsStr::new("C")) - { - // "%b %e %H:%M" - time::format_description::parse_borrowed::( - "[month repr:short] [day padding:space] [hour]:[minute]", - ) - .unwrap() - } else { - // "%Y-%m-%d %H:%M" - time::format_description::parse_borrowed::( - "[year]-[month]-[day] [hour]:[minute]", - ) - .unwrap() - }; - ut.login_time().format(&time_format).unwrap() + NOW.with(|n| format_quiet_time(n.unix_timestamp() - read_at)) } -fn gecos_to_fullname(pw: &Passwd) -> Option { - let mut gecos = if let Some(gecos) = &pw.user_info { - gecos.clone() +/// Render `quiet_for` seconds of silence: blanks under a minute, +/// `hours:minutes` under a day, and a count of days past that. +fn format_quiet_time(quiet_for: i64) -> String { + if quiet_for < 60 { + " ".to_owned() + } else if quiet_for < 24 * 3600 { + format!("{:02}:{:02}", quiet_for / 3600, (quiet_for % 3600) / 60) } else { - return None; - }; - if let Some(n) = gecos.find(',') { - gecos.truncate(n); + format!("{}d", quiet_for / (24 * 3600)) } - Some(gecos.replace('&', &pw.name.capitalize())) } -impl Pinky { - fn write_entry(&self, writer: &mut impl Write, ut: &UtmpxRecord) -> io::Result<()> { - let mut pts_path = PathBuf::from("/dev"); - pts_path.push(ut.tty_device().as_str()); - - let mesg; - let last_change; +/// Render an entry's login time. +fn format_timestamp(ut: &UtmpxRecord) -> String { + const FORMAT_DESCRIPTION_VERSION: usize = 2; - #[allow(clippy::unnecessary_cast)] - if let Ok(meta) = pts_path.metadata() { - mesg = if (meta.mode() & (S_IWGRP as u32)) == 0 { - '*' + thread_local! { + /// The C locale gets the terse month-and-day form; everything else gets + /// the ISO-like one. Neither the environment nor the descriptions change + /// while we print, so settle on one and parse it once. + static TIME_FORMAT: Vec> = { + let description = if ["LC_ALL", "LC_TIME", "LANG"] + .into_iter() + .find_map(std::env::var_os) + .as_deref() + == Some(std::ffi::OsStr::new("C")) + { + // "%b %e %H:%M" + "[month repr:short] [day padding:space] [hour]:[minute]" } else { - ' ' + // "%Y-%m-%d %H:%M" + "[year]-[month]-[day] [hour]:[minute]" }; - last_change = meta.atime(); - } else { - mesg = '?'; - last_change = 0; - } + time::format_description::parse_borrowed::(description) + .unwrap() + }; + } - write!(writer, "{1:<8.0$}", utmpx::UT_NAMESIZE, ut.user())?; + TIME_FORMAT.with(|description| ut.login_time().format(description).unwrap()) +} - if self.include_fullname { - let fullname = if let Ok(pw) = Passwd::locate(ut.user().as_ref()) { - gecos_to_fullname(&pw) - } else { - None - }; - if let Some(fullname) = fullname { - write!(writer, " {fullname:<19.19}")?; - } else { - write!(writer, " {:19}", " ???")?; +/// Pull the real name out of a password entry's comment field: it is the part +/// ahead of the first comma, with `&` standing in for the login name. +fn real_name(login: &str, comment: Option<&str>) -> Option { + let comment = comment?; + let name = &comment[..comment.find(',').unwrap_or(comment.len())]; + Some(name.replace('&', &capitalize(login))) +} + +/// Append one column to a row under construction. Only a `Display` impl that +/// fails can fail here, since the sink is a `String`, and no column formats +/// anything but strings and numbers — so say so rather than truncate quietly. +macro_rules! column { + ($row:expr, $($arg:tt)*) => {{ + write!($row, $($arg)*).expect("a column cannot fail to format"); + }}; +} + +struct Pinky { + resolve_hosts: bool, + layout: Layout, + details: Details, + names: Vec, +} + +impl Pinky { + /// Assemble one short-format line, column by column. + fn session_row(&self, ut: &UtmpxRecord) -> String { + let terminal = Terminal::query(ut.tty_device().as_str()); + let mut row = String::new(); + + column!(row, "{1:<8.0$}", utmpx::UT_NAMESIZE, ut.user()); + + if self.layout.real_name { + let name = Passwd::locate(ut.user().as_ref()) + .ok() + .and_then(|pw| real_name(&pw.name, pw.user_info.as_deref())); + match name { + Some(name) => { + column!(row, " {name:<19.19}"); + } + None => { + column!(row, " {:19}", " ???"); + } } } - write!( - writer, - " {mesg}{:<8.*}", + column!( + row, + " {}{:<8.*}", + terminal.messages, utmpx::UT_LINESIZE, ut.tty_device() - )?; + ); - if self.include_idle { - if last_change == 0 { - write!(writer, " {:<6}", "?????")?; - } else { - write!(writer, " {:<6}", idle_string(last_change))?; - } + if self.layout.idle { + let idle = match terminal.read_at { + Some(read_at) => format_idle(read_at), + None => "?????".to_owned(), + }; + column!(row, " {idle:<6}"); } - write!(writer, " {}", time_string(ut))?; + column!(row, " {}", format_timestamp(ut)); - if self.include_where { - let s: String = if self.do_lookup { + if self.layout.origin { + let host = if self.resolve_hosts { ut.canon_host().unwrap_or(ut.host()) } else { ut.host() }; - - if !s.is_empty() { - write!(writer, " {s}")?; + if !host.is_empty() { + column!(row, " {host}"); } } - writeln!(writer)?; - Ok(()) + row } - fn write_heading(&self, writer: &mut impl Write) -> io::Result<()> { - write!(writer, "{:<8}", translate!("pinky-column-login"))?; - if self.include_fullname { - write!(writer, " {:<19}", translate!("pinky-column-name"))?; + /// Name each column that `Layout` turned on. + fn header_row(&self) -> String { + let mut row = String::new(); + + column!(row, "{:<8}", translate!("pinky-column-login")); + if self.layout.real_name { + column!(row, " {:<19}", translate!("pinky-column-name")); } - write!(writer, " {:<9}", translate!("pinky-column-tty"))?; - if self.include_idle { - write!(writer, " {:<6}", translate!("pinky-column-idle"))?; + column!(row, " {:<9}", translate!("pinky-column-tty")); + if self.layout.idle { + column!(row, " {:<6}", translate!("pinky-column-idle")); } - write!(writer, " {:<16}", translate!("pinky-column-when"))?; - if self.include_where { - write!(writer, " {}", translate!("pinky-column-where"))?; + column!(row, " {:<16}", translate!("pinky-column-when")); + if self.layout.origin { + column!(row, " {}", translate!("pinky-column-where")); } - writeln!(writer)?; - Ok(()) + + row } - fn write_short(&self, writer: &mut impl Write) -> io::Result<()> { - if self.include_heading { - self.write_heading(writer)?; + /// One line per session, restricted to the named accounts when any were + /// given on the command line. + fn list_sessions(&self, writer: &mut impl Write) -> io::Result<()> { + if self.layout.header { + writeln!(writer, "{}", self.header_row())?; } for ut in Utmpx::iter_all_records() { if ut.is_user_process() && (self.names.is_empty() || self.names.iter().any(|n| n.as_str() == ut.user())) { - self.write_entry(writer, &ut)?; + writeln!(writer, "{}", self.session_row(&ut))?; } } Ok(()) } - fn write_long(&self, writer: &mut impl Write) -> io::Result<()> { - for u in &self.names { + /// A paragraph per named account, drawn from the password file and from + /// the dot files in the account's home directory. + fn describe_users(&self, writer: &mut impl Write) -> io::Result<()> { + for name in &self.names { write!( writer, - "{} {u:<28}{} ", + "{} {name:<28}{} ", translate!("pinky-login-name-label"), translate!("pinky-real-life-label") )?; - if let Ok(pw) = Passwd::locate(u.as_str()) { - let fullname = gecos_to_fullname(&pw).unwrap_or_default(); - let user_dir = pw.user_dir.unwrap_or_default(); - let user_shell = pw.user_shell.unwrap_or_default(); - writeln!(writer, " {fullname}")?; - if self.include_home_and_shell { - write!( - writer, - "{} {user_dir:<29}", - translate!("pinky-directory-label") - )?; - writeln!(writer, "{} {user_shell}", translate!("pinky-shell-label"))?; - } - if self.include_project { - let mut p = PathBuf::from(&user_dir); - p.push(".project"); - if let Ok(mut reader) = File::open(p) { - write!(writer, "{} ", translate!("pinky-project-label"))?; - io::copy(&mut reader, writer)?; - } - } - if self.include_plan { - let mut p = PathBuf::from(&user_dir); - p.push(".plan"); - if let Ok(mut reader) = File::open(p) { - writeln!(writer, "{}:", translate!("pinky-plan-label"))?; - io::copy(&mut reader, writer)?; - } - } - writeln!(writer)?; - } else { + + // An unknown account gets the header line and nothing else, not + // even the blank line that closes a paragraph below: GNU returns + // from the entry right here. + let Ok(pw) = Passwd::locate(name.as_str()) else { writeln!(writer, " ???")?; + continue; + }; + + writeln!( + writer, + " {}", + real_name(&pw.name, pw.user_info.as_deref()).unwrap_or_default() + )?; + + let home = pw.user_dir.unwrap_or_default(); + if self.details.home_and_shell { + write!(writer, "{} {home:<29}", translate!("pinky-directory-label"))?; + writeln!( + writer, + "{} {}", + translate!("pinky-shell-label"), + pw.user_shell.unwrap_or_default() + )?; } + + if self.details.project + && let Ok(mut reader) = File::open(PathBuf::from(&home).join(".project")) + { + write!(writer, "{} ", translate!("pinky-project-label"))?; + io::copy(&mut reader, writer)?; + } + if self.details.plan + && let Ok(mut reader) = File::open(PathBuf::from(&home).join(".plan")) + { + writeln!(writer, "{}:", translate!("pinky-plan-label"))?; + io::copy(&mut reader, writer)?; + } + + writeln!(writer)?; } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::{format_quiet_time, real_name}; + + #[test] + fn idle_under_a_minute_is_blank() { + assert_eq!(format_quiet_time(0), " "); + assert_eq!(format_quiet_time(30), " "); + assert_eq!(format_quiet_time(59), " "); + } + + #[test] + fn idle_under_a_day_is_hours_and_minutes() { + assert_eq!(format_quiet_time(60), "00:01"); + assert_eq!(format_quiet_time(90), "00:01"); + assert_eq!(format_quiet_time(2 * 3600 + 3 * 60 + 30), "02:03"); + assert_eq!(format_quiet_time(23 * 3600), "23:00"); + } + + #[test] + fn idle_past_a_day_is_counted_in_days() { + assert_eq!(format_quiet_time(24 * 3600), "1d"); + assert_eq!(format_quiet_time(25 * 3600), "1d"); + assert_eq!(format_quiet_time(9 * 24 * 3600), "9d"); + } + + #[test] + fn real_name_stops_at_the_first_comma() { + assert_eq!( + real_name("alice", Some("Alice Smith,101,555-0100,")).unwrap(), + "Alice Smith" + ); + assert_eq!( + real_name("alice", Some("Alice Smith")).unwrap(), + "Alice Smith" + ); + assert_eq!(real_name("alice", Some(",101")).unwrap(), ""); + } + + #[test] + fn real_name_expands_ampersands_to_the_login() { + assert_eq!(real_name("alice", Some("&")).unwrap(), "Alice"); + assert_eq!(real_name("alice", Some("& Smith")).unwrap(), "Alice Smith"); + assert_eq!(real_name("alice", Some("& &,room")).unwrap(), "Alice Alice"); + } + + #[test] + fn real_name_without_a_comment_field() { + assert_eq!(real_name("alice", None), None); + assert_eq!(real_name("alice", Some("")).unwrap(), ""); + } +} diff --git a/src/uu/pr/Cargo.toml b/src/uu/pr/Cargo.toml index 5a464bfda7f..9fa3eaa3e8d 100644 --- a/src/uu/pr/Cargo.toml +++ b/src/uu/pr/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/pr.rs" test = false @@ -21,12 +18,15 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["entries", "time"] } +fluent = { workspace = true } itertools = { workspace = true } memchr = { workspace = true } regex = { workspace = true } thiserror = { workspace = true } -fluent = { workspace = true } +uucore = { workspace = true, features = ["entries", "time"] } + +[lints] +workspace = true [[bin]] name = "pr" diff --git a/src/uu/pr/locales/en-US.ftl b/src/uu/pr/locales/en-US.ftl index 22635349f72..916b5817ff6 100644 --- a/src/uu/pr/locales/en-US.ftl +++ b/src/uu/pr/locales/en-US.ftl @@ -57,7 +57,7 @@ pr-help-across = across the page in a round-robin order (for example, when column is 2, the first input line heads column 1, the second heads column 2, the third is the second line in column 1, and so on). -pr-help-column = +pr-help-columns = Produce multi-column output that is arranged in column columns (the default shall be 1) and is written down each column in the order in which the text is received from the input file. This option should not be used with -m. @@ -101,5 +101,6 @@ pr-error-no-such-file = pr: cannot open {$file}, No such file or directory pr-error-column-merge-conflict = cannot specify number of columns when printing in parallel pr-error-across-merge-conflict = cannot specify both printing across and printing in parallel pr-error-invalid-pages-range = invalid --pages argument '{$start}:{$end}' +pr-error-starting-page-exceeds-page-count = starting page number {$start} exceeds page count {$count} pr-error-invalid-expand-tab-argument ='-e' extra characters or invalid number in the argument: ‘{$arg}’ pr-error-invalid-number-argument ='-n' extra characters or invalid number in the argument: ‘{$arg}’ diff --git a/src/uu/pr/locales/fr-FR.ftl b/src/uu/pr/locales/fr-FR.ftl index 7928aab6908..387cc0e7829 100644 --- a/src/uu/pr/locales/fr-FR.ftl +++ b/src/uu/pr/locales/fr-FR.ftl @@ -55,7 +55,7 @@ pr-help-across = à travers la page dans un ordre round-robin (par exemple, quand colonne est 2, la première ligne d'entrée va en tête de colonne 1, la seconde va en tête de colonne 2, la troisième est la seconde ligne en colonne 1, et ainsi de suite). -pr-help-column = +pr-help-columns = Produire une sortie multi-colonnes qui est arrangée en colonnes colonnes (la valeur par défaut sera 1) et est écrite dans chaque colonne dans l'ordre dans lequel le texte est reçu du fichier d'entrée. Cette option ne doit pas être @@ -100,5 +100,6 @@ pr-error-no-such-file = pr : impossible d'ouvrir {$file}, Aucun fichier ou répe pr-error-column-merge-conflict = impossible de spécifier le nombre de colonnes lors de l'impression en parallèle pr-error-across-merge-conflict = impossible de spécifier à la fois l'impression transversale et l'impression en parallèle pr-error-invalid-pages-range = argument --pages invalide '{$start}:{$end}' +pr-error-starting-page-exceeds-page-count = le numéro de page de début {$start} dépasse le nombre de pages {$count} pr-error-invalid-expand-tab-argument = Caractères supplémentaires ou nombre invalide dans l'argument de '-e': '{$arg}' pr-error-invalid-number-argument = Caractères supplémentaires ou nombre invalide dans l'argument de '-n': '{$arg}' diff --git a/src/uu/pr/src/pr.rs b/src/uu/pr/src/pr.rs index 4441c1f3a54..b8d196dc5bc 100644 --- a/src/uu/pr/src/pr.rs +++ b/src/uu/pr/src/pr.rs @@ -2,27 +2,26 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// -// spell-checker:ignore (ToDO) adFfmprt, kmerge +// spell-checker:ignore (ToDO) adFfmprt, kmerge Ffabm use clap::{Arg, ArgAction, ArgMatches, Command}; use itertools::Itertools; use regex::Regex; use std::ffi::OsStr; use std::fs::metadata; -use std::io::{Read, Write, stderr, stdin, stdout}; +use std::io::{self, Read, Write, stderr, stdin, stdout}; use std::num::IntErrorKind; +use std::path::PathBuf; use std::str::Utf8Error; use std::string::FromUtf8Error; use std::time::SystemTime; -use thiserror::Error; use uucore::display::Quotable; -use uucore::error::UResult; +use uucore::error::{UResult, strip_errno}; use uucore::format_usage; use uucore::time::{FormatSystemTimeFallback, format, format_system_time}; -use uucore::translate; +use uucore::{show_error, translate}; const TAB: char = '\t'; const LINES_PER_PAGE: usize = 66; @@ -35,6 +34,7 @@ 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'; +const MAX_INT_VALUE: usize = i32::MAX as usize; mod options { pub const HEADER: &str = "header"; @@ -52,7 +52,7 @@ mod options { 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 COLUMNS: &str = "columns"; pub const COLUMN_CHAR_SEPARATOR: &str = "separator"; pub const COLUMN_STRING_SEPARATOR: &str = "sep-string"; pub const MERGE: &str = "merge"; @@ -137,6 +137,16 @@ struct NumberingMode { first_number: usize, } +impl Default for NumberingMode { + fn default() -> Self { + Self { + width: 5, + separator: TAB.to_string(), + first_number: 1, + } + } +} + #[derive(Debug)] struct ExpandTabsOptions { input_char: char, @@ -152,22 +162,19 @@ impl Default for ExpandTabsOptions { } } -impl Default for NumberingMode { - fn default() -> Self { - Self { - width: 5, - separator: TAB.to_string(), - first_number: 1, - } - } -} +#[derive(Debug, thiserror::Error)] +enum PrError { + #[error("pr: {msg}")] + EncounteredErrors { msg: String }, -impl From for PrError { - fn from(err: std::io::Error) -> Self { - Self::EncounteredErrors { - msg: err.to_string(), - } - } + #[error("pr: {}", strip_errno(.0))] + Read(io::Error), + + #[error("pr: {}", strip_errno(.0))] + Write(io::Error), + + #[error("pr: {path}: {}", strip_errno(error))] + ReadPath { path: PathBuf, error: io::Error }, } impl From for PrError { @@ -186,12 +193,6 @@ impl From for PrError { } } -#[derive(Debug, Error)] -enum PrError { - #[error("pr: {msg}")] - EncounteredErrors { msg: String }, -} - pub fn uu_app() -> Command { Command::new("pr") .version(uucore::crate_version!()) @@ -235,6 +236,8 @@ pub fn uu_app() -> Command { .long(options::NUMBER_LINES) .help(translate!("pr-help-number-lines")) .allow_hyphen_values(true) + // GNU pr makes -n optional (defaults to width 5, tab). + .num_args(0..=1) .value_name("[char][width]"), ) .arg( @@ -275,7 +278,7 @@ pub fn uu_app() -> Command { .arg( Arg::new(options::FORM_FEED) .short('F') - .short_alias('f') + .visible_short_alias('f') .long(options::FORM_FEED) .help(translate!("pr-help-form-feed")) .action(ArgAction::SetTrue), @@ -309,10 +312,10 @@ pub fn uu_app() -> Command { .action(ArgAction::SetTrue), ) .arg( - Arg::new(options::COLUMN) - .long(options::COLUMN) - .help(translate!("pr-help-column")) - .value_name("column"), + Arg::new(options::COLUMNS) + .long(options::COLUMNS) + .help(translate!("pr-help-columns")) + .value_name("columns"), ) .arg( Arg::new(options::COLUMN_CHAR_SEPARATOR) @@ -349,6 +352,7 @@ pub fn uu_app() -> Command { .arg( Arg::new(options::JOIN_LINES) .short('J') + .long(options::JOIN_LINES) .help(translate!("pr-help-join-lines")) .action(ArgAction::SetTrue), ) @@ -361,6 +365,7 @@ pub fn uu_app() -> Command { .arg( Arg::new(options::FILES) .action(ArgAction::Append) + .default_value(FILE_STDIN) .value_hint(clap::ValueHint::FilePath), ) .arg( @@ -382,14 +387,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let command = uu_app(); let matches = uucore::clap_localization::handle_clap_result(command, opt_args)?; - let mut files = matches + #[allow(clippy::unwrap_used, reason = "default value is set by clap")] + let files = matches .get_many::(options::FILES) .map(|v| v.map(String::as_str).collect::>()) - .unwrap_or_default() - .clone(); - if files.is_empty() { - files.insert(0, FILE_STDIN); - } + .unwrap(); let file_groups: Vec<_> = if matches.get_flag(options::MERGE) { vec![files] @@ -409,21 +411,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } }; - let cmd_result = if let Ok(group) = file_group.iter().exactly_one() { - pr(group, &options) - } else { - mpr(&file_group, &options) - }; + let cmd_result = file_group + .iter() + .exactly_one() + .map_or_else(|_| mpr(&file_group, &options), |group| pr(group, &options)); - let status = match cmd_result { - Err(error) => { - print_error(&matches, &error); - 1 - } - _ => 0, - }; - if status != 0 { - return Err(status.into()); + if let Err(e) = cmd_result { + print_error(&matches, &e); + return Err(1.into()); } } Ok(()) @@ -432,33 +427,32 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { /// Rewrite arguments before clap parsing, preserving legacy numeric operands. fn recreate_arguments(args: &[String]) -> Vec { let num_regex = Regex::new(r"^[^-]\d*$").unwrap(); - let n_regex = Regex::new(r"^-n\s*$").unwrap(); - let e_regex = Regex::new(r"^-e").unwrap(); + // `-e` ends a cluster of short flags that take no value of their own, as in `-tre`. + // Options that do take a value are excluded so that `-se` keeps meaning `-s e`. + let e_regex = Regex::new(r"^-[dtTrFfabmJ]*e$").unwrap(); let mut arguments = args.to_owned(); let num_option = args .iter() .take_while(|arg| arg.as_str() != "--") - .find_position(|x| n_regex.is_match(x.trim())); - if let Some((pos, _value)) = num_option { - if let Some(num_val_opt) = args.get(pos + 1) { - if !num_regex.is_match(num_val_opt) { - let could_be_file = arguments.remove(pos + 1); - arguments.insert(pos + 1, format!("{}", NumberingMode::default().width)); - arguments.insert(pos + 2, could_be_file); - } - } + .position(|x| x.trim() == "-n"); + if let Some(pos) = num_option + && let Some(num_val_opt) = args.get(pos + 1) + && !num_regex.is_match(num_val_opt) + { + let could_be_file = arguments.remove(pos + 1); + arguments.insert(pos + 1, format!("{}", NumberingMode::default().width)); + arguments.insert(pos + 2, could_be_file); } - // To ensure not to accidentally delete the next argument after a short flag for -e we insert - // the default values for the -e flag is '-e' is present without direct arguments. + // `-e` takes an optional attached argument, which clap cannot express, so it is filled in + // here. Without it clap would report a missing value for `-tre`, or swallow the following + // argument, usually a file name, for a bare `-e`. let expand_tabs_option = arguments .iter() .take_while(|arg| arg.as_str() != "--") .find_position(|x| e_regex.is_match(x.trim())); if let Some((pos, value)) = expand_tabs_option { - if value.trim().len() <= 2 { - arguments[pos] = "-e\t8".to_string(); - } + arguments[pos] = format!("{}\t8", value.trim()); } // Remove only whole-token legacy operands before clap parsing. @@ -491,16 +485,16 @@ fn parse_column_page_operands(args: &[String]) -> ColumnPageOperands { if arg == "--" { break; } - if operands.column.is_none() { - if let Some(digits) = as_column_operand(arg) { - operands.column = Some(digits.to_string()); - continue; - } + if operands.column.is_none() + && let Some(digits) = as_column_operand(arg) + { + operands.column = Some(digits.to_string()); + continue; } - if operands.page.is_none() { - if let Some(spec) = as_page_operand(arg) { - operands.page = Some(spec.to_string()); - } + if operands.page.is_none() + && let Some(spec) = as_page_operand(arg) + { + operands.page = Some(spec.to_string()); } } operands @@ -529,18 +523,36 @@ fn print_error(matches: &ArgMatches, err: &PrError) { } } -fn parse_usize(matches: &ArgMatches, opt: &str) -> Option> { - let from_parse_error_to_pr_error = |value_to_parse: (String, String)| { - let i = value_to_parse.0; - let option = value_to_parse.1; - i.parse().map_err(|_e| PrError::EncounteredErrors { - msg: format!("invalid -{option} argument {}", i.quote()), - }) - }; - matches - .get_one::(opt) - .map(|i| (i.to_owned(), format!("-{opt}"))) - .map(from_parse_error_to_pr_error) +fn value_too_large(option_message: &str, raw: &str) -> String { + format!( + "{option_message}: {}: Value too large for defined data type", + raw.quote() + ) +} + +fn parse_usize( + matches: &ArgMatches, + opt: &str, + too_large_error_message: &str, +) -> Option> { + matches.get_one::(opt).map(|i| { + let raw = i.as_str(); + match raw.parse::() { + Ok(n @ ..=MAX_INT_VALUE) => Ok(n), + Ok(_) => Err(PrError::EncounteredErrors { + msg: value_too_large(too_large_error_message, raw), + }), + Err(e) if *e.kind() == IntErrorKind::PosOverflow => Err(PrError::EncounteredErrors { + msg: value_too_large(too_large_error_message, raw), + }), + Err(_) => { + let option = format!("-{opt}"); + Err(PrError::EncounteredErrors { + msg: format!("invalid -{option} argument {}", raw.quote()), + }) + } + } + }) } fn get_date_format(matches: &ArgMatches) -> String { @@ -572,7 +584,7 @@ fn build_options( let is_merge_mode = matches.get_flag(options::MERGE); - if is_merge_mode && matches.contains_id(options::COLUMN) { + if is_merge_mode && matches.contains_id(options::COLUMNS) { return Err(PrError::EncounteredErrors { msg: translate!("pr-error-column-merge-conflict"), }); @@ -603,8 +615,12 @@ fn build_options( .to_string(); let default_first_number = NumberingMode::default().first_number; - let first_number = - parse_usize(matches, options::FIRST_LINE_NUMBER).unwrap_or(Ok(default_first_number))?; + let first_number = parse_usize( + matches, + options::FIRST_LINE_NUMBER, + "'-N NUMBER' invalid starting line number", + ) + .unwrap_or(Ok(default_first_number))?; let number = matches .get_one::(options::NUMBER_LINES) @@ -617,8 +633,26 @@ fn build_options( ), }; + let invalid_too_large = |arg: &str| PrError::EncounteredErrors { + msg: format!( + "{}\n{}", + value_too_large( + "'-n' extra characters or invalid number in the argument", + arg + ), + translate!("pr-try-help-message") + ), + }; + let parse_result = i.parse::(); + if matches!( + &parse_result, + Err(e) if *e.kind() == IntErrorKind::PosOverflow + ) { + return Err(invalid_too_large(i)); + } + let separator = if parse_result.is_err() { match i.chars().next() { Some(c) if c.is_ascii() => c.to_string(), @@ -629,12 +663,19 @@ fn build_options( }; let width = match parse_result { - Ok(res) => res, - Err(_) => i - .get(1..) - .unwrap_or_default() - .parse::() - .unwrap_or(NumberingMode::default().width), + Ok(res @ ..=MAX_INT_VALUE) => res, + Ok(_) => return Err(invalid_too_large(i)), + Err(_) => { + let digits = i.get(1..).unwrap_or_default(); + match digits.parse::() { + Ok(res @ ..=MAX_INT_VALUE) => res, + Ok(_) => return Err(invalid_too_large(digits)), + Err(e) if *e.kind() == IntErrorKind::PosOverflow => { + return Err(invalid_too_large(digits)); + } + Err(_) => NumberingMode::default().width, + } + } }; Ok(NumberingMode { @@ -797,12 +838,10 @@ fn build_options( None => end_page_in_plus_option, }; - if let Some(end_page) = end_page { - if start_page > end_page { - return Err(PrError::EncounteredErrors { - msg: translate!("pr-error-invalid-pages-range", "start" => start_page, "end" => end_page), - }); - } + if let Some(end_page) = end_page.filter(|end| start_page > *end) { + return Err(PrError::EncounteredErrors { + msg: translate!("pr-error-invalid-pages-range", "start" => start_page, "end" => end_page), + }); } let default_lines_per_page = if form_feed_used { @@ -811,8 +850,12 @@ fn build_options( LINES_PER_PAGE }; - let page_length = - parse_usize(matches, options::PAGE_LENGTH).unwrap_or(Ok(default_lines_per_page))?; + let page_length = parse_usize( + matches, + options::PAGE_LENGTH, + "'-l PAGE_LENGTH' invalid number of lines", + ) + .unwrap_or(Ok(default_lines_per_page))?; if page_length == 0 { return Err(PrError::EncounteredErrors { @@ -820,7 +863,10 @@ fn build_options( }); } - let page_length_le_ht = page_length < (HEADER_LINES_PER_PAGE + TRAILER_LINES_PER_PAGE); + // `pr --help` states the rule twice: a page length of 10 or less implies + // `-t`. At exactly 10 the old `<` left the header and trailer in place and + // subtracted them from the page, so the page had no room for content at all. + 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) @@ -855,8 +901,12 @@ fn build_options( DEFAULT_COLUMN_WIDTH }; - let column_width = - parse_usize(matches, options::COLUMN_WIDTH).unwrap_or(Ok(default_column_width))?; + let column_width = parse_usize( + matches, + options::COLUMN_WIDTH, + "'-w PAGE_WIDTH' invalid number of characters", + ) + .unwrap_or(Ok(default_column_width))?; if column_width == 0 { return Err(PrError::EncounteredErrors { @@ -867,7 +917,11 @@ fn build_options( let page_width = if matches.get_flag(options::JOIN_LINES) { None } else { - match parse_usize(matches, options::PAGE_WIDTH) { + match parse_usize( + matches, + options::PAGE_WIDTH, + "'-W PAGE_WIDTH' invalid number of characters", + ) { Some(res) => Some(res?), None => None, } @@ -879,34 +933,43 @@ fn build_options( }); } - let res = operands.column.as_deref().map(|unparsed_num| { - unparsed_num - .parse::() - .map_err(|_e| PrError::EncounteredErrors { + let res = operands + .column + .as_deref() + .map(|unparsed_num| match unparsed_num.parse::() { + Ok(n @ ..=MAX_INT_VALUE) => Ok(n), + Ok(_) => Err(PrError::EncounteredErrors { + msg: value_too_large("invalid number of columns", unparsed_num), + }), + Err(e) if *e.kind() == IntErrorKind::PosOverflow => Err(PrError::EncounteredErrors { + msg: value_too_large("invalid number of columns", unparsed_num), + }), + Err(_) => Err(PrError::EncounteredErrors { msg: format!("invalid {} argument {}", "-", unparsed_num.quote()), - }) - }); + }), + }); let start_column_option = match res { Some(Ok(0)) => { return Err(PrError::EncounteredErrors { - msg: "invalid --column argument '0'".to_string(), + msg: "invalid --columns argument '0'".to_string(), }); } Some(res) => Some(res?), None => None, }; - // --column has more priority than -column + // --columns has more priority than -column - let column_option_value = match parse_usize(matches, options::COLUMN) { - Some(Ok(0)) => { - return Err(PrError::EncounteredErrors { - msg: "invalid --column argument '0'".to_string(), - }); - } - Some(res) => Some(res?), - None => start_column_option, - }; + let column_option_value = + match parse_usize(matches, options::COLUMNS, "invalid number of columns") { + Some(Ok(0)) => { + return Err(PrError::EncounteredErrors { + msg: "invalid --columns argument '0'".to_string(), + }); + } + Some(res) => Some(res?), + None => start_column_option, + }; let column_mode_options = column_option_value.map(|columns| ColumnModeOptions { columns, @@ -920,13 +983,10 @@ fn build_options( // Parse as i32 to match GNU pr's behavior // Store the count. Spaces are streamed at print time to avoid huge allocations. Some(raw) => match raw.parse::() { - Ok(n) if n >= 0 => n as usize, - Err(e) if matches!(e.kind(), IntErrorKind::PosOverflow) => { + Ok(n @ 0..) => n as usize, + Err(e) if *e.kind() == IntErrorKind::PosOverflow => { return Err(PrError::EncounteredErrors { - msg: format!( - "'-o MARGIN' invalid line offset: {}: Value too large for defined data type", - raw.quote() - ), + msg: value_too_large("'-o MARGIN' invalid line offset", raw), }); } _ => { @@ -988,15 +1048,16 @@ 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 == "-" { +/// If `name` is `"-"`, then read from stdin. +fn read_to_end(name: &str) -> Result, PrError> { + if name == "-" { let mut f = stdin(); let mut buf = vec![]; - f.read_to_end(&mut buf)?; + f.read_to_end(&mut buf).map_err(PrError::Read)?; Ok(buf) } else { - std::fs::read(path) + let path = PathBuf::from(name); + std::fs::read(&path).map_err(|error| PrError::ReadPath { path, error }) } } @@ -1021,19 +1082,31 @@ fn apply_expand_tab(chunk: &mut Vec, byte: u8, expand_options: &ExpandTabsOp } } -fn pr(path: &str, options: &OutputOptions) -> Result { +fn pr(name: &str, options: &OutputOptions) -> Result { // Read the entire contents of the file into a buffer. // // TODO Read incrementally. - let buf = read_to_end(path)?; - - let pages = get_pages(options, 0, &buf); + let buf = read_to_end(name)?; + + let mut writer = stdout().lock(); + let (pages, page_count) = get_pages(options, 0, &buf); + if options.start_page > page_count { + show_error!( + "{}", + translate!( + "pr-error-starting-page-exceeds-page-count", + "start" => options.start_page, + "count" => page_count + ) + ); + return Ok(0); + } // 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; - print_page(&page, options, page_number)?; + write_page(&mut writer, &page, options, page_number).map_err(PrError::Write)?; } Ok(0) @@ -1041,9 +1114,13 @@ fn pr(path: &str, options: &OutputOptions) -> Result { /// Group lines of a file into pages. /// -/// Returns a list of the form `(page_num, lines)`. +/// Returns a list of the form `(page_num, lines)` and the total page count. /// -fn get_pages(options: &OutputOptions, file_id: usize, buf: &[u8]) -> Vec<(usize, Vec)> { +fn get_pages( + options: &OutputOptions, + file_id: usize, + buf: &[u8], +) -> (Vec<(usize, Vec)>, usize) { let start_page = options.start_page; let end_page = options.end_page; let lines_needed_per_page = lines_to_read_for_page(options); @@ -1093,7 +1170,7 @@ fn get_pages(options: &OutputOptions, file_id: usize, buf: &[u8]) -> Vec<(usize, // 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())); + pages.push((page_num, std::mem::take(&mut page))); } page_num += 1; page.clear(); @@ -1117,7 +1194,7 @@ fn get_pages(options: &OutputOptions, file_id: usize, buf: &[u8]) -> Vec<(usize, // 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())); + pages.push((page_num, std::mem::take(&mut page))); } page_num += 1; page.clear(); @@ -1133,10 +1210,10 @@ fn get_pages(options: &OutputOptions, file_id: usize, buf: &[u8]) -> Vec<(usize, // 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())); + pages.push((page_num, std::mem::take(&mut page))); } - pages + (pages, page_num + 1) } /// Key used to group lines together according to their file and page number. @@ -1163,8 +1240,7 @@ fn group_lines(num_files: usize, lines: Vec) -> Vec<(usize, Vec { - result.push((key, current_group.clone())); - current_group.clear(); + result.push((key, std::mem::take(&mut current_group))); current_key = Some(group_key(num_files, &file_line)); current_group.push(file_line); } @@ -1193,7 +1269,8 @@ fn get_file_line_groups( // Split the text into pages and collect each line for // subsequent grouping. - for (_, mut page) in get_pages(options, file_id, &buf) { + let (pages, _) = get_pages(options, file_id, &buf); + for (_, mut page) in pages { all_lines.append(&mut page); } } @@ -1210,6 +1287,7 @@ fn mpr(paths: &[&str], options: &OutputOptions) -> Result { return Ok(0); } + let mut writer = stdout().lock(); let start_page = options.start_page; let mut lines = Vec::new(); let mut page_counter = start_page; @@ -1218,7 +1296,7 @@ fn mpr(paths: &[&str], options: &OutputOptions) -> Result { for file_line in file_line_group { let new_page_number = file_line.page_number + 1; if page_counter != new_page_number { - print_page(&lines, options, page_counter)?; + write_page(&mut writer, &lines, options, page_counter).map_err(PrError::Write)?; lines = Vec::new(); page_counter = new_page_number; } @@ -1226,40 +1304,40 @@ fn mpr(paths: &[&str], options: &OutputOptions) -> Result { } } - print_page(&lines, options, page_counter)?; + write_page(&mut writer, &lines, options, page_counter).map_err(PrError::Write)?; Ok(0) } -fn print_page( +fn write_page( + writer: &mut impl Write, lines: &[FileLine], options: &OutputOptions, page: usize, -) -> Result<(), std::io::Error> { +) -> io::Result<()> { let line_separator = options.line_separator.as_bytes(); let page_separator = options.page_separator_char.as_bytes(); let header = header_content(options, page); let trailer_content = trailer_content(options); - let out = stdout(); - let mut out = out.lock(); - for x in header { - out.write_all(x.as_bytes())?; - out.write_all(line_separator)?; + writer.write_all(x.as_bytes())?; + writer.write_all(line_separator)?; } - write_columns(lines, options, &mut out)?; + write_columns(writer, lines, options)?; for (index, x) in trailer_content.iter().enumerate() { - out.write_all(x.as_bytes())?; + writer.write_all(x.as_bytes())?; if index + 1 != trailer_content.len() { - out.write_all(line_separator)?; + writer.write_all(line_separator)?; } } - out.write_all(page_separator)?; - out.flush()?; + if options.display_header_and_trailer || options.form_feed_used { + writer.write_all(page_separator)?; + } + writer.flush()?; Ok(()) } @@ -1315,14 +1393,37 @@ fn to_table( /// /// This function should be applied when there are fewer lines than the /// total number of cells in the table. +/// +/// The lines are spread over the columns the way `pr` fills a page it cannot +/// fill completely: every column takes the ceiling of what is still unplaced +/// divided by the number of columns left to fill, so the leftmost columns are +/// the long ones and no line is left over. fn to_table_short_file( content_lines_per_page: usize, columns: usize, lines: &[FileLine], ) -> Vec>> { - let num_rows = lines.len() / columns; + let mut bounds = Vec::with_capacity(columns + 1); + bounds.push(0); + let mut placed = 0; + for column in 0..columns { + placed += (lines.len() - placed).div_ceil(columns - column); + bounds.push(placed); + } + + let num_rows = (0..columns) + .map(|j| bounds[j + 1] - bounds[j]) + .max() + .unwrap_or(0); let mut table: Vec> = (0..num_rows) - .map(|i| (0..columns).map(|j| lines.get(num_rows * j + i)).collect()) + .map(|i| { + (0..columns) + .map(|j| { + let index = bounds[j] + i; + (index < bounds[j + 1]).then(|| &lines[index]) + }) + .collect() + }) .collect(); // Fill the rest with Nones. for _ in num_rows..content_lines_per_page { @@ -1333,7 +1434,7 @@ fn to_table_short_file( /// Write `n` space characters to `out` in fixed-size chunks, so the indent is /// streamed rather than allocated up front. -fn write_offset_spaces(out: &mut impl Write, mut n: usize) -> Result<(), std::io::Error> { +fn write_offset_spaces(out: &mut impl Write, mut n: usize) -> io::Result<()> { const SPACES: [u8; 256] = [b' '; 256]; while n > 0 { let chunk = n.min(SPACES.len()); @@ -1345,10 +1446,10 @@ fn write_offset_spaces(out: &mut impl Write, mut n: usize) -> Result<(), std::io #[allow(clippy::cognitive_complexity)] fn write_columns( + writer: &mut impl Write, lines: &[FileLine], options: &OutputOptions, - out: &mut impl Write, -) -> Result<(), std::io::Error> { +) -> io::Result<()> { let line_separator = options.content_line_separator.as_bytes(); let content_lines_per_page = if options.double_space { @@ -1393,19 +1494,28 @@ fn write_columns( // cells, where each row will be printed as a single line in the // output. let merge = options.merge_files_print.is_some(); - let table = if !merge && (lines.len() < (content_lines_per_page * columns)) { - to_table_short_file(content_lines_per_page, columns, lines) + let table = if merge { + to_table_merged(content_lines_per_page, columns, filled_lines) } else if across_mode { to_table_across(content_lines_per_page, columns, lines) - } else if merge { - to_table_merged(content_lines_per_page, columns, filled_lines) + } else if lines.len() < (content_lines_per_page * columns) { + to_table_short_file(content_lines_per_page, columns, lines) } else { to_table(content_lines_per_page, columns, lines) }; let blank_line = FileLine::default(); for row in table { - let indexes = row.len(); + // On a page that is not completely filled the last row stops part way + // through, and its final cell must not be followed by a column + // separator. Merging prints an empty cell for every column instead, so + // there the row always spans the full width. + let indexes = if merge { + row.len() + } else { + row.iter().take_while(|cell| cell.is_some()).count() + }; + let mut cells_written = 0; for (i, cell) in row.iter().enumerate() { let line_to_print = match cell { None if options.merge_files_print.is_some() => &blank_line, @@ -1416,16 +1526,23 @@ fn write_columns( Some(file_line) => file_line, }; - write_offset_spaces(out, options.offset_spaces)?; - out.write_all( + write_offset_spaces(writer, options.offset_spaces)?; + writer.write_all( get_line_for_printing(options, line_to_print, columns, i, line_width, indexes) .as_bytes(), )?; + cells_written += 1; } - if not_found_break && feed_line_present { + // The last row of a partly filled page has content in its leading + // columns and nothing in the rest, so it still needs to be terminated. + // Only a row without any content at all means the page ran out. + if cells_written == 0 + && not_found_break + && (feed_line_present || !options.display_header_and_trailer) + { break; } - out.write_all(line_separator)?; + writer.write_all(line_separator)?; } Ok(()) @@ -1460,7 +1577,7 @@ fn get_line_for_printing( "{}{sep}", line_width .map(|i| { - let min_width = (i - (columns - 1)) / columns; + let min_width = i.saturating_sub(columns - 1) / columns; if display_length < min_width { for _i in 0..(min_width - display_length) { complete_line.push(' '); @@ -1480,12 +1597,12 @@ fn get_formatted_line_number(opts: &OutputOptions, line_number: usize, index: us let line_str = line_number.to_string(); let num_opt = opts.number.as_ref().unwrap(); let width = num_opt.width; - let separator = &num_opt.separator; - if line_str.len() >= width { - format!("{:>width$}{separator}", &line_str[line_str.len() - width..]) - } else { - format!("{line_str:>width$}{separator}") - } + let digits = &line_str[line_str.len().saturating_sub(width)..]; + let mut output = String::with_capacity(width + num_opt.separator.len()); + output.extend(std::iter::repeat_n(' ', width - digits.len())); + output.push_str(digits); + output.push_str(&num_opt.separator); + output } else { String::new() } @@ -1519,10 +1636,13 @@ fn header_content(options: &OutputOptions, page: usize) -> Vec { 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}{:padding_before_filename$}{filename}{:padding_after_filename$}{page_part}", - "", "" - ) + let mut line = String::with_capacity(total_width); + line.push_str(date_part); + line.extend(std::iter::repeat_n(' ', padding_before_filename)); + line.push_str(filename); + line.extend(std::iter::repeat_n(' ', padding_after_filename)); + line.push_str(&page_part); + line } else { // If content is too long, just use single spaces format!("{date_part} {filename} {page_part}") diff --git a/src/uu/printenv/Cargo.toml b/src/uu/printenv/Cargo.toml index fa0a2d56a03..a60b5bda9b2 100644 --- a/src/uu/printenv/Cargo.toml +++ b/src/uu/printenv/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/printenv.rs" test = false @@ -21,8 +18,11 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true } + +[lints] +workspace = true [[bin]] name = "printenv" diff --git a/src/uu/printf/Cargo.toml b/src/uu/printf/Cargo.toml index b976d8cd618..c571fcbc13d 100644 --- a/src/uu/printf/Cargo.toml +++ b/src/uu/printf/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/printf.rs" test = false @@ -21,8 +18,14 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["format", "quoting-style"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["format", "quoting-style"] } + +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true [[bin]] name = "printf" diff --git a/src/uu/printf/locales/en-US.ftl b/src/uu/printf/locales/en-US.ftl index 7ecea31aa85..e4cdf32690e 100644 --- a/src/uu/printf/locales/en-US.ftl +++ b/src/uu/printf/locales/en-US.ftl @@ -13,68 +13,53 @@ printf-after-help = basic anonymous string templating: literally, with the exception of the below escaped character sequences, and the substitution sequences described further down. - ### ESCAPE SEQUENCES + ESCAPE SEQUENCES: The following escape sequences, organized here in alphabetical order, will print the corresponding character literal: - - \" double quote - - - \\ backslash - - - \\a alert (BEL) - - - \\b backspace - - - \\c End-of-Input - - - \\e escape - - - \\f form feed - - - \\n new line - - - \\r carriage return - - - \\t horizontal tab - - - \\v vertical tab - - - \\NNN byte with value expressed in octal value NNN (1 to 3 digits) - values greater than 256 will be treated - - - \\xHH byte with value expressed in hexadecimal value NN (1 to 2 digits) - - - \\uHHHH Unicode (IEC 10646) character with value expressed in hexadecimal value HHHH (4 digits) - - - \\uHHHH Unicode character with value expressed in hexadecimal value HHHH (8 digits) - - - %% a single % - - ### SUBSTITUTIONS - - #### SUBSTITUTION QUICK REFERENCE + - \" double quote + - \\ backslash + - \\a alert (BEL) + - \\b backspace + - \\c End-of-Input + - \\e escape + - \\f form feed + - \\n new line + - \\r carriage return + - \\t horizontal tab + - \\v vertical tab + - \\NNN byte with value expressed in octal value NNN (1 to 3 digits) values greater than 256 will be treated + - \\xHH byte with value expressed in hexadecimal value NN (1 to 2 digits) + - \\uHHHH Unicode (IEC 10646) character with value expressed in hexadecimal value HHHH (4 digits) + - \\uHHHH Unicode character with value expressed in hexadecimal value HHHH (8 digits) + - %% a single % + + SUBSTITUTIONS: + + SUBSTITUTION QUICK REFERENCE: Fields - - %s: string - - %b: string parsed for literals second parameter is max length + - %s: string + - %b: string parsed for literals second parameter is max length + + - %c: char no second parameter - - %c: char no second parameter + - %i or %d: 64-bit integer + - %u: 64 bit unsigned integer + - %x or %X: 64-bit unsigned integer as hex + - %o: 64-bit unsigned integer as octal - - %i or %d: 64-bit integer - - %u: 64 bit unsigned integer - - %x or %X: 64-bit unsigned integer as hex - - %o: 64-bit unsigned integer as octal second parameter is min-width, integer output below that width is padded with leading zeroes - - %q: ARGUMENT is printed in a format that can be reused as shell input, escaping non-printable - characters with the proposed POSIX $'' syntax. - - %f or %F: decimal floating point value - - %e or %E: scientific notation floating point value - - %g or %G: shorter of specially interpreted decimal or SciNote floating point value. + - %q: ARGUMENT is printed in a format that can be reused as shell input, escaping non-printable characters with the proposed POSIX $'' syntax. + - %f or %F: decimal floating point value + - %e or %E: scientific notation floating point value + - %g or %G: shorter of specially interpreted decimal or SciNote floating point value. + second parameter is -max places after decimal point for floating point output -max number of significant digits for scientific notation output @@ -105,11 +90,11 @@ printf-after-help = basic anonymous string templating: special prefixes to numeric arguments - - 0: (e.g. 010) interpret argument as octal (integer output fields only) - - 0x: (e.g. 0xABC) interpret argument as hex (numeric output fields only) - - \': (e.g. \'a) interpret argument as a character constant + - 0: (e.g. 010) interpret argument as octal (integer output fields only) + - 0x: (e.g. 0xABC) interpret argument as hex (numeric output fields only) + - \': (e.g. \'a) interpret argument as a character constant - #### HOW TO USE SUBSTITUTIONS + HOW TO USE SUBSTITUTIONS: Substitutions are used to pass additional argument(s) into the FORMAT string, to be formatted a particular way. E.g. @@ -130,22 +115,22 @@ printf-after-help = basic anonymous string templating: will print - it is 22 F in Portland - it is 25 F in Boston - it is 27 F in Boston + it is 22 F in Portland + it is 25 F in Boston + it is 27 F in Boston If a format string is printed but there are less arguments remaining than there are substitution fields, substitution fields without an argument will default to empty strings, or for numeric fields the value 0 - #### AVAILABLE SUBSTITUTIONS + AVAILABLE SUBSTITUTIONS: This program, like GNU coreutils printf, interprets a modified subset of the POSIX C printf spec, a quick reference to substitutions is below. - #### STRING SUBSTITUTIONS + STRING SUBSTITUTIONS: All string fields have a 'max width' parameter %.3s means 'print no more than three characters of the original input' @@ -153,41 +138,37 @@ printf-after-help = basic anonymous string templating: - %s: string - %b: escaped string - the string will be checked for any escaped literals from - the escaped literal list above, and translate them to literal characters. - e.g. \\n will be transformed into a newline character. - One special rule about %b mode is that octal literals are interpreted differently - In arguments passed by %b, pass octal-interpreted literals must be in the form of \\0NNN - instead of \\NNN. (Although, for legacy reasons, octal literals in the form of \\NNN will - still be interpreted and not throw a warning, you will have problems if you use this for a - literal whose code begins with zero, as it will be viewed as in \\0NNN form.) + the escaped literal list above, and translate them to literal characters. + e.g. \\n will be transformed into a newline character. + One special rule about %b mode is that octal literals are interpreted differently. + In arguments passed by %b, pass octal-interpreted literals must be in the form of \\0NNN + instead of \\NNN. (Although, for legacy reasons, octal literals in the form of \\NNN will + still be interpreted and not throw a warning, you will have problems if you use this for a + literal whose code begins with zero, as it will be viewed as in \\0NNN form.) - %q: escaped string - the string in a format that can be reused as input by most shells. - Non-printable characters are escaped with the POSIX proposed ‘$''’ syntax, - and shell meta-characters are quoted appropriately. - This is an equivalent format to ls --quoting=shell-escape output. + Non-printable characters are escaped with the POSIX proposed ‘$''’ syntax, + and shell meta-characters are quoted appropriately. + This is an equivalent format to ls --quoting=shell-escape output. - #### CHAR SUBSTITUTIONS + CHAR SUBSTITUTIONS: The character field does not have a secondary parameter. - - %c: a single character + - %c: a single character - #### INTEGER SUBSTITUTIONS + INTEGER SUBSTITUTIONS: All integer fields have a 'pad with zero' parameter %.4i means an integer which if it is less than 4 digits in length, is padded with leading zeros until it is 4 digits in length. - - %d or %i: 64-bit integer - - - %u: 64-bit unsigned integer - - - %x or %X: 64-bit unsigned integer printed in Hexadecimal (base 16) - %X instead of %x means to use uppercase letters for 'a' through 'f' + - %d or %i: 64-bit integer + - %u: 64-bit unsigned integer + - %x or %X: 64-bit unsigned integer printed in Hexadecimal (base 16). %X instead of %x means to use uppercase letters for 'a' through 'f' + - %o: 64-bit unsigned integer printed in octal (base 8) - - %o: 64-bit unsigned integer printed in octal (base 8) - - #### FLOATING POINT SUBSTITUTIONS + FLOATING POINT SUBSTITUTIONS: All floating point fields have a 'max decimal places / max significant digits' parameter %.10f means a decimal floating point with 7 decimal places past 0 @@ -202,25 +183,26 @@ printf-after-help = basic anonymous string templating: 18th decimal place of +/- 1 - %f: floating point value presented in decimal, truncated and displayed to 6 decimal places by - default. There is not past-double behavior parity with Coreutils printf, values are not - estimated or adjusted beyond input values. + default. There is not past-double behavior parity with Coreutils printf, values are not + estimated or adjusted beyond input values. - %e or %E: floating point value presented in scientific notation - 7 significant digits by default - %E means use to use uppercase E for the mantissa. + 7 significant digits by default. + %E means use to use uppercase E for the mantissa. - %g or %G: floating point value presented in the shortest of decimal and scientific notation - behaves differently from %f and %E, please see posix printf spec for full details, - some examples of different behavior: - Sci Note has 6 significant digits by default - Trailing zeroes are removed - Instead of being truncated, digit after last is rounded + behaves differently from %f and %E, please see posix printf spec for full details, + some examples of different behavior: + + - Sci Note has 6 significant digits by default + - Trailing zeroes are removed + - Instead of being truncated, digit after last is rounded Like other behavior in this utility, the design choices of floating point behavior in this utility is selected to reproduce in exact the behavior of GNU coreutils' printf from an inputs and outputs standpoint. - ### USING PARAMETERS + USING PARAMETERS: Most substitution fields can be parameterized using up to 2 numbers that can be passed to the field, between the % sign and the field letter. @@ -231,7 +213,7 @@ printf-after-help = basic anonymous string templating: The 2nd parameter is proceeded by a dot. You do not have to use parameters - ### SPECIAL FORMS OF INPUT + SPECIAL FORMS OF INPUT: For numeric input, the following additional forms of input are accepted besides decimal: @@ -252,3 +234,8 @@ printf-error-missing-operand = missing operand printf-warning-ignoring-excess-arguments = ignoring excess arguments, starting with { $arg } printf-help-version = Print version information printf-help-help = Print help information + +# Diagnostic advice: shown under the caret when a format fails to parse +printf-diag-help-spec = %d, %s, %x, %f and the other C conversions are accepted, plus %b and %q; a literal % is written %% +printf-diag-help-escape-hex = \x takes one or two hexadecimal digits, \u takes four and \U takes eight +printf-diag-help-unicode = code points between D800 and DFFF or above 10FFFF are not Unicode characters diff --git a/src/uu/printf/locales/fr-FR.ftl b/src/uu/printf/locales/fr-FR.ftl index fdedf249736..bee46816004 100644 --- a/src/uu/printf/locales/fr-FR.ftl +++ b/src/uu/printf/locales/fr-FR.ftl @@ -13,68 +13,52 @@ printf-after-help = templating de chaîne anonyme de base : littéralement, à l'exception de ce qui suit séquences de caractères échappés, et les séquences de substitution décrites plus loin. - ### SÉQUENCES D'ÉCHAPPEMENT + SÉQUENCES D'ÉCHAPPEMENT : Les séquences d'échappement suivantes, organisées ici par ordre alphabétique, afficheront le littéral de caractère correspondant : - - \" guillemet double - - - \\ barre oblique inverse - - - \\a alerte (BEL) - - - \\b retour arrière - - - \\c Fin d'entrée - - - \\e échappement - - - \\f saut de page - - - \\n nouvelle ligne - - - \\r retour chariot - - - \\t tabulation horizontale - - - \\v tabulation verticale - - - \\NNN octet avec valeur exprimée en valeur octale NNN (1 à 3 chiffres) - les valeurs supérieures à 256 seront traitées - - - \\xHH octet avec valeur exprimée en valeur hexadécimale NN (1 à 2 chiffres) - - - \\uHHHH caractère Unicode (IEC 10646) avec valeur exprimée en valeur hexadécimale HHHH (4 chiffres) - - - \\uHHHH caractère Unicode avec valeur exprimée en valeur hexadécimale HHHH (8 chiffres) - - - %% un seul % - - ### SUBSTITUTIONS - - #### RÉFÉRENCE RAPIDE DES SUBSTITUTIONS + - \" guillemet double + - \\ barre oblique inverse + - \\a alerte (BEL) + - \\b retour arrière + - \\c Fin d'entrée + - \\e échappement + - \\f saut de page + - \\n nouvelle ligne + - \\r retour chariot + - \\t tabulation horizontale + - \\v tabulation verticale + - \\NNN octet avec valeur exprimée en valeur octale NNN (1 à 3 chiffres) les valeurs supérieures à 256 seront traitées + - \\xHH octet avec valeur exprimée en valeur hexadécimale NN (1 à 2 chiffres) + - \\uHHHH caractère Unicode (IEC 10646) avec valeur exprimée en valeur hexadécimale HHHH (4 chiffres) + - \\uHHHH caractère Unicode avec valeur exprimée en valeur hexadécimale HHHH (8 chiffres) + - %% un seul % + + SUBSTITUTIONS : + + RÉFÉRENCE RAPIDE DES SUBSTITUTIONS : Champs - - %s: chaîne - - %b: chaîne analysée pour les littéraux, le deuxième paramètre est la longueur max + - %s: chaîne + - %b: chaîne analysée pour les littéraux, le deuxième paramètre est la longueur max - - %c: caractère, pas de deuxième paramètre + - %c: caractère, pas de deuxième paramètre + + - %i ou %d: entier 64 bits + - %u: entier non signé 64 bits + - %x ou %X: entier non signé 64 bits en hexadécimal + - %o: entier non signé 64 bits en octal - - %i ou %d: entier 64 bits - - %u: entier non signé 64 bits - - %x ou %X: entier non signé 64 bits en hexadécimal - - %o: entier non signé 64 bits en octal le deuxième paramètre est la largeur min, entier la sortie en dessous de cette largeur est remplie avec des zéros en tête - - %q: ARGUMENT est affiché dans un format qui peut être réutilisé comme entrée shell, en échappant les - caractères non imprimables avec la syntaxe POSIX $'' proposée. + - %q: ARGUMENT est affiché dans un format qui peut être réutilisé comme entrée shell, en échappant les caractères non imprimables avec la syntaxe POSIX $'' proposée. + - %f ou %F: valeur en virgule flottante décimale + - %e ou %E: valeur en virgule flottante en notation scientifique + - %g ou %G: plus courte des valeurs en virgule flottante décimale ou SciNote interprétées spécialement. - - %f ou %F: valeur en virgule flottante décimale - - %e ou %E: valeur en virgule flottante en notation scientifique - - %g ou %G: plus courte des valeurs en virgule flottante décimale ou SciNote interprétées spécialement. le deuxième paramètre est -max places après la virgule pour la sortie en virgule flottante -max nombre de chiffres significatifs pour la sortie en notation scientifique @@ -105,11 +89,11 @@ printf-after-help = templating de chaîne anonyme de base : préfixes spéciaux pour les arguments numériques - - 0: (ex. 010) interpréter l'argument comme octal (champs de sortie entiers uniquement) - - 0x: (ex. 0xABC) interpréter l'argument comme hexadécimal (champs de sortie numériques uniquement) - - \': (ex. \'a) interpréter l'argument comme une constante de caractère + - 0: (ex. 010) interpréter l'argument comme octal (champs de sortie entiers uniquement) + - 0x: (ex. 0xABC) interpréter l'argument comme hexadécimal (champs de sortie numériques uniquement) + - \': (ex. \'a) interpréter l'argument comme une constante de caractère - #### COMMENT UTILISER LES SUBSTITUTIONS + COMMENT UTILISER LES SUBSTITUTIONS : Les substitutions sont utilisées pour passer des argument(s) supplémentaire(s) dans la chaîne FORMAT, pour être formatés d'une manière particulière. Par ex. @@ -130,22 +114,22 @@ printf-after-help = templating de chaîne anonyme de base : affichera - il fait 22 F à Portland - il fait 25 F à Boston - il fait 27 F à Boston + il fait 22 F à Portland + il fait 25 F à Boston + il fait 27 F à Boston Si une chaîne de format est affichée mais qu'il reste moins d'arguments qu'il n'y a de champs de substitution, les champs de substitution sans argument auront par défaut des chaînes vides, ou pour les champs numériques la valeur 0 - #### SUBSTITUTIONS DISPONIBLES + SUBSTITUTIONS DISPONIBLES : Ce programme, comme GNU coreutils printf, interprète un sous-ensemble modifié de la spécification printf C POSIX, une référence rapide aux substitutions est ci-dessous. - #### SUBSTITUTIONS DE CHAÎNES + SUBSTITUTIONS DE CHAÎNES : Tous les champs de chaîne ont un paramètre 'largeur max' %.3s signifie 'afficher pas plus de trois caractères de l'entrée originale' @@ -153,41 +137,37 @@ printf-after-help = templating de chaîne anonyme de base : - %s: chaîne - %b: chaîne échappée - la chaîne sera vérifiée pour tout littéral échappé de - la liste de littéraux échappés ci-dessus, et les traduire en caractères littéraux. - ex. \\n sera transformé en caractère de nouvelle ligne. - Une règle spéciale sur le mode %b est que les littéraux octaux sont interprétés différemment - Dans les arguments passés par %b, les littéraux interprétés en octal doivent être sous la forme \\0NNN - au lieu de \\NNN. (Bien que, pour des raisons d'héritage, les littéraux octaux sous la forme \\NNN seront - toujours interprétés et ne lanceront pas d'avertissement, vous aurez des problèmes si vous utilisez cela pour un - littéral dont le code commence par zéro, car il sera vu comme étant sous forme \\0NNN.) + la liste de littéraux échappés ci-dessus, et les traduire en caractères littéraux. + ex. \\n sera transformé en caractère de nouvelle ligne. + Une règle spéciale sur le mode %b est que les littéraux octaux sont interprétés différemment. + Dans les arguments passés par %b, les littéraux interprétés en octal doivent être sous la forme \\0NNN + au lieu de \\NNN. (Bien que, pour des raisons d'héritage, les littéraux octaux sous la forme \\NNN seront + toujours interprétés et ne lanceront pas d'avertissement, vous aurez des problèmes si vous utilisez cela pour un + littéral dont le code commence par zéro, car il sera vu comme étant sous forme \\0NNN.) - %q: chaîne échappée - la chaîne dans un format qui peut être réutilisé comme entrée par la plupart des shells. - Les caractères non imprimables sont échappés avec la syntaxe POSIX proposée '$''', - et les méta-caractères shell sont cités de manière appropriée. - C'est un format équivalent à la sortie ls --quoting=shell-escape. + Les caractères non imprimables sont échappés avec la syntaxe POSIX proposée '$''', + et les méta-caractères shell sont cités de manière appropriée. + C'est un format équivalent à la sortie ls --quoting=shell-escape. - #### SUBSTITUTIONS DE CARACTÈRES + SUBSTITUTIONS DE CARACTÈRES : Le champ caractère n'a pas de paramètre secondaire. - - %c: un seul caractère + - %c: un seul caractère - #### SUBSTITUTIONS D'ENTIERS + SUBSTITUTIONS D'ENTIERS : Tous les champs entiers ont un paramètre 'remplir avec zéro' %.4i signifie un entier qui s'il fait moins de 4 chiffres de longueur, est rempli avec des zéros en tête jusqu'à ce qu'il fasse 4 chiffres de longueur. - - %d ou %i: entier 64 bits - - - %u: entier non signé 64 bits - - - %x ou %X: entier non signé 64 bits affiché en hexadécimal (base 16) - %X au lieu de %x signifie utiliser des lettres majuscules pour 'a' à 'f' + - %d ou %i: entier 64 bits + - %u: entier non signé 64 bits + - %x ou %X: entier non signé 64 bits affiché en hexadécimal (base 16). %X au lieu de %x signifie utiliser des lettres majuscules pour 'a' à 'f' + - %o: entier non signé 64 bits affiché en octal (base 8) - - %o: entier non signé 64 bits affiché en octal (base 8) - - #### SUBSTITUTIONS EN VIRGULE FLOTTANTE + SUBSTITUTIONS EN VIRGULE FLOTTANTE : Tous les champs en virgule flottante ont un paramètre 'max places décimales / max chiffres significatifs' %.10f signifie une virgule flottante décimale avec 7 places décimales après 0 @@ -202,25 +182,26 @@ printf-after-help = templating de chaîne anonyme de base : 18ème place décimale de +/- 1 - %f: valeur en virgule flottante présentée en décimal, tronquée et affichée à 6 places décimales par - défaut. Il n'y a pas de parité de comportement post-double avec Coreutils printf, les valeurs ne sont pas - estimées ou ajustées au-delà des valeurs d'entrée. + défaut. Il n'y a pas de parité de comportement post-double avec Coreutils printf, les valeurs ne sont pas + estimées ou ajustées au-delà des valeurs d'entrée. - %e ou %E: valeur en virgule flottante présentée en notation scientifique - 7 chiffres significatifs par défaut - %E signifie utiliser E majuscule pour la mantisse. + 7 chiffres significatifs par défaut. + %E signifie utiliser E majuscule pour la mantisse. - %g ou %G: valeur en virgule flottante présentée dans la plus courte des notations décimale et scientifique - se comporte différemment de %f et %E, veuillez voir la spécification posix printf pour tous les détails, - quelques exemples de comportement différent : - Sci Note a 6 chiffres significatifs par défaut - Les zéros de fin sont supprimés - Au lieu d'être tronqué, le chiffre après le dernier est arrondi + se comporte différemment de %f et %E, veuillez voir la spécification posix printf pour tous les détails, + quelques exemples de comportement différent : + + - Sci Note a 6 chiffres significatifs par défaut + - Les zéros de fin sont supprimés + - Au lieu d'être tronqué, le chiffre après le dernier est arrondi Comme d'autres comportements dans cet utilitaire, les choix de conception du comportement en virgule flottante dans cet utilitaire sont sélectionnés pour reproduire exactement le comportement de printf de GNU coreutils du point de vue des entrées et sorties. - ### UTILISATION DES PARAMÈTRES + UTILISATION DES PARAMÈTRES : La plupart des champs de substitution peuvent être paramétrés en utilisant jusqu'à 2 nombres qui peuvent être passés au champ, entre le signe % et la lettre du champ. @@ -231,7 +212,7 @@ printf-after-help = templating de chaîne anonyme de base : Le 2ème paramètre est précédé d'un point. Vous n'êtes pas obligé d'utiliser des paramètres - ### FORMES SPÉCIALES D'ENTRÉE + FORMES SPÉCIALES D'ENTRÉE : Pour l'entrée numérique, les formes d'entrée supplémentaires suivantes sont acceptées en plus du décimal : @@ -253,3 +234,8 @@ printf-error-missing-operand = opérande manquant printf-warning-ignoring-excess-arguments = arguments excédentaires ignorés, en commençant par { $arg } printf-help-version = Afficher les informations de version printf-help-help = Afficher cette aide + +# Conseils de diagnostic : affichés sous le caret quand un format ne peut être analysé +printf-diag-help-spec = %d, %s, %x, %f et les autres conversions C sont acceptées, ainsi que %b et %q ; un % littéral s'écrit %% +printf-diag-help-escape-hex = \x prend un ou deux chiffres hexadécimaux, \u en prend quatre et \U en prend huit +printf-diag-help-unicode = les points de code entre D800 et DFFF ou au-delà de 10FFFF ne sont pas des caractères Unicode diff --git a/src/uu/printf/src/diagnostics.rs b/src/uu/printf/src/diagnostics.rs new file mode 100644 index 00000000000..c47c49139c3 --- /dev/null +++ b/src/uu/printf/src/diagnostics.rs @@ -0,0 +1,51 @@ +// 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. + +//! Maps a [`FormatError`] onto the part of the format string it came from, so +//! that [`uucore::diagnostics`] can render it with a caret. + +use std::ffi::{OsStr, OsString}; + +use uucore::diagnostics::Snapshot; +use uucore::format::FormatError; +use uucore::translate; + +/// Render `error` against `args` — the whole argument list, program name +/// included — where `format` is the format operand as typed. +/// +/// Returns `false` when the error does not point into the format string or +/// the format cannot be found among the arguments, in which case the caller +/// should fall back to the plain one-line message. +pub fn render(args: &[OsString], format: &[u8], error: &FormatError) -> bool { + let (span, help) = match error { + FormatError::SpecError(_, span) => (span, "printf-diag-help-spec"), + // A `None` span is an escape parsed out of something other than the + // format string, so there is nothing to point a caret at. + FormatError::MissingHex(Some(span)) => (span, "printf-diag-help-escape-hex"), + FormatError::InvalidCharacter(_, _, Some(span)) => (span, "printf-diag-help-unicode"), + _ => return false, + }; + // Offsets into the format are byte offsets; a format that is not text + // cannot be pointed into. + let Ok(format) = std::str::from_utf8(format) else { + return false; + }; + + // The format is the first operand that spells it out: printf takes hyphen + // values, so `printf -%y` is a format rather than an option, and counting + // positionals would walk right past it. + let snapshot = Snapshot::with_program(args); + let Some(index) = snapshot.index_of(OsStr::new(format)) else { + return false; + }; + snapshot.render_inside_at( + index, + format, + span.clone(), + &error.to_string(), + None, + Some(&translate!(help)), + ) +} diff --git a/src/uu/printf/src/printf.rs b/src/uu/printf/src/printf.rs index 4cf58bbeb5b..4d4d979d0c5 100644 --- a/src/uu/printf/src/printf.rs +++ b/src/uu/printf/src/printf.rs @@ -2,16 +2,21 @@ // // 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 std::ffi::OsString; -use std::io::stdout; +use std::io::{Write, stdout}; use std::ops::ControlFlow; use uucore::display::Quotable; -use uucore::error::{UResult, UUsageError}; -use uucore::format::{FormatArgument, FormatArguments, FormatItem, parse_spec_and_escape}; +use uucore::error::{FromIo, UError, UResult, UUsageError}; +use uucore::format::{ + FormatArgument, FormatArguments, FormatError, FormatItem, parse_spec_and_escape, +}; use uucore::translate; use uucore::{format_usage, os_str_as_bytes, show_warning}; +mod diagnostics; + const VERSION: &str = "version"; const HELP: &str = "help"; @@ -22,7 +27,51 @@ mod options { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + let result = print_formatted(args); + + // A format without a trailing newline leaves the output sitting in the + // buffer, so a failed write is only visible once it is flushed. Without + // this the data would be dropped while printf still reported success. + // A broken pipe is how a downstream reader normally ends a stream, so it + // is left alone; and a failure already reported is not repeated. + if result.is_ok() + && let Err(e) = stdout().flush() + && e.kind() != std::io::ErrorKind::BrokenPipe + { + return Err(e).map_err_context(|| translate!("common-write-error")); + } + result +} + +/// Mark where the options end, so that only a `--` written before FORMAT acts +/// as the terminator. +/// +/// GNU accepts a leading `--` (`printf -- '%s\n' a`), but past FORMAT every +/// operand is data: `printf '%s\n' --` prints `--`. clap would instead +/// swallow the first `--` wherever it appears, so insert one in front of the +/// format operand when the caller did not write it there. +fn mark_end_of_options(mut args: Vec) -> Vec { + let mut i = 1; + while i < args.len() { + // Both exit before FORMAT is looked at, and GNU honors them only here. + if args[i] == "--help" || args[i] == "--version" { + i += 1; + continue; + } + if args[i] != "--" { + args.insert(i, OsString::from("--")); + } + break; + } + args +} + +fn print_formatted(args: impl uucore::Args) -> UResult<()> { + let args: Vec = args.collect(); + // Kept for the caret in format diagnostics, which needs the format as typed. + let diag_args = uucore::diagnostics::capture(&args); + let matches = + uucore::clap_localization::handle_clap_result(uu_app(), mark_end_of_options(args))?; let format = matches .get_one::(options::FORMAT) @@ -36,6 +85,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { None => vec![], }; + // A parse error is rendered against the argument list when stderr is a + // terminal; the plain one-line message is kept anywhere else. + let raise = |error: FormatError| -> Box { + uucore::diagnostics::error_after_report(diag_args.as_deref(), error, |args, error| { + diagnostics::render(args, format, error) + }) + }; + let mut format_seen = false; // Parse and process the format string let mut args = FormatArguments::new(&values); @@ -43,7 +100,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { if let Ok(FormatItem::Spec(_)) = item { format_seen = true; } - match item?.write(stdout(), &mut args)? { + match item.map_err(raise)?.write(stdout(), &mut args)? { ControlFlow::Continue(()) => {} ControlFlow::Break(()) => return Ok(()), } @@ -70,7 +127,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { while !args.is_exhausted() { for item in parse_spec_and_escape(format) { - match item?.write(stdout(), &mut args)? { + match item.map_err(raise)?.write(stdout(), &mut args)? { ControlFlow::Continue(()) => {} ControlFlow::Break(()) => return Ok(()), } diff --git a/src/uu/ptx/Cargo.toml b/src/uu/ptx/Cargo.toml index 83225408a97..af66badbc74 100644 --- a/src/uu/ptx/Cargo.toml +++ b/src/uu/ptx/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/ptx.rs" test = false @@ -21,11 +18,23 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } regex = { workspace = true } -uucore = { workspace = true } +rustc-hash = { workspace = true } thiserror = { workspace = true } -fluent = { workspace = true } +uucore = { workspace = true } + +[dev-dependencies] +divan = { workspace = true } +uucore = { workspace = true, features = ["benchmark"] } + +[lints] +workspace = true [[bin]] name = "ptx" path = "src/main.rs" + +[[bench]] +name = "ptx_bench" +harness = false diff --git a/src/uu/ptx/benches/ptx_bench.rs b/src/uu/ptx/benches/ptx_bench.rs new file mode 100644 index 00000000000..f663ce23741 --- /dev/null +++ b/src/uu/ptx/benches/ptx_bench.rs @@ -0,0 +1,77 @@ +// 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 `ptx`. + +use divan::{Bencher, black_box}; +use uu_ptx::uumain; +use uucore::benchmark::{run_util_function, setup_test_file, text_data}; + +fn bench_ptx(bencher: Bencher, data: &[u8], args: &[&str]) { + let file_path = setup_test_file(data); + let file_path_str = file_path.to_str().unwrap(); + + let mut full_args: Vec<&str> = args.to_vec(); + full_args.push(file_path_str); + + bencher.bench(|| { + black_box(run_util_function(uumain, &full_args)); + }); +} + +/// Build a fixed ~1 MiB input spread across num_lines lines. +fn fixed_size_data(num_lines: usize) -> Vec { + let line_len = (1024 * 1024 / num_lines).max(1); + text_data::generate_by_lines(num_lines, line_len) +} + +/// Benchmark the common case of many short lines. +#[divan::bench(args = [100_000])] +fn ptx_short_lines(bencher: Bencher, num_lines: usize) { + let data = text_data::generate_by_lines(num_lines, 80); + bench_ptx(bencher, &data, &[]); +} + +/// Benchmark a fixed ~1 MiB input spread across 100 lines. +#[divan::bench(args = [100])] +fn ptx_long_lines(bencher: Bencher, num_lines: usize) { + bench_ptx(bencher, &fixed_size_data(num_lines), &[]); +} + +/// Benchmark -r on many short lines. +#[divan::bench(args = [100_000])] +fn ptx_input_references_short_lines(bencher: Bencher, num_lines: usize) { + let data = text_data::generate_by_lines(num_lines, 80); + bench_ptx(bencher, &data, &["-r"]); +} + +/// Benchmark -r on long lines +#[divan::bench(args = [1000])] +fn ptx_input_references_long_lines(bencher: Bencher, num_lines: usize) { + bench_ptx(bencher, &fixed_size_data(num_lines), &["-r"]); +} + +fn tex_special_data(num_lines: usize) -> Vec { + let lines = [ + "the permuted index of \\alpha sorted around each keyword", + "context $x$ lines paired with file_name and A&B output", + "roughly 50% of {group} entries escaped as #1 or x_1 here", + "each \\ref and $y$ carries a file_name style suffix too", + "sorted A&B context around 90% of the {group} keywords", + "index entries with x_1 and #1 spread across the \\alpha line", + ]; + text_data::generate_data_from_words(&lines, num_lines) +} + +/// Benchmark -T on many short lines. +#[divan::bench(args = [10000])] +fn ptx_tex(bencher: Bencher, num_lines: usize) { + let data = tex_special_data(num_lines); + bench_ptx(bencher, &data, &["-T"]); +} + +fn main() { + divan::main(); +} diff --git a/src/uu/ptx/src/ptx.rs b/src/uu/ptx/src/ptx.rs index d887f552d12..e0f6a9da228 100644 --- a/src/uu/ptx/src/ptx.rs +++ b/src/uu/ptx/src/ptx.rs @@ -3,26 +3,38 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDOs) corasick memchr Roff trunc oset iset CHARCLASS +// spell-checker:ignore (ToDOs) corasick memchr Roff trunc oset iset CHARCLASS rescanning use std::cmp; use std::cmp::PartialEq; -use std::collections::{BTreeSet, HashSet}; +use std::collections::BTreeSet; use std::ffi::{OsStr, OsString}; use std::fmt::Write as FmtWrite; use std::fs::File; use std::io::{BufRead, BufReader, BufWriter, Read, Write, stdin, stdout}; -use std::num::ParseIntError; +use std::ops::Range; use std::path::Path; -use clap::{Arg, ArgAction, Command}; +use clap::{Arg, ArgAction, Command, value_parser}; use regex::Regex; -use thiserror::Error; +use rustc_hash::FxHashSet; use uucore::display::Quotable; -use uucore::error::{FromIo, UError, UResult, USimpleError, UUsageError}; +use uucore::error::{FromIo, UResult, USimpleError, UUsageError}; use uucore::format_usage; use uucore::translate; +/// GNU's regex engine treats a trailing lone backslash as a literal backslash, +/// while the `regex` crate rejects it as an incomplete escape sequence. Double +/// it so that such patterns keep working instead of erroring out. +fn escape_trailing_backslash(pattern: &str) -> String { + let trailing = pattern.chars().rev().take_while(|&c| c == '\\').count(); + if trailing % 2 == 1 { + format!("{pattern}\\") + } else { + pattern.to_owned() + } +} + #[derive(Debug, PartialEq)] enum OutFormat { Dumb, @@ -68,7 +80,7 @@ impl Default for Config { fn read_word_filter_file( matches: &clap::ArgMatches, option: &str, -) -> std::io::Result> { +) -> std::io::Result> { let filename = matches .get_one::(option) .expect("parsing options failed!"); @@ -78,7 +90,7 @@ fn read_word_filter_file( let file = File::open(Path::new(filename))?; Box::new(file) }); - let mut words: HashSet = HashSet::new(); + let mut words: FxHashSet = FxHashSet::default(); for word in reader.lines() { words.insert(word?); } @@ -89,7 +101,7 @@ fn read_word_filter_file( fn read_char_filter_file( matches: &clap::ArgMatches, option: &str, -) -> std::io::Result> { +) -> std::io::Result> { let filename = matches .get_one::(option) .expect("parsing options failed!"); @@ -108,29 +120,29 @@ fn read_char_filter_file( struct WordFilter { only_specified: bool, ignore_specified: bool, - only_set: HashSet, - ignore_set: HashSet, + only_set: FxHashSet, + ignore_set: FxHashSet, word_regex: String, } impl WordFilter { #[allow(clippy::cognitive_complexity)] fn new(matches: &clap::ArgMatches, config: &Config) -> UResult { - let (o, oset): (bool, HashSet) = if matches.contains_id(options::ONLY_FILE) { + let (o, oset): (bool, FxHashSet) = if matches.contains_id(options::ONLY_FILE) { let words = read_word_filter_file(matches, options::ONLY_FILE).map_err_context(String::new)?; (true, words) } else { - (false, HashSet::new()) + (false, FxHashSet::default()) }; - let (i, iset): (bool, HashSet) = if matches.contains_id(options::IGNORE_FILE) { + let (i, iset): (bool, FxHashSet) = if matches.contains_id(options::IGNORE_FILE) { let words = read_word_filter_file(matches, options::IGNORE_FILE) .map_err_context(String::new)?; (true, words) } else { - (false, HashSet::new()) + (false, FxHashSet::default()) }; - let break_set: Option> = if matches.contains_id(options::BREAK_FILE) + let break_set: Option> = if matches.contains_id(options::BREAK_FILE) && !matches.contains_id(options::WORD_REGEXP) { let mut chars = @@ -147,16 +159,10 @@ impl WordFilter { }; // Ignore empty string regex from cmd-line-args let arg_reg: Option = if matches.contains_id(options::WORD_REGEXP) { - match matches.get_one::(options::WORD_REGEXP) { - Some(v) => { - if v.is_empty() { - None - } else { - Some(v.to_owned()) - } - } - None => None, - } + matches + .get_one::(options::WORD_REGEXP) + .filter(|v| !v.is_empty()) + .map(|v| escape_trailing_backslash(v)) } else { None }; @@ -188,21 +194,13 @@ impl WordFilter { #[derive(Debug, PartialOrd, PartialEq, Eq, Ord)] struct WordRef { word: String, - global_line_nr: usize, + file_index: usize, local_line_nr: usize, position: usize, position_end: usize, - filename: OsString, -} - -#[derive(Debug, Error)] -enum PtxError { - #[error("{0}")] - ParseError(ParseIntError), + char_start: usize, } -impl UError for PtxError {} - fn get_config(matches: &mut clap::ArgMatches) -> UResult { let mut config = Config::default(); let err_msg = "parsing options failed"; @@ -211,10 +209,13 @@ fn get_config(matches: &mut clap::ArgMatches) -> UResult { config.format = OutFormat::Roff; "[^ \t\n]+".clone_into(&mut config.context_regex); } - if let Some(regex) = matches.remove_one::(options::SENTENCE_REGEXP) { + if let Some(regex) = matches + .remove_one::(options::SENTENCE_REGEXP) + .map(|r| escape_trailing_backslash(&r)) + { // TODO: The regex crate used here is not fully compatible with GNU's regex implementation. // For example, it does not support backreferences. - // In the future, we might want to switch to the onig crate (like expr does) for better compatibility. + // In the future, we might want to switch to the fancy-regex crate for better compatibility. // Verify regex is valid and doesn't match empty string let re = Regex::new(®ex).map_err(|error| { @@ -246,20 +247,12 @@ fn get_config(matches: &mut clap::ArgMatches) -> UResult { .clone_into(&mut config.trunc_str); } if matches.contains_id(options::WIDTH) { - config.line_width = matches - .get_one::(options::WIDTH) - .expect(err_msg) - .parse() - .map_err(PtxError::ParseError)?; + config.line_width = *matches.get_one::(options::WIDTH).unwrap() as usize; } else if matches.get_flag(options::TYPESET_MODE) { config.line_width = 100; } if matches.contains_id(options::GAP_SIZE) { - config.gap_size = matches - .get_one::(options::GAP_SIZE) - .expect(err_msg) - .parse() - .map_err(PtxError::ParseError)?; + config.gap_size = *matches.get_one::(options::GAP_SIZE).unwrap() as usize; } if let Some(format) = matches.get_one::(options::FORMAT) { config.format = match format.as_str() { @@ -280,14 +273,12 @@ fn get_config(matches: &mut clap::ArgMatches) -> UResult { struct FileContent { lines: Vec, chars_lines: Vec>, - offset: usize, } type FileMap = Vec<(OsString, FileContent)>; fn read_input(input_files: &[OsString], config: &Config) -> UResult { let mut file_map: FileMap = FileMap::new(); - let mut offset: usize = 0; let sentence_splitter = config .sentence_regex @@ -311,16 +302,7 @@ fn read_input(input_files: &[OsString], config: &Config) -> UResult { // Indexing UTF-8 string requires walking from the beginning, which can hurts performance badly when the line is long. // Since we will be jumping around the line a lot, we dump the content into a Vec, 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.push(( - filename.clone(), - FileContent { - lines, - chars_lines, - offset, - }, - )); - offset += size; + file_map.push((filename.clone(), FileContent { lines, chars_lines })); } Ok(file_map) } @@ -329,17 +311,20 @@ fn read_lines( sentence_splitter: Option<&Regex>, reader: &mut dyn BufRead, ) -> std::io::Result> { - if let Some(re) = sentence_splitter { - let mut buffer = String::new(); - reader.read_to_string(&mut buffer)?; + // GNU ptx works on bytes, so invalid UTF-8 input must not be an error. + // Read everything and replace invalid sequences instead of failing. + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes)?; + let buffer = String::from_utf8_lossy(&bytes); + if let Some(re) = sentence_splitter { Ok(re .split(&buffer) .map(|s| s.replace('\n', " ")) // ptx behavior: newlines become spaces inside sentences .filter(|s| !s.is_empty()) // remove empty sentences .collect()) } else { - reader.lines().collect() + Ok(buffer.lines().map(ToOwned::to_owned).collect()) } } @@ -353,18 +338,22 @@ fn create_word_set(config: &Config, filter: &WordFilter, file_map: &FileMap) -> }; let mut word_set: BTreeSet = BTreeSet::new(); - for (file, lines) in file_map { + for (file_index, (_, lines)) in file_map.iter().enumerate() { let mut count: usize = 0; - let offs = lines.offset; for line in &lines.lines { // if -r, exclude reference from word set let (ref_beg, ref_end) = match ref_reg.find(line) { Some(x) => (x.start(), x.end()), None => (0, 0), }; + let mut last_counted_byte = 0; + let mut char_start = 0; // match words with given regex for mat in reg.find_iter(line) { let (mut beg, end) = (mat.start(), mat.end()); + if beg == end { + continue; + } // GNU-compatible default behavior: // with default regexp, keyword must start at first alphabetic char. @@ -390,13 +379,17 @@ fn create_word_set(config: &Config, filter: &WordFilter, file_map: &FileMap) -> if config.ignore_case { word = word.to_uppercase(); } + + // Count from the previous match to avoid rescanning the line prefix. + char_start += line[last_counted_byte..beg].chars().count(); + last_counted_byte = beg; word_set.insert(WordRef { word, - filename: file.clone(), - global_line_nr: offs + count, + file_index, local_line_nr: count, position: beg, position_end: end, + char_start, }); } count += 1; @@ -405,16 +398,18 @@ fn create_word_set(config: &Config, filter: &WordFilter, file_map: &FileMap) -> word_set } -fn get_reference(config: &Config, word_ref: &WordRef, line: &str, context_reg: &Regex) -> String { +fn get_reference( + config: &Config, + word_ref: &WordRef, + filename: &OsStr, + line: &str, + context_reg: &Regex, +) -> String { if config.auto_ref { - if word_ref.filename == "-" { + if filename == "-" { format!(":{}", word_ref.local_line_nr + 1) } else { - format!( - "{}:{}", - word_ref.filename.maybe_quote(), - word_ref.local_line_nr + 1 - ) + format!("{}:{}", filename.maybe_quote(), word_ref.local_line_nr + 1) } } else if config.input_ref { let (beg, end) = match context_reg.find(line) { @@ -427,194 +422,211 @@ fn get_reference(config: &Config, word_ref: &WordRef, line: &str, context_reg: & } } -fn assert_str_integrity(s: &[char], beg: usize, end: usize) { - assert!(beg <= end); - assert!(end <= s.len()); -} - -fn trim_broken_word_left(s: &[char], beg: usize, end: usize) -> usize { - assert_str_integrity(s, beg, end); - if beg == end || beg == 0 || s[beg].is_whitespace() || s[beg - 1].is_whitespace() { - return beg; - } - let mut b = beg; - while b < end && !s[b].is_whitespace() { - b += 1; +/// A line of text, addressed by character rather than by byte. +/// +/// ptx measures every field width in characters, so the text arrives already +/// decoded and each method below works on character indices. Fields are carved +/// out as ranges rather than strings because the caller needs to know how much +/// text was left over on either side: that, and not the field contents, is what +/// decides whether a truncation mark is printed. +struct Line<'a>(&'a [char]); + +impl Line<'_> { + fn len(&self) -> usize { + self.0.len() } - b -} -fn trim_broken_word_right(s: &[char], beg: usize, end: usize) -> usize { - assert_str_integrity(s, beg, end); - if beg == end || end == s.len() || s[end - 1].is_whitespace() || s[end].is_whitespace() { - return end; + fn text(&self, range: Range) -> String { + self.0[range].iter().collect() } - let mut e = end; - while beg < e && !s[e - 1].is_whitespace() { - e -= 1; - } - e -} -fn trim_idx(s: &[char], beg: usize, end: usize) -> (usize, usize) { - assert_str_integrity(s, beg, end); - let mut b = beg; - let mut e = end; - while b < e && s[b].is_whitespace() { - b += 1; + fn is_space(&self, index: usize) -> bool { + self.0[index].is_whitespace() } - while beg < e && s[e - 1].is_whitespace() { - e -= 1; + + /// How many bytes `range` takes once encoded back to UTF-8. + fn byte_len(&self, range: Range) -> usize { + self.0[range].iter().map(|c| c.len_utf8()).sum() } - (b, e) -} -fn get_output_chunks( - all_before: &[char], - keyword: &str, - all_after: &[char], - config: &Config, -) -> (String, String, String, String) { - // Chunk size logics are mostly copied from the GNU ptx source. - // https://github.com/MaiZure/coreutils-8.3/blob/master/src/ptx.c#L1234 - let half_line_size = config.line_width / 2; - let max_before_size = cmp::max(half_line_size as isize - config.gap_size as isize, 0) as usize; - - let keyword_len = keyword.chars().count(); - let trunc_len = config.trunc_str.chars().count(); - let max_after_size = cmp::max( - half_line_size as isize - (2 * trunc_len) as isize - keyword_len as isize - 1, - 0, - ) as usize; - - // Allocate plenty space for all the chunks. - let mut head = String::with_capacity(half_line_size); - let mut before = String::with_capacity(half_line_size); - let mut after = String::with_capacity(half_line_size); - let mut tail = String::with_capacity(half_line_size); - - // the before chunk - - // trim whitespace away from all_before to get the index where the before chunk should end. - let (_, before_end) = trim_idx(all_before, 0, all_before.len()); - - // the minimum possible begin index of the before_chunk is the end index minus the length. - let before_beg = cmp::max(before_end as isize - max_before_size as isize, 0) as usize; - // in case that falls in the middle of a word, trim away the word. - let before_beg = trim_broken_word_left(all_before, before_beg, before_end); - - // trim away white space. - let (before_beg, before_end) = trim_idx(all_before, before_beg, before_end); - - // and get the string. - let before_str: String = all_before[before_beg..before_end].iter().collect(); - before.push_str(&before_str); - assert!(max_before_size >= before.chars().count()); - - // the after chunk - - // must be no longer than the minimum between the max size and the total available string. - let after_end = cmp::min(max_after_size, all_after.len()); - // in case that falls in the middle of a word, trim away the word. - let after_end = trim_broken_word_right(all_after, 0, after_end); - - // trim away white space. - let (_, after_end) = trim_idx(all_after, 0, after_end); - - // and get the string - let after_str: String = all_after[0..after_end].iter().collect(); - after.push_str(&after_str); - assert!(max_after_size >= after.chars().count()); - - // the tail chunk - - // max size of the tail chunk = max size of left half - space taken by before chunk - gap size. - let max_tail_size = cmp::max( - max_before_size as isize - before.len() as isize - config.gap_size as isize, - 0, - ) as usize; - - // the tail chunk takes text starting from where the after chunk ends (with whitespace trimmed). - let (tail_beg, _) = trim_idx(all_after, after_end, all_after.len()); - - // end = begin + max length - let tail_end = cmp::min(all_after.len(), tail_beg + max_tail_size); - // in case that falls in the middle of a word, trim away the word. - let tail_end = trim_broken_word_right(all_after, tail_beg, tail_end); - - // trim away whitespace again. - let (tail_beg, mut tail_end) = trim_idx(all_after, tail_beg, tail_end); - // Fix: Manually trim trailing char (like "a") that are preceded by a space. - // This handles cases like "is a" which are not correctly trimmed by the - // preceding functions. - if tail_end >= 2 - && (tail_end - 2) > tail_beg - && all_after[tail_end - 2].is_whitespace() - && !all_after[tail_end - 1].is_whitespace() - { - tail_end -= 1; - (_, tail_end) = trim_idx(all_after, tail_beg, tail_end); + /// `range` with whitespace dropped from both ends. A range holding nothing + /// but whitespace collapses to an empty range at `range.start`, so a field + /// made of whitespace alone contributes nothing and takes up no width. + fn trim(&self, range: Range) -> Range { + let Range { mut start, mut end } = range; + while start < end && self.is_space(start) { + start += 1; + } + while range.start < end && self.is_space(end - 1) { + end -= 1; + } + // The two loops cross each other on an all-whitespace range; pull + // `start` back so the result is an empty range rather than an inverted + // one, which would panic when used to slice the line. + start.min(end)..end } - // and get the string - let tail_str: String = all_after[tail_beg..tail_end].iter().collect(); - tail.push_str(&tail_str); - - // the head chunk - - // max size of the head chunk = max size of right half - space taken by after chunk - gap size. - let max_head_size = cmp::max( - max_after_size as isize - after.len() as isize - config.gap_size as isize, - 0, - ) as usize; - - // the head chunk takes text from before the before chunk - let (_, head_end) = trim_idx(all_before, 0, before_beg); - - // begin = end - max length - let head_beg = cmp::max(head_end as isize - max_head_size as isize, 0) as usize; - // in case that falls in the middle of a word, trim away the word. - let head_beg = trim_broken_word_left(all_before, head_beg, head_end); - - // trim away white space again. - let (head_beg, head_end) = trim_idx(all_before, head_beg, head_end); - - // and get the string. - let head_str: String = all_before[head_beg..head_end].iter().collect(); - head.push_str(&head_str); - //The TeX mode does not output truncation characters. - if config.format != OutFormat::Tex { - // put right context truncation string if needed - if after_end != all_after.len() && tail_beg == tail_end { - after.push_str(&config.trunc_str); - } else if after_end != all_after.len() && tail_end != all_after.len() { - tail.push_str(&config.trunc_str); + /// Move `range.start` forward past a word it cuts in half, so a field never + /// opens on a word fragment. A start that already sits on a boundary, or at + /// the beginning of the line, stays put. + fn align_start_to_word(&self, range: Range) -> Range { + let Range { mut start, end } = range; + if start == end || start == 0 || self.is_space(start) || self.is_space(start - 1) { + return range; + } + while start < end && !self.is_space(start) { + start += 1; } + start..end + } - // put left context truncation string if needed - if before_beg != 0 && head_beg == head_end { - before = format!("{}{before}", config.trunc_str); - } else if before_beg != 0 && head_beg != 0 { - head = format!("{}{head}", config.trunc_str); + /// The mirror of [`Self::align_start_to_word`]: pull `range.end` back off a + /// word it cuts in half. An end at the end of the line stays put, since + /// nothing was cut there. + fn align_end_to_word(&self, range: Range) -> Range { + let Range { start, mut end } = range; + if start == end || end == self.len() || self.is_space(end - 1) || self.is_space(end) { + return range; + } + while start < end && !self.is_space(end - 1) { + end -= 1; } + start..end + } + + /// At most `width` characters taken from the right-hand end of `range`, on + /// whole words and without surrounding whitespace. Fields left of the + /// keyword grow leftwards from a fixed right edge, so this is how they are + /// filled. + fn window_ending_at(&self, range: Range, width: usize) -> Range { + let end = self.trim(range).end; + let start = end.saturating_sub(width); + self.trim(self.align_start_to_word(start..end)) + } + + /// At most `width` characters taken from the left-hand end of `range`, on + /// whole words. The start is left exactly where the caller asked for it — + /// the after field butts against the keyword, so whitespace there is part + /// of the output and must survive. + fn window_starting_at(&self, range: Range, width: usize) -> Range { + let start = range.start; + let end = cmp::min(range.end, start + width); + let end = self.align_end_to_word(start..end).end; + start..self.trim(start..end).end } +} - (tail, before, after, head) +/// The keyword of an index entry and the four context fields laid out around +/// it, in the order `tail before KEYWORD after head`: `before` and `after` hold +/// the context adjacent to the keyword, while `tail` and `head` take the text +/// that wraps around the ends of the line when the keyword sits near one of +/// them. Only one of the two wrap-around fields is ever non-empty. +struct Chunks { + head: String, + before: String, + keyword: String, + after: String, + tail: String, } -fn tex_mapper(x: char) -> String { - match x { - '\\' => "\\backslash{}".to_owned(), - '$' | '%' | '#' | '&' | '_' => format!("\\{x}"), - '}' | '{' => format!("$\\{x}$"), - _ => x.to_string(), +impl Chunks { + /// Lay out `keyword` and the text on either side of it within the + /// configured line width. + /// + /// The widths have to agree with GNU's, because where the keyword sits in + /// the line is part of ptx's output: half the line width for the context on + /// either side, less the gap between fields, less the truncation marker at + /// each end and the keyword itself. That layout leaves the arithmetic very + /// little room to differ. + fn new(config: &Config, all_before: &[char], keyword: String, all_after: &[char]) -> Self { + let before_text = Line(all_before); + let after_text = Line(all_after); + + let half_line_size = config.line_width / 2; + let max_before_size = half_line_size.saturating_sub(config.gap_size); + let max_after_size = half_line_size + .saturating_sub(2 * config.trunc_str.chars().count() + keyword.chars().count() + 1); + + // The two fields next to the keyword, each reaching as far into the + // context as its half of the line allows. + let before = before_text.window_ending_at(0..before_text.len(), max_before_size); + let after = after_text.window_starting_at(0..after_text.len(), max_after_size); + + // Whatever the adjacent field left unused on its half of the line is + // what the wrap-around field on the opposite side may occupy. + let max_tail_size = max_before_size + .saturating_sub(before.len()) + .saturating_sub(config.gap_size); + let tail_start = after_text.trim(after.end..after_text.len()).start; + let mut tail = after_text.window_starting_at(tail_start..after_text.len(), max_tail_size); + + // A one-character word at the very end survives the alignment above, + // because the character before it is a space and so reads as a word + // boundary. Drop it, so a tail does not trail off mid-phrase. + if tail.len() > 2 && after_text.is_space(tail.end - 2) && !after_text.is_space(tail.end - 1) + { + tail = after_text.trim(tail.start..tail.end - 1); + } + + // Sizing the head against the after field's *byte* length is not + // deliberate: every other width here counts characters. The two agree + // on ASCII input, so the discrepancy only shows on wider characters. + let max_head_size = max_after_size + .saturating_sub(after_text.byte_len(after.clone())) + .saturating_sub(config.gap_size); + let head = before_text.window_ending_at(0..before.start, max_head_size); + + let mut chunks = Self { + head: before_text.text(head.clone()), + before: before_text.text(before.clone()), + keyword, + after: after_text.text(after.clone()), + tail: after_text.text(tail.clone()), + }; + + // TeX output carries no truncation marks. + if config.format != OutFormat::Tex { + // A mark goes on the outermost field that actually lost text, so + // that it appears at the edge of the line rather than in its middle. + if after.end != after_text.len() { + if tail.is_empty() { + chunks.after.push_str(&config.trunc_str); + } else if tail.end != after_text.len() { + chunks.tail.push_str(&config.trunc_str); + } + } + if before.start != 0 { + if head.is_empty() { + chunks.before.insert_str(0, &config.trunc_str); + } else if head.start != 0 { + chunks.head.insert_str(0, &config.trunc_str); + } + } + } + + chunks } } /// Escape special characters for TeX. fn format_tex_field(s: &str) -> String { - let mapped_chunks: Vec = s.chars().map(tex_mapper).collect(); - mapped_chunks.join("") + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\backslash{}"), + '$' | '%' | '#' | '&' | '_' => { + out.push('\\'); + out.push(c); + } + '}' | '{' => { + out.push_str("$\\"); + out.push(c); + out.push('$'); + } + _ => out.push(c), + } + } + out } fn format_tex_line( @@ -626,16 +638,15 @@ fn format_tex_line( ) -> String { let mut output = String::new(); write!(output, "\\{} ", config.macro_name).unwrap(); - let (tail, before, keyword, after, head) = - prepare_line_chunks(config, word_ref, line, chars_line, reference); + let chunks = prepare_line_chunks(config, word_ref, line, chars_line, reference); write!( output, "{{{0}}}{{{1}}}{{{2}}}{{{3}}}{{{4}}}", - format_tex_field(&tail), - format_tex_field(&before), - format_tex_field(&keyword), - format_tex_field(&after), - format_tex_field(&head), + format_tex_field(&chunks.tail), + format_tex_field(&chunks.before), + format_tex_field(&chunks.keyword), + format_tex_field(&chunks.after), + format_tex_field(&chunks.head), ) .unwrap(); if config.auto_ref || config.input_ref { @@ -644,6 +655,16 @@ fn format_tex_line( output } +/// Put `first` and `second` side by side, separated by a space when neither is +/// empty, so an absent field costs no padding. +fn join_fields(first: String, second: String) -> String { + match (first.is_empty(), second.is_empty()) { + (true, _) => second, + (_, true) => first, + _ => format!("{first} {second}"), + } +} + fn format_dumb_line( config: &Config, word_ref: &WordRef, @@ -651,27 +672,18 @@ fn format_dumb_line( chars_line: &[char], reference: &str, ) -> String { - let (tail, before, keyword, after, head) = - prepare_line_chunks(config, word_ref, line, chars_line, reference); - - // Calculate the position for the left part - // The left part consists of tail (if present) + space + before - let left_part = if tail.is_empty() { - before - } else if before.is_empty() { - tail - } else { - format!("{tail} {before}") - }; - - // Calculate the position for the right part - let right_part = if head.is_empty() { - after - } else if after.is_empty() { - head - } else { - format!("{after} {head}") - }; + let Chunks { + head, + before, + keyword, + after, + tail, + } = prepare_line_chunks(config, word_ref, line, chars_line, reference); + + // Left of the keyword the wrap-around field comes first, right of it last; + // a space joins them only when both are present. + let left_part = join_fields(tail, before); + let right_part = join_fields(after, head); // Calculate the width for the left half (before the keyword) let half_width = cmp::max(config.line_width / 2, config.gap_size); @@ -726,16 +738,15 @@ fn format_roff_line( ) -> String { let mut output = String::new(); write!(output, ".{}", config.macro_name).unwrap(); - let (tail, before, keyword, after, head) = - prepare_line_chunks(config, word_ref, line, chars_line, reference); + let chunks = prepare_line_chunks(config, word_ref, line, chars_line, reference); write!( output, " \"{}\" \"{}\" \"{}{}\" \"{}\"", - format_roff_field(&tail), - format_roff_field(&before), - format_roff_field(&keyword), - format_roff_field(&after), - format_roff_field(&head) + format_roff_field(&chunks.tail), + format_roff_field(&chunks.before), + format_roff_field(&chunks.keyword), + format_roff_field(&chunks.after), + format_roff_field(&chunks.head) ) .unwrap(); if config.auto_ref || config.input_ref { @@ -744,17 +755,15 @@ fn format_roff_line( output } -/// Extract and prepare text chunks for formatting in both TeX and roff output +/// Split `line` around the keyword `word_ref` points at and lay the pieces out. fn prepare_line_chunks( config: &Config, word_ref: &WordRef, line: &str, chars_line: &[char], reference: &str, -) -> (String, String, String, String, String) { - // Convert byte positions to character positions - let ref_char_position = line[..word_ref.position].chars().count(); - let char_position_end = ref_char_position +) -> Chunks { + let char_position_end = word_ref.char_start + line[word_ref.position..word_ref.position_end] .chars() .count(); @@ -762,26 +771,18 @@ fn prepare_line_chunks( // Extract the text before the keyword let all_before = if config.input_ref { let before = &line[..word_ref.position]; - let before_char_count = before.chars().count(); - let trimmed_char_count = before - .trim_start_matches(reference) - .trim_start() - .chars() - .count(); - let trim_offset = before_char_count - trimmed_char_count; - &chars_line[trim_offset..before_char_count] + let stripped = before.trim_start_matches(reference).trim_start(); + let trim_offset = before[..before.len() - stripped.len()].chars().count(); + &chars_line[trim_offset..word_ref.char_start] } else { - &chars_line[..ref_char_position] + &chars_line[..word_ref.char_start] }; // Extract the keyword and text after it let keyword = line[word_ref.position..word_ref.position_end].to_string(); let all_after = &chars_line[char_position_end..]; - // Get formatted output chunks - let (tail, before, after, head) = get_output_chunks(all_before, &keyword, all_after, config); - - (tail, before, keyword, after, head) + Chunks::new(config, all_before, keyword, all_after) } fn write_traditional_output( @@ -803,7 +804,7 @@ fn write_traditional_output( if !config.right_ref { let max_ref_len = if config.auto_ref { - get_auto_max_reference_len(words) + get_auto_max_reference_len(words, file_map) } else { 0 }; @@ -813,26 +814,12 @@ fn write_traditional_output( } for word_ref in words { - // 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, - ref chars_lines, - offset: _, - } = *(file_map_value); + let (filename, file_map_value) = &file_map[word_ref.file_index]; + let FileContent { lines, chars_lines } = file_map_value; let reference = get_reference( config, word_ref, + filename, &lines[word_ref.local_line_nr], &context_reg, ); @@ -870,7 +857,7 @@ fn write_traditional_output( Ok(()) } -fn get_auto_max_reference_len(words: &BTreeSet) -> usize { +fn get_auto_max_reference_len(words: &BTreeSet, file_map: &FileMap) -> usize { //Get the maximum length of the reference field let line_num = words .iter() @@ -886,8 +873,9 @@ fn get_auto_max_reference_len(words: &BTreeSet) -> usize { let filename_len = words .iter() - .filter(|w| w.filename != "-") - .map(|w| w.filename.maybe_quote().to_string().len()) + .map(|w| &file_map[w.file_index].0) + .filter(|filename| *filename != "-") + .map(|filename| filename.maybe_quote().to_string().len()) .max() .unwrap_or(0); @@ -1064,6 +1052,7 @@ pub fn uu_app() -> Command { Arg::new(options::GAP_SIZE) .short('g') .long(options::GAP_SIZE) + .value_parser(value_parser!(u64).range(1..)) .help(translate!("ptx-help-gap-size")) .value_name("NUMBER"), ) @@ -1104,7 +1093,135 @@ pub fn uu_app() -> Command { Arg::new(options::WIDTH) .short('w') .long(options::WIDTH) + .value_parser(value_parser!(u64).range(1..)) .help(translate!("ptx-help-width")) .value_name("NUMBER"), ) } + +#[cfg(test)] +mod tests { + use super::{Chunks, Config, Line, OutFormat}; + + fn chars(s: &str) -> Vec { + s.chars().collect() + } + + #[test] + fn trim_drops_only_the_outer_whitespace() { + let text = chars(" pear plum "); + let line = Line(&text); + assert_eq!(line.trim(0..14), 2..12); + // Trimming a window already free of whitespace leaves it alone. + assert_eq!(line.trim(2..12), 2..12); + // A window holding nothing but whitespace collapses onto its own + // start, rather than inverting into a range that cannot be sliced. + assert_eq!(line.trim(12..14), 12..12); + assert_eq!(line.trim(0..2), 0..0); + } + + #[test] + fn align_start_skips_a_word_it_would_split() { + let text = chars("pear plum quince"); + let line = Line(&text); + // Index 2 sits inside "pear", so the window opens at the space that + // ends it; a later trim moves it onto "plum". + assert_eq!(line.align_start_to_word(2..16), 4..16); + // A start already on a boundary, or at the very beginning, stays put. + assert_eq!(line.align_start_to_word(5..16), 5..16); + assert_eq!(line.align_start_to_word(0..16), 0..16); + } + + #[test] + fn align_end_drops_a_word_it_would_split() { + let text = chars("pear plum quince"); + let line = Line(&text); + // Index 12 sits inside "quince", so the window closes on the space + // that precedes it. + assert_eq!(line.align_end_to_word(0..12), 0..10); + // An end at the end of the line cut nothing and is kept. + assert_eq!(line.align_end_to_word(0..16), 0..16); + } + + #[test] + fn windows_take_whole_words_from_the_requested_side() { + let text = chars("pear plum quince "); + let line = Line(&text); + // Seven characters from the right end reach back into "plum", which is + // therefore dropped; the trailing space goes with it. + assert_eq!(line.text(line.window_ending_at(0..17, 7)), "quince"); + // Ten from the left end reach into "quince", so it is dropped too. + assert_eq!(line.text(line.window_starting_at(0..17, 10)), "pear plum"); + // A width wide enough for everything returns the whole trimmed line. + assert_eq!( + line.text(line.window_ending_at(0..17, 99)), + "pear plum quince" + ); + } + + #[test] + fn window_starting_at_keeps_leading_whitespace() { + let text = chars(" pear plum"); + let line = Line(&text); + // The after field butts against the keyword, so the space that + // separates them belongs to the field. + assert_eq!(line.text(line.window_starting_at(0..10, 5)), " pear"); + } + + #[test] + fn a_wide_line_needs_no_truncation_marks() { + let config = Config { + line_width: 60, + ..Config::default() + }; + let chunks = Chunks::new( + &config, + &chars("pear plum "), + "nut".to_owned(), + &chars(" cake tart pie"), + ); + assert_eq!(chunks.before, "pear plum"); + assert_eq!(chunks.keyword, "nut"); + assert_eq!(chunks.after, " cake tart pie"); + // Nothing wrapped around the ends of the line. + assert!(chunks.head.is_empty()); + assert!(chunks.tail.is_empty()); + } + + #[test] + fn a_narrow_line_marks_the_text_it_dropped() { + let config = Config { + line_width: 20, + ..Config::default() + }; + let chunks = Chunks::new( + &config, + &chars("pear plum "), + "nut".to_owned(), + &chars(" cake tart pie"), + ); + // "pear" did not fit, so the before field opens with the mark. + assert_eq!(chunks.before, "/plum"); + // No word at all fit to the right of the keyword. + assert_eq!(chunks.after, "/"); + assert!(chunks.head.is_empty()); + assert!(chunks.tail.is_empty()); + } + + #[test] + fn tex_output_carries_no_truncation_marks() { + let config = Config { + line_width: 20, + format: OutFormat::Tex, + ..Config::default() + }; + let chunks = Chunks::new( + &config, + &chars("pear plum "), + "nut".to_owned(), + &chars(" cake tart pie"), + ); + assert_eq!(chunks.before, "plum"); + assert!(chunks.after.is_empty()); + } +} diff --git a/src/uu/pwd/Cargo.toml b/src/uu/pwd/Cargo.toml index aad481d0c26..6274e94bbbe 100644 --- a/src/uu/pwd/Cargo.toml +++ b/src/uu/pwd/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/pwd.rs" test = false @@ -21,8 +18,11 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true } + +[lints] +workspace = true [[bin]] name = "pwd" diff --git a/src/uu/readlink/Cargo.toml b/src/uu/readlink/Cargo.toml index 75f133a05bc..9f5b1888668 100644 --- a/src/uu/readlink/Cargo.toml +++ b/src/uu/readlink/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/readlink.rs" test = false @@ -21,8 +18,11 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["fs"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["fs"] } + +[lints] +workspace = true [[bin]] name = "readlink" diff --git a/src/uu/readlink/locales/en-US.ftl b/src/uu/readlink/locales/en-US.ftl index bdf8e1e87bc..58369b3fdb3 100644 --- a/src/uu/readlink/locales/en-US.ftl +++ b/src/uu/readlink/locales/en-US.ftl @@ -12,6 +12,5 @@ readlink-help-verbose = report error message readlink-help-zero = separate output with NUL rather than newline # Error messages -readlink-error-missing-operand = missing operand readlink-error-ignoring-no-newline = ignoring --no-newline with multiple arguments readlink-error-invalid-argument = {$path}: Invalid argument diff --git a/src/uu/readlink/locales/fr-FR.ftl b/src/uu/readlink/locales/fr-FR.ftl index 610e6c2a662..a5bb565dc6d 100644 --- a/src/uu/readlink/locales/fr-FR.ftl +++ b/src/uu/readlink/locales/fr-FR.ftl @@ -12,6 +12,5 @@ readlink-help-verbose = signaler les messages d'erreur readlink-help-zero = séparer la sortie avec NUL plutôt qu'une nouvelle ligne # Messages d'erreur -readlink-error-missing-operand = opérande manquant readlink-error-ignoring-no-newline = ignorer --no-newline avec plusieurs arguments readlink-error-invalid-argument = {$path} : argument non valide diff --git a/src/uu/readlink/src/readlink.rs b/src/uu/readlink/src/readlink.rs index 89499cf388f..f4f50ec1f4e 100644 --- a/src/uu/readlink/src/readlink.rs +++ b/src/uu/readlink/src/readlink.rs @@ -12,7 +12,7 @@ use std::fs; use std::io::{Write, stdout}; use std::path::{Path, PathBuf}; use uucore::display::Quotable; -use uucore::error::{FromIo, UResult, UUsageError}; +use uucore::error::{FromIo, UResult}; use uucore::fs::{MissingHandling, ResolveMode, canonicalize}; use uucore::libc::EINVAL; use uucore::line_ending::LineEnding; @@ -58,17 +58,12 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { MissingHandling::Normal }; + #[expect(clippy::unwrap_used, reason = "set as required by clap")] let files: Vec = matches .get_many::(ARG_FILES) - .map(|v| v.map(PathBuf::from).collect()) - .unwrap_or_default(); - - if files.is_empty() { - return Err(UUsageError::new( - 1, - translate!("readlink-error-missing-operand"), - )); - } + .unwrap() + .map(PathBuf::from) + .collect(); if no_trailing_delimiter && files.len() > 1 { show_error!("{}", translate!("readlink-error-ignoring-no-newline")); @@ -81,6 +76,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Some(LineEnding::from_zero_flag(use_zero)) }; + // An operand that cannot be resolved does not stop the ones after it; the + // failure only decides the exit status once every operand has been tried. + let mut failed = false; for p in &files { let path_result = if res_mode == ResolveMode::None { fs::read_link(p) @@ -93,22 +91,20 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { show(&path, line_ending).map_err_context(String::new)?; } Err(err) => { - if !verbose { - return Err(1.into()); + failed = true; + if verbose { + let message = if err.raw_os_error() == Some(EINVAL) { + translate!("readlink-error-invalid-argument", "path" => p.maybe_quote()) + } else { + err.map_err_context(|| p.maybe_quote().to_string()) + .to_string() + }; + show_error!("{message}"); } - - let message = if err.raw_os_error() == Some(EINVAL) { - translate!("readlink-error-invalid-argument", "path" => p.maybe_quote()) - } else { - err.map_err_context(|| p.maybe_quote().to_string()) - .to_string() - }; - show_error!("{message}"); - return Err(1.into()); } } } - Ok(()) + if failed { Err(1.into()) } else { Ok(()) } } pub fn uu_app() -> Command { @@ -181,6 +177,7 @@ pub fn uu_app() -> Command { Arg::new(ARG_FILES) .action(ArgAction::Append) .value_parser(clap::value_parser!(OsString)) + .required(true) .value_hint(clap::ValueHint::AnyPath), ) } diff --git a/src/uu/realpath/Cargo.toml b/src/uu/realpath/Cargo.toml index f4d03ddf840..3968e748273 100644 --- a/src/uu/realpath/Cargo.toml +++ b/src/uu/realpath/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/realpath.rs" test = false @@ -21,8 +18,11 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["fs"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["fs"] } + +[lints] +workspace = true [[bin]] name = "realpath" diff --git a/src/uu/realpath/src/realpath.rs b/src/uu/realpath/src/realpath.rs index cb8936db31e..f215124fa9f 100644 --- a/src/uu/realpath/src/realpath.rs +++ b/src/uu/realpath/src/realpath.rs @@ -14,7 +14,6 @@ use std::{ io::{Write, stdout}, path::{Path, PathBuf}, }; -use uucore::fs::make_path_relative_to; use uucore::translate; use uucore::{ display::{Quotable, print_verbatim}, @@ -22,8 +21,8 @@ use uucore::{ format_usage, fs::{MissingHandling, ResolveMode, canonicalize}, line_ending::LineEnding, - show_if_err, }; +use uucore::{error::set_exit_code, fs::make_path_relative_to, show}; const OPT_QUIET: &str = "quiet"; const OPT_STRIP: &str = "strip"; @@ -115,13 +114,17 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { relative_to.as_deref(), relative_base.as_deref(), ); - if !quiet { - show_if_err!(result.map_err_context(|| path.maybe_quote().to_string())); + if let Err(e) = result.map_err_context(|| path.maybe_quote().to_string()) { + if quiet { + set_exit_code(e.code()); + } else { + show!(e); + } } } // Although we return `Ok`, it is possible that a call to - // `show!()` above has set the exit code for the program to a - // non-zero integer. + // `show!()` or `set_exit_code` above has set the exit code + // for the program to a non-zero integer. Ok(()) } diff --git a/src/uu/rm/Cargo.toml b/src/uu/rm/Cargo.toml index d82fc0b3aa9..23c840551d1 100644 --- a/src/uu/rm/Cargo.toml +++ b/src/uu/rm/Cargo.toml @@ -11,19 +11,16 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/rm.rs" doctest = false [dependencies] -thiserror = { workspace = true } clap = { workspace = true } -uucore = { workspace = true, features = ["fs", "parser", "quoting-style"] } fluent = { workspace = true } indicatif = { workspace = true } +thiserror = { workspace = true } +uucore = { workspace = true, features = ["fs", "parser", "quoting-style"] } [target.'cfg(all(unix, not(target_os = "redox")))'.dependencies] uucore = { workspace = true, features = ["safe-traversal"] } @@ -31,9 +28,6 @@ uucore = { workspace = true, features = ["safe-traversal"] } [target.'cfg(unix)'.dependencies] libc = { workspace = true } -[target.'cfg(windows)'.dependencies] -windows-sys = { workspace = true, features = ["Win32_Storage_FileSystem"] } - [[bin]] name = "rm" path = "src/main.rs" @@ -43,6 +37,9 @@ divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bench]] name = "rm_bench" harness = false diff --git a/src/uu/rm/locales/en-US.ftl b/src/uu/rm/locales/en-US.ftl index 1b29a2c32c8..38511105394 100644 --- a/src/uu/rm/locales/en-US.ftl +++ b/src/uu/rm/locales/en-US.ftl @@ -48,7 +48,9 @@ rm-error-refusing-to-remove-directory = refusing to remove '.' or '..' directory rm-error-skipping-different-device = skipping {$file}, since it's on a different device rm-error-and-preserve-root-all-in-effect = and --preserve-root=all is in effect rm-error-cannot-remove = cannot remove {$file} +rm-error-cannot-remove-changed = cannot remove {$file}: File changed while removing rm-error-may-not-abbreviate-no-preserve-root = you may not abbreviate the --no-preserve-root option +rm-error-standard-output = standard output: {$error} # 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 edbebb7654f..fdbf1275fec 100644 --- a/src/uu/rm/locales/fr-FR.ftl +++ b/src/uu/rm/locales/fr-FR.ftl @@ -46,7 +46,9 @@ rm-error-dangerous-recursive-operation-same-as-root = il est dangereux d'opérer 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-cannot-remove-changed = impossible de supprimer {$file} : Le fichier a changé pendant la suppression rm-error-may-not-abbreviate-no-preserve-root = Vous ne pouvez pas abréger l'option --no-preserve-root +rm-error-standard-output = sortie standard : {$error} # Messages verbeux rm-verbose-removed = {$file} supprimé diff --git a/src/uu/rm/src/platform/unix.rs b/src/uu/rm/src/platform/unix.rs index eb344bc4a99..acce2cc9032 100644 --- a/src/uu/rm/src/platform/unix.rs +++ b/src/uu/rm/src/platform/unix.rs @@ -3,27 +3,28 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// Unix-specific implementations for the rm utility +// spell-checker:ignore fstatat unlinkat statx behaviour automount -// spell-checker:ignore fstatat unlinkat statx behaviour +// Unix-specific implementations for the rm utility use indicatif::ProgressBar; -use std::ffi::OsStr; +use std::ffi::{OsStr, OsString}; use std::fs; use std::io::{IsTerminal, stdin}; +use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::Path; use uucore::display::Quotable; -use uucore::error::FromIo; +use uucore::error::{FromIo, strip_errno}; use uucore::prompt_yes; -use uucore::safe_traversal::{DirFd, SymlinkBehavior}; +use uucore::safe_traversal::{DirFd, FileStat, SymlinkBehavior}; use uucore::show_error; use uucore::translate; use super::super::{ InteractiveMode, Options, is_dir_empty, is_readable_metadata, prompt_descend, remove_file, - show_permission_denied_error, show_removal_error, verbose_removed_directory, - verbose_removed_file, + report_verbose_write_error, show_permission_denied_error, show_removal_error, + verbose_removed_directory, verbose_removed_file, }; #[inline] @@ -37,7 +38,7 @@ fn mode_writable(mode: libc::mode_t) -> bool { } /// File prompt that reuses existing stat data to avoid extra statx calls -fn prompt_file_with_stat(path: &Path, stat: &libc::stat, options: &Options) -> bool { +fn prompt_file_with_stat(path: &Path, stat: &FileStat, options: &Options) -> bool { if options.interactive == InteractiveMode::Never { return true; } @@ -87,8 +88,8 @@ fn prompt_dir_with_mode(path: &Path, mode: libc::mode_t, options: &Options) -> b let stdin_ok = options.__presume_input_tty.unwrap_or(false) || stdin().is_terminal(); match (stdin_ok, readable, writable, options.interactive) { - (false, _, _, InteractiveMode::PromptProtected) => true, - (false, false, false, InteractiveMode::Never) => true, + (false, _, _, InteractiveMode::PromptProtected) + | (false, false, false, InteractiveMode::Never) => true, (_, false, false, _) => prompt_yes!( "attempt removal of inaccessible directory {}?", path.quote() @@ -128,12 +129,12 @@ pub fn safe_remove_file( if let Some(pb) = progress_bar { pb.inc(1); } - verbose_removed_file(path, options); + report_verbose_write_error(verbose_removed_file(path, options)); Some(false) } Err(e) => { if e.kind() == std::io::ErrorKind::PermissionDenied { - show_error!("cannot remove {}: Permission denied", path.quote()); + show_error!("cannot remove {}: {}", path.quote(), strip_errno(&e)); } else { let _ = show_removal_error(e, path); } @@ -159,7 +160,7 @@ pub fn safe_remove_empty_dir( if let Some(pb) = progress_bar { pb.inc(1); } - verbose_removed_directory(path, options); + report_verbose_write_error(verbose_removed_directory(path, options)); Some(false) } Err(e) => { @@ -206,7 +207,7 @@ fn handle_permission_denied( return true; } // Successfully removed empty directory - verbose_removed_directory(entry_path, options); + report_verbose_write_error(verbose_removed_directory(entry_path, options)); false } @@ -224,11 +225,11 @@ fn handle_unlink( show_error!("{e}"); true } else { - if is_dir { - verbose_removed_directory(entry_path, options); + report_verbose_write_error(if is_dir { + verbose_removed_directory(entry_path, options) } else { - verbose_removed_file(entry_path, options); - } + verbose_removed_file(entry_path, options) + }); false } } @@ -258,7 +259,7 @@ pub fn remove_dir_with_special_cases(path: &Path, options: &Options, error_occur error_occurred } Ok(_) => { - verbose_removed_directory(path, options); + report_verbose_write_error(verbose_removed_directory(path, options)); false } } @@ -268,11 +269,10 @@ pub fn remove_dir_with_special_cases(path: &Path, options: &Options, error_occur /// own device differs from this is a mount point, which `--preserve-root=all` /// refuses to cross. fn parent_device(path: &Path) -> Option { - let parent = match path.parent() { + let parent = match path.parent()? { // A bare name like "b" has an empty parent, meaning the current dir. - Some(p) if p.as_os_str().is_empty() => Path::new("."), - Some(p) => p, - None => return None, + p if p.as_os_str().is_empty() => Path::new("."), + p => p, }; fs::metadata(parent).ok().map(|m| m.dev()) } @@ -294,13 +294,17 @@ pub fn safe_remove_dir_recursive( ) -> bool { // Base case 1: this is a file or a symbolic link. // Use lstat to avoid race condition between check and use - let (initial_mode, root_dev) = match fs::symlink_metadata(path) { + let (initial_mode, root_dev, root_ino) = match fs::symlink_metadata(path) { Ok(metadata) if !metadata.is_dir() => { return remove_file(path, options, progress_bar); } // root_dev is the tree-root device, captured once and compared against // every subdirectory for --one-file-system (not recomputed per level). - Ok(metadata) => (metadata.permissions().mode(), metadata.dev()), + Ok(metadata) => ( + metadata.permissions().mode(), + metadata.dev(), + metadata.ino(), + ), Err(e) => { return show_removal_error(e, path); } @@ -314,8 +318,13 @@ pub fn safe_remove_dir_recursive( return true; } - // Try to open the directory using DirFd for secure traversal - let dir_fd = match DirFd::open(path, SymlinkBehavior::Follow) { + // Open the directory with DirFd for secure traversal. The lstat above + // already established that the operand is a real directory, so a symlink + // here can only have been swapped in since, and following it would land on + // a tree we never named. GNU refuses the same way, opening directories + // O_NOFOLLOW under FTS_PHYSICAL. The descent was already hardened this way; + // this is the entry point. + let dir_fd = match DirFd::open(path, SymlinkBehavior::NoFollow) { Ok(fd) => fd, Err(e) => { // If we can't open the directory for safe traversal, @@ -323,7 +332,7 @@ pub fn safe_remove_dir_recursive( if e.kind() == std::io::ErrorKind::PermissionDenied { // Try to remove the directory directly if it's empty if fs::remove_dir(path).is_ok() { - verbose_removed_directory(path, options); + report_verbose_write_error(verbose_removed_directory(path, options)); return false; } // If we can't read the directory AND can't remove it, @@ -334,8 +343,27 @@ pub fn safe_remove_dir_recursive( } }; - // Entries of the root directory have the root itself as their parent. - let error = safe_remove_dir_recursive_impl(path, &dir_fd, options, root_dev, root_dev); + // O_NOFOLLOW only rejects a symlink as the *final* component, and a trailing + // slash ("dir/") makes the link stop being final, so it still resolves. Pin + // the result down by confirming the fd we hold is the inode we checked. + match dir_fd.metadata() { + Ok(m) if m.dev() == root_dev && m.ino() == root_ino => {} + Ok(_) => { + // Not necessarily a symlink: a directory swapped for another + // directory, or an automount that only lstat failed to trigger, + // lands here too, so don't claim ELOOP. + show_error!( + "{}", + translate!("rm-error-cannot-remove-changed", "file" => path.quote()) + ); + return true; + } + Err(e) => { + return show_removal_error(e, path); + } + } + + let error = safe_remove_dir_recursive_impl(path, dir_fd, options, root_dev, root_ino); // After processing all children, remove the directory itself if error { @@ -371,16 +399,80 @@ pub fn safe_remove_dir_recursive( } } +/// A directory suspended while one of its entries is being emptied. Together +/// with the directory being walked, only the deepest [`OPEN_DIR_FDS`] keep +/// their descriptor; the others get it back through ".." on the way up, so +/// depth costs memory, not file descriptors. +#[cfg(not(target_os = "redox"))] +struct Frame { + dir_fd: Option, + dev: u64, + ino: u64, + entries: std::vec::IntoIter, + error: bool, + /// The entry being emptied, removed once that is done. + entry_name: OsString, + entry_mode: libc::mode_t, + /// Length of this directory's own path, to cut the entry back off with. + path_len: usize, +} + +#[cfg(not(target_os = "redox"))] +const OPEN_DIR_FDS: usize = 16; + +/// The path of the directory being walked, kept as raw bytes so that stepping +/// into an entry and back out again is a truncation rather than a fresh +/// allocation: a tree deep enough to need this walk has paths long enough that +/// copying one per entry dominates the removal. +#[cfg(not(target_os = "redox"))] +fn path_of(buf: &[u8]) -> &Path { + Path::new(OsStr::from_bytes(buf)) +} + +#[cfg(not(target_os = "redox"))] +fn path_push(buf: &mut Vec, name: &OsStr) { + if !buf.is_empty() && buf.last() != Some(&b'/') { + buf.push(b'/'); + } + buf.extend_from_slice(name.as_bytes()); +} + +/// Whether `name` is an empty directory in `dir_fd`, asked through the +/// descriptor rather than the path: past PATH_MAX a path-based check just +/// fails, and a failure counts as non-empty, so rm would prompt before +/// descending into an empty directory and leave it behind when that prompt is +/// declined. An unreadable directory still counts as non-empty. +#[cfg(not(target_os = "redox"))] +fn is_subdir_empty(dir_fd: &DirFd, name: &OsStr) -> bool { + dir_fd + .open_subdir(name, SymlinkBehavior::NoFollow) + .and_then(|fd| fd.read_dir()) + .is_ok_and(|entries| entries.is_empty()) +} + +/// Reopen the parent of `child_fd` through "..", checking it is still the +/// directory we descended from and not one swapped in mid-walk. +#[cfg(not(target_os = "redox"))] +fn reopen_parent(child_fd: &DirFd, dev: u64, ino: u64) -> std::io::Result { + let parent_fd = child_fd.open_subdir(OsStr::new(".."), SymlinkBehavior::NoFollow)?; + let info = parent_fd.metadata()?.file_info(); + if info.device() == dev && info.inode() == ino { + Ok(parent_fd) + } else { + Err(std::io::Error::from(std::io::ErrorKind::NotFound)) + } +} + #[cfg(not(target_os = "redox"))] pub fn safe_remove_dir_recursive_impl( path: &Path, - dir_fd: &DirFd, + mut cur_fd: DirFd, options: &Options, root_dev: u64, - parent_dev: u64, + root_ino: u64, ) -> bool { // Read directory entries using safe traversal - let entries = match dir_fd.read_dir() { + let entries = match cur_fd.read_dir() { Ok(entries) => entries, Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { if !options.force { @@ -394,52 +486,110 @@ pub fn safe_remove_dir_recursive_impl( }; let mut error = false; + let mut entries = entries.into_iter(); + let mut path_buf = path.as_os_str().as_bytes().to_vec(); + // Entries of the root directory have the root itself as their parent. + let (mut parent_dev, mut ino) = (root_dev, root_ino); + // Walk with an explicit stack: recursing once per level overflows the real + // stack on a tree tens of thousands of levels deep. + let mut stack: Vec = Vec::new(); // Process each entry - for entry_name in entries { - let entry_path = path.join(&entry_name); - - // Get metadata for the entry using fstatat - let entry_stat = match dir_fd.stat_at(&entry_name, SymlinkBehavior::NoFollow) { - Ok(stat) => stat, - Err(e) => { - error |= handle_error_with_force(e, &entry_path, options); - continue; + loop { + let Some(entry_name) = entries.next() else { + // This directory is done: resume its parent, which removes it. + let Some(parent) = stack.pop() else { + return error; + }; + cur_fd = if let Some(fd) = parent.dir_fd { + fd + } else { + match reopen_parent(&cur_fd, parent.dev, parent.ino) { + Ok(fd) => fd, + Err(e) => { + // Name the parent, not the entry we came up from. + path_buf.truncate(parent.path_len); + return show_removal_error(e, path_of(&path_buf)); + } + } + }; + let child_error = error; + (parent_dev, ino, entries) = (parent.dev, parent.ino, parent.entries); + error = parent.error | child_error; + + // Ask user permission if needed for this subdirectory, then remove + // the now-empty subdirectory using safe unlinkat. + if !child_error + && (options.interactive != InteractiveMode::Always + || prompt_dir_with_mode(path_of(&path_buf), parent.entry_mode, options)) + { + error |= handle_unlink( + &cur_fd, + &parent.entry_name, + path_of(&path_buf), + true, + options, + ); } + path_buf.truncate(parent.path_len); + continue; }; - // Check if it's a directory - let is_dir = ((entry_stat.st_mode as libc::mode_t) & libc::S_IFMT) == libc::S_IFDIR; + // Build the entry's path in place: a tree deep enough to need this + // walk also has paths too long to reallocate once per entry. + let parent_len = path_buf.len(); + path_push(&mut path_buf, &entry_name); + let descended = 'entry: { + // Get metadata for the entry using fstatat + let entry_stat = match cur_fd.stat_at(&entry_name, SymlinkBehavior::NoFollow) { + Ok(stat) => stat, + Err(e) => { + error |= handle_error_with_force(e, path_of(&path_buf), options); + break 'entry false; + } + }; + + // Check if it's a directory + let is_dir = ((entry_stat.st_mode as libc::mode_t) & libc::S_IFMT) == libc::S_IFDIR; + if !is_dir { + // Remove file - check if user wants to remove it first + if prompt_file_with_stat(path_of(&path_buf), &entry_stat, options) { + error |= + handle_unlink(&cur_fd, &entry_name, path_of(&path_buf), false, options); + } + break 'entry false; + } - if is_dir { // st_dev's type varies by platform (i32 on macOS, u64 on Linux). #[allow(clippy::unnecessary_cast)] let entry_dev = entry_stat.st_dev as u64; + #[allow(clippy::unnecessary_cast)] + let entry_ino = entry_stat.st_ino as u64; if options.one_fs && entry_dev != root_dev { show_error!( "{}", - translate!("rm-error-skipping-different-device", "file" => entry_path.quote()) + translate!("rm-error-skipping-different-device", "file" => path_of(&path_buf).quote()) ); error = true; - continue; + break 'entry false; } // --preserve-root=all compares against the immediate parent rather // than the tree root, so a mount nested anywhere in the tree is // caught even when --one-file-system is not in effect. if options.preserve_root_all && entry_dev != parent_dev { - show_preserve_root_all_skip(&entry_path); + show_preserve_root_all_skip(path_of(&path_buf)); error = true; - continue; + break 'entry false; } // Ask user if they want to descend into this directory if options.interactive == InteractiveMode::Always - && !is_dir_empty(&entry_path) - && !prompt_descend(&entry_path) + && !is_subdir_empty(&cur_fd, &entry_name) + && !prompt_descend(path_of(&path_buf)) { - continue; + break 'entry false; } // Recursively remove subdirectory using safe traversal. rm never @@ -447,64 +597,70 @@ pub fn safe_remove_dir_recursive_impl( // attacker swaps this just-stat'd directory for a symlink before the // open, O_NOFOLLOW makes openat fail instead of descending off-tree // and deleting unrelated files. - let child_dir_fd = match dir_fd.open_subdir(&entry_name, SymlinkBehavior::NoFollow) { + let child_dir_fd = match cur_fd.open_subdir(&entry_name, SymlinkBehavior::NoFollow) { Ok(fd) => fd, Err(e) => { // 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( - dir_fd, - entry_name.as_ref(), - &entry_path, + &cur_fd, + &entry_name, + path_of(&path_buf), options, ); } else { - error |= handle_error_with_force(e, &entry_path, options); + error |= handle_error_with_force(e, path_of(&path_buf), options); } - continue; + break 'entry false; } }; - let child_error = safe_remove_dir_recursive_impl( - &entry_path, - &child_dir_fd, - options, - root_dev, - entry_dev, - ); - error |= child_error; - - // 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 as libc::mode_t, options) - { - continue; - } + let child_entries = match child_dir_fd.read_dir() { + Ok(child_entries) => (child_entries.into_iter(), false), + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + if !options.force { + show_permission_denied_error(path_of(&path_buf)); + } + (Vec::new().into_iter(), !options.force) + } + Err(e) => ( + Vec::new().into_iter(), + handle_error_with_force(e, path_of(&path_buf), options), + ), + }; - // Remove the now-empty subdirectory using safe unlinkat - if !child_error { - 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); + // Suspend this directory and empty the subdirectory first. + stack.push(Frame { + dir_fd: Some(std::mem::replace(&mut cur_fd, child_dir_fd)), + dev: parent_dev, + ino, + entries: std::mem::replace(&mut entries, child_entries.0), + error: std::mem::replace(&mut error, child_entries.1), + entry_name, + entry_mode: entry_stat.st_mode as libc::mode_t, + path_len: parent_len, + }); + if let Some(closable) = stack.len().checked_sub(OPEN_DIR_FDS) { + stack[closable].dir_fd = None; } + parent_dev = entry_dev; + ino = entry_ino; + true + }; + if !descended { + path_buf.truncate(parent_len); } } - - error } #[cfg(target_os = "redox")] pub fn safe_remove_dir_recursive_impl( _path: &Path, - _dir_fd: &DirFd, + _dir_fd: DirFd, _options: &Options, _root_dev: u64, - _parent_dev: u64, + _root_ino: u64, ) -> bool { // safe_traversal stat_at is not supported on Redox // This shouldn't be called on Redox, but provide a stub for compilation diff --git a/src/uu/rm/src/rm.rs b/src/uu/rm/src/rm.rs index 39bd99ba311..63e23f30f62 100644 --- a/src/uu/rm/src/rm.rs +++ b/src/uu/rm/src/rm.rs @@ -18,9 +18,9 @@ use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::PermissionsExt; use std::path::MAIN_SEPARATOR; use std::path::Path; -use thiserror::Error; +use std::sync::atomic::{AtomicBool, Ordering}; use uucore::display::Quotable; -use uucore::error::{FromIo, UError, UResult}; +use uucore::error::{FromIo, UError, UResult, USimpleError, strip_errno}; use uucore::parser::shortcut_value_parser::ShortcutValueParser; use uucore::quoting_style::{QuotingStyle, locale_aware_escape_name}; use uucore::translate; @@ -30,7 +30,7 @@ mod platform; #[cfg(all(unix, not(target_os = "redox")))] use platform::{safe_remove_dir_recursive, safe_remove_empty_dir, safe_remove_file}; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum RmError { #[error("{}", translate!("rm-error-missing-operand", "util_name" => uucore::execution_phrase()))] MissingOperand, @@ -52,41 +52,69 @@ enum RmError { impl UError for RmError {} +/// Write one verbose line to standard output, turning a write failure +/// (e.g. a full device or a closed pipe) into an error instead of +/// panicking like `println!` would. +fn write_verbose_line(message: &str) -> UResult<()> { + writeln!(io::stdout().lock(), "{message}").map_err(|err| { + USimpleError::new( + 1, + translate!("rm-error-standard-output", "error" => strip_errno(&err)), + ) + })?; + Ok(()) +} + /// Helper function to print verbose message for removed file -fn verbose_removed_file(path: &Path, options: &Options) { +fn verbose_removed_file(path: &Path, options: &Options) -> UResult<()> { if options.verbose { - println!( - "{}", - translate!("rm-verbose-removed", "file" => uucore::fs::normalize_path(path).quote()) - ); + write_verbose_line(&translate!( + "rm-verbose-removed", + "file" => uucore::fs::normalize_path(path).quote() + ))?; } + Ok(()) } /// Helper function to print verbose message for removed directory -fn verbose_removed_directory(path: &Path, options: &Options) { +fn verbose_removed_directory(path: &Path, options: &Options) -> UResult<()> { if options.verbose { - println!( - "{}", - translate!("rm-verbose-removed-directory", "file" => uucore::fs::normalize_path(path).quote()) - ); + write_verbose_line(&translate!( + "rm-verbose-removed-directory", + "file" => uucore::fs::normalize_path(path).quote() + ))?; } + Ok(()) } -/// Helper function to show error with context and return error status -fn show_removal_error(error: io::Error, path: &Path) -> bool { - if error.kind() == io::ErrorKind::PermissionDenied { - show_error!("cannot remove {}: Permission denied", path.quote()); - } else { - let e = - error.map_err_context(|| translate!("rm-error-cannot-remove", "file" => path.quote())); +/// Set once a verbose write failure has happened. Like GNU, a broken standard +/// output neither interrupts the removal nor is reported more than once, but it +/// does make `rm` exit with a failure status. +static VERBOSE_WRITE_FAILED: AtomicBool = AtomicBool::new(false); + +/// Helper function to report a verbose output write error, at most once +fn report_verbose_write_error(result: UResult<()>) { + if let Err(e) = result + && !VERBOSE_WRITE_FAILED.swap(true, Ordering::Relaxed) + { show_error!("{e}"); } +} + +/// Helper function to show error with context and return error status +fn show_removal_error(error: io::Error, path: &Path) -> bool { + let e = error.map_err_context(|| translate!("rm-error-cannot-remove", "file" => path.quote())); + show_error!("{e}"); true } /// Helper function for permission denied errors fn show_permission_denied_error(path: &Path) -> bool { - show_error!("cannot remove {}: Permission denied", path.quote()); + show_error!( + "{}", + translate!("rm-error-cannot-remove-permission-denied", "file" => path.quote()) + ); + true } @@ -94,20 +122,21 @@ fn show_permission_denied_error(path: &Path) -> bool { fn remove_dir_with_feedback(path: &Path, options: &Options) -> bool { match fs::remove_dir(path) { Ok(_) => { - verbose_removed_directory(path, options); + report_verbose_write_error(verbose_removed_directory(path, options)); false } Err(e) => show_removal_error(e, path), } } -#[derive(Eq, PartialEq, Clone, Copy)] /// Enum, determining when the `rm` will prompt the user about the file deletion +#[derive(Eq, PartialEq, Clone, Copy)] pub enum InteractiveMode { /// Never prompt Never, - /// Prompt once before removing more than three files, or when removing - /// recursively. + /// Ask for confirmation a single time, covering the whole operation, when + /// the request looks broad: a recursive removal, or more than three + /// operands. Once, /// Prompt before every removal Always, @@ -165,9 +194,9 @@ pub struct Options { pub verbose: bool, /// `-g`, `--progress` pub progress: bool, - #[doc(hidden)] /// `---presume-input-tty` /// Always use `None`; GNU flag for testing use only + #[doc(hidden)] pub __presume_input_tty: Option, } @@ -295,7 +324,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } } - if remove(&files, &options) { + if remove(&files, &options) || VERBOSE_WRITE_FAILED.load(Ordering::Relaxed) { return Err(1.into()); } @@ -314,7 +343,7 @@ fn handle_parse_error(e: clap::Error, args: &[OsString]) -> Box { // The path is shell-escaped (quoted only if needed), the file name is // always quoted, matching GNU's two quoting styles. let path = locale_aware_escape_name(file, QuotingStyle::SHELL_ESCAPE); - let quoted = locale_aware_escape_name(file, QuotingStyle::SHELL_ESCAPE_QUOTE); + let quoted = locale_aware_escape_name(file, QuotingStyle::SHELL_ESCAPE_ALWAYS); let _ = writeln!( io::stderr(), "{}", @@ -431,12 +460,10 @@ pub fn uu_app() -> Command { .help(translate!("rm-help-progress")) .action(ArgAction::SetTrue), ) - // From the GNU source code: - // This is solely for testing. - // Do not document. - // It is relatively difficult to ensure that there is a tty on stdin. - // Since rm acts differently depending on that, without this option, - // it'd be harder to test the parts of rm that depend on that setting. + // Hidden, and meant only for the test suite: handing the process a real + // tty on stdin is awkward to arrange, and the prompting paths behave + // differently once it has one, so this switch stands in for that state + // and makes those paths reachable from a test. // In contrast with Arg::long, Arg::alias does not strip leading // hyphens. Therefore it supports 3 leading hyphens. .arg( @@ -504,7 +531,9 @@ fn count_files_in_directory(p: &Path) -> u64 { entries .flatten() .map(|entry| match entry.file_type() { - Ok(ft) if ft.is_dir() => count_files_in_directory(&entry.path()), + Ok(ft) if ft.is_dir() && !ft.is_symlink() => { + count_files_in_directory(&entry.path()) + } Ok(_) => 1, Err(_) => 0, }) @@ -580,10 +609,10 @@ pub fn remove(files: &[&OsStr], options: &Options) -> bool { } // Only finish progress bar if it was created and files were processed - if let Some(pb) = progress_bar { - if any_files_processed { - pb.finish(); - } + if let Some(pb) = progress_bar + && any_files_processed + { + pb.finish(); } had_err @@ -637,7 +666,14 @@ fn remove_dir_recursive( // a directory and we don't want to recurse. In particular, this // avoids an infinite recursion in the case of a link to the current // directory, like `ln -s . link`. - if !path.is_dir() || path.is_symlink() { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(e) => return show_removal_error(e, path), + }; + if is_symlink_dir(&metadata) { + return remove_dir(path, options, progress_bar); + } + if !metadata.is_dir() || metadata.file_type().is_symlink() { return remove_file(path, options, progress_bar); } @@ -659,17 +695,15 @@ fn remove_dir_recursive( // 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 { - match fs::remove_dir_all(path) { - Ok(_) => return false, - Err(e) => { - let e = e.map_err_context( - || translate!("rm-error-cannot-remove", "file" => path.quote()), - ); - show_error!("{e}"); - return true; - } + if path.to_str().is_some_and(|s| s.len() > 1000) { + match fs::remove_dir_all(path) { + Ok(_) => return false, + Err(e) => { + let e = e.map_err_context( + || translate!("rm-error-cannot-remove", "file" => path.quote()), + ); + show_error!("{e}"); + return true; } } } @@ -722,27 +756,34 @@ fn remove_dir_recursive( // show another error message as we return from each level // of the recursion. } - Ok(_) => verbose_removed_directory(path, options), + Ok(_) => report_verbose_write_error(verbose_removed_directory(path, options)), } error } } -/// Check if a path resolves to the root directory. +/// Check if a path is the root directory. /// Returns true if the path is root, false otherwise. fn is_root_path(path: &Path) -> bool { - // Check simple case: literal "/" path + // Check simple case: literal "/" path. Costs no syscall. if path.has_root() && path.parent().is_none() { return true; } - // Check if path resolves to "/" after following symlinks + // Otherwise settle by (st_dev, st_ino): a bind mount of "/" is a directory + // whose path never resolves to "/", so a name check misses it (symlinks too). + if uucore::fs::path_is_root_dir(path, true) { + return true; + } + + // Platforms without (st_dev, st_ino) keep the name-based test. + #[cfg(not(unix))] if let Ok(canonical) = path.canonicalize() { - canonical.has_root() && canonical.parent().is_none() - } else { - false + return canonical.has_root() && canonical.parent().is_none(); } + + false } /// Show error message for attempting to remove root. @@ -847,7 +888,7 @@ fn remove_file(path: &Path, options: &Options, progress_bar: Option<&ProgressBar // Fallback method for non-Unix, Redox, or when safe traversal is unavailable match fs::remove_file(path) { Ok(_) => { - verbose_removed_file(path, options); + report_verbose_write_error(verbose_removed_file(path, options)); } Err(e) => { if e.kind() == io::ErrorKind::PermissionDenied { @@ -999,6 +1040,7 @@ fn handle_writable_directory(path: &Path, options: &Options, metadata: &Metadata is_writable_metadata(metadata), options.interactive, ) { + #[expect(clippy::match_same_arms)] // needs comment (false, _, _, InteractiveMode::PromptProtected) => true, (false, false, false, InteractiveMode::Never) => true, // Don't prompt when interactive is never (_, false, false, _) => prompt_yes!( @@ -1015,12 +1057,10 @@ fn handle_writable_directory(path: &Path, options: &Options, metadata: &Metadata } } -// For windows we can use windows metadata trait and file attributes to see if a directory is readonly +// For Windows, metadata.permissions().readonly() checks FILE_ATTRIBUTE_READONLY #[cfg(windows)] fn handle_writable_directory(path: &Path, options: &Options, metadata: &Metadata) -> bool { - use std::os::windows::prelude::MetadataExt; - use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_READONLY; - let not_user_writable = (metadata.file_attributes() & FILE_ATTRIBUTE_READONLY) != 0; + let not_user_writable = metadata.permissions().readonly(); let stdin_ok = options.__presume_input_tty.unwrap_or(false) || stdin().is_terminal(); match (stdin_ok, not_user_writable, options.interactive) { (false, _, InteractiveMode::PromptProtected) => true, @@ -1086,11 +1126,9 @@ fn is_symlink_dir(_metadata: &Metadata) -> bool { #[cfg(windows)] fn is_symlink_dir(metadata: &Metadata) -> bool { - use std::os::windows::prelude::MetadataExt; - use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_DIRECTORY; + use std::os::windows::fs::FileTypeExt; - metadata.file_type().is_symlink() - && ((metadata.file_attributes() & FILE_ATTRIBUTE_DIRECTORY) != 0) + metadata.file_type().is_symlink_dir() } mod tests { diff --git a/src/uu/rmdir/Cargo.toml b/src/uu/rmdir/Cargo.toml index 0f46670db53..694b8385f9d 100644 --- a/src/uu/rmdir/Cargo.toml +++ b/src/uu/rmdir/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/rmdir.rs" test = false @@ -21,9 +18,12 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["fs"] } -libc = { workspace = true } fluent = { workspace = true } +libc = { workspace = true } +uucore = { workspace = true, features = ["fs"] } + +[lints] +workspace = true [[bin]] name = "rmdir" diff --git a/src/uu/runcon/Cargo.toml b/src/uu/runcon/Cargo.toml index 51a468fb2b0..6212ce4b2be 100644 --- a/src/uu/runcon/Cargo.toml +++ b/src/uu/runcon/Cargo.toml @@ -11,9 +11,6 @@ license.workspace = true version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/runcon.rs" test = false @@ -22,11 +19,14 @@ doctest = false # 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"] } +fluent = { workspace = true } +libc = { workspace = true } selinux = { workspace = true } thiserror = { workspace = true } -libc = { workspace = true } -fluent = { workspace = true } +uucore = { workspace = true, features = ["entries", "fs", "perms", "selinux"] } + +[lints] +workspace = true [[bin]] name = "runcon" diff --git a/src/uu/runcon/src/errors.rs b/src/uu/runcon/src/errors.rs index 31a108e18ff..cae48944e7a 100644 --- a/src/uu/runcon/src/errors.rs +++ b/src/uu/runcon/src/errors.rs @@ -25,7 +25,7 @@ pub(crate) mod error_exit_status { pub const ANOTHER_ERROR: i32 = libc::EXIT_FAILURE; } -#[derive(thiserror::Error, Debug)] +#[derive(Debug, thiserror::Error)] pub(crate) enum Error { #[error("{}", translate!("runcon-error-no-command"))] MissingCommand, @@ -128,14 +128,8 @@ impl Display for RunconError { impl UError for Error { fn code(&self) -> i32 { match self { - Self::MissingCommand => error_exit_status::ANOTHER_ERROR, - Self::SELinuxNotEnabled => error_exit_status::ANOTHER_ERROR, - Self::NotUTF8(_) => error_exit_status::ANOTHER_ERROR, Self::CommandLine(e) => e.exit_code(), - Self::SELinux { .. } => error_exit_status::ANOTHER_ERROR, - Self::Io { .. } => error_exit_status::ANOTHER_ERROR, - Self::Io1 { .. } => error_exit_status::ANOTHER_ERROR, - Self::Write(_) => error_exit_status::ANOTHER_ERROR, + _ => error_exit_status::ANOTHER_ERROR, } } } diff --git a/src/uu/runcon/src/runcon.rs b/src/uu/runcon/src/runcon.rs index a1ef88d2d04..4cc55e1ce1e 100644 --- a/src/uu/runcon/src/runcon.rs +++ b/src/uu/runcon/src/runcon.rs @@ -15,8 +15,9 @@ use clap::{Arg, ArgAction, Command}; use selinux::{OpaqueSecurityContext, SecurityClass, SecurityContext}; use uucore::format_usage; +use core::ffi::CStr; use std::borrow::Cow; -use std::ffi::{CStr, CString, OsStr, OsString}; +use std::ffi::{CString, OsStr, OsString}; use std::io; use std::io::Write; use std::os::unix::ffi::OsStrExt; diff --git a/src/uu/seq/Cargo.toml b/src/uu/seq/Cargo.toml index e05bfb4e715..7a2cc943417 100644 --- a/src/uu/seq/Cargo.toml +++ b/src/uu/seq/Cargo.toml @@ -1,4 +1,5 @@ -# spell-checker:ignore bigdecimal extendedbigdecimal +# spell-checker:ignore extendedbigdecimal + [package] name = "uu_seq" description = "seq ~ (uutils) display a sequence of numbers" @@ -12,9 +13,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/seq.rs" doctest = false @@ -22,6 +20,7 @@ doctest = false [dependencies] bigdecimal = { workspace = true } clap = { workspace = true } +fluent = { workspace = true } num-bigint = { workspace = true } num-traits = { workspace = true } thiserror = { workspace = true } @@ -33,7 +32,6 @@ uucore = { workspace = true, features = [ "quoting-style", "signals", ] } -fluent = { workspace = true } [[bin]] name = "seq" @@ -43,6 +41,9 @@ path = "src/main.rs" divan = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bench]] name = "seq_bench" harness = false diff --git a/src/uu/seq/benches/seq_bench.rs b/src/uu/seq/benches/seq_bench.rs index 11956e8c04a..d4d559d5ff1 100644 --- a/src/uu/seq/benches/seq_bench.rs +++ b/src/uu/seq/benches/seq_bench.rs @@ -5,49 +5,46 @@ use divan::{Bencher, black_box}; use uu_seq::uumain; -use uucore::benchmark::run_util_function; +use uucore::benchmark::get_bench_args; /// Benchmark simple integer sequence #[divan::bench] fn seq_integers(bencher: Bencher) { - bencher.bench(|| { - black_box(run_util_function(uumain, &["1", "1000000"])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"1", &"1000000"]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark large integer #[divan::bench] fn seq_large_integers(bencher: Bencher) { - bencher.bench(|| { - black_box(run_util_function(uumain, &["4e10003", "4e10003"])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"4e10003", &"4e10003"]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark sequence with custom separator #[divan::bench] fn seq_custom_separator(bencher: Bencher) { - bencher.bench(|| { - black_box(run_util_function(uumain, &["-s", ",", "1", "1000000"])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-s", &",", &"1", &"1000000"]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark sequence with step #[divan::bench] fn seq_with_step(bencher: Bencher) { - bencher.bench(|| { - black_box(run_util_function(uumain, &["1", "2", "1000000"])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"1", &"2", &"1000000"]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark formatted output #[divan::bench] fn seq_formatted(bencher: Bencher) { - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-f", "%.3f", "1", "0.1", "10000"], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-f", &"%.3f", &"1", &"0.1", &"10000"]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/seq/locales/en-US.ftl b/src/uu/seq/locales/en-US.ftl index 34a97d43868..285727f24af 100644 --- a/src/uu/seq/locales/en-US.ftl +++ b/src/uu/seq/locales/en-US.ftl @@ -18,3 +18,6 @@ seq-error-format-and-equal-width = format string may not be specified when print # Parse error types seq-parse-error-type-float = floating point seq-parse-error-type-nan = 'not-a-number' + +# Diagnostics +seq-diag-help-format = a format holds exactly one float conversion: %f, %e, %g or %a, as in -f%.3f diff --git a/src/uu/seq/locales/fr-FR.ftl b/src/uu/seq/locales/fr-FR.ftl index ed804982051..df98846079d 100644 --- a/src/uu/seq/locales/fr-FR.ftl +++ b/src/uu/seq/locales/fr-FR.ftl @@ -18,3 +18,6 @@ seq-error-format-and-equal-width = la chaîne de format ne peut pas être spéci # Types d'erreur d'analyse seq-parse-error-type-float = nombre à virgule flottante seq-parse-error-type-nan = 'non-un-nombre' + +# Diagnostics +seq-diag-help-format = un format contient exactement une conversion flottante : %f, %e, %g ou %a, comme dans -f%.3f diff --git a/src/uu/seq/src/diagnostics.rs b/src/uu/seq/src/diagnostics.rs new file mode 100644 index 00000000000..6cdb09205b4 --- /dev/null +++ b/src/uu/seq/src/diagnostics.rs @@ -0,0 +1,46 @@ +// 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. + +//! Maps a [`FormatError`] onto the part of the `-f` format it came from, so +//! that [`uucore::diagnostics`] can render it with a caret. + +use std::ffi::OsString; +use std::ops::Range; + +use uucore::diagnostics::{OptionValue, Snapshot}; +use uucore::format::FormatError; +use uucore::translate; + +/// Render `error` against `args` — the whole argument list, program name +/// included — where `format` is the value of `-f`/`--format` as typed. +/// +/// # Returns +/// +/// `false` when the error is not about the format string, or when the format +/// cannot be found among the arguments, in which case the caller should fall +/// back to the plain one-line message. +pub fn render(args: &[OsString], format: &str, error: &FormatError) -> bool { + let span: Range = match error { + FormatError::SpecError(_, span) + | FormatError::MissingHex(Some(span)) + | FormatError::InvalidCharacter(_, _, Some(span)) => span.clone(), + // These are about the format as a whole — it holds no directive, or + // more than one, or one seq cannot print a number with — so the caret + // takes all of it. + FormatError::TooManySpecs(_) + | FormatError::NeedAtLeastOneSpec(_) + | FormatError::EndsWithPercent(_) + | FormatError::WrongSpecType => 0..format.len(), + _ => return false, + }; + + Snapshot::with_program(args).render_option( + &OptionValue::new(format, 'f', crate::OPT_FORMAT), + span, + &error.to_string(), + None, + Some(&translate!("seq-diag-help-format")), + ) +} diff --git a/src/uu/seq/src/error.rs b/src/uu/seq/src/error.rs index 2fd92938c10..6ea36d38a3e 100644 --- a/src/uu/seq/src/error.rs +++ b/src/uu/seq/src/error.rs @@ -2,15 +2,17 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore numberparse + //! Errors returned by seq. + use crate::numberparse::ParseNumberError; -use thiserror::Error; use uucore::display::Quotable; use uucore::error::UError; use uucore::translate; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum SeqError { /// An error parsing the input arguments. /// diff --git a/src/uu/seq/src/number.rs b/src/uu/seq/src/number.rs index f23e42b1cf8..2c338a7b431 100644 --- a/src/uu/seq/src/number.rs +++ b/src/uu/seq/src/number.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore extendedbigdecimal + use num_traits::Zero; use uucore::extendedbigdecimal::ExtendedBigDecimal; diff --git a/src/uu/seq/src/numberparse.rs b/src/uu/seq/src/numberparse.rs index d30b1e827e5..a6bce793422 100644 --- a/src/uu/seq/src/numberparse.rs +++ b/src/uu/seq/src/numberparse.rs @@ -2,11 +2,14 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore extendedbigdecimal bigdecimal numberparse hexadecimalfloat + //! Parsing numbers for use in `seq`. //! //! This module provides an implementation of [`FromStr`] for the //! [`PreciseNumber`] struct. + use std::str::FromStr; use uucore::parser::num_parser::{ExtendedParser, ExtendedParserError}; diff --git a/src/uu/seq/src/seq.rs b/src/uu/seq/src/seq.rs index f718b22087b..e661b4f7a28 100644 --- a/src/uu/seq/src/seq.rs +++ b/src/uu/seq/src/seq.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (ToDO) bigdecimal extendedbigdecimal numberparse hexadecimalfloat biguint + use std::ffi::{OsStr, OsString}; use std::io::{BufWriter, Write, stdout}; @@ -17,6 +19,7 @@ use uucore::format::num_format::FloatVariant; use uucore::format::{Format, num_format}; use uucore::{fast_inc::fast_inc, format_usage}; +mod diagnostics; mod error; // public to allow fuzzing @@ -28,14 +31,14 @@ mod numberparse; use crate::error::SeqError; use crate::number::PreciseNumber; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] use uucore::signals; use uucore::translate; const OPT_SEPARATOR: &str = "separator"; const OPT_TERMINATOR: &str = "terminator"; const OPT_EQUAL_WIDTH: &str = "equal-width"; -const OPT_FORMAT: &str = "format"; +pub(crate) const OPT_FORMAT: &str = "format"; const ARG_NUMBERS: &str = "numbers"; @@ -94,8 +97,14 @@ fn select_precision( #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = - uucore::clap_localization::handle_clap_result(uu_app(), split_short_args_with_value(args))?; + let raw_args: Vec = args.collect(); + // Captured before `-f%q` is split into two arguments, so that the caret + // echoes the command line as it was typed. + let diag_args = uucore::diagnostics::capture(&raw_args); + let matches = uucore::clap_localization::handle_clap_result( + uu_app(), + split_short_args_with_value(raw_args.into_iter()), + )?; let numbers_option = matches.get_many::(ARG_NUMBERS); @@ -123,18 +132,16 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } let first = if numbers.len() > 1 { - match numbers[0].parse() { - Ok(num) => num, - Err(e) => return Err(SeqError::ParseError(numbers[0].to_owned(), e).into()), - } + numbers[0] + .parse() + .map_err(|e| SeqError::ParseError(numbers[0].to_owned(), e))? } else { PreciseNumber::one() }; let increment = if numbers.len() > 2 { - match numbers[1].parse() { - Ok(num) => num, - Err(e) => return Err(SeqError::ParseError(numbers[1].to_owned(), e).into()), - } + numbers[1] + .parse() + .map_err(|e| SeqError::ParseError(numbers[1].to_owned(), e))? } else { PreciseNumber::one() }; @@ -146,20 +153,23 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // and at most three because of the argument specification in // `uu_app()`. let n: usize = numbers.len(); - match numbers[n - 1].parse() { - Ok(num) => num, - Err(e) => return Err(SeqError::ParseError(numbers[n - 1].to_owned(), e).into()), - } + numbers[n - 1] + .parse() + .map_err(|e| SeqError::ParseError(numbers[n - 1].to_owned(), e))? }; // If a format was passed on the command line, use that. // If not, use some default format based on parameters precision. let (format, padding, fast_allowed) = if let Some(str) = options.format { - ( - Format::::parse(str)?, - 0, - false, - ) + let format = + Format::::parse(str).map_err(|error| { + uucore::diagnostics::error_after_report( + diag_args.as_deref(), + error, + |args, error| diagnostics::render(args, str, error), + ) + })?; + (format, 0, false) } else { let precision = select_precision(&first, &increment, &last); @@ -220,7 +230,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // 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)] + #[cfg(all(unix, not(target_os = "fuchsia")))] if signals::sigpipe_was_ignored() { uucore::error::set_exit_code(1); } diff --git a/src/uu/sha1sum/Cargo.toml b/src/uu/sha1sum/Cargo.toml index bc265752872..8444e76b4eb 100644 --- a/src/uu/sha1sum/Cargo.toml +++ b/src/uu/sha1sum/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/sha1sum.rs" test = false @@ -21,6 +18,7 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } uu_checksum_common = { workspace = true } uucore = { workspace = true, features = [ "checksum", @@ -28,13 +26,15 @@ uucore = { workspace = true, features = [ "sum", "hardware", ] } -fluent = { workspace = true } + +[dev-dependencies] +uucore = { workspace = true, features = ["benchmark"] } [features] openssl = ["uucore/openssl"] -[dev-dependencies] -uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true [[bin]] name = "sha1sum" diff --git a/src/uu/sha224sum/Cargo.toml b/src/uu/sha224sum/Cargo.toml index be0362f3f3f..a09f0987da4 100644 --- a/src/uu/sha224sum/Cargo.toml +++ b/src/uu/sha224sum/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/sha224sum.rs" test = false @@ -21,6 +18,7 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } uu_checksum_common = { workspace = true } uucore = { workspace = true, features = [ "checksum", @@ -28,13 +26,15 @@ uucore = { workspace = true, features = [ "sum", "hardware", ] } -fluent = { workspace = true } + +[dev-dependencies] +uucore = { workspace = true, features = ["benchmark"] } [features] openssl = ["uucore/openssl"] -[dev-dependencies] -uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true [[bin]] name = "sha224sum" diff --git a/src/uu/sha256sum/Cargo.toml b/src/uu/sha256sum/Cargo.toml index 1d2c167c833..7eb8b668476 100644 --- a/src/uu/sha256sum/Cargo.toml +++ b/src/uu/sha256sum/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/sha256sum.rs" test = false @@ -21,6 +18,7 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } uu_checksum_common = { workspace = true } uucore = { workspace = true, features = [ "checksum", @@ -28,13 +26,15 @@ uucore = { workspace = true, features = [ "sum", "hardware", ] } -fluent = { workspace = true } + +[dev-dependencies] +uucore = { workspace = true, features = ["benchmark"] } [features] openssl = ["uucore/openssl"] -[dev-dependencies] -uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true [[bin]] name = "sha256sum" diff --git a/src/uu/sha384sum/Cargo.toml b/src/uu/sha384sum/Cargo.toml index 52ac45c21b5..a461139cede 100644 --- a/src/uu/sha384sum/Cargo.toml +++ b/src/uu/sha384sum/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/sha384sum.rs" test = false @@ -21,6 +18,7 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } uu_checksum_common = { workspace = true } uucore = { workspace = true, features = [ "checksum", @@ -28,13 +26,15 @@ uucore = { workspace = true, features = [ "sum", "hardware", ] } -fluent = { workspace = true } + +[dev-dependencies] +uucore = { workspace = true, features = ["benchmark"] } [features] openssl = ["uucore/openssl"] -[dev-dependencies] -uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true [[bin]] name = "sha384sum" diff --git a/src/uu/sha512sum/Cargo.toml b/src/uu/sha512sum/Cargo.toml index df5e60ebc6b..728f90cf1f1 100644 --- a/src/uu/sha512sum/Cargo.toml +++ b/src/uu/sha512sum/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/sha512sum.rs" test = false @@ -21,6 +18,7 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } uu_checksum_common = { workspace = true } uucore = { workspace = true, features = [ "checksum", @@ -28,13 +26,15 @@ uucore = { workspace = true, features = [ "sum", "hardware", ] } -fluent = { workspace = true } + +[dev-dependencies] +uucore = { workspace = true, features = ["benchmark"] } [features] openssl = ["uucore/openssl"] -[dev-dependencies] -uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true [[bin]] name = "sha512sum" diff --git a/src/uu/shred/Cargo.toml b/src/uu/shred/Cargo.toml index 5a9a109ae0a..c613aab445c 100644 --- a/src/uu/shred/Cargo.toml +++ b/src/uu/shred/Cargo.toml @@ -11,19 +11,19 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/shred.rs" doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } +libc = { workspace = true } rand = { workspace = true } uucore = { workspace = true, features = ["parser-size"] } -libc = { workspace = true } -fluent = { workspace = true } + +[lints] +workspace = true [[bin]] name = "shred" diff --git a/src/uu/shred/src/shred.rs b/src/uu/shred/src/shred.rs index 726720d0d3e..5d550a98edf 100644 --- a/src/uu/shred/src/shred.rs +++ b/src/uu/shred/src/shred.rs @@ -16,8 +16,9 @@ use std::io::{self, Read, Seek, SeekFrom, Write}; #[cfg(unix)] use std::os::unix::prelude::PermissionsExt; use std::path::{Path, PathBuf}; +use uucore::diagnostics::OptionValue; use uucore::display::Quotable; -use uucore::error::{FromIo, UResult, USimpleError, UUsageError}; +use uucore::error::{FromIo, UResult, USimpleError, UUsageError, strip_errno}; use uucore::parser::parse_size::parse_size_u64; use uucore::parser::shortcut_value_parser::ShortcutValueParser; use uucore::translate; @@ -210,7 +211,7 @@ impl BytesWriter { Pattern::Single(byte) => [*byte; PATTERN_BUFFER_SIZE], Pattern::Multi(bytes) => { let mut buf = [0; PATTERN_BUFFER_SIZE]; - for chunk in buf.chunks_exact_mut(PATTERN_LENGTH) { + for chunk in buf.as_chunks_mut::().0 { chunk.copy_from_slice(bytes); } buf @@ -244,7 +245,13 @@ impl BytesWriter { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + // The command line is kept for the caret in size diagnostics, which needs + // the size as typed. + let (matches, diag_args) = uucore::clap_localization::handle_clap_result_with_diagnostics( + uu_app(), + args.collect(), + 1, + )?; if !matches.contains_id(options::FILE) { return Err(UUsageError::new( @@ -264,12 +271,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }; let random_source = match matches.get_one::(options::RANDOM_SOURCE) { - Some(filepath) => Some(RefCell::new(File::open(filepath).map_err(|_| { - USimpleError::new( - 1, - translate!("shred-cannot-open-random-source", "source" => filepath.quote()), - ) - })?)), + Some(filepath) => Some(RefCell::new( + File::open(filepath).map_err_context(|| filepath.clone())?, + )), None => None, }; @@ -293,7 +297,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let size_arg = matches .get_one::(options::SIZE) .map(ToOwned::to_owned); - let size = get_size(size_arg); + let size = get_size(size_arg, diag_args.as_deref())?; let exact = matches.get_flag(options::EXACT) || size.is_some(); let zero = matches.get_flag(options::ZERO); let verbose = matches.get_flag(options::VERBOSE); @@ -402,21 +406,31 @@ pub fn uu_app() -> Command { ) } -fn get_size(size_str_opt: Option) -> Option { - size_str_opt - .as_ref() - .and_then(|size| parse_size_u64(size.as_str()).ok()) - .or_else(|| { - if let Some(size) = size_str_opt { - show_error!( - "{}", - translate!("shred-invalid-file-size", "size" => size.quote()) - ); - // TODO: replace with our error management - std::process::exit(1); - } - None - }) +/// The value of `-s`/`--size` as a number of bytes. +/// +/// # Arguments +/// +/// * `size_str_opt` - The value as typed, or `None` when the option was not +/// given. +/// * `diag_args` - The arguments as typed, for the caret, or `None` when they +/// were not kept. +fn get_size(size_str_opt: Option, diag_args: Option<&[OsString]>) -> UResult> { + let Some(size) = size_str_opt else { + return Ok(None); + }; + match parse_size_u64(&size) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) => { + let message = translate!("shred-invalid-file-size", "size" => size.quote()); + Err(error.size_value_error( + diag_args, + &OptionValue::new(&size, 's', options::SIZE), + 0, + &message, + USimpleError::new(1, message.clone()), + )) + } + } } fn pass_name(pass_type: &PassType) -> String { @@ -512,35 +526,37 @@ fn create_test_compatible_sequence( .seek(SeekFrom::Start(0)) .map_err_context(|| translate!("shred-failed-to-seek-file"))?; let mut buffer = [0u8; 1024]; - if let Ok(bytes_read) = random_source.borrow_mut().read(&mut buffer) { - if bytes_read > 0 && buffer[..bytes_read].iter().all(|&b| b == 0x55) { - // This is the test scenario - replicate exact algorithm - let test_patterns = vec![ - 0xFFF, 0x924, 0x888, 0xDB6, 0x777, 0x492, 0xBBB, 0x555, 0xAAA, 0x6DB, 0x249, 0x999, - 0x111, 0x000, 0xB6D, 0xEEE, 0x333, - ]; - - if num_passes >= 3 { - let mut sequence = Vec::new(); - let n_random = (num_passes / 10).max(3); - let n_pattern = num_passes - n_random; - - // Standard algorithm: first random, patterns with middle random(s), final random - sequence.push(PassType::Random); - - let middle_randoms = n_random - 2; - let mut pattern_sequence = generate_patterns_with_middle_randoms( - &test_patterns, - n_pattern, - middle_randoms, - num_passes, - ); - sequence.append(&mut pattern_sequence); + if random_source + .borrow_mut() + .read(&mut buffer) + .is_ok_and(|bytes_read| bytes_read > 0 && buffer[..bytes_read].iter().all(|&b| b == 0x55)) + { + // This is the test scenario - replicate exact algorithm + let test_patterns = vec![ + 0xFFF, 0x924, 0x888, 0xDB6, 0x777, 0x492, 0xBBB, 0x555, 0xAAA, 0x6DB, 0x249, 0x999, + 0x111, 0x000, 0xB6D, 0xEEE, 0x333, + ]; + + if num_passes >= 3 { + let mut sequence = Vec::new(); + let n_random = (num_passes / 10).max(3); + let n_pattern = num_passes - n_random; + + // Standard algorithm: first random, patterns with middle random(s), final random + sequence.push(PassType::Random); + + let middle_randoms = n_random - 2; + let mut pattern_sequence = generate_patterns_with_middle_randoms( + &test_patterns, + n_pattern, + middle_randoms, + num_passes, + ); + sequence.append(&mut pattern_sequence); - sequence.push(PassType::Random); + sequence.push(PassType::Random); - return Ok(sequence); - } + return Ok(sequence); } } @@ -625,17 +641,26 @@ fn wipe_file( } } - if !path.exists() { - return Err(USimpleError::new( - 1, - translate!("shred-no-such-file-or-directory", "file" => path.maybe_quote()), - )); - } - if !path.is_file() { - return Err(USimpleError::new( - 1, - translate!("shred-not-a-file", "file" => path.maybe_quote()), - )); + // `Path::exists()` and `Path::is_file()` both collapse any metadata error + // (including a permission error) into `false`, which made shred report a + // file whose parent directory lacks search permission as "No such file or + // directory". Inspect the metadata directly so a genuine `ENOENT` stays a + // "no such file" error while a permission error falls through to the + // open-for-writing below, which surfaces the real reason. + match fs::metadata(path) { + Ok(md) if !md.is_file() => { + return Err(USimpleError::new( + 1, + translate!("shred-not-a-file", "file" => path.maybe_quote()), + )); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + return Err(USimpleError::new( + 1, + translate!("shred-no-such-file-or-directory", "file" => path.maybe_quote()), + )); + } + _ => {} } let metadata = @@ -809,7 +834,7 @@ fn wipe_name(orig_path: &Path, verbose: bool, remove_method: RemoveMethod) -> Pa break; } Err(e) => { - let msg = translate!("shred-couldnt-rename", "file" => last_path.maybe_quote(), "new_name" => new_path.quote(), "error" => e); + let msg = translate!("shred-couldnt-rename", "file" => last_path.maybe_quote(), "new_name" => new_path.quote(), "error" => strip_errno(&e)); show_error!("{msg}"); // TODO: replace with our error management std::process::exit(1); diff --git a/src/uu/shuf/Cargo.toml b/src/uu/shuf/Cargo.toml index 54b8d73811b..dd2a0bac41c 100644 --- a/src/uu/shuf/Cargo.toml +++ b/src/uu/shuf/Cargo.toml @@ -11,22 +11,19 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/shuf.rs" doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } itoa = { workspace = true } rand = { workspace = true } rand_chacha = { workspace = true } +rustc-hash = { workspace = true } sha3 = { workspace = true } uucore = { workspace = true } -fluent = { workspace = true } -rustc-hash = { workspace = true } [[bin]] name = "shuf" @@ -39,3 +36,6 @@ harness = false [dev-dependencies] divan = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } + +[lints] +workspace = true diff --git a/src/uu/shuf/benches/shuf_bench.rs b/src/uu/shuf/benches/shuf_bench.rs index 62c3be0bad7..7f1036ce4fd 100644 --- a/src/uu/shuf/benches/shuf_bench.rs +++ b/src/uu/shuf/benches/shuf_bench.rs @@ -5,19 +5,18 @@ use divan::{Bencher, black_box}; use uu_shuf::uumain; -use uucore::benchmark::{run_util_function, setup_test_file, text_data}; +use uucore::benchmark::{get_bench_args, setup_test_file, text_data}; /// Benchmark shuffling lines from a file /// Tests the default mode with a large number of lines -#[divan::bench(args = [100_000])] -fn shuf_lines(bencher: Bencher, num_lines: usize) { - let data = text_data::generate_by_lines(num_lines, 80); +#[divan::bench(args = [(100_000, 80), (100_000, 10)])] +fn shuf_lines(bencher: Bencher, (num_lines, avg_line_length): (usize, usize)) { + let data = text_data::generate_by_lines(num_lines, avg_line_length); let file_path = setup_test_file(&data); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &[file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark shuffling a numeric range with -i @@ -26,26 +25,22 @@ fn shuf_lines(bencher: Bencher, num_lines: usize) { fn shuf_input_range(bencher: Bencher, range_size: usize) { let range_arg = format!("1-{range_size}"); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-i", &range_arg])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-i", &range_arg]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark shuffling with repeat (sampling with replacement) /// Tests the -r flag combined with -n to output a specific count -#[divan::bench(args = [50_000])] -fn shuf_repeat_sampling(bencher: Bencher, num_lines: usize) { - let data = text_data::generate_by_lines(10_000, 80); +#[divan::bench(args = [(50_000, 80), (50_000, 10)])] +fn shuf_repeat_sampling(bencher: Bencher, (head_count, avg_line_length): (usize, usize)) { + let data = text_data::generate_by_lines(10_000, avg_line_length); let file_path = setup_test_file(&data); - let file_path_str = file_path.to_str().unwrap(); - let count = format!("{num_lines}"); - - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-r", "-n", &count, file_path_str], - )); - }); + let count = format!("{head_count}"); + + bencher + .with_inputs(|| get_bench_args(&[&"-r", &"-n", &count, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/shuf/src/shuf.rs b/src/uu/shuf/src/shuf.rs index b523001ce51..6689f09c902 100644 --- a/src/uu/shuf/src/shuf.rs +++ b/src/uu/shuf/src/shuf.rs @@ -14,8 +14,8 @@ use std::str::FromStr; use clap::{Arg, ArgAction, Command, builder::ValueParser}; use rand::{ - RngExt as _, - rngs::ThreadRng, + RngExt as _, SeedableRng, + rngs::{SmallRng, SysRng}, seq::{IndexedRandom, SliceRandom}, }; @@ -139,7 +139,12 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // here (missing input, unreadable random source) leaves an existing output // file untouched, matching GNU and avoiding silent data loss. let mut rng = match options.random_source { - RandomSource::None => WrappedRng::Default(rand::rng()), + RandomSource::None => { + // GNU returns error on the legacy kernel getrandom is missing. But fallback is allowed + let r = SmallRng::try_from_rng(&mut SysRng) + .unwrap_or_else(|_| SmallRng::seed_from_u64(&raw const options as u64)); + WrappedRng::Default(r) + } RandomSource::Seed(ref seed) => WrappedRng::Seed(SeededRng::new(seed)), RandomSource::File(ref r) => { let file = File::open(r).map_err_context( @@ -460,7 +465,7 @@ fn parse_range(input_range: &str) -> Result, String> { } enum WrappedRng { - Default(ThreadRng), + Default(SmallRng), Seed(SeededRng), File(RandomSourceAdapter>), } diff --git a/src/uu/sleep/Cargo.toml b/src/uu/sleep/Cargo.toml index a8cc22305a6..ff80b5c3ced 100644 --- a/src/uu/sleep/Cargo.toml +++ b/src/uu/sleep/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/sleep.rs" test = false @@ -21,8 +18,11 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["parser"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["parser"] } + +[lints] +workspace = true [[bin]] name = "sleep" diff --git a/src/uu/sleep/locales/en-US.ftl b/src/uu/sleep/locales/en-US.ftl index d411be3e0e4..e18b1a0fc2e 100644 --- a/src/uu/sleep/locales/en-US.ftl +++ b/src/uu/sleep/locales/en-US.ftl @@ -7,9 +7,5 @@ sleep-after-help = Pause for NUMBER seconds. SUFFIX may be 's' for seconds (the point number. Given two or more arguments, pause for the amount of time specified by the sum of their values. -# Error messages -sleep-error-missing-operand = missing operand - Try '{ $program } --help' for more information. - # Help messages sleep-help-number = pause for NUMBER seconds diff --git a/src/uu/sleep/locales/fr-FR.ftl b/src/uu/sleep/locales/fr-FR.ftl index 61393d1e9ad..0c3c96c1d5f 100644 --- a/src/uu/sleep/locales/fr-FR.ftl +++ b/src/uu/sleep/locales/fr-FR.ftl @@ -6,9 +6,5 @@ sleep-after-help = Faire une pause de NOMBRE secondes. SUFFIXE peut être 's' po qui exigent que NOMBRE soit un entier, ici NOMBRE peut être un nombre à virgule flottante arbitraire. Avec deux arguments ou plus, faire une pause pour la durée spécifiée par la somme de leurs valeurs. -# Messages d'erreur -sleep-error-missing-operand = opérande manquant - Essayez '{ $program } --help' pour plus d'informations. - # Messages d'aide sleep-help-number = faire une pause de NOMBRE secondes diff --git a/src/uu/sleep/src/sleep.rs b/src/uu/sleep/src/sleep.rs index cd82aa59185..2765d64ec9b 100644 --- a/src/uu/sleep/src/sleep.rs +++ b/src/uu/sleep/src/sleep.rs @@ -8,7 +8,7 @@ use std::thread; use std::time::Duration; use uucore::translate; use uucore::{ - error::{UResult, USimpleError, UUsageError}, + error::{UResult, UUsageError}, format_usage, parser::parse_time, show_error, @@ -22,14 +22,10 @@ mod options { pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + #[expect(clippy::unwrap_used, reason = "set as required by clap")] let numbers = matches .get_many::(options::NUMBER) - .ok_or_else(|| { - USimpleError::new( - 1, - translate!("sleep-error-missing-operand", "program" => uucore::execution_phrase()), - ) - })? + .unwrap() .map(String::as_str) .collect::>(); @@ -48,6 +44,7 @@ pub fn uu_app() -> Command { Arg::new(options::NUMBER) .help(translate!("sleep-help-number")) .value_name(options::NUMBER) + .required(true) .action(ArgAction::Append), ) } diff --git a/src/uu/sort/Cargo.toml b/src/uu/sort/Cargo.toml index db51104c096..8a9f447e18f 100644 --- a/src/uu/sort/Cargo.toml +++ b/src/uu/sort/Cargo.toml @@ -1,5 +1,3 @@ -# spell-checker:ignore bigdecimal foldhash - [package] name = "uu_sort" description = "sort ~ (uutils) sort input lines" @@ -13,22 +11,15 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/sort.rs" doctest = false -[features] -default = ["i18n-collator"] -i18n-collator = ["uucore/i18n-collator"] - [dependencies] bigdecimal = { workspace = true } -binary-heap-plus = { workspace = true } clap = { workspace = true } -compare = { workspace = true } +fluent = { workspace = true } +foldhash = { workspace = true } itertools = { workspace = true } memchr = { workspace = true } rand = { workspace = true } @@ -44,11 +35,9 @@ uucore = { workspace = true, features = [ "i18n-collator", "i18n-datetime", ] } -fluent = { workspace = true } -foldhash = { workspace = true } [target.'cfg(all(unix, not(any(target_os = "redox", target_os = "fuchsia", target_os = "haiku", target_os = "solaris", target_os = "illumos"))))'.dependencies] -rustix = { workspace = true, features = ["system", "process"] } +rustix = { workspace = true, features = ["fs", "system", "process"] } [target.'cfg(not(any(target_os = "redox", target_os = "wasi")))'.dependencies] ctrlc = { workspace = true } @@ -68,6 +57,14 @@ uucore = { workspace = true, features = [ "i18n-datetime", ] } +[features] +default = ["i18n-collator"] +diagnostics = ["uucore/diagnostics"] +i18n-collator = ["uucore/i18n-collator"] + +[lints] +workspace = true + [[bin]] name = "sort" path = "src/main.rs" diff --git a/src/uu/sort/benches/sort_bench.rs b/src/uu/sort/benches/sort_bench.rs index 08a23b65602..ef6d6a0a925 100644 --- a/src/uu/sort/benches/sort_bench.rs +++ b/src/uu/sort/benches/sort_bench.rs @@ -6,7 +6,7 @@ use divan::{Bencher, black_box}; use tempfile::NamedTempFile; use uu_sort::uumain; -use uucore::benchmark::{run_util_function, setup_test_file, text_data}; +use uucore::benchmark::{get_bench_args, setup_test_file, text_data}; /// Benchmark sorting ASCII-only data #[divan::bench(args = [500_000])] @@ -14,14 +14,11 @@ fn sort_ascii_only(bencher: Bencher, num_lines: usize) { let data = text_data::generate_ascii_data(num_lines); let file_path = setup_test_file(&data); let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-o", output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark sorting accented/non-ASCII data @@ -30,14 +27,11 @@ fn sort_accented_data(bencher: Bencher, num_lines: usize) { let data = text_data::generate_accented_data(num_lines); let file_path = setup_test_file(&data); let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-o", output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark sorting mixed ASCII/non-ASCII data @@ -46,14 +40,11 @@ fn sort_mixed_data(bencher: Bencher, num_lines: usize) { let data = text_data::generate_mixed_data(num_lines); let file_path = setup_test_file(&data); let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-o", output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark case-sensitive sorting with mixed case data @@ -62,14 +53,11 @@ fn sort_case_sensitive(bencher: Bencher, num_lines: usize) { let data = text_data::generate_case_sensitive_data(num_lines); let file_path = setup_test_file(&data); let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-o", output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark case-insensitive sorting (fold case) @@ -78,14 +66,11 @@ fn sort_case_insensitive(bencher: Bencher, num_lines: usize) { let data = text_data::generate_case_sensitive_data(num_lines); let file_path = setup_test_file(&data); let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-f", "-o", output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-f", &"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark dictionary order sorting (only blanks and alphanumeric) @@ -94,14 +79,11 @@ fn sort_dictionary_order(bencher: Bencher, num_lines: usize) { let data = text_data::generate_mixed_data(num_lines); let file_path = setup_test_file(&data); let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-d", "-o", output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-d", &"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark numeric sorting with mixed data @@ -118,14 +100,11 @@ fn sort_numeric(bencher: Bencher, num_lines: usize) { let file_path = setup_test_file(&data); let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-n", "-o", output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-n", &"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark general numeric sorting (-g) with decimal and exponent notation @@ -146,12 +125,9 @@ fn sort_general_numeric(bencher: Bencher, num_lines: usize) { 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()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-g", &"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark reverse sorting with locale-aware data @@ -160,14 +136,11 @@ fn sort_reverse_locale(bencher: Bencher, num_lines: usize) { let data = text_data::generate_accented_data(num_lines); let file_path = setup_test_file(&data); let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-r", "-o", output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-r", &"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark sorting with specific key field @@ -187,15 +160,12 @@ fn sort_key_field(bencher: Bencher, num_lines: usize) { let file_path = setup_test_file(&data); let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap(); + let output_path = output_file.path(); - bencher.bench(|| { + bencher // Sort by second field - black_box(run_util_function( - uumain, - &["-k", "2", "-o", output_path, file_path.to_str().unwrap()], - )); - }); + .with_inputs(|| get_bench_args(&[&"-k", &"2", &"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark unique sorting with locale-aware data @@ -204,14 +174,11 @@ fn sort_unique_locale(bencher: Bencher, num_lines: usize) { let data = text_data::generate_accented_data(num_lines); let file_path = setup_test_file(&data); let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-u", "-o", output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-u", &"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark sorting with very long lines exceeding `DEFAULT_BUF_SIZE` (8192 bytes) @@ -227,19 +194,11 @@ fn sort_long_line(bencher: Bencher, line_size: usize) { let file_a = setup_test_file(&data_a); let file_b = setup_test_file(&data_b); let output_file = NamedTempFile::new().unwrap(); - let output_path = output_file.path().to_str().unwrap(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &[ - file_a.to_str().unwrap(), - file_b.to_str().unwrap(), - "-o", - output_path, - ], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_a, &file_b, &"-o", &output_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/sort/benches/sort_locale_c_bench.rs b/src/uu/sort/benches/sort_locale_c_bench.rs index 8335992716c..26567cc1d86 100644 --- a/src/uu/sort/benches/sort_locale_c_bench.rs +++ b/src/uu/sort/benches/sort_locale_c_bench.rs @@ -11,7 +11,7 @@ use divan::{Bencher, black_box}; use tempfile::NamedTempFile; use uu_sort::uumain; -use uucore::benchmark::{run_util_function, setup_test_file, text_data}; +use uucore::benchmark::{get_bench_args, setup_test_file, text_data}; /// Benchmark ASCII-only data sorting with C locale (byte comparison) #[divan::bench] @@ -19,14 +19,11 @@ fn sort_ascii_c_locale(bencher: Bencher) { let data = text_data::generate_ascii_data_simple(2_000_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 output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-o", &output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark mixed ASCII/Unicode data with C locale (byte comparison) @@ -35,14 +32,11 @@ 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(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-o", &output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark German locale-specific data with C locale (byte comparison) @@ -51,14 +45,38 @@ 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(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-o", &output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); +} + +/// Benchmark whole-line sorting of lines that all share a long prefix, such as +/// timestamped log lines or paths under one directory. The sorter has to find +/// and skip that prefix before it can key each line on the bytes that differ. +#[divan::bench] +fn sort_shared_prefix_c_locale(bencher: Bencher) { + const PREFIX: &[u8] = b"/srv/data/2026/09/03/node-07/service/events/request-"; + let mut data = Vec::new(); + let mut state: u32 = 0x9e37_79b9; + for _ in 0..500_000 { + // Cheap deterministic pseudo-random suffix. + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + data.extend_from_slice(PREFIX); + data.extend_from_slice(format!("{state:08x}.json\n").as_bytes()); + } + let file_path = setup_test_file(&data); + let output_file = NamedTempFile::new().unwrap(); + let output_path = output_file.path(); + + bencher + .with_inputs(|| { + get_bench_args(&[&"--parallel=1", &"-o", &output_path, &file_path]).into_iter() + }) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/sort/benches/sort_locale_de_bench.rs b/src/uu/sort/benches/sort_locale_de_bench.rs index 5c760a694e8..a2236a955d7 100644 --- a/src/uu/sort/benches/sort_locale_de_bench.rs +++ b/src/uu/sort/benches/sort_locale_de_bench.rs @@ -11,7 +11,7 @@ use divan::{Bencher, black_box}; use tempfile::NamedTempFile; use uu_sort::uumain; -use uucore::benchmark::{run_util_function, setup_test_file, text_data}; +use uucore::benchmark::{get_bench_args, setup_test_file, text_data}; /// Benchmark German locale-specific data with German locale #[divan::bench] @@ -19,14 +19,11 @@ 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(); + let output_path = output_file.path(); - bencher.bench(|| { - black_box(run_util_function( - uumain, - &["-o", &output_path, file_path.to_str().unwrap()], - )); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-o", &output_path, &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/sort/benches/sort_locale_utf8_bench.rs b/src/uu/sort/benches/sort_locale_utf8_bench.rs index 81e39c8971f..25845293d80 100644 --- a/src/uu/sort/benches/sort_locale_utf8_bench.rs +++ b/src/uu/sort/benches/sort_locale_utf8_bench.rs @@ -11,7 +11,7 @@ use divan::{Bencher, black_box}; use tempfile::NamedTempFile; use uu_sort::uumain; -use uucore::benchmark::{run_util_function, setup_test_file, text_data}; +use uucore::benchmark::{get_bench_args, run_util_function, setup_test_file, text_data}; /// Benchmark ASCII-only data sorting with UTF-8 locale #[divan::bench] @@ -19,13 +19,13 @@ fn sort_ascii_utf8_locale(bencher: Bencher) { let data = text_data::generate_ascii_data_simple(1_500_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 output_path = output_file.path(); - 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, &args)); - }); + let args = get_bench_args(&[&"-o", &output_path, &file_path]); + black_box(uumain(args.clone().into_iter())); + bencher + .with_inputs(|| args.clone().into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark mixed ASCII/Unicode data with UTF-8 locale @@ -34,13 +34,13 @@ 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(); + let output_path = output_file.path(); - 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, &args)); - }); + let args = get_bench_args(&[&"-o", &output_path, &file_path]); + black_box(uumain(args.clone().into_iter())); + bencher + .with_inputs(|| args.clone().into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark numeric sorting with UTF-8 locale @@ -53,13 +53,13 @@ fn sort_numeric_utf8_locale(bencher: Bencher) { } 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 output_path = output_file.path(); - 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, &args)); - }); + let args = get_bench_args(&[&"-n", &"-o", &output_path, &file_path]); + black_box(uumain(args.clone().into_iter())); + bencher + .with_inputs(|| args.clone().into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark reverse sorting with UTF-8 locale @@ -68,13 +68,13 @@ 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 output_path = output_file.path(); - 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, &args)); - }); + let args = get_bench_args(&[&"-r", &"-o", &output_path, &file_path]); + black_box(uumain(args.clone().into_iter())); + bencher + .with_inputs(|| args.clone().into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark unique sorting with UTF-8 locale @@ -83,13 +83,13 @@ 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 output_path = output_file.path(); - 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, &args)); - }); + let args = get_bench_args(&[&"-u", &"-o", &output_path, &file_path]); + black_box(uumain(args.clone().into_iter())); + bencher + .with_inputs(|| args.clone().into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark sorting very long lines (single repeated character per line) with UTF-8 locale. @@ -232,6 +232,64 @@ fn merge_pre_sorted_files_utf8_locale(bencher: Bencher) { }); } +/// Benchmark a sort whose input does not fit the buffer (`-S`), so it is +/// sorted in chunks written to temporary files and merged back, all under a +/// UTF-8 locale. The merge compares each line about once and collates on +/// demand instead of computing a key per line again. +#[divan::bench] +fn sort_spill_to_tmp_files_utf8_locale(bencher: Bencher) { + let data = text_data::generate_mixed_data(200_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 = [ + "--parallel", + "1", + "-S", + "1M", + "-o", + &output_path, + file_path.to_str().unwrap(), + ]; + black_box(run_util_function(uumain, &args)); + bencher.bench(|| { + black_box(run_util_function(uumain, &args)); + }); +} + +/// Benchmark `sort -c` on already sorted input under a UTF-8 locale. Checking +/// compares each line once with its predecessor, so collating on demand beats +/// computing a key per line. +#[divan::bench] +fn check_sorted_utf8_locale(bencher: Bencher) { + let raw = text_data::generate_mixed_data(2_500_000); + let mut lines: Vec<&[u8]> = raw + .split(|&b| b == b'\n') + .filter(|l| !l.is_empty()) + .collect(); + lines.sort(); + let mut data = Vec::with_capacity(raw.len()); + for line in lines { + data.extend_from_slice(line); + data.push(b'\n'); + } + // Byte order is not locale order, so sort once more the way `-c` expects. + let unsorted_path = setup_test_file(&data); + let sorted_file = NamedTempFile::new().unwrap(); + let sorted_path = sorted_file.path().to_str().unwrap().to_string(); + run_util_function( + uumain, + &["-o", &sorted_path, unsorted_path.to_str().unwrap()], + ); + + let args = ["-c", &sorted_path]; + black_box(run_util_function(uumain, &args)); + bencher.bench(|| { + black_box(run_util_function(uumain, &args)); + }); +} + fn main() { // Set UTF-8 locale BEFORE any benchmarks run. // This must happen before divan::main() because the locale is cached diff --git a/src/uu/sort/locales/en-US.ftl b/src/uu/sort/locales/en-US.ftl index 5c83ee7803a..6f6e50056ae 100644 --- a/src/uu/sort/locales/en-US.ftl +++ b/src/uu/sort/locales/en-US.ftl @@ -12,8 +12,10 @@ sort-after-help = The key format is FIELD[.CHAR][OPTIONS][,FIELD[.CHAR]][OPTIONS # Error messages sort-open-failed = open failed: {$path}: {$error} +sort-truncate-failed = failed to truncate: {$path}: {$error} sort-parse-key-error = failed to parse key {$key}: {$msg} sort-cannot-read = cannot read: {$path}: {$error} +sort-read-failed = read failed: {$error} sort-open-tmp-file-failed = failed to open temporary file: {$error} sort-compress-prog-execution-failed = could not run compress program '{$prog}': {$error} sort-compress-prog-terminated-abnormally = {$prog} terminated abnormally @@ -24,6 +26,7 @@ sort-file-operands-combined = extra operand {$file} sort-multiple-output-files = multiple output files specified sort-minus-in-stdin = when reading file names from standard input, no file name of '-' allowed sort-no-input-from = no input from {$file} +sort-random-source-end-of-file = {$path}: end of file sort-invalid-zero-length-filename = {$file}:{$line_num}: invalid zero-length file name sort-options-incompatible = options '-{$opt1}{$opt2}' are incompatible sort-invalid-key = invalid key {$key} @@ -31,7 +34,6 @@ sort-failed-parse-field-index = failed to parse field index {$field} {$error} 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 @@ -41,6 +43,9 @@ 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-diag-label-missing-count = expected a number here +sort-diag-label-zero-count = counting starts at 1 +sort-diag-help-key-syntax = a key is FIELD[.CHAR][OPTS][,FIELD[.CHAR][OPTS]], as in -k2.3,4nr 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 fe6c1721591..503b6600a67 100644 --- a/src/uu/sort/locales/fr-FR.ftl +++ b/src/uu/sort/locales/fr-FR.ftl @@ -12,8 +12,10 @@ sort-after-help = Le format de clé est CHAMP[.CAR][OPTIONS][,CHAMP[.CAR]][OPTIO # Messages d'erreur sort-open-failed = échec d'ouverture : {$path} : {$error} +sort-truncate-failed = échec de troncature : {$path} : {$error} sort-parse-key-error = échec d'analyse de la clé {$key} : {$msg} sort-cannot-read = impossible de lire : {$path} : {$error} +sort-read-failed = échec de lecture : {$error} sort-open-tmp-file-failed = échec d'ouverture du fichier temporaire : {$error} sort-compress-prog-execution-failed = impossible d'exécuter le programme de compression '{$prog}' : {$error} sort-compress-prog-terminated-abnormally = {$prog} s'est terminé anormalement @@ -31,7 +33,6 @@ sort-failed-parse-field-index = échec d'analyse de l'index de champ {$field} {$ 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 @@ -41,6 +42,9 @@ 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-diag-label-missing-count = un nombre est attendu ici +sort-diag-label-zero-count = la numérotation commence à 1 +sort-diag-help-key-syntax = une clé s'écrit CHAMP[.CAR][OPTS][,CHAMP[.CAR][OPTS]], comme -k2.3,4nr 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/buffer_hint.rs b/src/uu/sort/src/buffer_hint.rs index 20539a789bf..a6780941f89 100644 --- a/src/uu/sort/src/buffer_hint.rs +++ b/src/uu/sort/src/buffer_hint.rs @@ -4,6 +4,7 @@ // file that was distributed with this source code. //! Heuristics for determining buffer size for external sorting. + use std::ffi::OsString; // Heuristics to size the external sort buffer without overcommit memory. diff --git a/src/uu/sort/src/check.rs b/src/uu/sort/src/check.rs index dbf598574fe..a826bc75507 100644 --- a/src/uu/sort/src/check.rs +++ b/src/uu/sort/src/check.rs @@ -53,7 +53,13 @@ pub fn check(path: &OsStr, settings: &GlobalSettings) -> UResult<()> { let mut prev_chunk: Option = None; let mut line_idx = 0; - for chunk in loaded_receiver { + let mut result: UResult<()> = Ok(()); + // Note that we iterate over a reference, so that `loaded_receiver` is still alive + // once we stop: `chunks::read` unwraps its `send`, so dropping our end while the + // reader thread is still going would panic it. Since we stop at the *first* + // disorder, the reader is usually still working at that point, so we shut it down + // in an orderly fashion below instead of just dropping our end. + 'outer: for chunk in &loaded_receiver { line_idx += 1; if let Some(prev_chunk) = prev_chunk.take() { // Check if the first element of the new chunk is greater than the last @@ -69,13 +75,14 @@ pub fn check(path: &OsStr, settings: &GlobalSettings) -> UResult<()> { chunk.line_data(), ) > max_allowed_cmp { - return Err(SortError::Disorder { + result = Err(SortError::Disorder { file: path.to_owned(), line_number: line_idx, line: String::from_utf8_lossy(new_first.line).into_owned(), silent: settings.check_silent, } .into()); + break 'outer; } let _ = recycled_sender.send(prev_chunk.recycle()); } @@ -83,19 +90,28 @@ pub fn check(path: &OsStr, settings: &GlobalSettings) -> UResult<()> { for (a, b) in chunk.lines().iter().tuple_windows() { line_idx += 1; if compare_by(a, b, settings, chunk.line_data(), chunk.line_data()) > max_allowed_cmp { - return Err(SortError::Disorder { + result = Err(SortError::Disorder { file: path.to_owned(), line_number: line_idx, line: String::from_utf8_lossy(b.line).into_owned(), silent: settings.check_silent, } .into()); + break 'outer; } } prev_chunk = Some(chunk); } - Ok(()) + + // Stop handing out buffers, so the reader runs out of work, then drain anything it + // has already produced. This lets its in-flight `send` complete instead of failing, + // and terminates because the reader can only own the (at most two) recycled chunks + // that are still outstanding. + drop(recycled_sender); + while loaded_receiver.recv().is_ok() {} + + result } /// The function running on the reader thread. diff --git a/src/uu/sort/src/chunks.rs b/src/uu/sort/src/chunks.rs index a465deb4407..ee8261dd856 100644 --- a/src/uu/sort/src/chunks.rs +++ b/src/uu/sort/src/chunks.rs @@ -3,9 +3,10 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore ELEMS + //! Utilities for reading files as chunks. -// spell-checker:ignore ELEMS #![allow(dead_code)] // Ignores non-used warning for `borrow_buffer` in `Chunk` @@ -17,11 +18,10 @@ use std::{ use memchr::memchr_iter; use self_cell::self_cell; -use uucore::error::{UResult, USimpleError}; +use uucore::error::{UResult, USimpleError, strip_errno}; +use uucore::translate; -use crate::{ - GeneralBigDecimalParseResult, GlobalSettings, Line, SortMode, numeric_str_cmp::NumInfo, -}; +use crate::{GeneralBigDecimalParseResult, GlobalSettings, Line, numeric_str_cmp::NumInfo}; const ALLOC_CHUNK_SIZE: usize = 64 * 1024; const MAX_TOKEN_BUFFER_BYTES: usize = 4 * 1024 * 1024; @@ -311,7 +311,7 @@ fn parse_lines<'a>( .parsed_floats .reserve(estimated.saturating_mul(settings.precomputed.floats_per_line)); } - if settings.mode == SortMode::Numeric { + if settings.precomputed.whole_line_numeric { line_data.line_num_floats.reserve(estimated); } let mut start = 0usize; @@ -362,7 +362,6 @@ fn read_to_buffer( separator: u8, ) -> UResult<(usize, bool)> { let mut read_target = &mut buffer[start_offset..]; - let mut last_file_empty = true; let mut newline_search_offset = 0; let mut found_newline = false; loop { @@ -370,14 +369,12 @@ fn read_to_buffer( Ok(0) => { if read_target.is_empty() { // chunk is full - if let Some(max_buffer_size) = max_buffer_size { - if max_buffer_size > buffer.len() { - // we can grow the buffer - let prev_len = buffer.len(); - buffer.resize(prev_len + ALLOC_CHUNK_SIZE, 0); - read_target = &mut buffer[prev_len..]; - continue; - } + if max_buffer_size.is_some_and(|max_buf_size| max_buf_size > buffer.len()) { + // we can grow the buffer + let prev_len = buffer.len(); + buffer.resize(prev_len + ALLOC_CHUNK_SIZE, 0); + read_target = &mut buffer[prev_len..]; + continue; } let search_start = newline_search_offset; @@ -400,20 +397,19 @@ fn read_to_buffer( } else { // This file has been fully read. let mut leftover_len = read_target.len(); - if !last_file_empty { - // The file was not empty. - let read_len = buffer.len() - leftover_len; - if buffer[read_len - 1] != separator { - // The file did not end with a separator. We have to insert one. - buffer[read_len] = separator; - leftover_len -= 1; - } - let read_len = buffer.len() - leftover_len; - read_target = &mut buffer[read_len..]; + // Decide from buffered bytes: a file may span calls, so a 0-byte read can + // still leave an unterminated tail that must be separated from the next file. + let read_len = buffer.len() - leftover_len; + if read_len > 0 && buffer[read_len - 1] != separator { + // The data so far does not end with a separator. We have to insert one. + // `read_target` is non-empty here, so `buffer[read_len]` is in bounds. + buffer[read_len] = separator; + leftover_len -= 1; } + let read_len = buffer.len() - leftover_len; + read_target = &mut buffer[read_len..]; if let Some(next_file) = next_files.next() { // There is another file. - last_file_empty = true; *file = next_file?; } else { // This was the last file. @@ -424,12 +420,16 @@ fn read_to_buffer( } Ok(n) => { read_target = &mut read_target[n..]; - last_file_empty = false; } Err(e) if e.kind() == ErrorKind::Interrupted => { // retry } - Err(e) => return Err(USimpleError::new(2, e.to_string())), + Err(e) => { + return Err(USimpleError::new( + 2, + translate!("sort-read-failed", "error" => strip_errno(&e)), + )); + } } } } diff --git a/src/uu/sort/src/diagnostics.rs b/src/uu/sort/src/diagnostics.rs new file mode 100644 index 00000000000..7c6c04824c6 --- /dev/null +++ b/src/uu/sort/src/diagnostics.rs @@ -0,0 +1,44 @@ +// 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. + +//! Maps a [`KeyError`] onto the part of the `-k` spec it came from, so that +//! [`uucore::diagnostics`] can render it with a caret. + +use std::ffi::OsString; + +use uucore::diagnostics::Snapshot; +use uucore::translate; + +use crate::{KeyError, KeyErrorKind}; + +/// Render `err`, raised while parsing `key`, against `args` — the whole +/// argument list, program name included. +/// +/// Returns `false` when the key cannot be found among the arguments, in which +/// case the caller should fall back to the plain one-line message. +pub fn render(args: &[OsString], key: &str, err: &KeyError) -> bool { + // Labelled only where a label would add to the message, per the convention + // in `uucore::diagnostics`. + let (label, help) = match err.kind { + KeyErrorKind::MissingCount => (Some("sort-diag-label-missing-count"), true), + KeyErrorKind::ZeroCount => (Some("sort-diag-label-zero-count"), false), + KeyErrorKind::StrayCharacter => (None, true), + KeyErrorKind::IncompatibleOptions => (None, false), + }; + + let snapshot = Snapshot::with_program(args); + let Some(index) = snapshot.index_of_value(key, Some('k'), Some("key")) else { + return false; + }; + snapshot.render_inside_at( + index, + key, + err.span.clone(), + &err.message, + label.map(|label| translate!(label)).as_deref(), + help.then(|| translate!("sort-diag-help-key-syntax")) + .as_deref(), + ) +} diff --git a/src/uu/sort/src/ext_sort/threaded.rs b/src/uu/sort/src/ext_sort/threaded.rs index 7dd089d0fe8..3f6f9271298 100644 --- a/src/uu/sort/src/ext_sort/threaded.rs +++ b/src/uu/sort/src/ext_sort/threaded.rs @@ -9,6 +9,7 @@ use std::cmp::Ordering; use std::fs::File; use std::io::{Read, Write, stderr}; +use std::iter; use std::path::PathBuf; use std::sync::mpsc::{Receiver, SyncSender}; use std::thread; @@ -110,12 +111,12 @@ fn reader_writer< ) -> UResult<()> { let separator = settings.line_ending.into(); - // Cap oversized buffer requests to avoid unnecessary allocations and give the automatic - // heuristic room to grow when the user does not provide an explicit value. - let mut buffer_size = match settings.buffer_size { - size if size <= 512 * 1024 * 1024 => size, - size => size / 2, - }; + // Two chunks are kept in memory, so split an explicit limit between them. + // Keep the automatic sizing heuristic's cap and minimum growth allowance. + let mut buffer_size = settings.buffer_size; + if settings.buffer_size_is_explicit || buffer_size > 512 * 1024 * 1024 { + buffer_size /= 2; + } if !settings.buffer_size_is_explicit { buffer_size = buffer_size.max(8 * 1024 * 1024); } @@ -175,7 +176,8 @@ fn reader_writer< } } ReadResult::EmptyInput => { - // don't output anything + // output empty too, as coreutils does + print_sorted(iter::empty::<&Line<'_>>(), settings, output)?; } } Ok(()) diff --git a/src/uu/sort/src/ext_sort/wasi.rs b/src/uu/sort/src/ext_sort/wasi.rs index 50bd5f63033..fb0577324d5 100644 --- a/src/uu/sort/src/ext_sort/wasi.rs +++ b/src/uu/sort/src/ext_sort/wasi.rs @@ -8,14 +8,15 @@ use std::cmp::Ordering; use std::io::Read; +use std::iter; use itertools::Itertools; use uucore::error::UResult; -use crate::Output; use crate::chunks::{self, Chunk}; use crate::tmp_dir::TmpDirWrapper; use crate::{GlobalSettings, compare_by, print_sorted, sort_by}; +use crate::{Line, Output}; /// Sort files by reading all input into memory, sorting in a single thread, and outputting directly. pub fn ext_sort( @@ -33,9 +34,13 @@ pub fn ext_sort( for file in files { file?.read_to_end(&mut input)?; } + if input.is_empty() { + // empty files are sorted to empty like in coreutils + print_sorted(iter::empty::<&Line<'_>>(), settings, output)?; return Ok(()); } + let mut chunk = Chunk::try_new(input, |buffer| { Ok::<_, Box>(chunks::parse_into_chunk( buffer, separator, settings, diff --git a/src/uu/sort/src/merge.rs b/src/uu/sort/src/merge.rs index 85276fce6d8..6604b60bb6c 100644 --- a/src/uu/sort/src/merge.rs +++ b/src/uu/sort/src/merge.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + //! Merge already sorted files. //! //! We achieve performance by splitting the tasks of sorting and writing, and reading and parsing between two threads. @@ -13,18 +14,18 @@ use std::{ cmp::Ordering, + collections::BinaryHeap, ffi::{OsStr, OsString}, fs::{self, File}, - io::{BufWriter, Read, Write}, + io::{self, BufWriter, Read, Write}, iter, path::{Path, PathBuf}, process::{Child, ChildStdin, ChildStdout, Command, Stdio}, rc::Rc, - sync::mpsc::{Receiver, Sender, SyncSender, channel, sync_channel}, + sync::mpsc::{Receiver, Sender, SyncSender, TryRecvError, channel, sync_channel}, thread::{self, JoinHandle}, }; -use compare::Compare; use uucore::error::{FromIo, UResult}; use crate::{ @@ -44,17 +45,23 @@ fn replace_output_file_in_input_files( let mut copy: Option = None; if let Some(Ok(output_path)) = output.map(|path| Path::new(path).canonicalize()) { for file in files { - if let Ok(file_path) = Path::new(file).canonicalize() { - if file_path == output_path { - if let Some(copy) = © { - *file = copy.clone().into_os_string(); - } else { - let (_file, copy_path) = tmp_dir.next_file()?; - fs::copy(file_path, ©_path) - .map_err(|error| SortError::OpenTmpFileFailed { error })?; - *file = copy_path.clone().into_os_string(); - copy = Some(copy_path); - } + if Path::new(file) + .canonicalize() + .is_ok_and(|file_path| file_path == output_path) + { + if let Some(copy) = © { + *file = copy.clone().into_os_string(); + } else { + // Write through the descriptor `next_file` just opened rather + // than `fs::copy`, which would put the source's permission bits + // on the temporary file and undo the 0600 it was created with. + let (mut copy_file, copy_path) = tmp_dir.next_file()?; + let mut source = File::open(&output_path) + .map_err(|error| SortError::OpenTmpFileFailed { error })?; + io::copy(&mut source, &mut copy_file) + .map_err(|error| SortError::OpenTmpFileFailed { error })?; + *file = copy_path.clone().into_os_string(); + copy = Some(copy_path); } } } @@ -139,7 +146,7 @@ pub fn merge_with_file_limit< let mut tmp_file = Tmp::create(tmp_dir.next_file()?, settings.compress_prog.as_deref())?; - merger.write_all_to(settings, tmp_file.as_write())?; + merger.write_all_to(settings, tmp_file.as_write(), || "write failed".into())?; temporary_files.push(tmp_file.finished_writing()?); } } @@ -150,7 +157,7 @@ pub fn merge_with_file_limit< let mut tmp_file = Tmp::create(tmp_dir.next_file()?, settings.compress_prog.as_deref())?; - merger.write_all_to(settings, tmp_file.as_write())?; + merger.write_all_to(settings, tmp_file.as_write(), || "write failed".into())?; temporary_files.push(tmp_file.finished_writing()?); } merge_with_file_limit::<_, _, Tmp>( @@ -220,15 +227,13 @@ fn merge_without_limit>>( file_number, line_idx: 0, receiver, + settings, }); } } Ok(FileMerger { - heap: binary_heap_plus::BinaryHeap::from_vec_cmp( - mergeable_files, - FileComparator { settings }, - ), + heap: BinaryHeap::from(mergeable_files), request_sender, prev: None, reader_join_handle, @@ -276,11 +281,45 @@ fn reader( Ok(()) } /// The struct on the main thread representing an input file -pub struct MergeableFile { +pub struct MergeableFile<'a> { current_chunk: Rc, line_idx: usize, receiver: Receiver, file_number: usize, + settings: &'a GlobalSettings, +} + +impl PartialEq for MergeableFile<'_> { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for MergeableFile<'_> {} + +impl PartialOrd for MergeableFile<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for MergeableFile<'_> { + fn cmp(&self, other: &Self) -> Ordering { + let mut cmp = compare_by( + &self.current_chunk.lines()[self.line_idx], + &other.current_chunk.lines()[other.line_idx], + self.settings, + self.current_chunk.line_data(), + other.current_chunk.line_data(), + ); + if cmp == Ordering::Equal { + // To make sorting stable, we need to consider the file number as well, + // as lines from a file with a lower number are to be considered "earlier". + cmp = self.file_number.cmp(&other.file_number); + } + // BinaryHeap is a max heap. We use it as a min heap, so we need to reverse the ordering. + cmp.reverse() + } } /// A struct to keep track of the previous line we encountered. @@ -294,7 +333,7 @@ struct PreviousLine { /// Merges files together. This is **not** an iterator because of lifetime problems. struct FileMerger<'a> { - heap: binary_heap_plus::BinaryHeap>, + heap: BinaryHeap>, request_sender: Sender<(usize, RecycledChunk)>, prev: Option, reader_join_handle: JoinHandle>, @@ -303,24 +342,63 @@ struct FileMerger<'a> { impl FileMerger<'_> { /// Write the merged contents to the output file. fn write_all(self, settings: &GlobalSettings, output: Output) -> UResult<()> { - let mut out = output.into_write(); - self.write_all_to(settings, &mut out) + let ctx = output.write_failed_context(); + let mut out = output.into_write()?; + self.write_all_to(settings, &mut out, &ctx)?; + out.flush().map_err_context(ctx) } - fn write_all_to(mut self, settings: &GlobalSettings, out: &mut impl Write) -> UResult<()> { - while self - .write_next(out, settings) - .map_err_context(|| "write failed".into())? - {} - drop(self.request_sender); - self.reader_join_handle.join().unwrap() + /// Write the merged contents to `out`, reporting write errors with `ctx`. + fn write_all_to( + mut self, + settings: &GlobalSettings, + out: &mut impl Write, + ctx: impl FnOnce() -> String, + ) -> UResult<()> { + let write_result = loop { + match self.write_next(out, settings) { + Ok(true) => (), + Ok(false) => break Ok(()), + Err(error) => { + // Don't return yet: we still have to shut the reader thread down in an + // orderly fashion below. Returning here would drop our receivers while + // the reader is still sending, and `chunks::read` unwraps that send. + break Err(error); + } + } + }; + + let Self { + heap, + request_sender, + reader_join_handle, + .. + } = self; + + // Stop asking for chunks, so the reader runs out of work and returns. + drop(request_sender); + // Until it does, keep draining the files it might still be sending to. We have to + // poll all of them in turn rather than draining one at a time: the reader can be + // blocked on any one channel, and blocking on a different one would deadlock. + let mut files = heap.into_vec(); + while !files.is_empty() { + files.retain(|file| { + !matches!(file.receiver.try_recv(), Err(TryRecvError::Disconnected)) + }); + thread::yield_now(); + } + + let reader_result = reader_join_handle.join().unwrap(); + // A write failure is what the user needs to hear about; the reader hitting an error + // on the way down is secondary. + write_result.map_err_context(ctx).and(reader_result) } fn write_next( &mut self, writer: &mut impl Write, settings: &GlobalSettings, - ) -> std::io::Result { + ) -> io::Result { if let Some(file) = self.heap.peek() { let prev = self.prev.replace(PreviousLine { chunk: file.current_chunk.clone(), @@ -330,18 +408,18 @@ impl FileMerger<'_> { file.current_chunk.with_dependent(|_, contents| { let current_line = &contents.lines[file.line_idx]; - if settings.unique { - if let Some(prev) = &prev { - let cmp = compare_by( - &prev.chunk.lines()[prev.line_idx], - current_line, - settings, - prev.chunk.line_data(), - file.current_chunk.line_data(), - ); - if cmp == Ordering::Equal { - return Ok(()); - } + if settings.unique + && let Some(prev) = &prev + { + let cmp = compare_by( + &prev.chunk.lines()[prev.line_idx], + current_line, + settings, + prev.chunk.line_data(), + file.current_chunk.line_data(), + ); + if cmp == Ordering::Equal { + return Ok(()); } } current_line.write(writer, settings) @@ -362,44 +440,20 @@ impl FileMerger<'_> { self.heap.peek_mut().unwrap().line_idx += 1; } - if let Some(prev) = prev { - if let Ok(prev_chunk) = Rc::try_unwrap(prev.chunk) { - // If nothing is referencing the previous chunk anymore, this means that the previous line - // was the last line of the chunk. We can recycle the chunk. - self.request_sender - .send((prev.file_number, prev_chunk.recycle())) - .ok(); - } + if let Some(prev) = prev + && let Ok(prev_chunk) = Rc::try_unwrap(prev.chunk) + { + // If nothing is referencing the previous chunk anymore, this means that the previous line + // was the last line of the chunk. We can recycle the chunk. + self.request_sender + .send((prev.file_number, prev_chunk.recycle())) + .ok(); } } Ok(!self.heap.is_empty()) } } -/// Compares files by their current line. -struct FileComparator<'a> { - settings: &'a GlobalSettings, -} - -impl Compare for FileComparator<'_> { - fn compare(&self, a: &MergeableFile, b: &MergeableFile) -> Ordering { - let mut cmp = compare_by( - &a.current_chunk.lines()[a.line_idx], - &b.current_chunk.lines()[b.line_idx], - self.settings, - a.current_chunk.line_data(), - b.current_chunk.line_data(), - ); - if cmp == Ordering::Equal { - // To make sorting stable, we need to consider the file number as well, - // as lines from a file with a lower number are to be considered "earlier". - cmp = a.file_number.cmp(&b.file_number); - } - // BinaryHeap is a max heap. We use it as a min heap, so we need to reverse the ordering. - cmp.reverse() - } -} - /// Wait for the child to exit and check its exit code. fn check_child_success(mut child: Child, program: &str) -> UResult<()> { if matches!(child.wait().map(|e| e.code()), Ok(Some(0) | None) | Err(_)) { @@ -596,3 +650,33 @@ impl MergeInput for PlainMergeInput { &mut self.inner } } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + /// When the output file is also an input it is copied to a temporary file + /// first. That copy must stay private to the owner: `fs::copy` would carry the + /// source's 0644 over and undo what `next_file` created the file with. + #[test] + fn output_copy_keeps_the_tmp_file_private() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("out"); + fs::write(&out, b"a\n").unwrap(); + fs::set_permissions(&out, fs::Permissions::from_mode(0o644)).unwrap(); + + let mut tmp_dir = TmpDirWrapper::new(dir.path().to_owned()); + let mut files = vec![out.clone().into_os_string()]; + replace_output_file_in_input_files(&mut files, Some(out.as_os_str()), &mut tmp_dir) + .unwrap(); + + let copy = Path::new(&files[0]); + assert_ne!(copy, out, "the input was not replaced by a copy"); + assert_eq!( + fs::metadata(copy).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(fs::read(copy).unwrap(), b"a\n"); + } +} diff --git a/src/uu/sort/src/numeric_str_cmp.rs b/src/uu/sort/src/numeric_str_cmp.rs index 40530cc5145..0d665acedf2 100644 --- a/src/uu/sort/src/numeric_str_cmp.rs +++ b/src/uu/sort/src/numeric_str_cmp.rs @@ -3,6 +3,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore KMGTPEZYRQ + //! Fast comparison for strings representing a base 10 number without precision loss. //! //! To be able to short-circuit when comparing, [`NumInfo`] must be passed along with each number @@ -72,29 +74,14 @@ impl NumInfo { } first_char = false; - if matches!( - parse_settings.thousands_separator, - Some(c) if c == char - ) { + if parse_settings.thousands_separator == Some(char) { continue; } if Self::is_invalid_char(char, &mut had_decimal_pt, parse_settings) { return if let Some(start) = start { - let has_si_unit = parse_settings.accept_si_units - && matches!( - char, - b'K' | b'k' - | b'M' - | b'G' - | b'T' - | b'P' - | b'E' - | b'Z' - | b'Y' - | b'R' - | b'Q' - ); + let has_si_unit = + parse_settings.accept_si_units && b"kKMGTPEZYRQ".contains(&char); ( Self { exponent, sign }, start..if has_si_unit { idx + 1 } else { idx }, @@ -178,22 +165,13 @@ impl NumInfo { } fn get_unit(unit: Option) -> u8 { - if let Some(unit) = unit { - match unit { - b'K' | b'k' => 1, - b'M' => 2, - b'G' => 3, - b'T' => 4, - b'P' => 5, - b'E' => 6, - b'Z' => 7, - b'Y' => 8, - b'R' => 9, - b'Q' => 10, - _ => 0, - } - } else { - 0 + match unit { + Some(b'k') => 1, + Some(u) => b"KMGTPEZYRQ" + .iter() + .position(|&c| c == u) + .map_or(0, |pos| (pos + 1) as u8), + None => 0, } } diff --git a/src/uu/sort/src/sort.rs b/src/uu/sort/src/sort.rs index 67de78a167f..723a99ac36d 100644 --- a/src/uu/sort/src/sort.rs +++ b/src/uu/sort/src/sort.rs @@ -3,17 +3,18 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore (misc) kKMGTPEZYRQ HFKJFK Mbdfhn getrlimit Nofile rlim bigdecimal extendedbigdecimal hexdigit behaviour keydef GETFD localeconv foldhash +// spell-checker:ignore (misc) uppercased qsort getmonth juin juil + // Although these links don't always seem to describe reality, check out the POSIX and GNU specs: // 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 Nofile rlim bigdecimal extendedbigdecimal hexdigit behaviour keydef GETFD localeconv foldhash -// spell-checker:ignore (misc) uppercased qsort getmonth juin juil - mod buffer_hint; mod check; mod chunks; mod custom_str_cmp; +mod diagnostics; mod ext_sort; mod merge; mod numeric_str_cmp; @@ -39,13 +40,11 @@ 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; use std::sync::OnceLock; -use thiserror::Error; +use uucore::diagnostics::OptionValue; use uucore::display::Quotable; use uucore::error::{FromIo, strip_errno}; use uucore::error::{UError, UResult, USimpleError, UUsageError}; @@ -61,6 +60,7 @@ use uucore::parser::shortcut_value_parser::ShortcutValueParser; use uucore::posix::{MODERN, TRADITIONAL}; use uucore::show_error; use uucore::translate; +use uucore::translate_text; use uucore::version_cmp::version_cmp; use uucore::{format_usage, i18n}; @@ -133,7 +133,7 @@ const MIN_AUTOMATIC_BUF_SIZE: usize = 512 * 1024; // 512 KiB const FALLBACK_AUTOMATIC_BUF_SIZE: usize = 32 * 1024 * 1024; // 32 MiB const MAX_AUTOMATIC_BUF_SIZE: usize = 1024 * 1024 * 1024; // 1 GiB -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum SortError { #[error("{}", format_disorder(.file, .line_number, .line, .silent))] Disorder { @@ -149,6 +149,12 @@ pub enum SortError { error: std::io::Error, }, + #[error("{}", translate!("sort-truncate-failed", "path" => format!("{}", .path.maybe_quote()), "error" => strip_errno(.error)))] + TruncateFailed { + path: PathBuf, + error: std::io::Error, + }, + #[error("{}", translate!("sort-cannot-read", "path" => format!("{}", .path.maybe_quote()), "error" => strip_errno(.error)))] ReadFailed { path: PathBuf, @@ -182,6 +188,9 @@ pub enum SortError { #[error("{}", translate!("sort-no-input-from", "file" => format!("{}", .file.quote())))] EmptyInputFile { file: PathBuf }, + #[error("{}", translate!("sort-random-source-end-of-file", "path" => format!("{}", .path.quote())))] + RandomSourceEndOfFile { path: PathBuf }, + #[error("{}", translate!("sort-invalid-zero-length-filename", "file" => .file.maybe_quote(), "line_num" => .line_num))] ZeroLengthFileName { file: PathBuf, line_num: usize }, } @@ -201,7 +210,11 @@ fn format_disorder(file: &OsString, line_number: &usize, line: &String, silent: if *silent { String::new() } else { - translate!("sort-error-disorder", "file" => file.maybe_quote(), "line_number" => line_number, "line" => line.to_owned()) + // `line` is the offending input line echoed back verbatim (GNU sort does the + // same): it must not be reinterpreted as a number by `translate!`, which would + // reformat it via Fluent's numeric formatting (e.g. "1.10" -> "1.1", "1e5" -> + // "100000", "nan" -> "NaN"). + translate_text!("sort-error-disorder", "file" => file.maybe_quote(), "line_number" => line_number, "line" => line.to_owned()) } } @@ -247,15 +260,20 @@ impl Output { Ok(Self { file }) } - fn into_write(self) -> BufWriter> { - BufWriter::new(match self.file { - Some((_name, file)) => { - // truncate the file - let _ = file.set_len(0); + fn into_write(self) -> UResult>> { + Ok(BufWriter::new(match self.file { + Some((name, file)) => { + // Only regular files can be truncated; there a failure leaves stale bytes. + if file.metadata().is_ok_and(|meta| meta.is_file()) { + file.set_len(0).map_err(|error| SortError::TruncateFailed { + path: PathBuf::from(name), + error, + })?; + } Box::new(file) } None => Box::new(stdout()), - }) + })) } fn as_output_name(&self) -> Option<&OsStr> { @@ -264,6 +282,15 @@ impl Output { None => None, } } + + /// Error context for a failed write to this output, naming the file or standard output. + fn write_failed_context(&self) -> impl Fn() -> String + use<> { + let output_name = self + .as_output_name() + .unwrap_or(OsStr::new("standard output")) + .to_owned(); + move || translate!("sort-error-write-failed", "output" => output_name.maybe_quote()) + } } #[derive(Clone)] @@ -329,6 +356,7 @@ struct Precomputed { fast_lexicographic: bool, fast_locale_collation: bool, fast_ascii_insensitive: bool, + whole_line_numeric: bool, tokenize_blank_thousands_sep: bool, tokenize_allow_unit_after_blank: bool, } @@ -394,6 +422,28 @@ impl GlobalSettings { self.precomputed.fast_locale_collation = disable_fast_lexicographic && self.can_use_fast_lexicographic(); self.precomputed.fast_ascii_insensitive = self.can_use_fast_ascii_insensitive(); + self.precomputed.whole_line_numeric = self.can_use_whole_line_numeric(); + } + + /// Returns true when a number parsed from the whole line can stand in for + /// the key. + /// + /// `-n` parses the line once up front and compares those numbers before + /// looking at any key. That is only the same comparison when the single + /// key spans the entire line: `-n -k1.2` sorts on the line's second + /// character onwards, and `-n -t. -k2` on its second field, neither of + /// which the line as a whole stands for. A key of its own `r` also has to + /// go the long way, since the shortcut only knows the global one. + fn can_use_whole_line_numeric(&self) -> bool { + let [selector] = &self.selectors[..] else { + return false; + }; + self.mode == SortMode::Numeric + && selector.settings.mode == SortMode::Numeric + && !selector.settings.reverse + && selector.from.field == 1 + && selector.from.char == 1 + && selector.to.is_none() } /// Returns true when the fast lexicographic path can be used safely. @@ -401,40 +451,38 @@ impl GlobalSettings { /// 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 { + let [selector] = &self.selectors[..] else { + return false; + }; self.mode == SortMode::Default && !self.ignore_case && !self.dictionary_order && !self.ignore_non_printing && !self.ignore_leading_blanks - && self.selectors.len() == 1 - && { - let selector = &self.selectors[0]; - !selector.needs_selection - && matches!(selector.settings.mode, SortMode::Default) - && !selector.settings.ignore_case - && !selector.settings.dictionary_order - && !selector.settings.ignore_non_printing - && !selector.settings.ignore_blanks - } + && !selector.needs_selection + && matches!(selector.settings.mode, SortMode::Default) + && !selector.settings.ignore_case + && !selector.settings.dictionary_order + && !selector.settings.ignore_non_printing + && !selector.settings.ignore_blanks } /// Returns true when the ASCII case-insensitive fast path is valid. fn can_use_fast_ascii_insensitive(&self) -> bool { + let [selector] = &self.selectors[..] else { + return false; + }; self.mode == SortMode::Default && self.ignore_case && !self.dictionary_order && !self.ignore_non_printing && !self.ignore_leading_blanks - && self.selectors.len() == 1 - && { - let selector = &self.selectors[0]; - !selector.needs_selection - && matches!(selector.settings.mode, SortMode::Default) - && selector.settings.ignore_case - && !selector.settings.dictionary_order - && !selector.settings.ignore_non_printing - && !selector.settings.ignore_blanks - } + && !selector.needs_selection + && matches!(selector.settings.mode, SortMode::Default) + && selector.settings.ignore_case + && !selector.settings.dictionary_order + && !selector.settings.ignore_non_printing + && !selector.settings.ignore_blanks } } @@ -601,16 +649,19 @@ fn ordering_incompatible( false } -fn incompatible_options_error(opts: &str) -> Box { - USimpleError::new( - 2, - translate!( - "sort-options-incompatible", - "opt1" => opts, - "opt2" => "" - ), +fn incompatible_options_message(opts: &str) -> String { + translate!( + "sort-options-incompatible", + "opt1" => opts, + "opt2" => "" ) } + +#[cold] +#[inline(never)] +fn incompatible_options_error(opts: &str) -> Box { + USimpleError::new(2, incompatible_options_message(opts)) +} enum Selection<'a> { AsBigDecimal(GeneralBigDecimalParseResult), WithNumInfo(&'a [u8], NumInfo), @@ -650,7 +701,7 @@ impl<'a> Line<'a> { || settings.precomputed.selections_per_line > 0 || settings.precomputed.num_infos_per_line > 0 || settings.precomputed.floats_per_line > 0 - || settings.mode == SortMode::Numeric; + || settings.precomputed.whole_line_numeric; if !needs_line_data { return Self { line, index }; } @@ -663,7 +714,7 @@ impl<'a> Line<'a> { &settings.precomputed, ); } - if settings.mode == SortMode::Numeric { + if settings.precomputed.whole_line_numeric { // exclude inf, nan, scientific notation; GNU -n does not treat '+' as a sign let line_num_float = (!line.iter().any(u8::is_ascii_alphabetic)) .then(|| std::str::from_utf8(line).ok()) @@ -761,19 +812,18 @@ impl<'a> Line<'a> { selection.end += leading_whitespace; } else { // include a trailing si unit - if selector.settings.mode == SortMode::HumanNumeric { - if let Some( - b'k' | b'K' | b'M' | b'G' | b'T' | b'P' | b'E' | b'Z' | b'Y' | b'R' - | b'Q', - ) = self.line[selection.end..initial_selection.end].first() - { - selection.end += 1; - } + if selector.settings.mode == SortMode::HumanNumeric + && self.line[selection.end..initial_selection.end] + .first() + .is_some_and(|c| b"kKMGTPEZYRQ".contains(c)) + { + selection.end += 1; } // include leading zeroes, a leading minus or a leading decimal point - while let Some(b'-' | b'0' | b'.') = - self.line[initial_selection.start..selection.start].last() + while self.line[initial_selection.start..selection.start] + .last() + .is_some_and(|c| b"-0.".contains(c)) { selection.start -= 1; } @@ -922,15 +972,9 @@ fn is_blank_thousands_sep(line: &[u8], idx: usize, allow_unit_after_blank: bool) } let next = line.get(idx + 1).copied(); - match next { - Some(c) if c.is_ascii_digit() => true, - Some(b'K' | b'k' | b'M' | b'G' | b'T' | b'P' | b'E' | b'Z' | b'Y' | b'R' | b'Q') - if allow_unit_after_blank => - { - true - } - _ => false, - } + next.is_some_and(|c| { + c.is_ascii_digit() || (allow_unit_after_blank && b"kKMGTPEZYRQ".contains(&c)) + }) } /// Split between separators. These separators are not included in fields. @@ -969,46 +1013,179 @@ 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() - ), - ) +/// A `-k` spec that does not parse, and the part of it that is at fault. +/// +/// The message is built here so that it reads exactly as it always has; `kind` +/// exists only so the crate's `diagnostics` module can pick a label for the +/// caret. +pub struct KeyError { + message: String, + /// Byte range inside the key spec. + span: Range, + kind: KeyErrorKind, +} + +pub enum KeyErrorKind { + /// A field or character number was expected, and there were no digits. + MissingCount, + /// A number was given where counting starts at one. + ZeroCount, + /// Something that is not a key option, past the end of the spec. + StrayCharacter, + /// Ordering options that contradict each other. + IncompatibleOptions, } -fn invalid_count_error(msg_key: &str, input: &str) -> Box { - USimpleError::new( - 2, - format!( +impl From for Box { + fn from(error: KeyError) -> Self { + USimpleError::new(2, error.message) + } +} + +/// `msg_key` explains what was expected; `rest` is what was found instead. +fn missing_count(span_start: usize, msg_key: &str, rest: &str) -> KeyError { + KeyError { + message: format!( "{}: {}", translate!(msg_key), - translate!("sort-invalid-count-at-start-of", "string" => input.quote()) + translate!("sort-invalid-count-at-start-of", "string" => rest.quote()) ), + span: span_start..span_start + rest.len(), + kind: KeyErrorKind::MissingCount, + } +} + +/// Something past the end of the spec that is neither an ordering option nor a +/// separator; the caret covers just that character. +fn stray_character(key: &str, offset: usize) -> KeyError { + let width = key[offset..].chars().next().map_or(0, char::len_utf8); + bad_field_spec( + key, + offset..offset + width, + "sort-stray-character-field-spec", + KeyErrorKind::StrayCharacter, ) } -fn parse_field_count<'a>(input: &'a str, msg_key: &str) -> UResult<(usize, &'a str)> { +fn bad_field_spec(spec: &str, span: Range, msg_key: &str, kind: KeyErrorKind) -> KeyError { + KeyError { + message: translate!( + "sort-invalid-field-spec", + "msg" => translate!(msg_key), + "spec" => spec.quote() + ), + span, + kind, + } +} + +/// Read the leading digits of `input`, returning them and what follows. +/// +/// `offset` is where `input` starts inside the whole key spec, so that a failure +/// can point back at it. +fn parse_field_count<'a>( + input: &'a str, + offset: usize, + msg_key: &str, +) -> Result<(usize, &'a str), KeyError> { let bytes = input.as_bytes(); let mut idx = 0; - while idx < bytes.len() && bytes[idx].is_ascii_digit() { + while bytes.get(idx).is_some_and(u8::is_ascii_digit) { idx += 1; } if idx == 0 { - return Err(invalid_count_error(msg_key, input)); + return Err(missing_count(offset, 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)), + Err(_) => return Err(missing_count(offset, msg_key, input)), }; Ok((value, rest)) } +/// Which end of a key a `FIELD[.CHAR]` position belongs to. +/// +/// The two ends read the same but do not mean the same: a key starts at a +/// character, so `.0` is a mistake there, while it stands for the whole field +/// at the end of a key. +#[derive(Clone, Copy, PartialEq)] +enum KeyEnd { + /// Before the comma, or the whole spec when there is none. + From, + /// After the comma. + To, +} + +impl KeyEnd { + /// What to say when the field number is missing. + fn missing_field_key(self) -> &'static str { + match self { + Self::From => "sort-invalid-number-at-field-start", + Self::To => "sort-invalid-number-after-comma", + } + } + + /// The character offset to assume when the spec gives none. + fn default_char(self) -> usize { + match self { + Self::From => 1, + Self::To => 0, + } + } +} + +/// Read a `FIELD[.CHAR]` position from the head of `input`. +/// +/// # Arguments +/// +/// * `key` - The whole key spec, for the error message and for the offsets a +/// caret is placed at. +/// * `input` - What is left to parse, a suffix of `key`. +/// * `end` - Which end of the key this position is. +/// +/// # Returns +/// +/// The field, the character offset inside it, and what follows the position. +fn parse_position<'a>( + key: &str, + input: &'a str, + end: KeyEnd, +) -> Result<(usize, usize, &'a str), KeyError> { + // Everything the parser hands around is a suffix of `key`, so how much is + // left is also where we are. + let at = |rest: &str| key.len() - rest.len(); + + let (field, mut rest) = parse_field_count(input, at(input), end.missing_field_key())?; + if field == 0 { + return Err(bad_field_spec( + key, + at(input)..at(rest), + "sort-field-number-is-zero", + KeyErrorKind::ZeroCount, + )); + } + + let mut char = end.default_char(); + if let Some(stripped) = rest.strip_prefix('.') { + let (char_idx, rest_after) = + parse_field_count(stripped, at(stripped), "sort-invalid-number-after-dot")?; + if char_idx == 0 && end == KeyEnd::From { + return Err(bad_field_spec( + key, + at(stripped)..at(rest_after), + "sort-character-offset-is-zero", + KeyErrorKind::ZeroCount, + )); + } + char = char_idx; + rest = rest_after; + } + + Ok((field, char, rest)) +} + fn is_ordering_option_char(byte: u8) -> bool { matches!( byte, @@ -1016,6 +1193,20 @@ fn is_ordering_option_char(byte: u8) -> bool { ) } +/// The part of `key` that holds ordering options, from the first one to the +/// last: what the caret should cover when they contradict each other. The +/// field numbers around them are not at fault, so they stay outside the span. +fn ordering_options_span(key: &str) -> Range { + let is_option = |(_, byte): &(usize, u8)| is_ordering_option_char(*byte); + let mut options = key.bytes().enumerate().filter(is_option); + let Some((first, _)) = options.next() else { + // Nothing to point at; fall back to the whole spec. + return 0..key.len(); + }; + let last = options.next_back().map_or(first, |(index, _)| index); + first..last + 1 +} + fn parse_ordering_options<'a>( input: &'a str, settings: &mut KeySettings, @@ -1024,8 +1215,8 @@ fn parse_ordering_options<'a>( let mut ignore_blanks = false; let bytes = input.as_bytes(); let mut idx = 0; - while idx < bytes.len() { - match bytes[idx] { + while let Some(byte) = bytes.get(idx) { + match byte { b'b' => ignore_blanks = true, b'd' => { settings.dictionary_order = true; @@ -1064,7 +1255,11 @@ struct FieldSelector { } impl FieldSelector { - fn parse(key: &str, global_settings: &GlobalSettings) -> UResult { + fn parse(key: &str, global_settings: &GlobalSettings) -> Result { + // Everything the parser hands around is a suffix of `key`, so how much + // is left is also where we are. + let at = |rest: &str| key.len() - rest.len(); + let has_options = key.as_bytes().iter().copied().any(is_ordering_option_char); let mut settings = if has_options { KeySettings::default() @@ -1088,21 +1283,7 @@ impl FieldSelector { 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 (from_field, from_char, rest) = parse_position(key, key, KeyEnd::From)?; let (rest_after_opts, ignore_blanks) = parse_ordering_options(rest, &mut settings, &mut flags); @@ -1112,26 +1293,14 @@ impl FieldSelector { 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 (to_field, to_char, rest) = parse_position(key, rest_after_comma, KeyEnd::To)?; 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")); + return Err(stray_character(key, at(rest))); } to = Some(KeyPosition { field: to_field, @@ -1139,7 +1308,7 @@ impl FieldSelector { ignore_blanks: to_ignore_blanks, }); } else if !rest_after_opts.is_empty() { - return Err(bad_field_spec(key, "sort-stray-character-field-spec")); + return Err(stray_character(key, at(rest_after_opts))); } if ordering_incompatible( @@ -1153,7 +1322,11 @@ impl FieldSelector { settings.ignore_non_printing, settings.ignore_case, ); - return Err(incompatible_options_error(&opts)); + return Err(KeyError { + message: incompatible_options_message(&opts), + span: ordering_options_span(key), + kind: KeyErrorKind::IncompatibleOptions, + }); } settings.mode = flags.to_mode(); @@ -1163,29 +1336,23 @@ impl FieldSelector { char: from_char, ignore_blanks: from_ignore_blanks, }; - Self::new(from, to, settings).map_err(|msg| USimpleError::new(2, msg)) + Ok(Self::new(from, to, settings)) } - fn new( - from: KeyPosition, - to: Option, - settings: KeySettings, - ) -> Result { - if from.char == 0 { - Err(translate!("sort-invalid-char-index-zero-start")) - } else { - Ok(Self { - needs_selection: (from.field != 1 - || from.char != 1 - || to.is_some() - || matches!(settings.mode, SortMode::Numeric | SortMode::HumanNumeric) - || from.ignore_blanks) - && !matches!(settings.mode, SortMode::GeneralNumeric), - needs_tokens: from.field != 1 || from.char == 0 || to.is_some(), - from, - to, - settings, - }) + fn new(from: KeyPosition, to: Option, settings: KeySettings) -> Self { + // A zero start position is rejected by `parse` before getting here. + debug_assert_ne!(from.char, 0); + Self { + needs_selection: (from.field != 1 + || from.char != 1 + || to.is_some() + || matches!(settings.mode, SortMode::Numeric | SortMode::HumanNumeric) + || from.ignore_blanks) + && !matches!(settings.mode, SortMode::GeneralNumeric), + needs_tokens: from.field != 1 || to.is_some(), + from, + to, + settings, } } @@ -1404,6 +1571,16 @@ pub(crate) fn fd_soft_limit() -> Option { None } +/// The largest `--batch-size` argument that can be honoured with the current file +/// descriptor soft limit, or `None` if that limit is unknown. +/// +/// Three descriptors are always in use (stdin, stdout and stderr) and are therefore +/// not available for merge inputs. +fn max_merge_batch_size() -> Option { + const RESERVED_STDIO: usize = 3; + fd_soft_limit().map(|limit| limit.saturating_sub(RESERVED_STDIO)) +} + #[cfg(unix)] pub(crate) fn current_open_fd_count() -> Option { fn count_dir(path: &str) -> Option { @@ -1423,14 +1600,11 @@ pub(crate) fn current_open_fd_count() -> Option { return Some(count); } - let limit = fd_soft_limit()?; - if limit > 16_384 { - return None; - } + let limit = fd_soft_limit().filter(|l| *l <= 16_384)?; let mut count = 0usize; for fd in 0..limit { - let fd = fd as libc::c_int; + let fd = fd as core::ffi::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); @@ -1625,42 +1799,40 @@ where break; } - if arg.as_encoded_bytes().first() == Some(&b'+') { - 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()); - - 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 as_str = arg.to_string_lossy(); + if let Some(from_spec) = as_str.strip_prefix('+') + && 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 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}"))); + if let Some(next_str) = next_candidate + && let Some(stripped) = next_str + .strip_prefix('-') + .filter(|s| s.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()); + 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); @@ -1681,12 +1853,7 @@ fn index_legacy_warnings(processed_args: &[OsString], legacy_warnings: &mut [Leg let mut key_index = 0usize; let mut i = 0usize; - while i < processed_args.len() { - let arg = &processed_args[i]; - if arg == OsStr::new("--") { - break; - } - + while let Some(arg) = processed_args.get(i).filter(|&a| a != OsStr::new("--")) { let mut matched_key = false; if arg == OsStr::new("-k") || arg == OsStr::new("--key") { if i + 1 < processed_args.len() { @@ -1698,24 +1865,21 @@ fn index_legacy_warnings(processed_args: &[OsString], legacy_warnings: &mut [Leg } } 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; - } + if as_str + .strip_prefix("-k") + .is_some_and(|spec| !spec.is_empty()) + || as_str + .strip_prefix("--key=") + .is_some_and(|spec| !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); - } + if matched_key && let Some(&warning_idx) = index_by_arg.get(&i.saturating_sub(1)) { + legacy_warnings[warning_idx].key_index = Some(key_index); } } } @@ -1984,6 +2148,30 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { ..Default::default() }; + // Kept for the caret in `-k` diagnostics, which echoes the command line: + // taken before the legacy rewrite, so that a `-k` error in a command that + // also used `+POS` shows what was typed rather than the `-k` the rewrite + // synthesized. + let args: Vec = args.collect(); + let key_args = uucore::diagnostics::capture(&args); + + // GNU `sort` supports `-t=` to set the field separator to `=`. + // Clap strips the first `=` after a short option (see + // https://github.com/uutils/coreutils/issues/2424#issuecomment-863825242, + // and the same rewrite in `cut`), so rewrite every attached `-t` + // argument to its long form, which preserves the separator verbatim. + let args = args.into_iter().map(|x| { + // Non-UTF-8 separators are rejected later anyway, so lossy conversion + // here only affects arguments that cannot become a valid separator. + let as_str = x.to_string_lossy(); + if as_str.starts_with("-t") && as_str.chars().count() > 2 { + OsString::from(format!("--{}={}", options::SEPARATOR, &as_str[2..])) + } else { + x + } + }); + let args: Vec = args.collect(); + let (processed_args, mut legacy_warnings) = preprocess_legacy_args(args); if !legacy_warnings.is_empty() { index_legacy_warnings(&processed_args, &mut legacy_warnings); @@ -1992,12 +2180,16 @@ 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 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()); - } - } + if matches + .get_many::(options::OUTPUT) + .is_some_and(|mut outputs| { + outputs + .next() + .as_ref() + .is_some_and(|first| outputs.any(|out| out != *first)) + }) + { + return Err(SortError::MultipleOutputFiles.into()); } settings.debug = matches.get_flag(options::DEBUG); @@ -2027,7 +2219,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { path: files0_from.clone(), error, })?; - + if line.as_slice() == STDIN_FILE.as_bytes() { + return Err(SortError::MinusInStdIn.into()); + } if line.is_empty() { return Err(SortError::ZeroLengthFileName { file: files0_from, @@ -2035,26 +2229,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } .into()); } - - let f: OsString = { - #[cfg(unix)] - { - OsStr::from_bytes(&line).to_os_string() - } - #[cfg(not(unix))] - { - OsString::from(String::from_utf8_lossy(&line).into_owned()) - } - }; - - match f.to_str() { - Some(s) if s == STDIN_FILE => { - return Err(SortError::MinusInStdIn.into()); - } - _ => {} - } - - files.push(f); + files.push(uucore::os_string_from_vec(line)?); } if files.is_empty() { @@ -2126,8 +2301,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } if let Some(size_str) = matches.get_one::(options::BUF_SIZE) { - settings.buffer_size = GlobalSettings::parse_byte_count(size_str).map_err(|e| { - USimpleError::new(2, format_error_message(&e, size_str, options::BUF_SIZE)) + settings.buffer_size = GlobalSettings::parse_byte_count(size_str).map_err(|error| { + let message = format_error_message(&error, size_str, options::BUF_SIZE); + error.size_value_error( + key_args.as_deref(), + &OptionValue::new(size_str, 'S', options::BUF_SIZE), + 0, + &message, + USimpleError::new(2, message.clone()), + ) })?; settings.buffer_size_is_explicit = true; } else { @@ -2157,54 +2339,51 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .map(String::from); if let Some(n_merge) = matches.get_one::(options::BATCH_SIZE) { - match n_merge.parse::() { - Ok(parsed_value) => { - if parsed_value < 2 { - show_error!( - "{}", - translate!("sort-invalid-batch-size-arg", "arg" => n_merge) - ); - return Err(UUsageError::new( - 2, - translate!("sort-minimum-batch-size-two"), - )); - } - settings.merge_batch_size = parsed_value; + // `None` means the value does not even fit in a `usize`, which is always too large. + let parsed_value = match n_merge.parse::() { + Ok(parsed_value) => Some(parsed_value), + Err(e) if *e.kind() == IntErrorKind::PosOverflow => None, + Err(_) => { + return Err(UUsageError::new( + 2, + translate!("sort-invalid-batch-size-arg", "arg" => n_merge), + )); } - Err(e) => { - let error_message = if *e.kind() == IntErrorKind::PosOverflow { - let batch_too_large = translate!( - "sort-batch-size-too-large", - "arg" => n_merge.quote() - ); + }; - #[cfg(target_os = "linux")] - { - show_error!("{batch_too_large}"); - - translate!( - "sort-maximum-batch-size-rlimit", - "rlimit" => { - fd_soft_limit().ok_or_else(|| { - UUsageError::new(2, translate!("sort-failed-fetch-rlimit")) - })? - } - ) - } - #[cfg(not(target_os = "linux"))] - { - batch_too_large - } - } else { - translate!( - "sort-invalid-batch-size-arg", - "arg" => n_merge, - ) - }; + if parsed_value.is_some_and(|value| value < 2) { + show_error!( + "{}", + translate!("sort-invalid-batch-size-arg", "arg" => n_merge) + ); + return Err(UUsageError::new( + 2, + translate!("sort-minimum-batch-size-two"), + )); + } - return Err(UUsageError::new(2, error_message)); - } + let max_batch_size = max_merge_batch_size(); + let too_large = match (parsed_value, max_batch_size) { + (None, _) => true, + (Some(value), Some(max)) => value > max, + (Some(_), None) => false, + }; + if too_large { + let batch_too_large = translate!( + "sort-batch-size-too-large", + "arg" => n_merge.quote() + ); + let error_message = match max_batch_size { + Some(max) => { + show_error!("{batch_too_large}"); + translate!("sort-maximum-batch-size-rlimit", "rlimit" => max) + } + None => batch_too_large, + }; + return Err(UUsageError::new(2, error_message)); } + + settings.merge_batch_size = parsed_value.unwrap_or(usize::MAX); } settings.line_ending = LineEnding::from_zero_flag(matches.get_flag(options::ZERO_TERMINATED)); @@ -2271,7 +2450,16 @@ 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)?; + let selector = match FieldSelector::parse(value, &settings) { + Ok(selector) => selector, + Err(error) => { + return Err(uucore::diagnostics::error_after_report( + key_args.as_deref(), + error, + |args, error| diagnostics::render(args, value, error), + )); + } + }; settings.selectors.push(selector); } } @@ -2279,18 +2467,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { if !matches.contains_id(options::KEY) { // add a default selector matching the whole line let key_settings = KeySettings::from(&settings); - settings.selectors.push( - FieldSelector::new( - KeyPosition { - field: 1, - char: 1, - ignore_blanks: key_settings.ignore_blanks, - }, - None, - key_settings, - ) - .unwrap(), - ); + settings.selectors.push(FieldSelector::new( + KeyPosition { + field: 1, + char: 1, + ignore_blanks: key_settings.ignore_blanks, + }, + None, + key_settings, + )); } let needs_random = settings.mode == SortMode::Random @@ -2305,12 +2490,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }); } - // 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, - // probably to prevent running out of file descriptors. - for file in &files { - open(file)?; + if !(settings.merge || settings.check) { + check_inputs(&files)?; } let output = Output::new(matches.get_one::(options::OUTPUT))?; @@ -2597,6 +2778,8 @@ fn exec( check::check(files.first().unwrap(), settings) } } else { + // Open each input once, when it is reached, so that only one input is open + // at a time and FIFOs are never reopened. let mut lines = files.iter().map(open); ext_sort(&mut lines, settings, output, tmp_dir) } @@ -2788,7 +2971,14 @@ fn compare_by<'a>( { Ordering::Equal } else { - a.line.cmp(b.line) + #[cfg(feature = "i18n-collator")] + { + locale_cmp(a.line, b.line) + } + #[cfg(not(feature = "i18n-collator"))] + { + a.line.cmp(b.line) + } }; if global_settings.reverse { @@ -2961,6 +3151,10 @@ 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. +/// +/// The file has to hold at least [`SALT_LEN`] bytes. GNU asks for the same 128 +/// bits and reports `end of file` when the source cannot supply them, rather +/// than shuffling with whatever it managed to read. 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]; @@ -2968,20 +3162,14 @@ fn salt_from_random_source(path: &Path) -> UResult<[u8; SALT_LEN]> { // freeze seed for --random-source let mut hasher = FoldHasher::with_seed(1, SharedSeed::global_fixed()); - 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; - } + while let n @ 1.. = reader + .read(&mut buf) + .map_err(|error| SortError::ReadFailed { + path: path.to_owned(), + error, + })? + && let remaining @ 1.. = MAX_BYTES.saturating_sub(total) + { let take = n.min(remaining); hasher.write(&buf[..take]); total = total.saturating_add(take); @@ -2990,6 +3178,13 @@ fn salt_from_random_source(path: &Path) -> UResult<[u8; SALT_LEN]> { } } + if total < SALT_LEN { + return Err(SortError::RandomSourceEndOfFile { + path: path.to_owned(), + } + .into()); + } + let first = hasher.finish(); // freeze seed for --random-source let mut second_hasher = FoldHasher::with_seed(2, SharedSeed::global_fixed()); @@ -3082,8 +3277,9 @@ fn month_parse(line: &[u8]) -> (Month, usize) { if let Some(table) = get_locale_month_table() { let mut best = None; for (name, month) in table { - if line.len() >= name.len() - && line[..name.len()].eq_ignore_ascii_case(name) + if line + .get(..name.len()) + .is_some_and(|l| l.eq_ignore_ascii_case(name)) && best.as_ref().is_none_or(|&(len, _)| name.len() > len) { best = Some((name.len(), *month)); @@ -3124,20 +3320,47 @@ fn print_sorted<'a, T: Iterator>>( settings: &GlobalSettings, output: Output, ) -> UResult<()> { - let output_name = output - .as_output_name() - .unwrap_or(OsStr::new("standard output")) - .to_owned(); - let ctx = || translate!("sort-error-write-failed", "output" => output_name.maybe_quote()); - - let mut writer = output.into_write(); + let ctx = output.write_failed_context(); + let mut writer = output.into_write()?; for line in iter { - line.write(&mut writer, settings).map_err_context(ctx)?; + line.write(&mut writer, settings).map_err_context(&ctx)?; } writer.flush().map_err_context(ctx)?; Ok(()) } +/// Check that all inputs are readable before sorting starts, like GNU sort's +/// `check_inputs`. The inputs are probed with `access(2)` rather than opened: +/// opening a FIFO here would block until a writer appears and lose data on the +/// reopen, and keeping every input open would be bounded by `RLIMIT_NOFILE`. +fn check_inputs(files: &[OsString]) -> UResult<()> { + for file in files { + if file == STDIN_FILE { + continue; + } + let path = Path::new(file); + check_readable(path).map_err(|error| SortError::ReadFailed { + path: path.to_owned(), + error, + })?; + } + Ok(()) +} + +#[cfg(all(unix, not(target_os = "redox")))] +fn check_readable(path: &Path) -> std::io::Result<()> { + use rustix::fs::{Access, access}; + + access(path, Access::READ_OK).map_err(|e| std::io::Error::from_raw_os_error(e.raw_os_error())) +} + +#[cfg(any(not(unix), target_os = "redox"))] +fn check_readable(path: &Path) -> std::io::Result<()> { + // No `rustix::fs::access` here, so open the file and close it right away. + File::open(path)?; + Ok(()) +} + fn open(path: impl AsRef) -> UResult> { let path = path.as_ref(); if path == STDIN_FILE { diff --git a/src/uu/sort/src/tmp_dir.rs b/src/uu/sort/src/tmp_dir.rs index e3851bb054b..ee92d18a5c1 100644 --- a/src/uu/sort/src/tmp_dir.rs +++ b/src/uu/sort/src/tmp_dir.rs @@ -7,8 +7,13 @@ use std::path::Path; #[cfg(not(any(target_os = "redox", target_os = "wasi")))] use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(unix)] use std::{ - fs::File, + fs::Permissions, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, +}; +use std::{ + fs::{File, OpenOptions}, path::PathBuf, sync::{Arc, LazyLock, Mutex}, }; @@ -59,26 +64,25 @@ fn ensure_signal_handler_installed(state: Arc>) -> UR return Ok(()); } - let handler_state = state.clone(); if let Err(e) = ctrlc::set_handler(move || { // Load the latest lock/path snapshot so the handler cleans the active temp dir. let (lock, path) = { - let state = handler_state.lock().unwrap(); + let state = state.lock().unwrap(); (state.lock.clone(), state.path.clone()) }; if let Some(lock) = lock { let _guard = lock.lock().unwrap(); - if let Some(path) = path { - if let Err(e) = remove_tmp_dir(&path) { - show_error!( - "{}", - translate!( - "sort-failed-to-delete-temporary-directory", - "error" => e - ) - ); - } + if let Some(path) = path + && let Err(e) = remove_tmp_dir(&path) + { + show_error!( + "{}", + translate!( + "sort-failed-to-delete-temporary-directory", + "error" => e + ) + ); } } @@ -113,14 +117,17 @@ impl TmpDirWrapper { fn init_tmp_dir(&mut self) -> UResult<()> { assert!(self.temp_dir.is_none()); assert_eq!(self.size, 0); - self.temp_dir = Some( - tempfile::Builder::new() - .prefix("uutils_sort") - .tempdir_in(&self.parent_path) - .map_err(|_| SortError::TmpFileCreationFailed { - path: self.parent_path.clone(), - })?, - ); + let mut builder = tempfile::Builder::new(); + builder.prefix("uutils_sort"); + // The chunks hold a copy of the input, so keep them out of reach of other + // users instead of relying on whatever umask the process inherited. + #[cfg(unix)] + builder.permissions(Permissions::from_mode(0o700)); + self.temp_dir = Some(builder.tempdir_in(&self.parent_path).map_err(|_| { + SortError::TmpFileCreationFailed { + path: self.parent_path.clone(), + } + })?); let path = self.temp_dir.as_ref().unwrap().path().to_owned(); let state = HANDLER_STATE.clone(); @@ -146,8 +153,16 @@ impl TmpDirWrapper { let file_name = self.size.to_string(); self.size += 1; let path = self.temp_dir.as_ref().unwrap().path().join(file_name); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + // Restrict the chunks too, so a directory whose mode is later relaxed + // doesn't expose them. + #[cfg(unix)] + options.mode(0o600); Ok(( - File::create(&path).map_err(|error| SortError::OpenTmpFileFailed { error })?, + options + .open(&path) + .map_err(|error| SortError::OpenTmpFileFailed { error })?, path, )) } @@ -196,3 +211,46 @@ fn remove_tmp_dir(path: &Path) -> std::io::Result<()> { } std::fs::remove_dir(path) } + +#[cfg(all(test, unix))] +mod tests { + use super::TmpDirWrapper; + use std::os::unix::fs::PermissionsExt; + + fn mode(path: &std::path::Path) -> u32 { + std::fs::metadata(path).unwrap().permissions().mode() & 0o777 + } + + /// Restores the process umask on drop, so a panic in the test cannot leak the + /// value into the rest of the binary. + struct UmaskGuard(libc::mode_t); + + impl UmaskGuard { + fn set(mask: libc::mode_t) -> Self { + // SAFETY: umask(2) has no failure mode; it returns the previous value. + Self(unsafe { libc::umask(mask) }) + } + } + + impl Drop for UmaskGuard { + fn drop(&mut self) { + unsafe { libc::umask(self.0) }; + } + } + + #[test] + fn tmp_files_are_private_regardless_of_umask() { + // Pin a permissive umask: under 0077 the umask alone would produce 0700 and + // 0600, so the assertions would hold for a broken implementation too. The + // guard restores it, and the only other test here that creates files sets + // the modes it cares about explicitly. + let _umask = UmaskGuard::set(0o022); + + let parent = tempfile::tempdir().unwrap(); + let mut wrapper = TmpDirWrapper::new(parent.path().to_owned()); + let (_file, path) = wrapper.next_file().unwrap(); + + assert_eq!(mode(path.parent().unwrap()), 0o700); + assert_eq!(mode(&path), 0o600); + } +} diff --git a/src/uu/split/Cargo.toml b/src/uu/split/Cargo.toml index 60dbba0ddd0..053308f9f78 100644 --- a/src/uu/split/Cargo.toml +++ b/src/uu/split/Cargo.toml @@ -11,20 +11,19 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/split.rs" doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } memchr = { workspace = true } -rustix = { workspace = true } -uucore = { workspace = true, features = ["fs", "parser-size"] } thiserror = { workspace = true } -fluent = { workspace = true } +uucore = { workspace = true, features = ["fs", "parser-size"] } + +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))'.dependencies] +rustix = { workspace = true, features = ["fs"] } [[bin]] name = "split" @@ -35,6 +34,9 @@ divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bench]] name = "split_bench" harness = false diff --git a/src/uu/split/benches/split_bench.rs b/src/uu/split/benches/split_bench.rs index d09d658b01d..4e52247349b 100644 --- a/src/uu/split/benches/split_bench.rs +++ b/src/uu/split/benches/split_bench.rs @@ -6,7 +6,7 @@ use divan::{Bencher, black_box}; use tempfile::TempDir; use uu_split::uumain; -use uucore::benchmark::{run_util_function, setup_test_file, text_data}; +use uucore::benchmark::{get_bench_args, setup_test_file, text_data}; /// Benchmark splitting by line count #[divan::bench] @@ -18,13 +18,11 @@ fn split_lines(bencher: Bencher) { .with_inputs(|| { let output_dir = TempDir::new().unwrap(); let prefix = output_dir.path().join("x"); - (output_dir, prefix.to_str().unwrap().to_string()) + let args = get_bench_args(&[&"-l", &"1000", &file_path, &prefix]).into_iter(); + (output_dir, args) }) - .bench_values(|(output_dir, prefix)| { - black_box(run_util_function( - uumain, - &["-l", "1000", file_path.to_str().unwrap(), &prefix], - )); + .bench_values(|(output_dir, args)| { + black_box(uumain(args)); drop(output_dir); }); } @@ -39,13 +37,11 @@ fn split_bytes(bencher: Bencher) { .with_inputs(|| { let output_dir = TempDir::new().unwrap(); let prefix = output_dir.path().join("x"); - (output_dir, prefix.to_str().unwrap().to_string()) + let args = get_bench_args(&[&"-b", &"100K", &file_path, &prefix]).into_iter(); + (output_dir, args) }) - .bench_values(|(output_dir, prefix)| { - black_box(run_util_function( - uumain, - &["-b", "100K", file_path.to_str().unwrap(), &prefix], - )); + .bench_values(|(output_dir, args)| { + black_box(uumain(args)); drop(output_dir); }); } @@ -60,13 +56,30 @@ fn split_number_chunks(bencher: Bencher) { .with_inputs(|| { let output_dir = TempDir::new().unwrap(); let prefix = output_dir.path().join("x"); - (output_dir, prefix.to_str().unwrap().to_string()) + let args = get_bench_args(&[&"-n", &"10", &file_path, &prefix]).into_iter(); + (output_dir, args) }) - .bench_values(|(output_dir, prefix)| { - black_box(run_util_function( - uumain, - &["-n", "10", file_path.to_str().unwrap(), &prefix], - )); + .bench_values(|(output_dir, args)| { + black_box(uumain(args)); + drop(output_dir); + }); +} + +/// Benchmark splitting by round-robin chunks +#[divan::bench] +fn split_number_chunks_round_robin(bencher: Bencher) { + let data = text_data::generate_by_lines(100_000, 80); + let file_path = setup_test_file(&data); + + bencher + .with_inputs(|| { + let output_dir = TempDir::new().unwrap(); + let prefix = output_dir.path().join("x"); + let args = get_bench_args(&[&"-n", &"r/10", &file_path, &prefix]).into_iter(); + (output_dir, args) + }) + .bench_values(|(output_dir, args)| { + black_box(uumain(args)); drop(output_dir); }); } @@ -81,13 +94,11 @@ fn split_numeric_suffix(bencher: Bencher) { .with_inputs(|| { let output_dir = TempDir::new().unwrap(); let prefix = output_dir.path().join("x"); - (output_dir, prefix.to_str().unwrap().to_string()) + let args = get_bench_args(&[&"-d", &"-l", &"500", &file_path, &prefix]).into_iter(); + (output_dir, args) }) - .bench_values(|(output_dir, prefix)| { - black_box(run_util_function( - uumain, - &["-d", "-l", "500", file_path.to_str().unwrap(), &prefix], - )); + .bench_values(|(output_dir, args)| { + black_box(uumain(args)); drop(output_dir); }); } diff --git a/src/uu/split/locales/en-US.ftl b/src/uu/split/locales/en-US.ftl index 1734f70485c..1a9c60c5293 100644 --- a/src/uu/split/locales/en-US.ftl +++ b/src/uu/split/locales/en-US.ftl @@ -8,12 +8,12 @@ split-after-help = Output fixed-size pieces of INPUT to PREFIXaa, PREFIXab, ...; CHUNKS may be: - - N split into N files based on size of input - - K/N output Kth of N to stdout - - l/N split into N files without splitting lines/records - - l/K/N output Kth of N to stdout without splitting lines/records - - r/N like 'l' but use round robin distribution - - r/K/N likewise but only output Kth of N to stdout + - N split into N files based on size of input + - K/N output Kth of N to stdout + - l/N split into N files without splitting lines/records + - l/K/N output Kth of N to stdout without splitting lines/records + - r/N like 'l' but use round robin distribution + - r/K/N likewise but only output Kth of N to stdout # messages split-creating-file = creating file { $file } diff --git a/src/uu/split/locales/fr-FR.ftl b/src/uu/split/locales/fr-FR.ftl index 8d6bbc067f5..795df9df4d5 100644 --- a/src/uu/split/locales/fr-FR.ftl +++ b/src/uu/split/locales/fr-FR.ftl @@ -8,12 +8,12 @@ split-after-help = Sortir des morceaux de taille fixe d'ENTRÉE vers PREFIXEaa, CHUNKS peut être: - - N diviser en N fichiers basé sur la taille de l'entrée - - K/N sortir le Kème de N vers stdout - - l/N diviser en N fichiers sans diviser les lignes/enregistrements - - l/K/N sortir le Kème de N vers stdout sans diviser les lignes/enregistrements - - r/N comme 'l' mais utiliser la distribution round robin - - r/K/N pareillement mais ne sortir que le Kème de N vers stdout + - N diviser en N fichiers basé sur la taille de l'entrée + - K/N sortir le Kème de N vers stdout + - l/N diviser en N fichiers sans diviser les lignes/enregistrements + - l/K/N sortir le Kème de N vers stdout sans diviser les lignes/enregistrements + - r/N comme 'l' mais utiliser la distribution round robin + - r/K/N pareillement mais ne sortir que le Kème de N vers stdout # Messages d'erreur split-error-suffix-not-parsable = longueur de suffixe invalide : { $value } diff --git a/src/uu/split/src/filenames.rs b/src/uu/split/src/filenames.rs index bc7adfb58d2..21e0e715909 100644 --- a/src/uu/split/src/filenames.rs +++ b/src/uu/split/src/filenames.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore zaaa zaab stype + //! Compute filenames from a given index. //! //! The [`FilenameIterator`] yields filenames for use with ``split``. @@ -38,7 +40,6 @@ use crate::strategy::Strategy; use clap::ArgMatches; use std::ffi::{OsStr, OsString}; use std::path::is_separator; -use thiserror::Error; use uucore::display::Quotable; use uucore::error::{UResult, USimpleError}; use uucore::translate; @@ -84,7 +85,7 @@ pub struct Suffix { } /// An error when parsing suffix parameters from command-line arguments. -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum SuffixError { /// Invalid suffix length parameter. #[error("{}", translate!("split-error-suffix-not-parsable", "value" => .0.quote()))] diff --git a/src/uu/split/src/number.rs b/src/uu/split/src/number.rs index 818a5b88899..35df4a42b1f 100644 --- a/src/uu/split/src/number.rs +++ b/src/uu/split/src/number.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore zaaa zaab feff + //! A number in arbitrary radix expressed in a positional notation. //! //! Use the [`Number`] enum to represent an arbitrary number in an @@ -14,6 +16,7 @@ //! //! [radix]: https://en.wikipedia.org/wiki/Radix //! [positional notation]: https://en.wikipedia.org/wiki/Positional_notation + use std::error::Error; use std::fmt::{self, Display, Formatter, Write}; use uucore::translate; diff --git a/src/uu/split/src/platform/unix.rs b/src/uu/split/src/platform/unix.rs index dd9798539a9..caeaede59b3 100644 --- a/src/uu/split/src/platform/unix.rs +++ b/src/uu/split/src/platform/unix.rs @@ -11,7 +11,7 @@ use std::io::{ErrorKind, Write}; use std::path::Path; use std::process::{Child, Command, Stdio}; use uucore::display::Quotable; -use uucore::error::USimpleError; +use uucore::error::{USimpleError, strip_errno}; use uucore::fs; use uucore::fs::FileInformation; use uucore::show; @@ -197,7 +197,7 @@ fn create_or_truncate_output_file(input: &OsStr, filename: &OsStr) -> Result Error { - let e = uucore::error::strip_errno(&e); + let e = strip_errno(&e); Error::other(format!("{}: {e}", filename.quote())) } diff --git a/src/uu/split/src/platform/windows.rs b/src/uu/split/src/platform/windows.rs index ef70f709c3f..3d40bbc7363 100644 --- a/src/uu/split/src/platform/windows.rs +++ b/src/uu/split/src/platform/windows.rs @@ -2,11 +2,13 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use crate::platform::Writer; use std::ffi::OsStr; use std::io::{Error, ErrorKind, Result}; use std::path::Path; use uucore::display::Quotable; +use uucore::error::strip_errno; use uucore::fs; use uucore::translate; @@ -71,7 +73,7 @@ fn create_or_truncate_output_file(input: &OsStr, filename: &OsStr) -> Result Error { - let e = uucore::error::strip_errno(&e); + let e = strip_errno(&e); Error::other(format!("{}: {e}", filename.quote())) } diff --git a/src/uu/split/src/split.rs b/src/uu/split/src/split.rs index e8ab1a8f506..a2e2085337a 100644 --- a/src/uu/split/src/split.rs +++ b/src/uu/split/src/split.rs @@ -24,29 +24,40 @@ use std::fs::{File, metadata}; use std::io; use std::io::{BufRead, BufReader, ErrorKind, Read, Seek, SeekFrom, Write, stdin}; use std::path::Path; -use thiserror::Error; use uucore::display::Quotable; -use uucore::error::{FromIo, UResult, USimpleError, UUsageError}; +use uucore::error::{FromIo, UResult, USimpleError, UUsageError, set_exit_code, strip_errno}; use uucore::parser::parse_size::parse_size_u64; use uucore::translate; #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let (args, obs_lines) = handle_obsolete(args); + let raw_args: Vec = args.collect(); + // Capture before the obsolete `-22` spelling is rewritten to `-l 22`. + let diag_args = uucore::diagnostics::capture(&raw_args); + let (args, obs_lines) = handle_obsolete(raw_args.into_iter()); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; let settings = Settings::from(&matches, obs_lines.as_deref()).map_err(|e| { + let message = format!("{e}"); if e.requires_usage() { - UUsageError::new(1, format!("{e}")) - } else { - USimpleError::new(1, format!("{e}")) + return UUsageError::new(1, message); } + uucore::diagnostics::error_after_report( + diag_args.as_deref(), + USimpleError::new(1, message.clone()), + |args, _| match &e { + SettingsError::Strategy(error) => error.render(args, &message), + // The rest is about how the options combine rather than about + // one of them, so there is nothing to point a caret at. + _ => false, + }, + ) })?; // 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)] + #[cfg(all(unix, not(target_os = "fuchsia")))] if settings.filter.is_some() { let _ = uucore::signals::disable_pipe_errors(); } @@ -64,6 +75,7 @@ fn handle_obsolete(args: impl uucore::Args) -> (Vec, Option) { let mut obs_lines = None; let mut preceding_long_opt_req_value = false; let mut preceding_short_opt_req_value = false; + let mut after_double_dash = false; let filtered_args = args .filter_map(|os_slice| { @@ -72,6 +84,7 @@ fn handle_obsolete(args: impl uucore::Args) -> (Vec, Option) { &mut obs_lines, &mut preceding_long_opt_req_value, &mut preceding_short_opt_req_value, + &mut after_double_dash, ) }) .collect(); @@ -86,9 +99,19 @@ fn filter_args( obs_lines: &mut Option, preceding_long_opt_req_value: &mut bool, preceding_short_opt_req_value: &mut bool, + after_double_dash: &mut bool, ) -> Option { let filter: Option; if let Some(slice) = os_slice.to_str() { + if *after_double_dash { + // Past `--` everything is an operand, so `split -- -1` names a file + // rather than setting the line count. + return Some(OsString::from(slice)); + } + if slice == "--" { + *after_double_dash = true; + return Some(OsString::from(slice)); + } if should_extract_obs_lines( slice, *preceding_long_opt_req_value, @@ -234,8 +257,8 @@ struct Settings { io_blksize: Option, } -#[derive(Debug, Error)] /// An error when parsing settings from command-line arguments. +#[derive(Debug, thiserror::Error)] enum SettingsError { /// Invalid chunking strategy. #[error("{0}")] @@ -310,7 +333,7 @@ impl Settings { if let Some(s) = matches.get_one::(options::IO_BLKSIZE) { match parse_size_u64(s) { Ok(0) => return Err(SettingsError::InvalidIOBlockSize(s.to_owned())), - Ok(n) if n <= uucore::fs::sane_blksize::MAX => Some(n), + Ok(n @ ..=uucore::fs::sane_blksize::MAX) => Some(n), _ => return Err(SettingsError::InvalidIOBlockSize(s.to_owned())), } } else { @@ -521,6 +544,12 @@ struct ByteChunkWriter<'a> { /// Iterator that yields filenames for each chunk. filename_iterator: FilenameIterator<'a>, + + /// Current filename being written to. + current_filename: OsString, + + /// Whether an error has already been reported for the current file. + error_reported: bool, } impl<'a> ByteChunkWriter<'a> { @@ -530,7 +559,7 @@ impl<'a> ByteChunkWriter<'a> { USimpleError::new(1, translate!("split-error-output-file-suffixes-exhausted")) })?; if settings.verbose { - writeln!(io::stdout(), "creating file {}", filename.quote())?; + print_creating_file(&filename)?; } let inner = settings.instantiate_current_writer(&filename, true)?; Ok(ByteChunkWriter { @@ -540,71 +569,112 @@ impl<'a> ByteChunkWriter<'a> { num_chunks_written: 0, inner, filename_iterator, + current_filename: filename, + error_reported: false, }) } + + /// Report a write error on the current chunk, at most once per chunk. + /// + /// Returns an empty error: the message is already out, so the caller only + /// has to stop, without printing anything else. + fn report_error(&mut self, e: &io::Error) -> io::Error { + if !self.error_reported { + uucore::show_error!("{}: {}", self.current_filename.display(), strip_errno(e)); + set_exit_code(1); + self.error_reported = true; + } + io::Error::other("") + } + + /// Write to the current chunk, flushing right away. + /// + /// Without the flush, a write failure on a full device would only surface + /// when the buffer happens to be flushed, by which point we have moved on + /// to another chunk and would blame the wrong file. + fn write_chunk(&mut self, bytes: &[u8]) -> io::Result { + let written = custom_write(bytes, &mut self.inner, self.settings) + .and_then(|n| self.inner.flush().map(|()| n)); + match written { + Err(e) if ignorable_io_error(&e, self.settings) => Ok(bytes.len()), + Err(e) => Err(self.report_error(&e)), + ok => ok, + } + } +} + +impl Drop for ByteChunkWriter<'_> { + fn drop(&mut self) { + // Safety net: a buffered error must not be lost when the writer goes away. + let _ = self.inner.flush().map_err(|e| self.report_error(&e)); + } } -impl Write for ByteChunkWriter<'_> { - /// Implements `--bytes=SIZE` - fn write(&mut self, mut buf: &[u8]) -> io::Result { +impl ByteChunkWriter<'_> { + /// Implements `--bytes=SIZE`. GNU is unbuffered. + // todo: distinct read error and write error and show + fn copy(&mut self, mut reader: impl Read, io_blksize: usize) -> io::Result<()> { // If the length of `buf` exceeds the number of bytes remaining // in the current chunk, we will need to write to multiple // different underlying writers. In that case, each iteration of // this loop writes to the underlying writer that corresponds to // the current chunk number. - let mut carryover_bytes_written: usize = 0; - while !buf.is_empty() { - if self.num_bytes_remaining_in_current_chunk == 0 { - // Increment the chunk number, reset the number of bytes remaining, and instantiate the new underlying writer. - self.num_chunks_written += 1; - self.num_bytes_remaining_in_current_chunk = self.chunk_size; - - // Allocate the new file, since at this point we know there are bytes to be written to it. - let filename = self.filename_iterator.next().ok_or_else(|| { - io::Error::other(translate!("split-error-output-file-suffixes-exhausted")) - })?; - if self.settings.verbose { - writeln!(io::stdout(), "creating file {}", filename.quote())?; + let mut io_blk = vec![0u8; io_blksize]; + while let mut buf = reader.read(&mut io_blk).map(|n| &io_blk[..n])? + && !buf.is_empty() + { + while !buf.is_empty() { + if self.num_bytes_remaining_in_current_chunk == 0 { + // Flush before switching file, so a delayed write error is + // still attributed to the chunk it belongs to. + self.inner.flush().map_err(|e| self.report_error(&e))?; + + // Increment the chunk number, reset the number of bytes remaining, and instantiate the new underlying writer. + self.num_chunks_written += 1; + self.num_bytes_remaining_in_current_chunk = self.chunk_size; + + // Allocate the new file, since at this point we know there are bytes to be written to it. + let filename = self.filename_iterator.next().ok_or_else(|| { + io::Error::other(translate!("split-error-output-file-suffixes-exhausted")) + })?; + if self.settings.verbose { + print_creating_file(&filename)?; + } + self.inner = self.settings.instantiate_current_writer(&filename, true)?; + self.current_filename = filename; + self.error_reported = false; } - self.inner = self.settings.instantiate_current_writer(&filename, true)?; - } - // If the capacity of this chunk is greater than the number of - // bytes in `buf`, then write all the bytes in `buf`. Otherwise, - // write enough bytes to fill the current chunk, then increment - // the chunk number and repeat. - let buf_len = buf.len(); - if (buf_len as u64) < self.num_bytes_remaining_in_current_chunk { - let num_bytes_written = custom_write(buf, &mut self.inner, self.settings)?; - self.num_bytes_remaining_in_current_chunk -= num_bytes_written as u64; - return Ok(carryover_bytes_written + num_bytes_written); - } + // If the capacity of this chunk is greater than the number of + // bytes in `buf`, then write all the bytes in `buf`. Otherwise, + // write enough bytes to fill the current chunk, then increment + // the chunk number and repeat. + if (buf.len() as u64) < self.num_bytes_remaining_in_current_chunk { + let num_bytes_written = self.write_chunk(buf)?; + self.num_bytes_remaining_in_current_chunk -= num_bytes_written as u64; + break; + } - // Write enough bytes to fill the current chunk. - // - // Conversion to usize is safe because we checked that - // self.num_bytes_remaining_in_current_chunk is lower than - // n, which is already usize. - let i = self.num_bytes_remaining_in_current_chunk as usize; - let num_bytes_written = custom_write(&buf[..i], &mut self.inner, self.settings)?; - self.num_bytes_remaining_in_current_chunk -= num_bytes_written as u64; - - // It's possible that the underlying writer did not - // write all the bytes. - if num_bytes_written < i { - return Ok(carryover_bytes_written + num_bytes_written); - } + // Write enough bytes to fill the current chunk. + // + // Conversion to usize is safe because we checked that + // self.num_bytes_remaining_in_current_chunk is lower than + // n, which is already usize. + let i = self.num_bytes_remaining_in_current_chunk as usize; + let num_bytes_written = self.write_chunk(&buf[..i])?; + self.num_bytes_remaining_in_current_chunk -= num_bytes_written as u64; - // Move the window to look at only the remaining bytes. - buf = &buf[i..]; + // It's possible that the underlying writer did not + // write all the bytes. + if num_bytes_written < i { + break; + } - // Remember for the next iteration that we wrote these bytes. - carryover_bytes_written += num_bytes_written; + // Move the window to look at only the remaining bytes. + buf = &buf[i..]; + } } - Ok(carryover_bytes_written) - } - fn flush(&mut self) -> io::Result<()> { - self.inner.flush() + Ok(()) } } @@ -666,7 +736,7 @@ impl<'a> LineChunkWriter<'a> { io::Error::other(translate!("split-error-output-file-suffixes-exhausted")) })?; if settings.verbose { - writeln!(io::stdout(), "creating file {}", filename.quote())?; + print_creating_file(&filename)?; } settings.instantiate_current_writer(&filename, true) } @@ -1089,20 +1159,28 @@ fn n_chunks_by_line( // empty files in place(s) of skipped chunk(s) let num_line_bytes = bytes.len() as u64; num_bytes_written += num_line_bytes; - let mut skipped = -1; - while num_bytes_should_be_written <= num_bytes_written { - num_bytes_should_be_written += - chunk_size_base + (chunk_size_reminder > chunk_number) as u64; + let first_chunk_number = chunk_number; + // Cap at the last chunk to avoid an infinite loop when trailing chunks are + // zero-sized, and keep excess input from indexing past out_files. + while chunk_number < num_chunks && num_bytes_should_be_written <= num_bytes_written { + let chunk_size = chunk_size_base + (chunk_size_reminder > chunk_number) as u64; + if chunk_size == 0 { + // Every remaining chunk is zero-sized as well, so all of them + // would be skipped one at a time, which takes prohibitively + // long for a huge number of chunks. Jump to the last one. + chunk_number = num_chunks; + break; + } + num_bytes_should_be_written += chunk_size; chunk_number += 1; - skipped += 1; } // If a chunk was skipped and `elide_empty_files` flag is set, // roll chunk_number back to preserve sequential continuity // of file names for files written to, // except for Kth chunk of N mode - if settings.elide_empty_files && skipped > 0 && kth_chunk.is_none() { - chunk_number -= skipped as u64; + if settings.elide_empty_files && kth_chunk.is_none() { + chunk_number = chunk_number.min(first_chunk_number + 1); } if kth_chunk.is_some_and(|k| chunk_number > k) { break; @@ -1167,9 +1245,10 @@ fn n_chunks_by_line_round_robin( let mut closed_writers = 0; let mut i = 0; + let mut line = Vec::new(); loop { - let line = &mut Vec::new(); - let num_bytes_read = reader.by_ref().read_until(sep, line)?; + line.clear(); + let num_bytes_read = reader.by_ref().read_until(sep, &mut line)?; // if there is nothing else to read - exit the loop if num_bytes_read == 0 { @@ -1251,11 +1330,7 @@ fn line_bytes( USimpleError::new(1, translate!("split-error-output-file-suffixes-exhausted")) })?; if settings.verbose { - writeln!( - io::stdout(), - "{}", - translate!("split-creating-file", "file" => name.quote()) - )?; + print_creating_file(&name)?; } Ok(settings.instantiate_current_writer(&name, true)?) }; @@ -1311,7 +1386,7 @@ fn line_bytes( } fn split(settings: &Settings) -> UResult<()> { - let mut reader = if settings.input == "-" { + let reader = if settings.input == "-" { Box::new(stdin()) as Box } else { let r = File::open(Path::new(&settings.input)).map_err_context( @@ -1322,6 +1397,17 @@ fn split(settings: &Settings) -> UResult<()> { Box::new(r) as Box }; let io_blksize: usize = settings.io_blksize.unwrap_or(8 * 1024).try_into().unwrap(); + let mut reader = BufReader::with_capacity(io_blksize, reader); + + // Fixed-size modes only open an output when there is data to split. + // Keep the first block buffered so the selected strategy can consume it. + if matches!( + settings.strategy, + Strategy::Lines(_) | Strategy::Bytes(_) | Strategy::LineBytes(_) + ) && reader.fill_buf()?.is_empty() + { + return Ok(()); + } match settings.strategy { Strategy::Number(NumberType::Bytes(num_chunks)) => { @@ -1359,11 +1445,26 @@ fn split(settings: &Settings) -> UResult<()> { Strategy::Bytes(chunk_size) => { let mut writer = ByteChunkWriter::new(chunk_size, settings)?; // todo: distinct read error and write error - io::copy(&mut reader, &mut writer)?; - Ok(()) + match writer.copy(&mut reader, io_blksize) { + Ok(()) => Ok(()), + // The writer already reported the write error and set the exit + // code, and signals that with an empty error: stay quiet here. + Err(e) if e.kind() == ErrorKind::Other && e.to_string().is_empty() => { + Err(USimpleError::new(1, "")) + } + Err(e) => Err(e.into()), + } } Strategy::LineBytes(chunk_size) => { line_bytes(settings, &mut reader, chunk_size as usize, io_blksize) } } } + +fn print_creating_file(name: &OsString) -> io::Result<()> { + writeln!( + io::stdout(), + "{}", + translate!("split-creating-file", "file" => name.quote()) + ) +} diff --git a/src/uu/split/src/strategy.rs b/src/uu/split/src/strategy.rs index b65e3779e09..99c61486994 100644 --- a/src/uu/split/src/strategy.rs +++ b/src/uu/split/src/strategy.rs @@ -2,13 +2,15 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + //! Determine the strategy for breaking up the input (file or stdin) into chunks //! based on the command line options use crate::cli::options; use clap::{ArgMatches, parser::ValueSource}; -use thiserror::Error; +use std::ffi::OsString; use uucore::{ + diagnostics::OptionValue, display::Quotable, parser::parse_size::{ParseSizeError, parse_size_u64, parse_size_u64_max}, translate, @@ -44,18 +46,18 @@ impl NumberType { /// The number of chunks for this number type. pub fn num_chunks(&self) -> u64 { match self { - Self::Bytes(n) => *n, - Self::KthBytes(_, n) => *n, - Self::Lines(n) => *n, - Self::KthLines(_, n) => *n, - Self::RoundRobin(n) => *n, - Self::KthRoundRobin(_, n) => *n, + Self::Bytes(n) + | Self::KthBytes(_, n) + | Self::Lines(n) + | Self::KthLines(_, n) + | Self::RoundRobin(n) + | Self::KthRoundRobin(_, n) => *n, } } } /// An error due to an invalid parameter to the `-n` command-line option. -#[derive(Debug, PartialEq, Error)] +#[derive(Debug, PartialEq, thiserror::Error)] pub enum NumberTypeError { /// The number of chunks was invalid. /// @@ -202,15 +204,19 @@ pub enum Strategy { } /// An error when parsing a chunking strategy from command-line arguments. -#[derive(Debug, Error)] +/// +/// A bad size carries the option it was given to, so that a caret can point +/// inside it — `None` when it came from no option, as with the obsolete +/// `split -22` spelling. +#[derive(Debug, thiserror::Error)] pub enum StrategyError { /// Invalid number of lines. #[error("{}", translate!("split-error-invalid-number-of-lines", "error" => .0))] - Lines(ParseSizeError), + Lines(ParseSizeError, Option), /// Invalid number of bytes. #[error("{}", translate!("split-error-invalid-number-of-bytes", "error" => .0))] - Bytes(ParseSizeError), + Bytes(ParseSizeError, Option), /// Invalid number type. #[error("{0}")] @@ -221,21 +227,46 @@ pub enum StrategyError { MultipleWays, } +impl StrategyError { + /// Draw a caret under the part of the SIZE that is at fault. + /// + /// # Arguments + /// + /// * `diag_args` - The arguments as typed, program name included. + /// * `message` - The headline, already localized. + /// + /// # Returns + /// + /// `false` when this error is not about a SIZE given to an option, or when + /// nothing could be drawn; the caller then falls back to the plain + /// one-line message. + pub fn render(&self, diag_args: &[OsString], message: &str) -> bool { + let (Self::Lines(error, Some(option)) | Self::Bytes(error, Some(option))) = self else { + return false; + }; + error.render_size_value(diag_args, option, 0, message) + } +} + impl Strategy { /// Parse a strategy from the command-line arguments. pub fn from(matches: &ArgMatches, obs_lines: Option<&str>) -> Result { fn get_and_parse( matches: &ArgMatches, - option: &str, + option: &'static str, + short: char, strategy: fn(u64) -> Strategy, - error: fn(ParseSizeError) -> StrategyError, + error: fn(ParseSizeError, Option) -> StrategyError, ) -> Result { let s = matches.get_one::(option).unwrap(); - let n = parse_size_u64_max(s).map_err(error)?; + // `None` for a size that did not come from an option, such as the + // obsolete `split -22` spelling: there is nothing to point at. + let origin = || Some(OptionValue::new(s, short, option)); + let n = parse_size_u64_max(s).map_err(|e| error(e, origin()))?; if n > 0 { Ok(strategy(n)) } else { - Err(error(ParseSizeError::ParseFailure(s.to_owned()))) + Err(error(ParseSizeError::ParseFailure(s.to_owned()), origin())) } } // Check that the user is not specifying more than one strategy. @@ -251,26 +282,36 @@ impl Strategy { ) { (Some(v), false, false, false, false) => { let v = parse_size_u64_max(v).map_err(|_| { - StrategyError::Lines(ParseSizeError::ParseFailure(v.to_string())) + StrategyError::Lines(ParseSizeError::ParseFailure(v.to_string()), None) })?; if v > 0 { Ok(Self::Lines(v)) } else { - Err(StrategyError::Lines(ParseSizeError::ParseFailure( - v.to_string(), - ))) + Err(StrategyError::Lines( + ParseSizeError::ParseFailure(v.to_string()), + None, + )) } } (None, false, false, false, false) => Ok(Self::Lines(1000)), - (None, true, false, false, false) => { - get_and_parse(matches, options::LINES, Self::Lines, StrategyError::Lines) - } - (None, false, true, false, false) => { - get_and_parse(matches, options::BYTES, Self::Bytes, StrategyError::Bytes) - } + (None, true, false, false, false) => get_and_parse( + matches, + options::LINES, + 'l', + Self::Lines, + StrategyError::Lines, + ), + (None, false, true, false, false) => get_and_parse( + matches, + options::BYTES, + 'b', + Self::Bytes, + StrategyError::Bytes, + ), (None, false, false, true, false) => get_and_parse( matches, options::LINE_BYTES, + 'C', Self::LineBytes, StrategyError::Bytes, ), diff --git a/src/uu/stat/Cargo.toml b/src/uu/stat/Cargo.toml index 21693a57939..939e53b62cd 100644 --- a/src/uu/stat/Cargo.toml +++ b/src/uu/stat/Cargo.toml @@ -11,15 +11,15 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/stat.rs" doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } +rustix = { workspace = true, features = ["fs"] } +thiserror = { workspace = true } uucore = { workspace = true, features = [ "entries", "libc", @@ -29,12 +29,13 @@ uucore = { workspace = true, features = [ "quoting-style", "i18n-common", ] } -thiserror = { workspace = true } -fluent = { workspace = true } [features] selinux = ["uucore/selinux"] +[lints] +workspace = true + [[bin]] name = "stat" path = "src/main.rs" diff --git a/src/uu/stat/locales/en-US.ftl b/src/uu/stat/locales/en-US.ftl index 1b14b1b9d96..0bf0e40c4ab 100644 --- a/src/uu/stat/locales/en-US.ftl +++ b/src/uu/stat/locales/en-US.ftl @@ -2,52 +2,53 @@ stat-about = Display file or file system status. stat-usage = stat [OPTION]... FILE... stat-after-help = Valid format sequences for files (without `--file-system`): - -`%a`: access rights in octal (note '#' and '0' printf flags) - -`%A`: access rights in human readable form - -`%b`: number of blocks allocated (see %B) - -`%B`: the size in bytes of each block reported by %b - -`%C`: SELinux security context string - -`%d`: device number in decimal - -`%D`: device number in hex - -`%f`: raw mode in hex - -`%F`: file type - -`%g`: group ID of owner - -`%G`: group name of owner - -`%h`: number of hard links - -`%i`: inode number - -`%m`: mount point - -`%n`: file name - -`%N`: quoted file name with dereference (follow) if symbolic link - -`%o`: optimal I/O transfer size hint - -`%s`: total size, in bytes - -`%t`: major device type in hex, for character/block device special files - -`%T`: minor device type in hex, for character/block device special files - -`%u`: user ID of owner - -`%U`: user name of owner - -`%w`: time of file birth, human-readable; - if unknown - -`%W`: time of file birth, seconds since Epoch; 0 if unknown - -`%x`: time of last access, human-readable - -`%X`: time of last access, seconds since Epoch - -`%y`: time of last data modification, human-readable - - -`%Y`: time of last data modification, seconds since Epoch - -`%z`: time of last status change, human-readable - -`%Z`: time of last status change, seconds since Epoch + -`%a`: access rights in octal (note '#' and '0' printf flags) + -`%A`: access rights in human readable form + -`%b`: number of blocks allocated (see %B) + -`%B`: the size in bytes of each block reported by %b + -`%C`: SELinux security context string + -`%d`: device number in decimal + -`%D`: device number in hex + -`%f`: raw mode in hex + -`%F`: file type + -`%g`: group ID of owner + -`%G`: group name of owner + -`%h`: number of hard links + -`%i`: inode number + -`%m`: mount point + -`%n`: file name + -`%Qn`: quoted file name + -`%N`: quoted file name with dereference (follow) if symbolic link + -`%o`: optimal I/O transfer size hint + -`%s`: total size, in bytes + -`%t`: major device type in hex, for character/block device special files + -`%T`: minor device type in hex, for character/block device special files + -`%u`: user ID of owner + -`%U`: user name of owner + -`%w`: time of file birth, human-readable; - if unknown + -`%W`: time of file birth, seconds since Epoch; 0 if unknown + -`%x`: time of last access, human-readable + -`%X`: time of last access, seconds since Epoch + -`%y`: time of last data modification, human-readable + -`%Y`: time of last data modification, seconds since Epoch + -`%z`: time of last status change, human-readable + -`%Z`: time of last status change, seconds since Epoch Valid format sequences for file systems: - -`%a`: free blocks available to non-superuser - -`%b`: total data blocks in file system - -`%c`: total file nodes in file system - -`%d`: free file nodes in file system - -`%f`: free blocks in file system - -`%i`: file system ID in hex - -`%l`: maximum length of filenames - -`%n`: file name - -`%s`: block size (for faster transfers) - -`%S`: fundamental block size (for block counts) - -`%t`: file system type in hex - -`%T`: file system type in human readable form + -`%a`: free blocks available to non-superuser + -`%b`: total data blocks in file system + -`%c`: total file nodes in file system + -`%d`: free file nodes in file system + -`%f`: free blocks in file system + -`%i`: file system ID in hex + -`%l`: maximum length of filenames + -`%n`: file name + -`%Qn`: quoted file name + -`%s`: block size (for faster transfers) + -`%S`: fundamental block size (for block counts) + -`%t`: file system type in hex + -`%T`: file system type in human readable form NOTE: your shell may have its own version of stat, which usually supersedes the version described here. Please refer to your shell's documentation @@ -55,10 +56,6 @@ stat-after-help = Valid format sequences for files (without `--file-system`): ## Error messages -stat-error-invalid-quoting-style = Invalid quoting style: {$style} -stat-warning-invalid-env-quoting-style = ignoring invalid value of environment variable QUOTING_STYLE: '{$style}' -stat-error-missing-operand = missing operand - Try 'stat --help' for more information. stat-error-invalid-directive = {$directive}: invalid directive stat-error-cannot-read-filesystem = cannot read table of mounted file systems: {$error} stat-error-stdin-filesystem-mode = using '-' to denote standard input does not work in file system mode @@ -93,6 +90,7 @@ stat-word-block = Block stat-word-size = Size stat-word-fundamental = Fundamental stat-word-block-size = block size +stat-word-block-size-capitalized = Block size stat-word-blocks = Blocks stat-word-total = Total stat-word-free = Free @@ -114,3 +112,6 @@ stat-word-birth = Birth stat-selinux-failed-get-context = failed to get security context stat-selinux-unsupported-system = unsupported on this system stat-selinux-unsupported-os = unsupported for this operating system + +# Diagnostics +stat-diag-help-directive = a directive is %[FLAGS][WIDTH][.PRECISION]LETTER, as in %-10.2s; a literal % is written %% diff --git a/src/uu/stat/locales/fr-FR.ftl b/src/uu/stat/locales/fr-FR.ftl index 824b092bb6e..2f5c7737e40 100644 --- a/src/uu/stat/locales/fr-FR.ftl +++ b/src/uu/stat/locales/fr-FR.ftl @@ -2,51 +2,53 @@ stat-about = afficher le statut du fichier ou du système de fichiers. stat-usage = stat [OPTION]... FICHIER... stat-after-help = Séquences de format valides pour les fichiers (sans `--file-system`) : - -`%a` : droits d'accès en octal (note : drapeaux printf '#' et '0') - -`%A` : droits d'accès en format lisible - -`%b` : nombre de blocs alloués (voir %B) - -`%B` : la taille en octets de chaque bloc rapporté par %b - -`%C` : chaîne de contexte de sécurité SELinux - -`%d` : numéro de périphérique en décimal - -`%D` : numéro de périphérique en hexadécimal - -`%f` : mode brut en hexadécimal - -`%F` : type de fichier - -`%g` : ID de groupe du propriétaire - -`%G` : nom de groupe du propriétaire - -`%h` : nombre de liens physiques - -`%i` : numéro d'inode - -`%m` : point de montage - -`%n` : nom de fichier - -`%N` : nom de fichier avec guillemets et déréférencement (suivi) si lien symbolique - -`%o` : suggestion de taille optimale de transfert E/S - -`%s` : taille totale, en octets - -`%t` : type de périphérique majeur en hex, pour les fichiers spéciaux caractère/bloc - -`%T` : type de périphérique mineur en hex, pour les fichiers spéciaux caractère/bloc - -`%u` : ID utilisateur du propriétaire - -`%U` : nom d'utilisateur du propriétaire - -`%w` : heure de création du fichier, lisible ; - si inconnue - -`%W` : heure de création du fichier, secondes depuis l'Époque ; 0 si inconnue - -`%x` : heure du dernier accès, lisible - -`%X` : heure du dernier accès, secondes depuis l'Époque - -`%y` : heure de la dernière modification de données, lisible - -`%Y` : heure de la dernière modification de données, secondes depuis l'Époque - -`%z` : heure du dernier changement de statut, lisible - -`%Z` : heure du dernier changement de statut, secondes depuis l'Époque + -`%a` : droits d'accès en octal (note : drapeaux printf '#' et '0') + -`%A` : droits d'accès en format lisible + -`%b` : nombre de blocs alloués (voir %B) + -`%B` : la taille en octets de chaque bloc rapporté par %b + -`%C` : chaîne de contexte de sécurité SELinux + -`%d` : numéro de périphérique en décimal + -`%D` : numéro de périphérique en hexadécimal + -`%f` : mode brut en hexadécimal + -`%F` : type de fichier + -`%g` : ID de groupe du propriétaire + -`%G` : nom de groupe du propriétaire + -`%h` : nombre de liens physiques + -`%i` : numéro d'inode + -`%m` : point de montage + -`%n` : nom de fichier + -`%Qn` : nom de fichier avec guillemets + -`%N` : nom de fichier avec guillemets et déréférencement (suivi) si lien symbolique + -`%o` : suggestion de taille optimale de transfert E/S + -`%s` : taille totale, en octets + -`%t` : type de périphérique majeur en hex, pour les fichiers spéciaux caractère/bloc + -`%T` : type de périphérique mineur en hex, pour les fichiers spéciaux caractère/bloc + -`%u` : ID utilisateur du propriétaire + -`%U` : nom d'utilisateur du propriétaire + -`%w` : heure de création du fichier, lisible ; - si inconnue + -`%W` : heure de création du fichier, secondes depuis l'Époque ; 0 si inconnue + -`%x` : heure du dernier accès, lisible + -`%X` : heure du dernier accès, secondes depuis l'Époque + -`%y` : heure de la dernière modification de données, lisible + -`%Y` : heure de la dernière modification de données, secondes depuis l'Époque + -`%z` : heure du dernier changement de statut, lisible + -`%Z` : heure du dernier changement de statut, secondes depuis l'Époque Séquences de format valides pour les systèmes de fichiers : - -`%a` : blocs libres disponibles pour les non-superutilisateurs - -`%b` : blocs de données totaux dans le système de fichiers - -`%c` : nœuds de fichiers totaux dans le système de fichiers - -`%d` : nœuds de fichiers libres dans le système de fichiers - -`%f` : blocs libres dans le système de fichiers - -`%i` : ID du système de fichiers en hexadécimal - -`%l` : longueur maximale des noms de fichiers - -`%n` : nom de fichier - -`%s` : taille de bloc (pour des transferts plus rapides) - -`%S` : taille de bloc fondamentale (pour les comptes de blocs) - -`%t` : type de système de fichiers en hexadécimal - -`%T` : type de système de fichiers en format lisible + -`%a` : blocs libres disponibles pour les non-superutilisateurs + -`%b` : blocs de données totaux dans le système de fichiers + -`%c` : nœuds de fichiers totaux dans le système de fichiers + -`%d` : nœuds de fichiers libres dans le système de fichiers + -`%f` : blocs libres dans le système de fichiers + -`%i` : ID du système de fichiers en hexadécimal + -`%l` : longueur maximale des noms de fichiers + -`%n` : nom de fichier + -`%Qn` : nom de fichier avec guillemets + -`%s` : taille de bloc (pour des transferts plus rapides) + -`%S` : taille de bloc fondamentale (pour les comptes de blocs) + -`%t` : type de système de fichiers en hexadécimal + -`%T` : type de système de fichiers en format lisible NOTE : votre shell peut avoir sa propre version de stat, qui remplace généralement la version décrite ici. Veuillez vous référer à la documentation de votre shell @@ -73,6 +75,7 @@ stat-word-block = Bloc stat-word-size = Taille stat-word-fundamental = Fondamentale stat-word-block-size = taille bloc +stat-word-block-size-capitalized = Taille bloc stat-word-blocks = Blocs stat-word-total = Total stat-word-free = Libres @@ -91,10 +94,6 @@ stat-word-birth = Créé ## Messages d'erreur -stat-error-invalid-quoting-style = Style de guillemets invalide : {$style} -stat-warning-invalid-env-quoting-style = valeur invalide de la variable d'environnement QUOTING_STYLE ignorée : '{$style}' -stat-error-missing-operand = opérande manquant - Essayez 'stat --help' pour plus d'informations. stat-error-invalid-directive = {$directive} : directive invalide stat-error-cannot-read-filesystem = impossible de lire la table des systèmes de fichiers montés : {$error} stat-error-stdin-filesystem-mode = utiliser '-' pour désigner l'entrée standard ne fonctionne pas en mode système de fichiers @@ -113,3 +112,6 @@ stat-warning-unrecognized-escape = séquence d'échappement non reconnue '\{$esc stat-selinux-failed-get-context = impossible d'obtenir le contexte de sécurité stat-selinux-unsupported-system = non pris en charge sur ce système stat-selinux-unsupported-os = non pris en charge pour ce système d'exploitation + +# Diagnostics +stat-diag-help-directive = une directive s'écrit %[DRAPEAUX][LARGEUR][.PRÉCISION]LETTRE, comme dans %-10.2s ; un % littéral s'écrit %% diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index de11038558b..41fe5bf895c 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -2,11 +2,14 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore datetime +use std::ops::Range; +use uucore::diagnostics::OptionValue; use uucore::error::{UError, UResult, USimpleError}; -use uucore::i18n::UEncoding; -use uucore::quoting_style::{QuotingStyle as UucoreQuotingStyle, escape_name}; +use uucore::i18n::get_ctype_encoding; +use uucore::quoting_style::{QuotingStyle, escape_name, quoting_style_from_env}; use uucore::translate; use clap::builder::ValueParser; @@ -25,29 +28,25 @@ use std::borrow::Cow; use std::cell::OnceCell; use std::ffi::{OsStr, OsString}; use std::fs::{FileType, Metadata}; -use std::io::Write; +use std::io::{self, Write}; use std::os::unix::fs::{FileTypeExt, MetadataExt}; use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; use std::{env, fs}; -use thiserror::Error; use uucore::time::{FormatSystemTimeFallback, format_system_time, system_time_to_sec}; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum StatError { - #[error("{}", translate!("stat-error-invalid-quoting-style", "style" => style.clone()))] - InvalidQuotingStyle { style: String }, - #[error("{}", translate!("stat-error-missing-operand"))] - MissingOperand, - #[error("{}", translate!("stat-error-invalid-directive", "directive" => directive.clone()))] + #[error("{}", translate!("stat-error-invalid-directive", "directive" => directive))] InvalidDirective { directive: String }, - #[error("{}", translate!("stat-error-cannot-read-filesystem", "error" => error.clone()))] + #[error("{}", translate!("stat-error-cannot-read-filesystem", "error" => error))] CannotReadFilesystem { error: String }, #[error("{}", translate!("stat-error-stdin-filesystem-mode"))] StdinFilesystemMode, - #[error("{}", translate!("stat-error-cannot-read-filesystem-info", "file" => file.clone(), "error" => error.clone()))] + #[error("{}", translate!("stat-error-cannot-read-filesystem-info", "file" => file, "error" => error))] CannotReadFilesystemInfo { file: String, error: String }, - #[error("{}", translate!("stat-error-cannot-statx", "file" => file.clone(), "error" => error.clone()))] + #[error("{}", translate!("stat-error-cannot-statx", "file" => file, "error" => error))] CannotStatx { file: String, error: String }, } @@ -76,27 +75,98 @@ struct Flags { group: bool, major: bool, minor: bool, + quote: bool, } /// checks if the string is within the specified bound, /// if it gets out of bound, error out by printing sub-string from index `beg` to`end`, /// where `beg` & `end` is the beginning and end index of sub-string, respectively -fn check_bound(slice: &str, bound: usize, beg: usize, end: usize) -> UResult<()> { +fn check_bound(slice: &str, bound: usize, beg: usize, end: usize) -> Result<(), DirectiveError> { if end >= bound { // `beg`/`end` are char indices, so take the directive by chars: byte-slicing // `slice` could land mid-UTF-8 when a multibyte char precedes the directive. let directive: String = slice.chars().skip(beg).take(end - beg).collect(); - return Err(USimpleError::new( - 1, - StatError::InvalidDirective { - directive: directive.quote().to_string(), - } - .to_string(), - )); + return Err(DirectiveError::new(slice, &directive, beg, end)); } Ok(()) } +/// Converts a character index to a byte index in a UTF-8 string +/// +/// This is necessary because Rust strings are UTF-8 encoded, so character +/// positions don't always align with byte positions for multi-byte characters. +/// An index past the last character gives the end of the string. +fn char_index_to_byte_index(format_str: &str, char_index: usize) -> usize { + format_str + .char_indices() + .nth(char_index) + .map_or(format_str.len(), |(byte_idx, _)| byte_idx) +} + +/// A directive stat does not know, and where it sat in the format string. +/// +/// The message is the one stat always printed; the byte range is what a caret +/// needs to point inside the format rather than at all of it. +#[derive(Debug)] +struct DirectiveError { + directive: String, + span: Range, +} + +impl DirectiveError { + /// # Arguments + /// + /// * `format_str` - The format string the directive came from. + /// * `directive` - The directive as written, without its quotes. + /// * `beg`, `end` - Its char indices in `format_str`; `end` may sit past + /// the end, for a directive the format stops in the middle of. + fn new(format_str: &str, directive: &str, beg: usize, end: usize) -> Self { + Self { + directive: directive.quote().to_string(), + span: char_index_to_byte_index(format_str, beg) + ..char_index_to_byte_index(format_str, end), + } + } + + /// The error to raise, a caret under the directive when the format was + /// given on the command line and stderr is a terminal. + /// + /// # Arguments + /// + /// * `diag_args` - The arguments as typed, or `None` when they were not + /// kept. + /// * `option` - The format as typed and the option it was given to, or + /// `None` for a format stat built itself, which is not on the command + /// line and has nothing to point at. + #[cold] + #[inline(never)] + fn to_error( + &self, + diag_args: Option<&[OsString]>, + option: Option<&OptionValue>, + ) -> Box { + let message = StatError::InvalidDirective { + directive: self.directive.clone(), + } + .to_string(); + uucore::diagnostics::error_after_report( + diag_args, + USimpleError::new(1, message.clone()), + |args, _| { + option.is_some_and(|option| { + uucore::diagnostics::Snapshot::with_program(args).render_option( + option, + self.span.clone(), + &message, + None, + Some(&translate!("stat-diag-help-directive")), + ) + }) + }, + ) + } +} + enum Padding { Zero, Space, @@ -130,9 +200,9 @@ fn write_padded_bytes( left: bool, width: usize, precision: Precision, -) -> Result<(), std::io::Error> { +) -> io::Result<()> { let display_bytes = match precision { - Precision::Number(p) if p < bytes.len() => &bytes[..p], + Precision::Number(p) => bytes.get(..p).unwrap_or(bytes), _ => bytes, }; @@ -159,7 +229,7 @@ fn write_padded_bytes( /// 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 write_padding(writer: &mut W, n: usize) -> Result<(), std::io::Error> { +fn write_padding(writer: &mut W, n: usize) -> io::Result<()> { for _ in 0..n { writer.write_all(b" ")?; } @@ -174,35 +244,10 @@ pub enum OutputType<'a> { Unsigned(u64), UnsignedHex(u64), UnsignedOct(u32), - Float(f64), + Timestamp(i64, u32), Unknown, } -#[derive(Default)] -enum QuotingStyle { - Locale, - Shell, - #[default] - ShellEscapeAlways, - Quote, -} - -impl std::str::FromStr for QuotingStyle { - type Err = StatError; - - fn from_str(s: &str) -> Result { - match s { - "locale" => Ok(Self::Locale), - "shell" => Ok(Self::Shell), - "shell-escape-always" => Ok(Self::ShellEscapeAlways), - // The others aren't exposed to the user - _ => Err(StatError::InvalidQuotingStyle { - style: s.to_string(), - }), - } - } -} - #[derive(Debug, PartialEq, Eq, Clone, Copy)] enum Precision { NotSpecified, @@ -315,8 +360,15 @@ struct Stater { files: Vec, mount_list: OnceCell>>, mount_list_needed: bool, + /// The quoting style of `%N` and `%Qn`, read on first use. + quoting_style: OnceCell, default_tokens: Vec, default_dev_tokens: Vec, + /// A bad directive, raised once the tokens before it are printed. + format_error: Option, + /// What the caret needs: the format as typed, and the command line. + format_option: Option, + diag_args: Option>, } /// Prints a formatted output based on the provided output type, flags, width, and precision. @@ -372,8 +424,15 @@ fn print_it(output: &OutputType, flags: Flags, width: usize, precision: Precisio OutputType::UnsignedHex(num) => { print_unsigned_hex(*num, flags, width, precision, padding_char); } - OutputType::Float(num) => { - print_float(*num, flags, width, precision, padding_char); + OutputType::Timestamp(seconds, nanoseconds) => { + print_timestamp( + *seconds, + *nanoseconds, + flags, + width, + precision, + padding_char, + ); } OutputType::Unknown => print!("?"), } @@ -405,11 +464,8 @@ fn determine_padding_char(flags: Flags) -> Padding { /// * `width` - The width of the field for the printed string. /// * `precision` - How many digits of precision, if any. fn print_str(s: &str, flags: Flags, width: usize, precision: Precision) { - let s = match precision { - Precision::Number(p) if p < s.len() => &s[..p], - _ => s, - }; - pad_and_print(s, flags.left, width, Padding::Space); + // Truncate and pad on the byte representation, so a precision that lands inside a multibyte character does not cause a panic. + let _ = write_padded_bytes(io::stdout(), s.as_bytes(), flags.left, width, precision); } /// Prints a `OsString` value based on the provided flags, width, and precision. @@ -430,7 +486,7 @@ fn print_os_str(s: &OsString, flags: Flags, width: usize, precision: Precision) let bytes = s.as_bytes(); - if write_padded_bytes(std::io::stdout(), bytes, flags.left, width, precision).is_err() { + if write_padded_bytes(io::stdout(), bytes, flags.left, width, precision).is_err() { // if an error occurred while trying to print bytes fall back to normal lossy string so it can be printed let fallback_string = s.to_string_lossy(); print_str(&fallback_string, flags, width, precision); @@ -443,59 +499,23 @@ fn print_os_str(s: &OsString, flags: Flags, width: usize, precision: Precision) } } -fn quote_file_name(file_name: &str, quoting_style: &QuotingStyle) -> String { - match quoting_style { - QuotingStyle::Locale | QuotingStyle::Shell => { - let escaped = file_name.replace('\'', r"\'"); - format!("'{escaped}'") - } - QuotingStyle::ShellEscapeAlways => escape_name( - OsStr::new(file_name), - UucoreQuotingStyle::Shell { - escape: true, - always_quote: true, - show_control: true, - }, - UEncoding::Utf8, - ) +fn quote_file_name(file_name: &OsStr, quoting_style: QuotingStyle) -> String { + escape_name(file_name, quoting_style, get_ctype_encoding()) .to_string_lossy() - .to_string(), - QuotingStyle::Quote => file_name.to_string(), - } -} - -fn warn_invalid_quoting_style(style: &str) { - use std::sync::atomic::{AtomicBool, Ordering}; - static WARNED: AtomicBool = AtomicBool::new(false); - if !WARNED.swap(true, Ordering::Relaxed) { - show_error!( - "{}", - translate!("stat-warning-invalid-env-quoting-style", "style" => style.to_string()) - ); - } + .to_string() } fn get_quoted_file_name( display_name: &str, file: &OsString, file_type: FileType, - from_user: bool, + quoting_style: QuotingStyle, ) -> Result { - let quoting_style = match env::var("QUOTING_STYLE") { - Ok(style) => style.parse().unwrap_or_else(|_| { - // Match GNU coreutils 9.11: warn (once) when QUOTING_STYLE is set - // to a value we don't understand, then fall back to the default. - warn_invalid_quoting_style(&style); - QuotingStyle::default() - }), - Err(_) => QuotingStyle::default(), - }; - if file_type.is_symlink() { - let quoted_display_name = quote_file_name(display_name, "ing_style); + let quoted_display_name = quote_file_name(OsStr::new(display_name), quoting_style); match fs::read_link(file) { Ok(dst) => { - let quoted_dst = quote_file_name(&dst.to_string_lossy(), "ing_style); + let quoted_dst = quote_file_name(dst.as_os_str(), quoting_style); Ok(format!("{quoted_display_name} -> {quoted_dst}")) } Err(e) => { @@ -504,16 +524,16 @@ fn get_quoted_file_name( } } } else { - let style = if from_user { - quoting_style - } else { - QuotingStyle::Quote - }; - Ok(quote_file_name(display_name, &style)) + Ok(quote_file_name(OsStr::new(display_name), quoting_style)) } } -fn process_token_filesystem(t: &Token, meta: &StatFs, display_name: &str) { +fn process_token_filesystem( + t: &Token, + meta: &StatFs, + display_name: &str, + quote_name: impl Fn(&str) -> String, +) { match *t { Token::Byte(byte) => print_raw_byte(byte), Token::Char(c) => print!("{c}"), @@ -538,6 +558,8 @@ fn process_token_filesystem(t: &Token, meta: &StatFs, display_name: &str) { 'i' => OutputType::UnsignedHex(meta.fsid()), // maximum length of filenames 'l' => OutputType::Unsigned(meta.namelen()), + // quoted file name + 'n' if flag.quote => OutputType::Str(quote_name(display_name)), // file name 'n' => OutputType::Str(display_name.to_string()), // block size (for faster transfers) @@ -586,53 +608,58 @@ fn print_integer( "" }; let extended = match precision { - Precision::NotSpecified => format!("{prefix}{arg}"), - Precision::NoNumber => format!("{prefix}{arg}"), + Precision::NotSpecified | Precision::NoNumber => format!("{prefix}{arg}"), Precision::Number(p) => format!("{prefix}{arg:0>p$}"), }; pad_and_print(&extended, flags.left, width, padding_char); } -/// Truncate a float to the given number of digits after the decimal point. -fn precision_trunc(num: f64, precision: Precision) -> String { - // GNU `stat` doesn't round, it just seems to truncate to the - // given precision: - // - // $ stat -c "%.5Y" /dev/pts/ptmx - // 1736344012.76399 - // $ stat -c "%.4Y" /dev/pts/ptmx - // 1736344012.7639 - // $ stat -c "%.3Y" /dev/pts/ptmx - // 1736344012.763 - // - // Contrast this with `printf`, which seems to round the - // numbers: - // - // $ printf "%.5f\n" 1736344012.76399 - // 1736344012.76399 - // $ printf "%.4f\n" 1736344012.76399 - // 1736344012.7640 - // $ printf "%.3f\n" 1736344012.76399 - // 1736344012.764 - // - let num_str = num.to_string(); - let n = num_str.len(); - match (num_str.find('.'), precision) { - (None, Precision::NotSpecified) => num_str, - (None, Precision::NoNumber) => num_str, - (None, Precision::Number(0)) => num_str, - (None, Precision::Number(p)) => format!("{num_str}.{zeros}", zeros = "0".repeat(p)), - (Some(i), Precision::NotSpecified) => num_str[..i].to_string(), - (Some(_), Precision::NoNumber) => num_str, - (Some(i), Precision::Number(0)) => num_str[..i].to_string(), - (Some(i), Precision::Number(p)) if p < n - i => num_str[..i + 1 + p].to_string(), - (Some(i), Precision::Number(p)) => { - format!("{num_str}{zeros}", zeros = "0".repeat(p - (n - i - 1))) - } +fn format_timestamp(seconds: i64, nanoseconds: u32, precision: Precision) -> String { + let precision = match precision { + Precision::NotSpecified | Precision::Number(0) => return seconds.to_string(), + Precision::NoNumber => 9, + Precision::Number(p) => p, + }; + + let total_nanoseconds = i128::from(seconds) * 1_000_000_000 + i128::from(nanoseconds); + if precision <= 9 { + let divisor = 10_i128.pow((9 - precision) as u32); + // Integer seconds use the floor, but fractional output truncates toward zero. + let value = total_nanoseconds / divisor; + format_scaled_decimal(value, precision, total_nanoseconds < 0) + } else { + let mut result = format_scaled_decimal(total_nanoseconds, 9, total_nanoseconds < 0); + result.push_str(&"0".repeat(precision - 9)); + result + } +} + +fn system_time_to_timestamp(time: SystemTime) -> (i64, u32) { + let (mut seconds, mut nanoseconds) = system_time_to_sec(time); + if time < UNIX_EPOCH && nanoseconds != 0 { + seconds -= 1; + nanoseconds = 1_000_000_000 - nanoseconds; } + (seconds, nanoseconds) +} + +fn format_scaled_decimal(value: i128, precision: usize, negative: bool) -> String { + let scale = 10_u128.pow(precision as u32); + let magnitude = value.unsigned_abs(); + let whole = magnitude / scale; + let fraction = magnitude % scale; + let sign = if negative { "-" } else { "" }; + format!("{sign}{whole}.{fraction:0>precision$}") } -fn print_float(num: f64, flags: Flags, width: usize, precision: Precision, padding_char: Padding) { +fn print_timestamp( + seconds: i64, + nanoseconds: u32, + flags: Flags, + width: usize, + precision: Precision, + padding_char: Padding, +) { let prefix = if flags.sign { "+" } else if flags.space { @@ -640,7 +667,7 @@ fn print_float(num: f64, flags: Flags, width: usize, precision: Precision, paddi } else { "" }; - let num_str = precision_trunc(num, precision); + let num_str = format_timestamp(seconds, nanoseconds, precision); let extended = format!("{prefix}{num_str}"); pad_and_print(&extended, flags.left, width, padding_char); } @@ -668,8 +695,7 @@ fn print_unsigned( Cow::Borrowed(num.as_str()) }; let s = match precision { - Precision::NotSpecified => s, - Precision::NoNumber => s, + Precision::NotSpecified | Precision::NoNumber => s, Precision::Number(p) => format!("{s:0>p$}").into(), }; pad_and_print(&s, flags.left, width, padding_char); @@ -693,8 +719,7 @@ fn print_unsigned_oct( ) { let prefix = if flags.alter { "0" } else { "" }; let s = match precision { - Precision::NotSpecified => format!("{prefix}{num:o}"), - Precision::NoNumber => format!("{prefix}{num:o}"), + Precision::NotSpecified | Precision::NoNumber => format!("{prefix}{num:o}"), Precision::Number(p) => format!("{prefix}{num:0>p$o}"), }; pad_and_print(&s, flags.left, width, padding_char); @@ -718,20 +743,20 @@ fn print_unsigned_hex( ) { let prefix = if flags.alter { "0x" } else { "" }; let s = match precision { - Precision::NotSpecified => format!("{prefix}{num:x}"), - Precision::NoNumber => format!("{prefix}{num:x}"), + Precision::NotSpecified | Precision::NoNumber => format!("{prefix}{num:x}"), Precision::Number(p) => format!("{prefix}{num:0>p$x}"), }; pad_and_print(&s, flags.left, width, padding_char); } fn print_raw_byte(byte: u8) { - std::io::stdout().write_all(&[byte]).unwrap(); + io::stdout().write_all(&[byte]).unwrap(); } impl Stater { fn process_flags(chars: &[char], i: &mut usize, bound: usize, flag: &mut Flags) { while *i < bound { + #[expect(clippy::match_same_arms)] // needs comment match chars[*i] { '#' => flag.alter = true, '0' => flag.zero = true, @@ -749,22 +774,12 @@ impl Stater { } } - /// Converts a character index to a byte index in a UTF-8 string - /// This is necessary because Rust strings are UTF-8 encoded, so character positions - /// don't always align with byte positions for multi-byte characters - fn char_index_to_byte_index(format_str: &str, char_index: usize) -> usize { - format_str - .char_indices() - .nth(char_index) - .map_or(format_str.len(), |(byte_idx, _)| byte_idx) - } - fn handle_percent_case( chars: &[char], i: &mut usize, bound: usize, format_str: &str, - ) -> UResult { + ) -> Result { let old = *i; *i += 1; @@ -783,20 +798,20 @@ impl Stater { let mut precision = Precision::NotSpecified; let mut j = *i; - let j_byte = Self::char_index_to_byte_index(format_str, j); + let j_byte = char_index_to_byte_index(format_str, j); if let Some((field_width, offset)) = format_str[j_byte..].scan_num::() { width = field_width; j += offset; // Reject directives like `%` by checking if width has been parsed. if j >= bound || chars[j] == '%' { - let invalid_directive: String = chars[old..=j.min(bound - 1)].iter().collect(); - return Err(USimpleError::new( - 1, - StatError::InvalidDirective { - directive: invalid_directive.quote().to_string(), - } - .to_string(), + let end = j.min(bound - 1); + let invalid_directive: String = chars[old..=end].iter().collect(); + return Err(DirectiveError::new( + format_str, + &invalid_directive, + old, + end + 1, )); } } @@ -806,7 +821,7 @@ impl Stater { j += 1; check_bound(format_str, bound, old, j)?; - let j_byte = Self::char_index_to_byte_index(format_str, j); + let j_byte = char_index_to_byte_index(format_str, j); match format_str[j_byte..].scan_num::() { Some((value, offset)) => { if value >= 0 { @@ -822,21 +837,32 @@ impl Stater { *i = j; // Check for multi-character specifiers (e.g., `%Hd`, `%Lr`) - if *i + 1 < bound { - if let Some(&next_char) = chars.get(*i + 1) { - if (chars[*i] == 'H' || chars[*i] == 'L') && (next_char == 'd' || next_char == 'r') - { - flag.major = chars[*i] == 'H'; - flag.minor = chars[*i] == 'L'; - *i += 1; - return Ok(Token::Directive { - flag, - width, - precision, - format: next_char, - }); - } - } + if *i + 1 < bound + && let Some(&next_char) = chars.get(*i + 1).filter(|c| **c == 'd' || **c == 'r') + && (chars[*i] == 'H' || chars[*i] == 'L') + { + let is_major = chars[*i] == 'H'; + flag.major = is_major; + flag.minor = !is_major; // chars[*i] == 'L' + *i += 1; + return Ok(Token::Directive { + flag, + width, + precision, + format: next_char, + }); + } + + // `%Qn` is the quoted file name + if *i + 1 < bound && chars[*i] == 'Q' && chars[*i + 1] == 'n' { + flag.quote = true; + *i += 1; + return Ok(Token::Directive { + flag, + width, + precision, + format: 'n', + }); } Ok(Token::Directive { @@ -870,11 +896,14 @@ impl Stater { '"' => Token::Byte(b'"'), // Double quote '0'..='7' => { // Parse octal escape sequence (up to 3 digits) - let mut value = 0u8; + // Accumulate in a wider type: three octal digits can reach 511, + // and only the low byte is kept, which is what GNU prints for + // an out-of-range escape such as `\400`. + let mut value = 0u32; let mut count = 0; while *i < bound && count < 3 { if let Some(digit) = chars[*i].to_digit(8) { - value = value * 8 + digit as u8; + value = value * 8 + digit; *i += 1; count += 1; } else { @@ -882,13 +911,13 @@ impl Stater { } } *i -= 1; // Adjust index to account for the outer loop increment - Token::Byte(value) + Token::Byte(value as u8) } 'x' => { // Parse hexadecimal escape sequence (\xNN format) // Uses UTF-8 safe byte indexing to handle multi-byte characters properly if *i + 1 < bound { - let byte_index = Self::char_index_to_byte_index(format_str, *i + 1); + let byte_index = char_index_to_byte_index(format_str, *i + 1); if let Some((c, offset)) = format_str[byte_index..].scan_char(16) { *i += offset; Token::Byte(c as u8) @@ -911,16 +940,22 @@ impl Stater { } } - fn generate_tokens(format_str: &str, use_printf: bool) -> UResult> { + /// Split a format into its tokens, up to a directive stat does not know. + /// + /// Returns the tokens parsed before the bad one, so the caller can print + /// them the way GNU does, and the error that stopped the parse. A format + /// that ends in a bad directive keeps no trailing newline. + fn generate_tokens(format_str: &str, use_printf: bool) -> (Vec, Option) { let mut tokens = Vec::new(); let chars = format_str.chars().collect::>(); let bound = chars.len(); let mut i = 0; while i < bound { match chars.get(i) { - Some('%') => tokens.push(Self::handle_percent_case( - &chars, &mut i, bound, format_str, - )?), + Some('%') => match Self::handle_percent_case(&chars, &mut i, bound, format_str) { + Ok(token) => tokens.push(token), + Err(error) => return (tokens, Some(error)), + }, Some('\\') => { if use_printf { tokens.push(Self::handle_escape_sequences( @@ -938,7 +973,7 @@ impl Stater { if !use_printf && !format_str.ends_with('\n') { tokens.push(Token::Char('\n')); } - Ok(tokens) + (tokens, None) } fn populate_mount_list() -> UResult> { @@ -963,14 +998,12 @@ impl Stater { Ok(mount_list) } - fn new(matches: &ArgMatches) -> UResult { + fn new(matches: &ArgMatches, diag_args: Option<&[OsString]>) -> UResult { + #[expect(clippy::unwrap_used, reason = "set as required by clap")] let files: Vec = matches .get_many::(options::FILES) .map(|v| v.map(OsString::from).collect()) - .unwrap_or_default(); - if files.is_empty() { - return Err(Box::new(StatError::MissingOperand) as Box); - } + .unwrap(); let format_str = if matches.contains_id(options::PRINTF) { matches .get_one::(options::PRINTF) @@ -985,13 +1018,40 @@ impl Stater { let terse = matches.get_flag(options::TERSE); let show_fs = matches.get_flag(options::FILE_SYSTEM); + // Only the format the user typed can be pointed at; the ones stat + // builds for itself never fail, and are not on the command line. + // `--printf` has no short form; `--format` also answers to `-c`. + let given_option = || { + OptionValue::with_names( + format_str, + if use_printf { None } else { Some('c') }, + Some(if use_printf { + options::PRINTF + } else { + options::FORMAT + }), + ) + }; + // A format stat built itself cannot hold an unknown directive, so an + // error there is our bug, has nothing to point at, and is raised now. + let mut format_error = None; let default_tokens = if format_str.is_empty() { - Self::generate_tokens(&Self::default_format(show_fs, terse, false), use_printf)? + let (tokens, error) = + Self::generate_tokens(&Self::default_format(show_fs, terse, false), use_printf); + if let Some(error) = error { + return Err(error.to_error(diag_args, None)); + } + tokens } else { - Self::generate_tokens(format_str, use_printf)? + let (tokens, error) = Self::generate_tokens(format_str, use_printf); + format_error = error; + tokens }; - let default_dev_tokens = - Self::generate_tokens(&Self::default_format(show_fs, terse, true), use_printf)?; + let (default_dev_tokens, default_dev_error) = + Self::generate_tokens(&Self::default_format(show_fs, terse, true), use_printf); + if let Some(error) = default_dev_error { + return Err(error.to_error(diag_args, None)); + } // mount points aren't displayed when showing filesystem information, or // whenever the format string does not request the mount point. @@ -1007,8 +1067,20 @@ impl Stater { files, mount_list: OnceCell::new(), mount_list_needed, + quoting_style: OnceCell::new(), default_tokens, default_dev_tokens, + format_option: format_error.is_some().then(given_option), + format_error, + diag_args: diag_args.map(<[OsString]>::to_vec), + }) + } + + fn quoting_style(&self) -> QuotingStyle { + *self.quoting_style.get_or_init(|| { + quoting_style_from_env() + .unwrap_or(QuotingStyle::SHELL_ESCAPE) + .show_control(true) }) } @@ -1038,19 +1110,15 @@ impl Stater { .find(|root| path.starts_with(root)) } - fn exec(&self) -> i32 { - let mut stdin_is_fifo = false; - if cfg!(unix) { - if let Ok(md) = fs::metadata("/dev/stdin") { - stdin_is_fifo = md.file_type().is_fifo(); - } - } + fn exec(&self) -> UResult { + let stdin_is_fifo = rustix::fs::fstat(io::stdin()) + .is_ok_and(|s| rustix::fs::FileType::from_raw_mode(s.st_mode).is_fifo()); let mut ret = 0; for f in &self.files { - ret |= self.do_stat(f, stdin_is_fifo); + ret |= self.do_stat(f, stdin_is_fifo)?; } - ret + Ok(ret) } #[allow(clippy::too_many_arguments)] @@ -1061,7 +1129,6 @@ impl Stater { display_name: &str, file: &OsString, file_type: FileType, - from_user: bool, #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] follow_symbolic_links: bool, #[cfg(not(all(feature = "selinux", any(target_os = "linux", target_os = "android"))))] @@ -1069,7 +1136,9 @@ impl Stater { ) -> Result<(), i32> { match *t { Token::Byte(byte) => print_raw_byte(byte), - Token::Char(c) => print!("{c}"), + Token::Char(c) => io::stdout() + .write_all(c.to_string().as_bytes()) + .map_err(|_| 1)?, Token::Directive { flag, @@ -1145,12 +1214,21 @@ impl Stater { Some(s) => OutputType::OsStr(s), None => OutputType::Str(String::new()), }, + // quoted file name + 'n' if flag.quote => OutputType::Str(quote_file_name( + OsStr::new(display_name), + self.quoting_style(), + )), // file name 'n' => OutputType::Str(display_name.to_string()), // quoted file name with dereference if symbolic link 'N' => { - let file_name = - get_quoted_file_name(display_name, file, file_type, from_user)?; + let file_name = get_quoted_file_name( + display_name, + file, + file_type, + self.quoting_style(), + )?; OutputType::Str(file_name) } // optimal I/O transfer size hint @@ -1178,7 +1256,7 @@ impl Stater { // time of file birth, seconds since Epoch; 0 if unknown 'W' => OutputType::Integer( metadata_get_time(meta, MetadataTimeField::Birth) - .map_or(0, |x| system_time_to_sec(x).0), + .map_or(0, |x| system_time_to_timestamp(x).0), ), // time of last access, human-readable @@ -1186,24 +1264,24 @@ impl Stater { // time of last access, seconds since Epoch 'X' => { let (sec, nsec) = metadata_get_time(meta, MetadataTimeField::Access) - .map_or((0, 0), system_time_to_sec); - OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) + .map_or((0, 0), system_time_to_timestamp); + OutputType::Timestamp(sec, nsec) } // time of last data modification, human-readable 'y' => OutputType::Str(pretty_time(meta, MetadataTimeField::Modification)), // time of last data modification, seconds since Epoch 'Y' => { let (sec, nsec) = metadata_get_time(meta, MetadataTimeField::Modification) - .map_or((0, 0), system_time_to_sec); - OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) + .map_or((0, 0), system_time_to_timestamp); + OutputType::Timestamp(sec, nsec) } // time of last status change, human-readable 'z' => OutputType::Str(pretty_time(meta, MetadataTimeField::Change)), // time of last status change, seconds since Epoch 'Z' => { let (sec, nsec) = metadata_get_time(meta, MetadataTimeField::Change) - .map_or((0, 0), system_time_to_sec); - OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) + .map_or((0, 0), system_time_to_timestamp); + OutputType::Timestamp(sec, nsec) } 'R' => OutputType::UnsignedHex(meta.rdev()), 'r' if flag.major => OutputType::Unsigned(major(meta.rdev() as _) as u64), @@ -1217,12 +1295,26 @@ impl Stater { Ok(()) } - fn do_stat(&self, file: &OsStr, stdin_is_fifo: bool) -> i32 { + /// Raise a bad directive, now that the tokens before it are printed. + /// + /// stdout is flushed first, or the report would land ahead of them. + fn raise_format_error(&self) -> UResult<()> { + match &self.format_error { + None => Ok(()), + Some(error) => { + io::stdout().flush()?; + Err(error.to_error(self.diag_args.as_deref(), self.format_option.as_ref())) + } + } + } + + fn do_stat(&self, file: &OsStr, stdin_is_fifo: bool) -> UResult { let display_name = file.to_string_lossy(); + let quoted_name = || quote_file_name(file, QuotingStyle::SHELL_ESCAPE_ALWAYS); let file = if cfg!(unix) && display_name == "-" { if self.show_fs { show_error!("{}", StatError::StdinFilesystemMode); - return 1; + return Ok(1); } if let Ok(p) = Path::new("/dev/stdin").canonicalize() { p.into_os_string() @@ -1239,18 +1331,21 @@ impl Stater { // Usage for t in tokens { - process_token_filesystem(t, &meta, &display_name); + process_token_filesystem(t, &meta, &display_name, |name| { + quote_file_name(OsStr::new(name), self.quoting_style()) + }); } + self.raise_format_error()?; } Err(error) => { show_error!( "{}", StatError::CannotReadFilesystemInfo { - file: display_name.quote().to_string(), + file: quoted_name(), error } ); - return 1; + return Ok(1); } } } else { @@ -1278,26 +1373,26 @@ impl Stater { &display_name, &file, file_type, - self.from_user, follow_symbolic_links, ) { - return code; + return Ok(code); } } + self.raise_format_error()?; } Err(e) => { show_error!( "{}", StatError::CannotStatx { - file: display_name.quote().to_string(), + file: quoted_name(), error: strip_errno(&e) } ); - return 1; + return Ok(1); } } } - 0 + Ok(0) } fn default_format(show_fs: bool, terse: bool, show_dev_type: bool) -> String { @@ -1305,18 +1400,17 @@ impl Stater { if show_fs { if terse { - "%n %i %l %t %s %S %b %f %a %c %d\n".into() + "%Qn %i %l %t %s %S %b %f %a %c %d\n".into() } else { format!( - " {}: \"%n\"\n {}: %-8i {}: %-7l {}: %T\n{} \ - {}: %-10s {} {}: %S\n{}: {}: %-10b \ + " {}: %Qn\n {}: %-8i {}: %-7l {}: %T\n{}: %-10s \ + {} {}: %S\n{}: {}: %-10b \ {}: %-10f {}: %a\n{}: {}: %-10c {}: %d\n", translate!("stat-word-file"), translate!("stat-word-id"), translate!("stat-word-namelen"), translate!("stat-word-type"), - translate!("stat-word-block"), - translate!("stat-word-size"), + translate!("stat-word-block-size-capitalized"), translate!("stat-word-fundamental"), translate!("stat-word-block-size"), translate!("stat-word-blocks"), @@ -1329,7 +1423,7 @@ impl Stater { ) } } else if terse { - "%n %s %b %f %u %g %D %i %h %t %T %X %Y %Z %W %o\n".into() + "%Qn %s %b %f %u %g %D %i %h %t %T %X %Y %Z %W %o\n".into() } else { let device_line = if show_dev_type { format!( @@ -1370,10 +1464,16 @@ impl Stater { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - - let stater = Stater::new(&matches)?; - let exit_status = stater.exec(); + // The command line is kept for the caret in format diagnostics, which + // needs the format as typed. + let (matches, diag_args) = uucore::clap_localization::handle_clap_result_with_diagnostics( + uu_app(), + args.collect(), + 1, + )?; + + let stater = Stater::new(&matches, diag_args.as_deref())?; + let exit_status = stater.exec()?; if exit_status == 0 { Ok(()) } else { @@ -1415,18 +1515,21 @@ pub fn uu_app() -> Command { .short('c') .long(options::FORMAT) .help(translate!("stat-help-format")) - .value_name("FORMAT"), + .value_name("FORMAT") + .allow_hyphen_values(true), ) .arg( Arg::new(options::PRINTF) .long(options::PRINTF) .value_name("FORMAT") + .allow_hyphen_values(true) .help(translate!("stat-help-printf")), ) .arg( Arg::new(options::FILES) .action(ArgAction::Append) .value_parser(ValueParser::os_string()) + .required(true) .value_hint(clap::ValueHint::FilePath), ) } @@ -1453,8 +1556,10 @@ fn pretty_time(meta: &Metadata, md_time_field: MetadataTimeField) -> String { #[cfg(test)] mod tests { use crate::{quote_file_name, write_padded_bytes, write_padding}; + use std::ffi::OsStr; + use uucore::quoting_style::QuotingStyle; - use super::{Flags, Precision, ScanUtil, Stater, Token, group_num, precision_trunc}; + use super::{Flags, Precision, ScanUtil, Stater, Token, format_timestamp, group_num}; #[test] fn test_scanners() { @@ -1518,7 +1623,9 @@ mod tests { }, Token::Char('\n'), ]; - assert_eq!(&expected, &Stater::generate_tokens(s, false).unwrap()); + let (tokens, error) = Stater::generate_tokens(s, false); + assert!(error.is_none()); + assert_eq!(&expected, &tokens); } #[test] @@ -1561,19 +1668,65 @@ mod tests { Token::Byte(b'J'), Token::Byte(b'\n'), ]; - assert_eq!(&expected, &Stater::generate_tokens(s, true).unwrap()); + let (tokens, error) = Stater::generate_tokens(s, true); + assert!(error.is_none()); + assert_eq!(&expected, &tokens); } #[test] - fn test_precision_trunc() { - assert_eq!(precision_trunc(123.456, Precision::NotSpecified), "123"); - assert_eq!(precision_trunc(123.456, Precision::NoNumber), "123.456"); - assert_eq!(precision_trunc(123.456, Precision::Number(0)), "123"); - assert_eq!(precision_trunc(123.456, Precision::Number(1)), "123.4"); - assert_eq!(precision_trunc(123.456, Precision::Number(2)), "123.45"); - assert_eq!(precision_trunc(123.456, Precision::Number(3)), "123.456"); - assert_eq!(precision_trunc(123.456, Precision::Number(4)), "123.4560"); - assert_eq!(precision_trunc(123.456, Precision::Number(5)), "123.45600"); + fn test_format_timestamp() { + let cases = [ + (Precision::NotSpecified, "123"), + (Precision::NoNumber, "123.456000000"), + (Precision::Number(0), "123"), + (Precision::Number(1), "123.4"), + (Precision::Number(2), "123.45"), + (Precision::Number(3), "123.456"), + (Precision::Number(4), "123.4560"), + (Precision::Number(5), "123.45600"), + ]; + for (precision, expected) in cases { + assert_eq!(format_timestamp(123, 456_000_000, precision), expected); + } + + let zero_nanoseconds_cases = [ + (Precision::NotSpecified, "123"), + (Precision::NoNumber, "123.000000000"), + (Precision::Number(9), "123.000000000"), + ]; + for (precision, expected) in zero_nanoseconds_cases { + assert_eq!(format_timestamp(123, 0, precision), expected); + } + + let pre_epoch_cases = [ + (Precision::NotSpecified, "-1"), + (Precision::NoNumber, "-0.876543211"), + (Precision::Number(0), "-1"), + (Precision::Number(1), "-0.8"), + (Precision::Number(3), "-0.876"), + (Precision::Number(9), "-0.876543211"), + (Precision::Number(10), "-0.8765432110"), + ]; + for (precision, expected) in pre_epoch_cases { + assert_eq!(format_timestamp(-1, 123_456_789, precision), expected); + } + } + + #[test] + fn test_format_timestamp_negative_fraction_truncation() { + for (seconds, nanoseconds, precision, expected) in [ + (-3, 765_432_109, Precision::NotSpecified, "-3"), + (-3, 765_432_109, Precision::Number(0), "-3"), + (-3, 765_432_109, Precision::Number(2), "-2.23"), + (-3, 765_432_109, Precision::Number(6), "-2.234567"), + (-1, 999_999_999, Precision::Number(1), "-0.0"), + (-1, 999_999_999, Precision::Number(3), "-0.000"), + (-1, 999_999_999, Precision::Number(9), "-0.000000001"), + (-1, 999_999_999, Precision::Number(10), "-0.0000000010"), + (-3, 0, Precision::Number(3), "-3.000"), + ] { + assert_eq!(format_timestamp(seconds, nanoseconds, precision), expected); + } } #[test] @@ -1608,13 +1761,13 @@ mod tests { fn test_quote_file_name() { let file_name = "nice' file"; assert_eq!( - quote_file_name(file_name, &crate::QuotingStyle::ShellEscapeAlways), + quote_file_name(OsStr::new(file_name), QuotingStyle::SHELL_ESCAPE_ALWAYS), "\"nice' file\"" ); let file_name = "nice\" file"; assert_eq!( - quote_file_name(file_name, &crate::QuotingStyle::ShellEscapeAlways), + quote_file_name(OsStr::new(file_name), QuotingStyle::SHELL_ESCAPE_ALWAYS), "\'nice\" file\'" ); } diff --git a/src/uu/stdbuf/Cargo.toml b/src/uu/stdbuf/Cargo.toml index 9a65221598a..eb3d5d1a82e 100644 --- a/src/uu/stdbuf/Cargo.toml +++ b/src/uu/stdbuf/Cargo.toml @@ -1,4 +1,5 @@ # spell-checker:ignore dpkg + [package] name = "uu_stdbuf" description = "stdbuf ~ (uutils) run COMMAND with modified standard stream buffering" @@ -12,9 +13,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/stdbuf.rs" test = false @@ -22,13 +20,13 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } tempfile = { workspace = true } -uucore = { workspace = true, features = ["parser-size"] } thiserror = { workspace = true } -fluent = { workspace = true } +uucore = { workspace = true, features = ["parser-size"] } [target.'cfg(unix)'.dependencies] -libstdbuf = { package = "uu_stdbuf_libstdbuf", version = "0.9.0", path = "src/libstdbuf" } +libstdbuf = { package = "uu_stdbuf_libstdbuf", version = "0.13.0", path = "src/libstdbuf" } # "feat_external_libstdbuf": use an external libstdbuf.so for stdbuf instead of embedding it into # the stdbuf binary. @@ -41,6 +39,7 @@ libstdbuf = { package = "uu_stdbuf_libstdbuf", version = "0.9.0", path = "src/li # on some platforms, e.g. because the SELinux permissions may not allow # stdbuf to write to /tmp, /tmp may be read-only, libstdbuf.so may not work # at all without SELinux labels, etc. +# See https://github.com/rust-lang/cargo/issues/8317, still unresolved. # # 2. Installation of uutils-coreutils using an external tool, e.g. dpkg/apt on # debian. In this case, libstdbuf.so should be installed separately to its @@ -50,6 +49,9 @@ libstdbuf = { package = "uu_stdbuf_libstdbuf", version = "0.9.0", path = "src/li [features] feat_external_libstdbuf = [] +[lints] +workspace = true + [[bin]] name = "stdbuf" path = "src/main.rs" diff --git a/src/uu/stdbuf/build.rs b/src/uu/stdbuf/build.rs index 81a1e052b6d..6e71eb1c025 100644 --- a/src/uu/stdbuf/build.rs +++ b/src/uu/stdbuf/build.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (ToDO) bindeps dylib libstdbuf deps liblibstdbuf use std::env; @@ -18,26 +19,6 @@ fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=src/libstdbuf/src/libstdbuf.rs"); - // Check for external stdbuf feature requirements - #[cfg(feature = "feat_external_libstdbuf")] - { - if env::var("LIBSTDBUF_DIR").is_err() { - eprintln!( - "\n\x1b[31mError:\x1b[0m The 'feat_external_libstdbuf' feature requires the LIBSTDBUF_DIR environment variable to be set." - ); - eprintln!( - "\x1b[33mUsage:\x1b[0m LIBSTDBUF_DIR=/path/to/lib/directory cargo build --features feat_external_libstdbuf" - ); - eprintln!( - "\x1b[33mExample:\x1b[0m LIBSTDBUF_DIR=/usr/lib cargo build --features feat_external_libstdbuf" - ); - eprintln!( - "\nThis directory should point to where libstdbuf.so / libstdbuf.dylib will be installed on the target system." - ); - std::process::exit(1); - } - } - let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string()); diff --git a/src/uu/stdbuf/locales/en-US.ftl b/src/uu/stdbuf/locales/en-US.ftl index 7a8fcafdcbb..5dcd9b8a361 100644 --- a/src/uu/stdbuf/locales/en-US.ftl +++ b/src/uu/stdbuf/locales/en-US.ftl @@ -25,7 +25,7 @@ stdbuf-error-line-buffering-stdin-meaningless = line buffering stdin is meaningl stdbuf-error-invalid-mode = invalid mode {$error} stdbuf-error-value-too-large = invalid mode '{$value}': Value too large for defined data type stdbuf-error-external-libstdbuf-not-found = External libstdbuf not found at configured path: {$path} -stdbuf-error-permission-denied = failed to execute process: Permission denied -stdbuf-error-no-such-file = failed to execute process: No such file or directory +stdbuf-error-preload-path-separator = libstdbuf path {$path} contains ':', which cannot be represented in {$var} + stdbuf: set TMPDIR to a directory whose path has no ':' stdbuf-error-failed-to-execute = failed to execute process: {$error} stdbuf-error-killed-by-signal = process killed by signal {$signal} diff --git a/src/uu/stdbuf/locales/fr-FR.ftl b/src/uu/stdbuf/locales/fr-FR.ftl index b371361aec2..d408ca7531a 100644 --- a/src/uu/stdbuf/locales/fr-FR.ftl +++ b/src/uu/stdbuf/locales/fr-FR.ftl @@ -24,7 +24,7 @@ stdbuf-error-line-buffering-stdin-meaningless = la mise en mémoire tampon par l stdbuf-error-invalid-mode = mode invalide {$error} stdbuf-error-value-too-large = mode invalide '{$value}' : Valeur trop grande pour le type de données défini stdbuf-error-external-libstdbuf-not-found = libstdbuf externe introuvable au chemin configuré : {$path} -stdbuf-error-permission-denied = échec de l'exécution du processus : Permission refusée -stdbuf-error-no-such-file = échec de l'exécution du processus : Aucun fichier ou répertoire de ce type +stdbuf-error-preload-path-separator = le chemin de libstdbuf {$path} contient ':', ce qui ne peut pas être représenté dans {$var} + stdbuf: définissez TMPDIR vers un répertoire dont le chemin ne contient pas ':' stdbuf-error-failed-to-execute = échec de l'exécution du processus : {$error} stdbuf-error-killed-by-signal = processus tué par le signal {$signal} diff --git a/src/uu/stdbuf/src/libstdbuf/Cargo.toml b/src/uu/stdbuf/src/libstdbuf/Cargo.toml index 7969db29f58..df9e5a81a03 100644 --- a/src/uu/stdbuf/src/libstdbuf/Cargo.toml +++ b/src/uu/stdbuf/src/libstdbuf/Cargo.toml @@ -10,9 +10,6 @@ categories.workspace = true edition.workspace = true rust-version.workspace = true -[lints] -workspace = true - [lib] name = "stdbuf" path = "src/libstdbuf.rs" @@ -22,3 +19,6 @@ test = false [dependencies] ctor = { workspace = true } libc = { workspace = true } + +[lints] +workspace = true diff --git a/src/uu/stdbuf/src/libstdbuf/build.rs b/src/uu/stdbuf/src/libstdbuf/build.rs index e9c2265b71f..ab69b6c3b81 100644 --- a/src/uu/stdbuf/src/libstdbuf/build.rs +++ b/src/uu/stdbuf/src/libstdbuf/build.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (ToDO) libstdbuf use std::env; diff --git a/src/uu/stdbuf/src/libstdbuf/src/libstdbuf.rs b/src/uu/stdbuf/src/libstdbuf/src/libstdbuf.rs index ee218033e17..f1aa4dcb621 100644 --- a/src/uu/stdbuf/src/libstdbuf/src/libstdbuf.rs +++ b/src/uu/stdbuf/src/libstdbuf/src/libstdbuf.rs @@ -2,10 +2,12 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (ToDO) getreent reent IOFBF IOLBF IONBF setvbuf stderrp stdinp stdoutp fdopen +use core::ffi::{c_char, c_int}; use ctor::ctor; -use libc::{_IOFBF, _IOLBF, _IONBF, FILE, c_char, c_int, fileno, size_t}; +use libc::{_IOFBF, _IOLBF, _IONBF, FILE, fileno, size_t}; use std::io::{Write, stderr}; use std::{env, ptr}; @@ -19,7 +21,7 @@ fn init() { /// This function is unsafe because it calls a C API #[unsafe(no_mangle)] pub unsafe extern "C" fn __stdbuf_get_stdin() -> *mut FILE { - #[cfg(any(target_os = "macos", target_os = "freebsd"))] + #[cfg(any(target_vendor = "apple", target_os = "freebsd"))] { unsafe extern "C" { fn __stdinp() -> *mut FILE; @@ -64,7 +66,7 @@ pub unsafe extern "C" fn __stdbuf_get_stdin() -> *mut FILE { } #[cfg(not(any( - target_os = "macos", + target_vendor = "apple", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd", @@ -82,7 +84,7 @@ pub unsafe extern "C" fn __stdbuf_get_stdin() -> *mut FILE { /// This function is unsafe because it calls a C API #[unsafe(no_mangle)] pub unsafe extern "C" fn __stdbuf_get_stdout() -> *mut FILE { - #[cfg(any(target_os = "macos", target_os = "freebsd"))] + #[cfg(any(target_vendor = "apple", target_os = "freebsd"))] { unsafe extern "C" { fn __stdoutp() -> *mut FILE; @@ -127,7 +129,7 @@ pub unsafe extern "C" fn __stdbuf_get_stdout() -> *mut FILE { } #[cfg(not(any( - target_os = "macos", + target_vendor = "apple", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd", @@ -145,7 +147,7 @@ pub unsafe extern "C" fn __stdbuf_get_stdout() -> *mut FILE { /// This function is unsafe because it calls a C API #[unsafe(no_mangle)] pub unsafe extern "C" fn __stdbuf_get_stderr() -> *mut FILE { - #[cfg(any(target_os = "macos", target_os = "freebsd"))] + #[cfg(any(target_vendor = "apple", target_os = "freebsd"))] { unsafe extern "C" { fn __stderrp() -> *mut FILE; @@ -190,7 +192,7 @@ pub unsafe extern "C" fn __stdbuf_get_stderr() -> *mut FILE { } #[cfg(not(any( - target_os = "macos", + target_vendor = "apple", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd", diff --git a/src/uu/stdbuf/src/stdbuf.rs b/src/uu/stdbuf/src/stdbuf.rs index e4ffa9d6159..d2c3f2c054f 100644 --- a/src/uu/stdbuf/src/stdbuf.rs +++ b/src/uu/stdbuf/src/stdbuf.rs @@ -7,16 +7,19 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use std::ffi::OsString; -#[cfg(unix)] -use std::os::unix::process::CommandExt; +#[cfg(all(unix, not(feature = "feat_external_libstdbuf")))] +use std::fs::Permissions; +#[cfg(all(unix, not(feature = "feat_external_libstdbuf")))] +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::process; +#[cfg(not(feature = "feat_external_libstdbuf"))] use tempfile::TempDir; -use tempfile::tempdir; -use thiserror::Error; -use uucore::error::{UResult, USimpleError, UUsageError}; +use uucore::diagnostics::OptionValue; +use uucore::display::Quotable; +use uucore::error::{UResult, USimpleError, UUsageError, strip_errno}; use uucore::format_usage; -use uucore::parser::parse_size::parse_size_u64; +use uucore::parser::parse_size::{ParseSizeError, parse_size_u64}; use uucore::translate; mod options { @@ -42,7 +45,7 @@ const STDBUF_INJECT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libstdbuf #[cfg(all( not(feature = "feat_external_libstdbuf"), - any(target_os = "cygwin", target_os = "windows") + any(target_os = "cygwin", windows) ))] const STDBUF_INJECT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/libstdbuf.dll")); @@ -63,20 +66,31 @@ impl TryFrom<&ArgMatches> for ProgramOptions { fn try_from(matches: &ArgMatches) -> Result { Ok(Self { - stdin: check_option(matches, options::INPUT)?, - stdout: check_option(matches, options::OUTPUT)?, - stderr: check_option(matches, options::ERROR)?, + stdin: check_option(matches, options::INPUT, options::INPUT_SHORT)?, + stdout: check_option(matches, options::OUTPUT, options::OUTPUT_SHORT)?, + stderr: check_option(matches, options::ERROR, options::ERROR_SHORT)?, }) } } -#[derive(Debug, Error)] +/// A buffering mode that did not parse as a size, and where it came from. +/// +/// The message is built where it always was; the rest is what a caret needs: +/// the mode as typed with the option it was given to, and what the size parser +/// made of it. +#[derive(Debug)] +struct ModeError { + option: OptionValue, + error: ParseSizeError, +} + +#[derive(Debug, thiserror::Error)] enum ProgramOptionsError { #[error("{}", translate!("stdbuf-error-line-buffering-stdin-meaningless"))] LineBufferingStdinMeaningless, - #[error("{}", translate!("stdbuf-error-invalid-mode", "error" => _0.clone()))] - InvalidMode(String), - #[error("{}", translate!("stdbuf-error-value-too-large", "value" => _0.clone()))] + #[error("{}", translate!("stdbuf-error-invalid-mode", "error" => _0.error.to_string()))] + InvalidMode(Box), + #[error("{}", translate!("stdbuf-error-value-too-large", "value" => _0))] ValueTooLarge(String), } @@ -95,7 +109,11 @@ fn preload_strings() -> (&'static str, &'static str) { ("LD_PRELOAD", "dll") } -fn check_option(matches: &ArgMatches, name: &str) -> Result { +fn check_option( + matches: &ArgMatches, + name: &'static str, + short: char, +) -> Result { match matches.get_one::(name) { Some(value) => match value.as_str() { "L" => { @@ -106,7 +124,12 @@ fn check_option(matches: &ArgMatches, name: &str) -> Result parse_size_u64(x).map_or_else( - |e| Err(ProgramOptionsError::InvalidMode(e.to_string())), + |error| { + Err(ProgramOptionsError::InvalidMode(Box::new(ModeError { + option: OptionValue::new(x, short, name), + error, + }))) + }, |m| { Ok(BufferType::Size(m.try_into().map_err(|_| { ProgramOptionsError::ValueTooLarge(x.to_string()) @@ -132,23 +155,32 @@ fn set_command_env(command: &mut process::Command, buffer_name: &str, buffer_typ #[cfg(not(feature = "feat_external_libstdbuf"))] fn get_preload_env(tmp_dir: &TempDir) -> UResult<(String, PathBuf)> { - use std::fs::File; + use std::fs::OpenOptions; use std::io::Write; + #[cfg(unix)] + use std::os::unix::fs::OpenOptionsExt; let (preload, extension) = preload_strings(); let inject_path = tmp_dir.path().join("libstdbuf").with_extension(extension); - let mut file = File::create(&inject_path)?; + let mut open_options = OpenOptions::new(); + open_options.write(true).create_new(true); + #[cfg(unix)] + open_options.mode(0o600); + let mut file = open_options.open(&inject_path)?; file.write_all(STDBUF_INJECT)?; Ok((preload.to_owned(), inject_path)) } #[cfg(feature = "feat_external_libstdbuf")] -fn get_preload_env(_tmp_dir: &TempDir) -> UResult<(String, PathBuf)> { +fn get_preload_env() -> UResult<(String, PathBuf)> { // Use the directory provided at compile time via LIBSTDBUF_DIR environment variable - // This will fail to compile if LIBSTDBUF_DIR is not set, which is the desired behavior - const LIBSTDBUF_DIR: &str = env!("LIBSTDBUF_DIR"); + // cannot use unwrap_or + const LIBSTDBUF_DIR: &str = match option_env!("LIBSTDBUF_DIR") { + Some(v) => v, + None => "/usr/local/libexec/coreutils", + }; let (preload, extension) = preload_strings(); @@ -158,10 +190,10 @@ fn get_preload_env(_tmp_dir: &TempDir) -> UResult<(String, PathBuf)> { let mut search_paths: Vec = Vec::with_capacity(2); // First, try to get the directory where stdbuf is running from - if let Ok(exe_path) = std::env::current_exe() { - if let Some(exe_dir) = exe_path.parent() { - search_paths.push(exe_dir.to_path_buf()); - } + if let Ok(exe_path) = std::env::current_exe() + && let Some(exe_dir) = exe_path.parent() + { + search_paths.push(exe_dir.to_path_buf()); } // Add the compile-time directory as fallback @@ -185,13 +217,47 @@ fn get_preload_env(_tmp_dir: &TempDir) -> UResult<(String, PathBuf)> { )) } +/// The exit status to report for a child that has already terminated. +/// +/// `exec()` would have let the shell observe the child's own fate directly. +/// Now that a waiter sits in between, reproduce it: a child killed by a signal +/// is reported as `128 + signal`, the convention shells use, instead of the 0 +/// that `ExitStatus::code()` yields for a signalled process. +fn exit_status_code(status: process::ExitStatus) -> i32 { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + status.code().unwrap_or(1) +} + #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let raw_args: Vec = args.collect(); + // Kept for the caret in mode diagnostics, which needs the mode as typed. + let diag_args = uucore::diagnostics::capture(&raw_args); let matches = - uucore::clap_localization::handle_clap_result_with_exit_code(uu_app(), args, 125)?; - - let options = - ProgramOptions::try_from(&matches).map_err(|e| UUsageError::new(125, e.to_string()))?; + uucore::clap_localization::handle_clap_result_with_exit_code(uu_app(), raw_args, 125)?; + + let options = ProgramOptions::try_from(&matches).map_err(|e| { + let message = e.to_string(); + uucore::diagnostics::error_after_report( + diag_args.as_deref(), + UUsageError::new(125, message.clone()), + |args, _| match &e { + ProgramOptionsError::InvalidMode(mode) => { + mode.error + .render_size_value(args, &mode.option, 0, &message) + } + // The rest is not about a mode that failed to parse, so there + // is nothing to point a caret at. + _ => false, + }, + ) + })?; let mut command_values = matches .get_many::(options::COMMAND) @@ -202,41 +268,75 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let mut command = process::Command::new(first_command); let command_params: Vec<&OsString> = command_values.collect(); - let tmp_dir = tempdir() - .map_err(|e| UUsageError::new(125, format!("failed to create temp directory: {e}")))?; - let (preload_env, libstdbuf) = get_preload_env(&tmp_dir)?; + // When embedding the library, extract it into a temporary directory that is + // private to this user (0700 on Unix, where the mode is applied at mkdir + // time so the directory is never world-accessible in between). The TempDir + // is kept alive until the child exits, so the loader can still find the + // library, and is removed afterwards instead of being leaked. + #[cfg(not(feature = "feat_external_libstdbuf"))] + let (tmp_dir, preload_env, libstdbuf) = { + let mut builder = tempfile::Builder::new(); + #[cfg(unix)] + builder.permissions(Permissions::from_mode(0o700)); + let tmp_dir = builder + .tempdir() + .map_err(|e| UUsageError::new(125, format!("failed to create temp directory: {e}")))?; + let (preload_env, libstdbuf) = get_preload_env(&tmp_dir)?; + (tmp_dir, preload_env, libstdbuf) + }; + #[cfg(feature = "feat_external_libstdbuf")] + let (preload_env, libstdbuf) = get_preload_env()?; + // The preload variable is a colon-separated list with no escaping mechanism, + // so a path containing ':' does not round-trip: the dynamic loader splits it + // and treats the leading component as a library to load. Since the temp + // directory is derived from $TMPDIR, that component would be attacker-chosen + // whenever TMPDIR crosses a privilege boundary. Refuse instead of preloading + // something we did not select. + if libstdbuf.as_os_str().as_encoded_bytes().contains(&b':') { + return Err(USimpleError::new( + 125, + translate!("stdbuf-error-preload-path-separator", "path" => libstdbuf.quote(), "var" => preload_env), + )); + } command.env(preload_env, libstdbuf); set_command_env(&mut command, "_STDBUF_I", &options.stdin); set_command_env(&mut command, "_STDBUF_O", &options.stdout); set_command_env(&mut command, "_STDBUF_E", &options.stderr); command.args(command_params); - // Replace the current process with the target program (no fork) using exec. - #[cfg(unix)] - let e = command.exec(); - #[cfg(windows)] + // Windows has no exec(), so it has always waited on a child here; the + // library it preloads is Cygwin's, shipped by a separate package, and no + // temporary directory is involved. + // + // Unix used to exec(), which replaced this process before the TempDir + // destructor could run and leaked the extracted library on every + // invocation. Waiting on a child instead gives us somewhere to clean up + // from. let e = match command.spawn() { Ok(mut child) => { - let status = child.wait().unwrap(); - process::exit(status.code().unwrap_or(0)); + let status = child.wait(); + // The child is gone: the library is no longer needed, remove it. + #[cfg(not(feature = "feat_external_libstdbuf"))] + drop(tmp_dir); + let status = status.map_err(|err| { + USimpleError::new( + 1, + translate!("stdbuf-error-failed-to-execute", "error" => strip_errno(&err)), + ) + })?; + process::exit(exit_status_code(status)); } Err(err) => err, }; - // 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), - )), - } + let exit_code = match e.kind() { + std::io::ErrorKind::PermissionDenied => 126, + std::io::ErrorKind::NotFound => 127, + _ => 1, + }; + Err(USimpleError::new( + exit_code, + translate!("stdbuf-error-failed-to-execute", "error" => strip_errno(&e)), + )) } pub fn uu_app() -> Command { diff --git a/src/uu/stty/Cargo.toml b/src/uu/stty/Cargo.toml index e1fb8c5bac3..77491e59033 100644 --- a/src/uu/stty/Cargo.toml +++ b/src/uu/stty/Cargo.toml @@ -11,24 +11,23 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/stty.rs" doctest = false -[dependencies] +[target.'cfg(unix)'.dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["parser"] } fluent = { workspace = true } - -[target.'cfg(unix)'.dependencies] nix = { workspace = true, features = ["ioctl", "term"] } +uucore = { workspace = true, features = ["parser"] } + +[build-dependencies] +cfg_aliases = { workspace = true } + +[lints] +workspace = true [[bin]] name = "stty" path = "src/main.rs" - -[build-dependencies] -cfg_aliases = "0.2.1" +required-features = ["uucore/default"] diff --git a/src/uu/stty/build.rs b/src/uu/stty/build.rs index 19b913b947c..320a109d007 100644 --- a/src/uu/stty/build.rs +++ b/src/uu/stty/build.rs @@ -8,11 +8,11 @@ use cfg_aliases::cfg_aliases; fn main() { cfg_aliases! { bsd: { any( + target_vendor = "apple", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", - target_os = "openbsd", - target_vendor = "apple" + target_os = "openbsd" ) }, }; } diff --git a/src/uu/stty/src/flags.rs b/src/uu/stty/src/flags.rs index 0cf9924b079..d25936628d2 100644 --- a/src/uu/stty/src/flags.rs +++ b/src/uu/stty/src/flags.rs @@ -57,6 +57,7 @@ pub const CONTROL_FLAGS: &[Flag] = &[ Flag::new("cstopb", C::CSTOPB), Flag::new("cread", C::CREAD).sane(), Flag::new("clocal", C::CLOCAL), + #[cfg(not(target_os = "redox"))] Flag::new("crtscts", C::CRTSCTS), ]; @@ -75,9 +76,11 @@ pub const INPUT_FLAGS: &[Flag] = &[ Flag::new("ixon", I::IXON), // not supported by nix // Flag::new("iuclc", I::IUCLC), + #[cfg(not(target_os = "redox"))] Flag::new("ixany", I::IXANY), + #[cfg(not(target_os = "redox"))] Flag::new("imaxbel", I::IMAXBEL).sane(), - #[cfg(any(target_os = "android", target_os = "linux", target_os = "macos"))] + #[cfg(any(target_vendor = "apple", target_os = "android", target_os = "linux"))] Flag::new("iutf8", I::IUTF8), ]; @@ -95,122 +98,122 @@ pub const OUTPUT_FLAGS: &[Flag] = &[ Flag::new("onocr", O::ONOCR), Flag::new("onlret", O::ONLRET), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new("ofdel", O::OFDEL), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("nl0", O::NL0, O::NLDLY).sane(), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("nl1", O::NL1, O::NLDLY), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("cr0", O::CR0, O::CRDLY).sane(), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("cr1", O::CR1, O::CRDLY), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("cr2", O::CR2, O::CRDLY), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("cr3", O::CR3, O::CRDLY), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("tab0", O::TAB0, O::TABDLY).sane(), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("tab1", O::TAB1, O::TABDLY), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("tab2", O::TAB2, O::TABDLY), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("tab3", O::TAB3, O::TABDLY), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("bs0", O::BS0, O::BSDLY).sane(), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("bs1", O::BS1, O::BSDLY), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("vt0", O::VT0, O::VTDLY).sane(), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("vt1", O::VT1, O::VTDLY), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("ff0", O::FF0, O::FFDLY).sane(), #[cfg(any( + target_vendor = "apple", target_os = "android", target_os = "haiku", - target_os = "linux", - target_vendor = "apple" + target_os = "linux" ))] Flag::new_grouped("ff1", O::FF1, O::FFDLY), ]; @@ -228,16 +231,21 @@ pub const LOCAL_FLAGS: &[Flag] = &[ // Not supported by nix // Flag::new("xcase", L::XCASE), Flag::new("tostop", L::TOSTOP), - #[cfg(not(target_os = "cygwin"))] + #[cfg(not(any(target_os = "cygwin", target_os = "redox")))] Flag::new("echoprt", L::ECHOPRT), - #[cfg(not(target_os = "cygwin"))] + #[cfg(not(any(target_os = "cygwin", target_os = "redox")))] Flag::new("prterase", L::ECHOPRT).hidden(), + #[cfg(not(target_os = "redox"))] Flag::new("echoctl", L::ECHOCTL).sane(), + #[cfg(not(target_os = "redox"))] Flag::new("ctlecho", L::ECHOCTL).sane().hidden(), + #[cfg(not(target_os = "redox"))] Flag::new("echoke", L::ECHOKE).sane(), + #[cfg(not(target_os = "redox"))] Flag::new("crtkill", L::ECHOKE).sane().hidden(), + #[cfg(not(target_os = "redox"))] Flag::new("flusho", L::FLUSHO), - #[cfg(not(target_os = "cygwin"))] + #[cfg(not(any(target_os = "cygwin", target_os = "redox")))] Flag::new("extproc", L::EXTPROC), ]; @@ -263,13 +271,25 @@ pub const BAUD_RATES: &[(&str, BaudRate)] = &[ ("57600", BaudRate::B57600), ("115200", BaudRate::B115200), ("230400", BaudRate::B230400), - #[cfg(not(target_os = "cygwin"))] + // TODO: https://github.com/nix-rust/nix/pull/2815 + #[cfg(not(any( + target_os = "cygwin", + target_os = "fuchsia", + target_os = "hurd", + target_os = "redox" + )))] ("460800", BaudRate::B460800), #[cfg(any(target_os = "android", target_os = "linux"))] ("500000", BaudRate::B500000), #[cfg(any(target_os = "android", target_os = "linux"))] ("576000", BaudRate::B576000), - #[cfg(any(target_os = "android", target_os = "linux"))] + // TODO: https://github.com/nix-rust/nix/pull/2815 + #[cfg(not(any( + target_os = "cygwin", + target_os = "fuchsia", + target_os = "hurd", + target_os = "redox" + )))] ("921600", BaudRate::B921600), #[cfg(any(target_os = "android", target_os = "linux"))] ("1000000", BaudRate::B1000000), diff --git a/src/uu/stty/src/stty.rs b/src/uu/stty/src/stty.rs index 14d8ead13aa..d6e46d90916 100644 --- a/src/uu/stty/src/stty.rs +++ b/src/uu/stty/src/stty.rs @@ -14,6 +14,8 @@ // spell-checker:ignore notaflag notacombo notabaud // spell-checker:ignore baudrate TCGETS +#![cfg(unix)] + mod flags; use crate::flags::AllFlags; @@ -648,10 +650,13 @@ fn print_terminal_size( // BSDs and Linux (not ppc/big-endian ppc64) use a u32 for the baud rate, so we can simply // print it. - #[cfg(any(target_os = "linux", bsd))] - #[cfg(all( - not(target_arch = "powerpc"), - not(all(target_arch = "powerpc64", target_endian = "big")) + #[cfg(any( + bsd, + all( + target_os = "linux", + not(target_arch = "powerpc"), + not(all(target_arch = "powerpc64", target_endian = "big")) + ) ))] printer.print(&translate!("stty-output-speed", "speed" => speed)); @@ -1120,6 +1125,7 @@ fn string_to_control_char(s: &str) -> Result { fn combo_to_flags(combo: &str) -> Vec> { let mut flags = Vec::new(); let mut ccs = Vec::new(); + #[expect(clippy::match_same_arms)] match combo { "lcase" | "LCASE" => { flags = vec!["xcase", "iuclc", "olcuc"]; @@ -1251,6 +1257,11 @@ fn get_sane_control_char(cc_index: S) -> u8 { S::VEOL => 0, S::VEOL2 => 0, S::VMIN => 1, + #[cfg(not(any( + all(target_os = "linux", target_arch = "sparc64"), + target_os = "illumos", + target_os = "solaris" + )))] S::VTIME => 0, #[cfg(target_os = "linux")] S::VSWTC => 0, diff --git a/src/uu/sum/Cargo.toml b/src/uu/sum/Cargo.toml index 11b558b5703..1444a8aa3ea 100644 --- a/src/uu/sum/Cargo.toml +++ b/src/uu/sum/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/sum.rs" test = false @@ -21,9 +18,14 @@ doctest = false [dependencies] clap = { workspace = true } -rustix = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true } + +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))'.dependencies] +rustix = { workspace = true, features = ["fs"] } + +[lints] +workspace = true [[bin]] name = "sum" diff --git a/src/uu/sum/src/sum.rs b/src/uu/sum/src/sum.rs index 6af51aa51a9..4efe62196f0 100644 --- a/src/uu/sum/src/sum.rs +++ b/src/uu/sum/src/sum.rs @@ -76,7 +76,7 @@ fn open(name: &OsString) -> UResult { } else { let path = Path::new(name); // some platforms cannot catch those errors when open or read. needs additional cost - #[cfg(any(target_os = "wasi", target_os = "windows"))] + #[cfg(any(target_os = "wasi", windows))] match path.metadata() { Ok(_) => { if path.is_dir() { @@ -137,12 +137,21 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { continue; } }; - let (blocks, sum) = if sysv { + let result = if sysv { sysv_sum(reader) } else { bsd_sum(reader) - } - .map_err(|e| USimpleError::new(1, format!("{}: {1}", file.display(), strip_errno(&e))))?; + }; + let (blocks, sum) = match result { + Ok(r) => r, + Err(e) => { + show!(USimpleError::new( + 1, + format!("{}: {}", file.display(), strip_errno(&e)) + )); + continue; + } + }; let mut stdout = stdout().lock(); if print_names { write!(stdout, "{sum:0width$} {blocks:width$} ")?; diff --git a/src/uu/sync/Cargo.toml b/src/uu/sync/Cargo.toml index d3b84da2127..f5e57b8f027 100644 --- a/src/uu/sync/Cargo.toml +++ b/src/uu/sync/Cargo.toml @@ -14,9 +14,6 @@ readme.workspace = true [package.metadata.cargo-udeps.ignore] normal = ["uucore_procs"] -[lints] -workspace = true - [lib] path = "src/sync.rs" test = false @@ -24,19 +21,22 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["wide"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["wide"] } [target.'cfg(unix)'.dependencies] -nix = { workspace = true } +rustix = { workspace = true, features = ["fs"] } -[target.'cfg(target_os = "windows")'.dependencies] +[target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = [ "Win32_Storage_FileSystem", "Win32_System_WindowsProgramming", "Win32_Foundation", ] } +[lints] +workspace = true + [[bin]] name = "sync" path = "src/main.rs" diff --git a/src/uu/sync/src/sync.rs b/src/uu/sync/src/sync.rs index 697e33bf4f5..c240f49869f 100644 --- a/src/uu/sync/src/sync.rs +++ b/src/uu/sync/src/sync.rs @@ -4,12 +4,6 @@ // file that was distributed with this source code. use clap::{Arg, ArgAction, Command}; -#[cfg(any(target_os = "linux", target_os = "android"))] -use nix::errno::Errno; -#[cfg(any(target_os = "linux", target_os = "android"))] -use nix::fcntl::{OFlag, open}; -#[cfg(any(target_os = "linux", target_os = "android"))] -use nix::sys::stat::Mode; use std::path::Path; use uucore::display::Quotable; use uucore::error::{UResult, USimpleError, get_exit_code, set_exit_code}; @@ -26,11 +20,6 @@ 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, OpenOptions}; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -49,7 +38,7 @@ mod platform { reason = "fn sig must match on all platforms" )] pub fn do_sync() -> UResult<()> { - sync(); + rustix::fs::sync(); Ok(()) } @@ -60,18 +49,15 @@ mod platform { fn open_and_reset_nonblock(path: &str) -> UResult { let f = OpenOptions::new() .read(true) - .custom_flags(OFlag::O_NONBLOCK.bits()) + .custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32) .open(path) .map_err_context(|| path.to_string())?; // Reset O_NONBLOCK flag if it was set (matches GNU behavior) // This is non-critical, so we log errors but don't fail - if let Err(e) = fcntl(&f, FcntlArg::F_SETFL(OFlag::empty())) { + if let Err(e) = rustix::fs::fcntl_setfl(&f, rustix::fs::OFlags::empty()) { use std::io::{Write, stderr}; - let _ = writeln!( - stderr(), - "sync: {}", - translate!("sync-warning-fcntl-failed", "file" => path, "error" => e.to_string()) - ); + let msg = translate!("sync-warning-fcntl-failed", "file" => path, "error" => std::io::Error::from(e).to_string()); + let _ = writeln!(stderr(), "sync: {msg}"); uucore::error::set_exit_code(1); } Ok(f) @@ -80,11 +66,11 @@ mod platform { #[cfg(any(target_os = "linux", target_os = "android"))] pub fn do_sync_with(files: &[String], op: F) -> UResult<()> where - F: Fn(File) -> Result<(), nix::Error>, + F: Fn(File) -> Result<(), rustix::io::Errno>, { for path in files { let f = open_and_reset_nonblock(path)?; - op(f).map_err_context( + op(f).map_err(std::io::Error::from).map_err_context( || translate!("sync-error-syncing-file", "file" => path.quote()), )?; } @@ -93,34 +79,32 @@ mod platform { #[cfg(any(target_os = "linux", target_os = "android"))] pub fn do_syncfs(files: &[String]) -> UResult<()> { - do_sync_with(files, syncfs) + do_sync_with(files, rustix::fs::syncfs) } #[cfg(any(target_os = "linux", target_os = "android"))] pub fn do_fdatasync(files: &[String]) -> UResult<()> { - do_sync_with(files, fdatasync) + do_sync_with(files, rustix::fs::fdatasync) } } #[cfg(windows)] mod platform { use std::fs::OpenOptions; - use std::os::windows::prelude::*; use std::path::Path; use uucore::error::{UResult, USimpleError}; use uucore::translate; use uucore::wide::{FromWide, ToWide}; use windows_sys::Win32::Foundation::{ - ERROR_NO_MORE_FILES, GetLastError, HANDLE, INVALID_HANDLE_VALUE, MAX_PATH, + ERROR_NO_MORE_FILES, HANDLE, INVALID_HANDLE_VALUE, MAX_PATH, }; use windows_sys::Win32::Storage::FileSystem::{ - FindFirstVolumeW, FindNextVolumeW, FindVolumeClose, FlushFileBuffers, GetDriveTypeW, + FindFirstVolumeW, FindNextVolumeW, FindVolumeClose, GetDriveTypeW, }; use windows_sys::Win32::System::WindowsProgramming::DRIVE_FIXED; fn get_last_error() -> u32 { - // SAFETY: `GetLastError` has no safety preconditions - unsafe { GetLastError() as u32 } + std::io::Error::last_os_error().raw_os_error().unwrap_or(1) as u32 } fn flush_volume(name: &str) -> UResult<()> { @@ -139,13 +123,12 @@ mod platform { translate!("sync-error-create-volume-handle"), ) })?; - // SAFETY: `file` is a valid `File` - if unsafe { FlushFileBuffers(file.as_raw_handle() as HANDLE) } == 0 { - return Err(USimpleError::new( - get_last_error() as i32, + file.sync_all().map_err(|e| { + USimpleError::new( + e.raw_os_error().unwrap_or(1), translate!("sync-error-flush-file-buffer"), - )); - } + ) + })?; Ok(()) } @@ -229,17 +212,19 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } for f in &files { - // Use the Nix open to be able to set the NONBLOCK flags for fifo files + // open with O_NONBLOCK to handle fifo files #[cfg(any(target_os = "linux", target_os = "android"))] { + use uucore::error::strip_errno; let path = Path::new(f); - if let Err(e) = open(path, OFlag::O_NONBLOCK, Mode::empty()) - && (e != Errno::EACCES || path.is_dir()) + if let Err(e) = rustix::fs::open( + path, + rustix::fs::OFlags::NONBLOCK, + rustix::fs::Mode::empty(), + ) && (e != rustix::io::Errno::ACCESS || path.is_dir()) { - show_error!( - "{}", - translate!("sync-error-opening-file", "file" => f.quote(), "err" => e.desc()) - ); + let msg = translate!("sync-error-opening-file", "file" => f.quote(), "err" => strip_errno(&std::io::Error::from(e))); + show_error!("{msg}"); set_exit_code(1); } } @@ -264,7 +249,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { if files.is_empty() { sync()?; } else { - #[cfg(any(target_os = "linux", target_os = "android", target_os = "windows"))] + #[cfg(any(target_os = "linux", target_os = "android", windows))] syncfs(&files)?; } } else if matches.get_flag(options::DATA) { @@ -310,7 +295,7 @@ fn sync() -> UResult<()> { platform::do_sync() } -#[cfg(any(target_os = "linux", target_os = "android", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "android", windows))] fn syncfs(files: &[String]) -> UResult<()> { platform::do_syncfs(files) } diff --git a/src/uu/tac/Cargo.toml b/src/uu/tac/Cargo.toml index 4e93f8ed942..6232e2d9a2f 100644 --- a/src/uu/tac/Cargo.toml +++ b/src/uu/tac/Cargo.toml @@ -13,23 +13,25 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/tac.rs" doctest = false [dependencies] -memchr = { workspace = true } -memmap2 = { workspace = true } -regex = { workspace = true } clap = { workspace = true } -libc = { workspace = true } -uucore = { workspace = true, features = ["signals"] } -thiserror = { workspace = true } fluent = { workspace = true } +libc = { workspace = true } +memchr = { workspace = true } +regex = { workspace = true } tempfile = { workspace = true } +thiserror = { workspace = true } +uucore = { workspace = true, features = ["buf-copy", "fs", "signals"] } + +[target.'cfg(not(target_os = "wasi"))'.dependencies] +memmap2 = { workspace = true } + +[lints] +workspace = true [[bin]] name = "tac" diff --git a/src/uu/tac/src/error.rs b/src/uu/tac/src/error.rs index 97caeb5d8f6..5c66cdf607a 100644 --- a/src/uu/tac/src/error.rs +++ b/src/uu/tac/src/error.rs @@ -2,15 +2,15 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + //! Errors returned by tac during processing of a file. use std::ffi::OsString; -use thiserror::Error; use uucore::display::Quotable; use uucore::error::{UError, strip_errno}; use uucore::translate; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum TacError { /// A regular expression given by the user is invalid. #[error("{}", translate!("tac-error-invalid-regex", "error" => .0))] diff --git a/src/uu/tac/src/tac.rs b/src/uu/tac/src/tac.rs index 50e615c988b..582db769f66 100644 --- a/src/uu/tac/src/tac.rs +++ b/src/uu/tac/src/tac.rs @@ -9,14 +9,15 @@ mod error; use clap::{Arg, ArgAction, Command}; use memchr::memmem; +#[cfg(not(target_os = "wasi"))] use memmap2::Mmap; use std::ffi::{OsStr, OsString}; use std::io::{BufWriter, Read, Write, stdin, stdout}; -use std::{fs::File, io::copy, path::Path}; -#[cfg(unix)] +use std::{fs::File, path::Path}; +#[cfg(all(unix, not(target_os = "fuchsia")))] use uucore::error::UError; use uucore::error::UResult; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] use uucore::error::set_exit_code; use uucore::{format_usage, show}; @@ -46,12 +47,12 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } else { raw_separator }; - - let files: Vec = match matches.get_many::(options::FILE) { - Some(v) => v.cloned().collect(), - None => vec![OsString::from("-")], - }; - + #[allow(clippy::unwrap_used, reason = "default value is set by clap")] + let files: Vec = matches + .get_many::(options::FILE) + .unwrap() + .cloned() + .collect(); tac(&files, before, regex, separator) } @@ -88,6 +89,7 @@ pub fn uu_app() -> Command { Arg::new(options::FILE) .hide(true) .action(ArgAction::Append) + .default_value("-") .value_parser(clap::value_parser!(OsString)) .value_hint(clap::ValueHint::FilePath), ) @@ -250,13 +252,11 @@ fn translate_regex_flavor(bytes: &[u8]) -> String { } // Unescape escaped (), |, {} when not inside brackets b'\\' if !inside_brackets && !is_escaped => { - if let Some(next) = bytes.get(i + 1) { - if matches!(next, b'(' | b')' | b'|' | b'{' | b'}') { - result.push(*next); - last_byte = Some(*next); - i += 2; - continue; - } + if let Some(next) = bytes.get(i + 1).filter(|c| b"()|{}".contains(c)) { + result.push(*next); + last_byte = Some(*next); + i += 2; + continue; } result.push(b'\\'); @@ -291,8 +291,7 @@ fn translate_regex_flavor(bytes: &[u8]) -> String { } b'$' if !inside_brackets && !is_escaped => { let next_is_anchor_position = match bytes.get(i + 1) { - None => true, - Some(b')' | b'|') => true, + None | Some(b')' | b'|') => true, Some(b'\\') => { // Peek two ahead to see if it's \) or \| matches!(bytes.get(i + 2), Some(b')' | b'|')) @@ -337,11 +336,12 @@ fn tac(filenames: &[OsString], before: bool, regex: bool, separator: &OsStr) -> }; for filename in filenames { + #[cfg(not(target_os = "wasi"))] let mmap; let buf; let data: &[u8] = if filename == "-" { - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] if uucore::signals::stdin_was_closed() { let e: Box = TacError::ReadError( OsString::from("-"), @@ -352,25 +352,23 @@ fn tac(filenames: &[OsString], before: bool, regex: bool, separator: &OsStr) -> set_exit_code(1); continue; } - if let Some(mmap1) = try_mmap_stdin() { - mmap = mmap1; - &mmap - } else { - // 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; - } + // Spool stdin to a temp file and mmap that (buffer_stdin explains + // why mapping the temp file is sound). Mapping the raw stdin fd + // would expose `tac < file` to the same truncation SIGBUS as #9748, + // and the temp file also bounds memory for huge stdin (#10094). + match buffer_stdin() { + #[cfg(not(target_os = "wasi"))] + Ok(StdinData::Mmap(mmap1)) => { + mmap = mmap1; + &mmap + } + Ok(StdinData::Vec(buf1)) => { + buf = buf1; + &buf + } + Err(e) => { + show!(TacError::ReadError(OsString::from("stdin"), e)); + continue; } } } else { @@ -383,50 +381,37 @@ fn tac(filenames: &[OsString], before: bool, regex: bool, separator: &OsStr) -> } }; - if let Some(mmap1) = try_mmap_file(&file) { - mmap = mmap1; - &mmap - } else { - let mut contents = Vec::new(); - match file.read_to_end(&mut contents) { - Ok(_) => { - buf = contents; - &buf - } - Err(e) => { - show!(TacError::ReadError(filename.clone(), e)); - continue; - } + // Read the file into memory rather than memory-mapping it: a + // concurrent truncation of a mapped file raises SIGBUS and kills the + // process (e.g. during log rotation; #9748). This holds the whole + // file in memory, unlike the stdin path; reading seekable files + // backwards in blocks could bound memory without copying (future work). + let mut contents = Vec::new(); + match file.read_to_end(&mut contents) { + Ok(_) => { + buf = contents; + &buf + } + Err(e) => { + show!(TacError::ReadError(filename.clone(), e)); + continue; } } }; // Select the appropriate `tac` algorithm based on whether the // separator is given as a regular expression or a fixed string. - let result = match maybe_pattern { + match maybe_pattern { Some(ref pattern) => buffer_tac_regex(data, pattern, before), None => buffer_tac(data, before, separator), - }; - - // If there is any error in writing the output, terminate immediately. - if let Err(e) = result { - return Err(TacError::WriteError(e).into()); } + .map_err(TacError::WriteError)?; } Ok(()) } -fn try_mmap_stdin() -> Option { - // 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(&stdin()).ok()? }; - // On Windows, mmap on a pipe handle can "succeed" but return 0 bytes - // (the file size of a pipe is reported as 0). When that happens, return - // None so we fall through to buffer_stdin() which reads the pipe properly. - if mmap.is_empty() { None } else { Some(mmap) } -} - enum StdinData { + #[cfg(not(target_os = "wasi"))] Mmap(Mmap), Vec(Vec), } @@ -435,25 +420,21 @@ enum StdinData { /// 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) + #[cfg(not(target_os = "wasi"))] 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. + uucore::buf_copy::copy_fast(&mut stdin(), &mut tmp)?; + // SAFETY: `tmp` is an unlinked file owned by this process, so no other + // process can open and truncate it. The mapping therefore stays valid + // for its whole lifetime and cannot trigger SIGBUS (unlike mapping a + // caller-provided file; see #9748). 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_file(file: &File) -> Option { - // 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. - unsafe { Mmap::map(file).ok() } + return Ok(StdinData::Mmap(mmap)); + }; + // 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)) } #[cfg(test)] diff --git a/src/uu/tail/Cargo.toml b/src/uu/tail/Cargo.toml index 6462fa28595..f1a1fdbf3ee 100644 --- a/src/uu/tail/Cargo.toml +++ b/src/uu/tail/Cargo.toml @@ -1,4 +1,5 @@ # spell-checker:ignore (libs) kqueue + [package] name = "uu_tail" description = "tail ~ (uutils) display the last lines of input" @@ -12,19 +13,20 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/tail.rs" doctest = false [dependencies] clap = { workspace = true } -memchr = { workspace = true } -uucore = { workspace = true, features = ["fs", "parser-size", "signals"] } -same-file = { workspace = true } fluent = { workspace = true } +memchr = { workspace = true } +uucore = { workspace = true, features = [ + "fs", + "parser-size", + "quoting-style", + "signals", +] } [target.'cfg(not(target_os = "wasi"))'.dependencies] libc = { workspace = true } @@ -45,6 +47,12 @@ windows-sys = { workspace = true, features = [ [dev-dependencies] rstest = { workspace = true } +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true + [[bin]] name = "tail" path = "src/main.rs" diff --git a/src/uu/tail/src/args.rs b/src/uu/tail/src/args.rs index 3f0f7abffd8..1cdf9d44b46 100644 --- a/src/uu/tail/src/args.rs +++ b/src/uu/tail/src/args.rs @@ -3,17 +3,17 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) kqueue Signum +// spell-checker:ignore (ToDO) kqueue Signum underflows use crate::paths::Input; use crate::{Quotable, parse, platform}; use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; -use same_file::Handle; use std::ffi::OsString; -use std::io::IsTerminal; +use std::io::{IsTerminal, Write}; use std::time::Duration; +use uucore::diagnostics::OptionValue; use uucore::error::{UResult, USimpleError, UUsageError}; -use uucore::parser::parse_signed_num::{SignPrefix, parse_signed_num_max}; +use uucore::parser::parse_signed_num::{SignPrefix, number_offset, parse_signed_num_max}; use uucore::parser::parse_size::ParseSizeError; use uucore::parser::parse_time; use uucore::parser::shortcut_value_parser::ShortcutValueParser; @@ -59,10 +59,12 @@ pub enum FilterMode { impl FilterMode { fn from_obsolete_args(args: &parse::ObsoleteArgs) -> Self { - let signum = if args.plus { - Signum::Positive(args.num) - } else { - Signum::Negative(args.num) + // Normalize zero as `parse_num` does: `Positive(0)` underflows at `count - 1`. + let signum = match (args.num, args.plus) { + (0, true) => Signum::PlusZero, + (0, false) => Signum::MinusZero, + (n, true) => Signum::Positive(n), + (n, false) => Signum::Negative(n), }; if args.lines { Self::Lines(signum, b'\n') @@ -71,16 +73,27 @@ impl FilterMode { } } - fn from(matches: &ArgMatches) -> UResult { + fn from(matches: &ArgMatches, diag_args: Option<&[OsString]>) -> UResult { + // The plain message, or a caret under the part of the size at fault. + let raise = |message: String, arg: &str, short, long, error: &ParseSizeError| { + error.size_value_error( + diag_args, + &OptionValue::new(arg, short, long), + // The parser never saw the sign; the caret has to count it back in. + number_offset(arg), + &message, + USimpleError::new(1, message.clone()), + ) + }; + let zero_term = matches.get_flag(options::ZERO_TERM); let mode = if let Some(arg) = matches.get_one::(options::BYTES) { match parse_num(arg) { Ok(signum) => Self::Bytes(signum), Err(e) => { - return Err(USimpleError::new( - 1, - translate!("tail-error-invalid-number-of-bytes", "arg" => e.to_string()), - )); + let message = + translate!("tail-error-invalid-number-of-bytes", "arg" => e.to_string()); + return Err(raise(message, arg, 'c', "bytes", &e)); } } } else if let Some(arg) = matches.get_one::(options::LINES) { @@ -89,11 +102,10 @@ impl FilterMode { let delimiter = if zero_term { 0 } else { b'\n' }; Self::Lines(signum, delimiter) } - Err(_) => { - return Err(USimpleError::new( - 1, - translate!("tail-error-invalid-number-of-lines", "arg" => arg.quote()), - )); + Err(e) => { + let message = + translate!("tail-error-invalid-number-of-lines", "arg" => arg.quote()); + return Err(raise(message, arg, 'n', "lines", &e)); } } } else if zero_term { @@ -134,7 +146,7 @@ pub struct Settings { pub follow: Option, pub max_unchanged_stats: u32, pub mode: FilterMode, - pub pid: platform::Pid, + pub pid: Option, pub retry: bool, pub sleep_sec: Duration, pub use_polling: bool, @@ -152,7 +164,7 @@ impl Default for Settings { sleep_sec: Duration::from_secs_f32(1.0), follow: Option::default(), mode: FilterMode::default(), - pid: Default::default(), + pid: Option::default(), retry: Default::default(), use_polling: Default::default(), verbose: Default::default(), @@ -184,6 +196,13 @@ impl Settings { } pub fn from(matches: &ArgMatches) -> UResult { + Self::from_with_diagnostics(matches, None) + } + + fn from_with_diagnostics( + matches: &ArgMatches, + diag_args: Option<&[OsString]>, + ) -> UResult { // We're parsing --follow, -F and --retry under the following conditions: // * -F sets --retry and --follow=name // * plain --follow or short -f is the same like specifying --follow=descriptor @@ -223,7 +242,7 @@ impl Settings { follow, retry, use_polling: matches.get_flag(options::USE_POLLING), - mode: FilterMode::from(matches)?, + mode: FilterMode::from(matches, diag_args)?, verbose: matches.get_flag(options::verbosity::VERBOSE), presume_input_pipe: matches.get_flag(options::PRESUME_INPUT_PIPE), debug: matches.get_flag(options::DEBUG), @@ -234,7 +253,7 @@ impl Settings { settings.sleep_sec = parse_time::from_str(source, false).map_err(|_| { UUsageError::new( 1, - translate!("tail-error-invalid-number-of-seconds", "source" => source.clone()), + translate!("tail-error-invalid-number-of-seconds", "source" => source), ) })?; } @@ -264,7 +283,7 @@ impl Settings { )); } - settings.pid = pid; + settings.pid = Some(pid); } Err(e) => { return Err(USimpleError::new( @@ -300,7 +319,7 @@ impl Settings { /// Check [`Settings`] for problematic configurations of tail originating from user provided /// command line arguments and print appropriate warnings. - pub fn check_warnings(&self) { + pub fn check_warnings(&self) -> std::io::Result<()> { if self.retry { if self.follow.is_none() { show_warning!("{}", translate!("tail-warning-retry-ignored")); @@ -309,10 +328,15 @@ impl Settings { } } - if self.pid != 0 { + if let Some(pid) = self.pid { if self.follow.is_none() { - show_warning!("{}", translate!("tail-warning-pid-ignored")); - } else if !platform::supports_pid_checks(self.pid) { + writeln!( + std::io::stderr().lock(), + "{}: warning: {}", + uucore::util_name(), + translate!("tail-warning-pid-ignored") + )?; + } else if pid != 0 && !platform::supports_pid_checks(pid) { show_warning!("{}", translate!("tail-warning-pid-not-supported")); } } @@ -322,20 +346,22 @@ impl Settings { // as `tty` (but no otherwise blocking stdin), then we print a warning that `--follow` // cannot be applied under these circumstances and is therefore ineffective. if self.follow.is_some() && self.has_stdin() { - let blocking_stdin = self.pid == 0 + #[cfg(unix)] + let stdin_is_regular = rustix::fs::fstat(std::io::stdin()) + .is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFREG); + #[cfg(not(unix))] + let stdin_is_regular = true; + let blocking_stdin = self.pid.unwrap_or_default() == 0 && self.follow == Some(FollowMode::Descriptor) && self.num_inputs() == 1 - && Handle::stdin().is_ok_and(|handle| { - handle - .as_file() - .metadata() - .is_ok_and(|meta| !meta.is_file()) - }); + && !stdin_is_regular; if !blocking_stdin && std::io::stdin().is_terminal() { show_warning!("{}", translate!("tail-warning-following-stdin-ineffective")); } } + + Ok(()) } /// Verify [`Settings`] and try to find unsolvable misconfigurations of tail originating from @@ -403,7 +429,12 @@ pub fn parse_args(args: impl uucore::Args) -> UResult { let args_vec: Vec = args.collect(); let clap_args = uu_app().try_get_matches_from(args_vec.clone()); let clap_result = match clap_args { - Ok(matches) => Ok(Settings::from(&matches)?), + // Kept for the caret in size diagnostics, which needs the value as + // typed. + Ok(matches) => Ok(Settings::from_with_diagnostics( + &matches, + uucore::diagnostics::capture(&args_vec).as_deref(), + )?), Err(err) => Err(err.into()), }; @@ -445,9 +476,9 @@ pub fn uu_app() -> Command { let polling_help = translate!("tail-help-polling-linux"); #[cfg(all(unix, not(target_os = "linux")))] let polling_help = translate!("tail-help-polling-unix"); - #[cfg(target_os = "windows")] + #[cfg(windows)] let polling_help = translate!("tail-help-polling-windows"); - #[cfg(not(any(unix, target_os = "windows")))] + #[cfg(not(any(unix, windows)))] let polling_help = translate!("tail-help-polling-unix"); Command::new("tail") diff --git a/src/uu/tail/src/chunks.rs b/src/uu/tail/src/chunks.rs index 873b485eecd..864ad9ed37f 100644 --- a/src/uu/tail/src/chunks.rs +++ b/src/uu/tail/src/chunks.rs @@ -3,13 +3,13 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore (ToDO) filehandle BUFSIZ + //! Iterating over a file by chunks, either starting at the end of the file with [`ReverseChunks`] //! or at the end of piped stdin with [`LinesChunk`] or [`BytesChunk`]. //! //! Use [`ReverseChunks::new`] to create a new iterator over chunks of bytes from the file. -// spell-checker:ignore (ToDO) filehandle BUFSIZ - use std::collections::VecDeque; use std::fs::File; use std::io::{BufRead, Read, Seek, SeekFrom, Write}; @@ -75,23 +75,22 @@ impl Iterator for ReverseChunks<'_> { // The chunk size is `BLOCK_SIZE` for all but the last chunk // (that is, the chunk closest to the beginning of the file), - // which contains the remainder of the bytes. + // which contains the remaining bytes. That is a full block when + // the size is an exact multiple of `BLOCK_SIZE`. let block_size = if self.block_idx == self.max_blocks_to_read - 1 { - self.size % BLOCK_SIZE + self.size - (self.max_blocks_to_read as u64 - 1) * BLOCK_SIZE } else { BLOCK_SIZE }; // Seek backwards by the next chunk, read the full chunk into // `buf`, and then seek back to the start of the chunk again. - let mut buf = vec![0; BLOCK_SIZE as usize]; + let mut buf = vec![0; block_size as usize]; let pos = self .file .seek(SeekFrom::Current(-(block_size as i64))) .unwrap(); - self.file - .read_exact(&mut buf[0..(block_size as usize)]) - .unwrap(); + self.file.read_exact(&mut buf).unwrap(); let pos2 = self .file .seek(SeekFrom::Current(-(block_size as i64))) @@ -100,7 +99,7 @@ impl Iterator for ReverseChunks<'_> { self.block_idx += 1; - Some(buf[0..(block_size as usize)].to_vec()) + Some(buf) } } @@ -291,23 +290,21 @@ impl BytesChunkBuffer { // fill chunks with all bytes from reader and reuse already instantiated chunks if possible while chunk.fill(reader)?.is_some() { self.bytes += chunk.bytes as u64; - self.chunks.push_back(chunk.clone()); + self.chunks.push_back(chunk); let first = &self.chunks[0]; if self.bytes - first.bytes as u64 > self.num_print { chunk = self.chunks.pop_front().unwrap(); self.bytes -= chunk.bytes as u64; } else { - *chunk = BytesChunk::new(); + chunk = Box::new(BytesChunk::new()); } } - // quit early if there are no chunks for example in case the pipe was empty - if self.chunks.is_empty() { + let Some(chunk) = self.chunks.pop_front() else { + // quit if there are no chunks for example in case the pipe was empty return Ok(()); - } - - let chunk = self.chunks.pop_front().unwrap(); + }; // calculate the offset in the first chunk and put the calculated chunk as first element in // the self.chunks collection. The calculated offset must be in the range 0 to BUFFER_SIZE @@ -567,15 +564,14 @@ impl LinesChunkBuffer { while chunk.fill(reader)?.is_some() { self.lines += chunk.lines as u64; - self.chunks.push_back(chunk.clone()); + self.chunks.push_back(chunk); let first = &self.chunks[0]; if self.lines - first.lines as u64 > self.num_print { chunk = self.chunks.pop_front().unwrap(); - self.lines -= chunk.lines as u64; } else { - *chunk = LinesChunk::new(self.delimiter); + chunk = Box::new(LinesChunk::new(self.delimiter)); } } diff --git a/src/uu/tail/src/follow/files.rs b/src/uu/tail/src/follow/files.rs index f043703becc..dfe90bd39a7 100644 --- a/src/uu/tail/src/follow/files.rs +++ b/src/uu/tail/src/follow/files.rs @@ -62,10 +62,11 @@ impl FileHandling { /// Canonicalize `path` if it is not already an absolute path fn canonicalize_path(path: &Path) -> PathBuf { - if path.is_relative() && !path.is_stdin() { - if let Ok(p) = path.canonicalize() { - return p; - } + if path.is_relative() + && !path.is_stdin() + && let Ok(p) = path.canonicalize() + { + return p; } path.to_owned() } diff --git a/src/uu/tail/src/follow/watch.rs b/src/uu/tail/src/follow/watch.rs index 8b5027f5587..eebab670a8e 100644 --- a/src/uu/tail/src/follow/watch.rs +++ b/src/uu/tail/src/follow/watch.rs @@ -133,7 +133,7 @@ impl Observer { settings.follow, settings.use_polling, FileHandling::from(settings), - settings.pid, + settings.pid.unwrap_or_default(), ) } @@ -273,10 +273,9 @@ impl Observer { if path.is_tailable() { // Add existing regular files to `Watcher` (InotifyWatcher). watcher_rx.watch_with_parent(&path)?; - } else if !path.is_orphan() { + } else if let Some(active_parent) = path.parent().filter(|p| p.is_dir()) { // If `path` is not a tailable file, add its parent to `Watcher`. - watcher_rx - .watch(path.parent().unwrap(), RecursiveMode::NonRecursive)?; + watcher_rx.watch(active_parent, RecursiveMode::NonRecursive)?; // Add symlinks to orphans for retry polling (target may not exist) if path.is_symlink() { self.orphans.push(path); @@ -307,7 +306,13 @@ impl Observer { let display_name = self.files.get(event_path).display_name.clone(); match event.kind { - EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any | MetadataKind::WriteTime) | ModifyKind::Data(DataChange::Any) | ModifyKind::Name(RenameMode::To)) | + // NOTE: `ModifyKind::Any` is emitted by the Windows `ReadDirectoryChangesW` + // backend (for `FILE_ACTION_MODIFIED`) and by the macOS/BSD `kqueue` backend + // (for `NOTE_WRITE`) whenever a watched file's contents change. Without it, + // appends are never followed on those platforms (see GH issue #4827). + // Linux inotify and the polling fallback report the more specific + // `Modify(Data(..))`/`Modify(Metadata(..))` instead, so this is a no-op there. + EventKind::Modify(ModifyKind::Any | ModifyKind::Metadata(MetadataKind::Any | MetadataKind::WriteTime) | ModifyKind::Data(DataChange::Any) | ModifyKind::Name(RenameMode::To)) | EventKind::Create(CreateKind::File | CreateKind::Folder | CreateKind::Any) => { if let Ok(new_md) = event_path.metadata() { // `metadata()` follows symlinks, so under --follow=name a @@ -322,9 +327,9 @@ impl Observer { let pd = self.files.get(event_path); if let Some(old_md) = &pd.metadata { if is_tailable { - // We resume tracking from the start of the file, - // assuming it has been truncated to 0. This mimics GNU's `tail` - // behavior and is the usual truncation operation for log files. + // The path was untailable (missing, or not a + // regular file) and has become tailable again: + // open the new file and read it from the start. if !old_md.is_tailable() { show_error!( "{}", @@ -345,6 +350,9 @@ impl Observer { ); self.files.update_reader(event_path)?; } else if old_md.got_truncated(&new_md)? { + // A file that has lost content is read again + // from offset zero, since shortening a log + // almost always means it was emptied outright. show_error!( "{}", translate!("tail-status-file-truncated", "file" => display_name) @@ -400,22 +408,19 @@ impl Observer { } } EventKind::Remove(RemoveKind::File | RemoveKind::Any) - - // | EventKind::Modify(ModifyKind::Name(RenameMode::Any)) | EventKind::Modify(ModifyKind::Name(RenameMode::From)) => { if self.follow_name() { if settings.retry { - if let Some(old_md) = self.files.get_mut_metadata(event_path) { - if old_md.is_tailable() && self.files.get(event_path).reader.is_some() { + if self.files.get_mut_metadata(event_path).is_some_and(MetadataExtTail::is_tailable) + && self.files.get(event_path).reader.is_some() { show_error!( "{} {}: {}", display_name.quote(), translate!("tail-become-inaccessible"), translate!("tail-no-such-file-or-directory") ); - } } - if event_path.is_orphan() && !self.orphans.contains(event_path) { + if !event_path.has_active_parent() && !self.orphans.contains(event_path) { show_error!("{}", translate!("tail-status-directory-containing-watched-file-removed")); show_error!( "{}", @@ -562,14 +567,16 @@ pub fn follow(mut observer: Observer, settings: &Settings) -> UResult<()> { settings: &Settings, paths: &mut Vec| -> UResult<()> { - if let Some(event_path) = event.paths.first() { - if observer.files.contains_key(event_path) { - // Handle Event if it is about a path that we are monitoring - let new_paths = observer.handle_event(&event, settings)?; - for p in new_paths { - if !paths.contains(&p) { - paths.push(p); - } + if event + .paths + .first() + .is_some_and(|path| observer.files.contains_key(path)) + { + // Handle Event if it is about a path that we are monitoring + let new_paths = observer.handle_event(&event, settings)?; + for p in new_paths { + if !paths.contains(&p) { + paths.push(p); } } } @@ -600,15 +607,13 @@ pub fn follow(mut observer: Observer, settings: &Settings) -> UResult<()> { kind: notify::ErrorKind::Io(ref e), paths, })) if e.kind() == std::io::ErrorKind::NotFound => { - if let Some(event_path) = paths.first() { - if observer.files.contains_key(event_path) { - let _ = observer - .watcher_rx - .as_mut() - .unwrap() - .watcher - .unwatch(event_path); - } + if let Some(event_path) = paths.first().filter(|p| observer.files.contains_key(p)) { + let _ = observer + .watcher_rx + .as_mut() + .unwrap() + .watcher + .unwatch(event_path); } } Ok(Err(notify::Error { @@ -657,14 +662,11 @@ pub fn follow(mut observer: Observer, settings: &Settings) -> UResult<()> { if timeout_counter == settings.max_unchanged_stats { /* TODO: [2021-10; jhscheer] implement timeout_counter for each file. - '--max-unchanged-stats=n' - When tailing a file by name, if there have been n (default n=5) consecutive iterations - for which the file has not changed, then open/fstat the file to determine if that file - name is still associated with the same device/inode-number pair as before. When - following a log file that is rotated, this is approximately the number of seconds - between when tail prints the last pre-rotation lines and when it prints the lines that - have accumulated in the new log file. This option is meaningful only when polling - (i.e., without inotify) and when following by name. + `--max-unchanged-stats=n` counts per file, not once for the whole run: + a name that goes n polls (5 by default) without changing should be + re-opened and its device and inode compared against the handle still + held for it, which is how the swap behind a rotated log is noticed. + Only the polling path needs this, and only when following by name. */ } } diff --git a/src/uu/tail/src/paths.rs b/src/uu/tail/src/paths.rs index ad1306c52cc..42791a1e8d9 100644 --- a/src/uu/tail/src/paths.rs +++ b/src/uu/tail/src/paths.rs @@ -14,6 +14,7 @@ use std::os::unix::fs::{FileTypeExt, MetadataExt}; use std::path::{Path, PathBuf}; #[cfg(not(target_os = "wasi"))] use uucore::error::UResult; +use uucore::quoting_style::locale_aware_shell_escape; use uucore::translate; #[derive(Debug, Clone)] @@ -84,11 +85,11 @@ impl Input { // on dev/fd/0 (or /dev/stdin) will fail (NotFound), // so we treat stdin as a pipe here // https://github.com/rust-lang/rust/issues/95239 - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] { None } - #[cfg(not(target_os = "macos"))] + #[cfg(not(target_vendor = "apple"))] { PathBuf::from(text::FD0).canonicalize().ok() } @@ -134,8 +135,10 @@ impl HeaderPrinter { pub fn print(&mut self, string: &str) { if self.verbose { println!( - "{}==> {string} <==", + "{}==> {} <==", if self.first_header { "" } else { "\n" }, + // GNU quotes the name shown in the header when it needs it. + locale_aware_shell_escape(string), ); self.first_header = false; } @@ -208,7 +211,7 @@ impl MetadataExtTail for Metadata { #[cfg(not(target_os = "wasi"))] pub trait PathExtTail { fn is_stdin(&self) -> bool; - fn is_orphan(&self) -> bool; + fn has_active_parent(&self) -> bool; fn is_tailable(&self) -> bool; } @@ -220,9 +223,9 @@ impl PathExtTail for Path { || self.eq(Self::new(&translate!("tail-stdin-header"))) } - /// Return true if `path` does not have an existing parent directory - fn is_orphan(&self) -> bool { - !matches!(self.parent(), Some(parent) if parent.is_dir()) + /// Return true if `path` has an existing parent directory + fn has_active_parent(&self) -> bool { + self.parent().is_some_and(Self::is_dir) } /// Return true if `path` is a file type that can be tailed @@ -236,13 +239,13 @@ pub fn path_is_tailable(path: &Path) -> bool { } #[inline] -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] pub fn stdin_is_bad_fd() -> bool { uucore::signals::stdin_was_closed() } #[inline] -#[cfg(not(unix))] +#[cfg(not(all(unix, not(target_os = "fuchsia"))))] pub fn stdin_is_bad_fd() -> bool { false } diff --git a/src/uu/tail/src/platform/unix.rs b/src/uu/tail/src/platform/unix.rs index d233f8eb6f3..e9e8c947a49 100644 --- a/src/uu/tail/src/platform/unix.rs +++ b/src/uu/tail/src/platform/unix.rs @@ -20,14 +20,8 @@ impl ProcessChecker { } pub fn is_dead(&self) -> bool { - let Some(pid) = RustixPid::from_raw(self.pid) else { - return true; - }; - match test_kill_process(pid) { - Ok(()) => false, - Err(rustix::io::Errno::PERM) => false, - Err(_) => true, - } + RustixPid::from_raw(self.pid) + .is_none_or(|pid| test_kill_process(pid).is_err_and(|e| e != rustix::io::Errno::PERM)) } } diff --git a/src/uu/tail/src/tail.rs b/src/uu/tail/src/tail.rs index 4ab5e34c60e..9db1ceadf51 100644 --- a/src/uu/tail/src/tail.rs +++ b/src/uu/tail/src/tail.rs @@ -5,7 +5,7 @@ // spell-checker:ignore (ToDO) seek'd tail'ing ringbuffer ringbuf unwatch // spell-checker:ignore (ToDO) Uncategorized filehandle Signum memrchr -// spell-checker:ignore (libs) kqueue +// spell-checker:ignore (libs) kqueue lseek // spell-checker:ignore (acronyms) // spell-checker:ignore (env/flags) // spell-checker:ignore (jargon) tailable untailable stdlib @@ -41,7 +41,7 @@ use uucore::{show, show_error}; pub fn uumain(args: impl uucore::Args) -> UResult<()> { let settings = parse_args(args)?; - settings.check_warnings(); + settings.check_warnings()?; match settings.verify() { args::VerificationResult::CannotFollowStdinByName => { @@ -76,7 +76,7 @@ fn uu_tail(settings: &Settings) -> UResult<()> { // 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() { + for input in &settings.inputs { match input.kind() { InputKind::Stdin => { tail_stdin(settings, &mut printer, input, &mut observer)?; @@ -102,7 +102,7 @@ fn uu_tail(settings: &Settings) -> UResult<()> { the input file is not a FIFO, pipe, or regular file, it is unspecified whether or not the -f option shall be ignored. */ - if !settings.has_only_stdin() || settings.pid != 0 { + if !settings.has_only_stdin() || settings.pid.is_some_and(|pid| pid != 0) { follow::follow(observer, settings)?; } } @@ -118,6 +118,8 @@ fn tail_file( observer: &mut Observer, offset: u64, ) -> UResult<()> { + // some platform has different read error message + #[cfg(not(unix))] if path .metadata() .is_err_and(|e| e.kind() == ErrorKind::NotFound) @@ -127,7 +129,7 @@ fn tail_file( "{}", translate!( "tail-error-cannot-open-no-such-file", - "file" => input.display_name.clone(), + "file" => input.display_name, "error" => translate!("tail-no-such-file-or-directory") ) ); @@ -143,7 +145,7 @@ fn tail_file( show_error!( "{}", - translate!("tail-error-reading-file", "file" => input.display_name.clone(), "error" => err_msg) + translate!("tail-error-reading-file", "file" => input.display_name, "error" => err_msg) ); if settings.follow.is_some() { let msg = if settings.retry { @@ -153,7 +155,7 @@ fn tail_file( }; show_error!( "{}", - translate!("tail-error-cannot-follow-file-type", "file" => input.display_name.clone(), "msg" => msg) + translate!("tail-error-cannot-follow-file-type", "file" => input.display_name, "msg" => msg) ); } if !observer.follow_name_retry() { @@ -162,7 +164,7 @@ fn tail_file( observer.add_bad_path(path, input.display_name.as_str(), false)?; } else { #[cfg(unix)] - let open_result = open_file(path, settings.pid != 0); + let open_result = open_file(path, settings.pid.is_some_and(|pid| pid != 0)); #[cfg(not(unix))] let open_result = File::open(path); @@ -196,13 +198,13 @@ fn tail_file( Err(e) if e.kind() == ErrorKind::PermissionDenied => { observer.add_bad_path(path, input.display_name.as_str(), false)?; show!(e.map_err_context(|| { - translate!("tail-error-cannot-open-for-reading", "file" => input.display_name.clone()) + translate!("tail-error-cannot-open-for-reading", "file" => input.display_name) })); } Err(e) => { observer.add_bad_path(path, input.display_name.as_str(), false)?; return Err(e.map_err_context(|| { - translate!("tail-error-cannot-open-for-reading", "file" => input.display_name.clone()) + translate!("tail-error-cannot-open-for-reading", "file" => input.display_name) })); } } @@ -256,15 +258,12 @@ fn tail_stdin( // bad file descriptor or might not catch directory cases // e.g. see the differences between running ls -l /dev/stdin /dev/fd/0 // on macOS and Linux. - #[cfg(target_os = "macos")] - if let Ok(mut stdin_handle) = same_file::Handle::stdin() - && let Ok(meta) = stdin_handle.as_file_mut().metadata() - && meta.file_type().is_dir() - { + #[cfg(target_vendor = "apple")] + if uucore::fs::is_stdin_directory(&stdin()) { set_exit_code(1); show_error!( "{}", - translate!("tail-error-cannot-open-no-such-file", "file" => input.display_name.clone(), "error" => translate!("tail-no-such-file-or-directory")) + translate!("tail-error-cannot-open-no-such-file", "file" => input.display_name, "error" => translate!("tail-no-such-file-or-directory")) ); return Ok(()); } @@ -286,9 +285,7 @@ fn tail_stdin( // Save the current seek position/offset of a stdin redirected file. // This is needed to pass "gnu/tests/tail-2/start-middle.sh" #[cfg(unix)] - let stdin_offset = same_file::Handle::stdin() - .and_then(|mut h| h.as_file_mut().stream_position()) - .unwrap_or(0); // fifo + let stdin_offset = rustix::fs::tell(stdin()).unwrap_or(0); // fifo tail_file( settings, header_printer, @@ -468,22 +465,29 @@ fn bounded_tail(file: &mut File, settings: &Settings) -> UResult<()> { let i = forwards_thru_file(file, *count - 1, *delimiter).unwrap(); file.seek(SeekFrom::Start(i as u64)).unwrap(); } - FilterMode::Lines(Signum::MinusZero, _) => { + FilterMode::Lines(Signum::MinusZero, _) | FilterMode::Bytes(Signum::MinusZero) => { file.seek(SeekFrom::End(0)).unwrap(); } FilterMode::Bytes(Signum::Negative(count)) => { - if file.seek(SeekFrom::End(-(*count as i64))).is_err() { - file.seek(SeekFrom::Start(0)).unwrap(); + // A count that does not fit into an `i64` cannot be negated for + // the seek; like a seek before the start, it means the whole file. + match i64::try_from(*count) { + Ok(count) if file.seek(SeekFrom::End(-count)).is_ok() => {} + _ => { + file.seek(SeekFrom::Start(0)).unwrap(); + } } limit = Some(*count); } FilterMode::Bytes(Signum::Positive(count)) if count > &1 => { // GNU `tail` seems to index bytes and lines starting at 1, not // at 0. It seems to treat `+0` and `+1` as the same thing. - file.seek(SeekFrom::Start(*count - 1)).unwrap(); - } - FilterMode::Bytes(Signum::MinusZero) => { - file.seek(SeekFrom::End(0)).unwrap(); + // A start offset past the largest seekable position makes the + // underlying `lseek` fail with `EINVAL`; treat that like a start + // beyond the end of the file and produce no output. + file.seek(SeekFrom::Start(*count - 1)) + .or_else(|_| file.seek(SeekFrom::End(0))) + .unwrap(); } _ => {} } @@ -500,7 +504,8 @@ fn unbounded_tail(reader: &mut BufReader, settings: &Settings) -> UR chunks.fill(reader)?; chunks.write(&mut writer)?; } - FilterMode::Lines(Signum::PlusZero | Signum::Positive(1), _) => { + FilterMode::Lines(Signum::PlusZero | Signum::Positive(1), _) + | FilterMode::Bytes(Signum::PlusZero | Signum::Positive(1)) => { io::copy(reader, &mut writer)?; } FilterMode::Lines(Signum::Positive(count), sep) => { @@ -529,27 +534,23 @@ fn unbounded_tail(reader: &mut BufReader, settings: &Settings) -> UR chunks.fill(reader)?; chunks.write(&mut writer)?; } - FilterMode::Bytes(Signum::PlusZero | Signum::Positive(1)) => { - io::copy(reader, &mut writer)?; - } FilterMode::Bytes(Signum::Positive(count)) => { let mut num_skip = *count - 1; let mut chunk = chunks::BytesChunk::new(); loop { - if let Some(bytes) = chunk.fill(reader)? { - let bytes: u64 = bytes as u64; - match bytes.cmp(&num_skip) { - Ordering::Less => num_skip -= bytes, - Ordering::Equal => { - break; - } - Ordering::Greater => { - writer.write_all(chunk.get_buffer_with(num_skip as usize))?; - break; - } - } - } else { + let Some(bytes) = chunk.fill(reader)? else { return Ok(()); + }; + let bytes: u64 = bytes as u64; + match bytes.cmp(&num_skip) { + Ordering::Less => num_skip -= bytes, + Ordering::Equal => { + break; + } + Ordering::Greater => { + writer.write_all(chunk.get_buffer_with(num_skip as usize))?; + break; + } } } @@ -557,11 +558,11 @@ fn unbounded_tail(reader: &mut BufReader, settings: &Settings) -> UR } _ => {} } - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] writer.flush()?; // SIGPIPE is not available on Windows. - #[cfg(target_os = "windows")] + #[cfg(windows)] writer.flush().inspect_err(|err| { if err.kind() == ErrorKind::BrokenPipe { std::process::exit(13); diff --git a/src/uu/tail/src/text.rs b/src/uu/tail/src/text.rs index b4874643028..16eb93bd71f 100644 --- a/src/uu/tail/src/text.rs +++ b/src/uu/tail/src/text.rs @@ -16,7 +16,7 @@ pub const DEV_PTMX: &str = "/dev/ptmx"; pub const BACKEND: &str = "inotify"; #[cfg(all(unix, not(target_os = "linux")))] pub const BACKEND: &str = "kqueue"; -#[cfg(target_os = "windows")] +#[cfg(windows)] pub const BACKEND: &str = "ReadDirectoryChanges"; -#[cfg(not(any(unix, target_os = "windows")))] +#[cfg(not(any(unix, windows)))] pub const BACKEND: &str = "polling"; diff --git a/src/uu/tee/Cargo.toml b/src/uu/tee/Cargo.toml index 9eb2eefa3e9..9d551285b12 100644 --- a/src/uu/tee/Cargo.toml +++ b/src/uu/tee/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/tee.rs" test = false @@ -21,19 +18,26 @@ doctest = false [dependencies] clap = { workspace = true } -rustix = { workspace = true, features = ["stdio", "fs"] } -uucore = { workspace = true, features = ["fs", "parser", "signals"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["fs", "parser", "signals"] } [dev-dependencies] divan = { workspace = true } -tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[target.'cfg(unix)'.dev-dependencies] +rustix = { workspace = true, features = ["fs"] } + +[target.'cfg(any(unix, target_os = "wasi"))'.dependencies] +rustix = { workspace = true, features = ["stdio"] } + [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] rustix = { workspace = true, features = ["pipe"] } uucore = { workspace = true, features = ["pipes"] } +[lints] +workspace = true + [[bin]] name = "tee" path = "src/main.rs" diff --git a/src/uu/tee/src/tee.rs b/src/uu/tee/src/tee.rs index e43a0a02051..d0f7d8245c9 100644 --- a/src/uu/tee/src/tee.rs +++ b/src/uu/tee/src/tee.rs @@ -7,11 +7,11 @@ use std::ffi::OsString; use std::fs::OpenOptions; -use std::io::{self, Error, ErrorKind, Write, stderr}; +use std::io::{self, Error, ErrorKind, Write}; use std::path::PathBuf; use uucore::display::Quotable; use uucore::error::{UResult, strip_errno}; -use uucore::translate; +use uucore::{show_error, translate}; mod cli; pub use crate::cli::uu_app; @@ -19,7 +19,7 @@ use crate::cli::{Options, OutputErrorMode, options}; #[cfg(target_os = "linux")] use uucore::signals::ensure_stdout_not_broken; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "fuchsia")))] use uucore::signals::{disable_pipe_errors, ignore_interrupts}; #[uucore::main] @@ -57,11 +57,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } fn tee(options: &Options) -> Result<(), ()> { - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] if options.ignore_interrupts { ignore_interrupts().map_err(|_| ())?; } - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "fuchsia")))] if options.output_error.is_some() { disable_pipe_errors().map_err(|_| ())?; } @@ -124,7 +124,7 @@ fn open( name: name.clone(), })), Err(f) => { - let _ = writeln!(stderr(), "{}: {f}", name.maybe_quote()); + show_error!("{}: {}", name.maybe_quote(), strip_errno(&f)); match output_error { Some(OutputErrorMode::Exit | OutputErrorMode::ExitNoPipe) => Some(Err(f)), _ => None, @@ -153,24 +153,41 @@ impl MultiWriter { let mut input = io::stdin(); #[cfg(any(target_os = "linux", target_os = "android"))] macro_rules! splice_or_detach { - ($pipe:expr, $writer:expr, $len:expr) => { - if let Err(e) = uucore::pipes::drain_pipe($pipe, $writer, $len) { - self.aborted |= - process_error(self.output_error_mode, e, $writer, &mut self.ignored_errors) - .is_err(); + ($pipe_read:ident, $pipe_write:ident, $writer:expr, $len:expr, $sized:expr) => { + if let Err(e) = uucore::pipes::drain_pipe(&$pipe_read, &$writer, $len) { + self.aborted |= process_error( + self.output_error_mode, + e, + &$writer, + &mut self.ignored_errors, + ) + .is_err(); $writer.name.clear(); //mark as exited + // the failed write can leave bytes in the pipe: replace it with an empty one. + // Free it first, so that the new one does not need more file descriptors. + drop($pipe_read); + drop($pipe_write); + // same size as the 2nd pipe got, so the 2nd is never smaller than the 1st + match if $sized { pipe::() } else { io::pipe() } { + Ok(pipe) => ($pipe_read, $pipe_write) = pipe, + Err(e) => { + show_error!("{}", strip_errno(&e)); + return Err(()); + } + } } }; } // needs 2 pipes to duplicate input multiple times #[cfg(any(target_os = "linux", target_os = "android"))] - if let Ok((pipe_read, pipe_write)) = io::pipe() - && let Ok((pipe2_read, pipe2_write)) = io::pipe() + if let Ok((mut pipe_read, mut pipe_write)) = io::pipe() + && let Ok((mut pipe2_read, mut pipe2_write)) = io::pipe() { use rustix::pipe::fcntl_setpipe_size; - use uucore::pipes::MAX_ROOTLESS_PIPE_SIZE; + use uucore::pipes::{MAX_ROOTLESS_PIPE_SIZE, pipe}; // improve throughput. 2nd pipe should be larger than 1st one for proper tee() length. - if fcntl_setpipe_size(&pipe2_read, MAX_ROOTLESS_PIPE_SIZE).is_ok() { + let sized = fcntl_setpipe_size(&pipe2_read, MAX_ROOTLESS_PIPE_SIZE).is_ok(); + if sized { let _ = fcntl_setpipe_size(&pipe_read, MAX_ROOTLESS_PIPE_SIZE); let _ = fcntl_setpipe_size(&self.writers[0], MAX_ROOTLESS_PIPE_SIZE); // stdout } @@ -186,10 +203,10 @@ impl MultiWriter { // do not consume input let tee_res = uucore::pipes::tee(&pipe_read, &pipe2_write, s); assert_eq!(tee_res, Ok(s), "2nd pipe should have enough spare"); - splice_or_detach!(&pipe2_read, other, s); + splice_or_detach!(pipe2_read, pipe2_write, *other, s, sized); } // last one consumes input - splice_or_detach!(&pipe_read, last, s); + splice_or_detach!(pipe_read, pipe_write, *last, s, sized); self.writers.retain(|w| !w.name.is_empty()); if self.aborted { return Err(()); @@ -214,9 +231,8 @@ impl MultiWriter { Ok(slice) => self.write_flush(slice)?, Err(e) if e.kind() == ErrorKind::Interrupted => {} Err(e) => { - let _ = writeln!( - stderr(), - "tee: {}", + show_error!( + "{}", translate!("tee-error-stdin", "error" => strip_errno(&e)) ); return Err(()); @@ -270,7 +286,7 @@ fn process_error( if ignore_pipe && e.kind() == ErrorKind::BrokenPipe { return Ok(()); } - let _ = writeln!(stderr(), "{}: {e}", writer.name.maybe_quote()); + show_error!("{}: {}", writer.name.maybe_quote(), strip_errno(&e)); if let Some(OutputErrorMode::Exit | OutputErrorMode::ExitNoPipe) = mode { Err(()) } else { diff --git a/src/uu/test/Cargo.toml b/src/uu/test/Cargo.toml index 4d9e58ba54f..c901e47d892 100644 --- a/src/uu/test/Cargo.toml +++ b/src/uu/test/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/test.rs" doctest = false @@ -21,13 +18,33 @@ doctest = false [dependencies] clap = { workspace = true } fluent = { workspace = true } -libc = { workspace = true } thiserror = { workspace = true } -uucore = { workspace = true, features = ["process"] } +uucore = { workspace = true, features = ["fs", "i18n-collator", "wide"] } + +[target.'cfg(not(any(windows, target_os = "wasi")))'.dependencies] +rustix = { workspace = true, features = ["fs", "process"] } + +[target.'cfg(not(windows))'.dependencies] +libc = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_Threading", +] } [dev-dependencies] tempfile = { workspace = true } +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true + [[bin]] name = "test" path = "src/main.rs" diff --git a/src/uu/test/locales/en-US.ftl b/src/uu/test/locales/en-US.ftl index 56d8d4f65a3..df33b65ebf6 100644 --- a/src/uu/test/locales/en-US.ftl +++ b/src/uu/test/locales/en-US.ftl @@ -149,12 +149,27 @@ test-after-help = Exit with the status determined by EXPRESSION. NOTE: your shell may have its own version of test and/or {"["}, which usually supersedes the version described here. Please refer to your shell's documentation for details about the options it supports. + Errors are reported with the expression echoed back and a caret under the + argument at fault whenever stderr is a terminal; anything else reading + stderr gets the usual single-line message. + # Error messages test-error-missing-closing-bracket = missing '{"]"}' -test-error-expected = expected { $value } -test-error-expected-value = expected value +test-error-expected = { $value } expected test-error-missing-argument = missing argument after { $argument } test-error-extra-argument = extra argument { $argument } test-error-unknown-operator = unknown operator { $operator } test-error-invalid-integer = invalid integer { $value } test-error-unary-operator-expected = { $operator }: unary operator expected +test-error-binary-operator-expected = { $operator }: binary operator expected +test-error-expected-found = { $expected } expected, found { $found } +test-error-does-not-accept-length = { $operator } does not accept -l + +# Diagnostic labels, used when errors are rendered with a source snippet +test-diag-label-unary-operator-expected = this needs an expression on both sides +test-diag-help-integer-op = -eq, -ne, -lt, -le, -gt and -ge compare integers; use =, {"!"}=, {"<"} or {">"} to compare strings +test-diag-help-integer-op-mnemonics = -eq equal, -ne not equal, -lt less than, -le less than or equal, -gt greater than, -ge greater than or equal +test-diag-help-file-descriptor = -t takes a file descriptor number: 0 is standard input, 1 standard output, 2 standard error +test-diag-help-missing-argument = an unset or empty variable expands to nothing, leaving the operator without an operand; quote it as "$var" +test-diag-help-extra-argument = an unquoted variable expanding to several words is the usual cause; quote it as "$var" to keep it a single operand +test-diag-help-unknown-operator = run '{ $name } --help' for the list of supported operators diff --git a/src/uu/test/locales/fr-FR.ftl b/src/uu/test/locales/fr-FR.ftl index 9255bf95a24..5bdd2c9e859 100644 --- a/src/uu/test/locales/fr-FR.ftl +++ b/src/uu/test/locales/fr-FR.ftl @@ -149,12 +149,27 @@ test-after-help = Quitter avec le statut déterminé par EXPRESSION. NOTE : votre shell peut avoir sa propre version de test et/ou {"["}, qui remplace généralement la version décrite ici. Veuillez vous référer à la documentation de votre shell pour les détails sur les options qu'il prend en charge. + Les erreurs sont signalées en réaffichant l'expression avec un caret sous + l'argument fautif dès que la sortie d'erreur est un terminal ; sinon, le + message habituel sur une seule ligne est conservé. + # Messages d'erreur test-error-missing-closing-bracket = '{"]"}' manquant test-error-expected = { $value } attendu -test-error-expected-value = valeur attendue test-error-missing-argument = argument manquant après { $argument } test-error-extra-argument = argument supplémentaire { $argument } test-error-unknown-operator = opérateur inconnu { $operator } test-error-invalid-integer = entier invalide { $value } test-error-unary-operator-expected = { $operator } : opérateur unaire attendu +test-error-binary-operator-expected = { $operator } : opérateur binaire attendu +test-error-expected-found = { $expected } attendu, trouvé { $found } +test-error-does-not-accept-length = { $operator } n'accepte pas -l + +# Étiquettes de diagnostic, utilisées quand les erreurs sont rendues avec un extrait +test-diag-label-unary-operator-expected = nécessite une expression de chaque côté +test-diag-help-integer-op = -eq, -ne, -lt, -le, -gt et -ge comparent des entiers ; utilisez =, {"!"}=, {"<"} ou {">"} pour comparer des chaînes +test-diag-help-integer-op-mnemonics = -eq égal, -ne différent, -lt inférieur, -le inférieur ou égal, -gt supérieur, -ge supérieur ou égal +test-diag-help-file-descriptor = -t prend un numéro de descripteur de fichier : 0 pour l'entrée standard, 1 la sortie standard, 2 la sortie d'erreur +test-diag-help-missing-argument = une variable vide ou non définie ne produit aucun mot, laissant l'opérateur sans opérande ; mettez-la entre guillemets : "$var" +test-diag-help-extra-argument = une variable non protégée qui produit plusieurs mots en est la cause habituelle ; mettez-la entre guillemets : "$var" +test-diag-help-unknown-operator = lancez « { $name } --help » pour la liste des opérateurs pris en charge diff --git a/src/uu/test/src/diagnostics.rs b/src/uu/test/src/diagnostics.rs new file mode 100644 index 00000000000..0736c255c73 --- /dev/null +++ b/src/uu/test/src/diagnostics.rs @@ -0,0 +1,77 @@ +// 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. + +//! Maps a [`ParseError`] onto the argument it points at, so that +//! [`uucore::diagnostics`] can render it with a caret. + +use std::ffi::OsString; + +use uucore::diagnostics::Snapshot; +use uucore::translate; + +use crate::error::{ErrorAt, ParseError, ParseErrorKind}; + +/// Render `err` against `args`. +/// +/// Returns `false` when the error cannot be tied to an argument, in which case +/// the caller should fall back to the plain one-line message. +pub fn render(args: &[OsString], err: &ParseError) -> bool { + let snapshot = Snapshot::new(args); + + let index = match &err.at { + ErrorAt::Unknown => return false, + ErrorAt::Token(index) => *index, + // Recovered from the value, for errors raised after parsing. + ErrorAt::Value(value) => match snapshot.index_of(value) { + Some(index) => index, + None => return false, + }, + }; + + // Labelled only where a label would add to the message, per the convention + // in `uucore::diagnostics`. + let (label, help) = match &err.kind { + ParseErrorKind::Expected(_) + | ParseErrorKind::ExpectedFound(_, _) + | ParseErrorKind::BinaryOperatorExpected(_) + | ParseErrorKind::DoesNotAcceptLength(_) => (None, None), + ParseErrorKind::ExtraArgument(_) => ( + Some(translate!("diagnostics-label-expression-complete")), + Some(translate!("test-diag-help-extra-argument")), + ), + ParseErrorKind::MissingArgument(_) => { + (None, Some(translate!("test-diag-help-missing-argument"))) + } + ParseErrorKind::UnaryOperatorExpected(_) => ( + Some(translate!("test-diag-label-unary-operator-expected")), + None, + ), + ParseErrorKind::InvalidInteger(_) => ( + None, + Some(format!( + "{}\n{}", + translate!("test-diag-help-integer-op"), + translate!("test-diag-help-integer-op-mnemonics") + )), + ), + ParseErrorKind::InvalidFileDescriptor(_) => { + (None, Some(translate!("test-diag-help-file-descriptor"))) + } + ParseErrorKind::UnknownOperator(_) => ( + None, + Some(translate!( + "test-diag-help-unknown-operator", + "name" => uucore::util_name() + )), + ), + }; + + snapshot.render( + index, + &err.kind.to_string(), + label.as_deref(), + help.as_deref(), + ) +} diff --git a/src/uu/test/src/error.rs b/src/uu/test/src/error.rs index cd9c071acb1..9912a3c3810 100644 --- a/src/uu/test/src/error.rs +++ b/src/uu/test/src/error.rs @@ -3,14 +3,12 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use thiserror::Error; +use std::ffi::{OsStr, OsString}; use uucore::translate; /// Represents an error encountered while parsing a test expression -#[derive(Error, Debug)] -pub enum ParseError { - #[error("{}", translate!("test-error-expected-value"))] - ExpectedValue, +#[derive(Debug, thiserror::Error)] +pub enum ParseErrorKind { #[error("{}", translate!("test-error-expected", "value" => .0))] Expected(String), #[error("{}", translate!("test-error-extra-argument", "argument" => .0))] @@ -21,8 +19,66 @@ pub enum ParseError { UnknownOperator(String), #[error("{}", translate!("test-error-invalid-integer", "value" => .0))] InvalidInteger(String), + /// Worded like [`Self::InvalidInteger`], but kept apart so `-t` gets advice about descriptors. + #[error("{}", translate!("test-error-invalid-integer", "value" => .0))] + InvalidFileDescriptor(String), #[error("{}", translate!("test-error-unary-operator-expected", "operator" => .0))] UnaryOperatorExpected(String), + #[error("{}", translate!("test-error-binary-operator-expected", "operator" => .0))] + BinaryOperatorExpected(String), + #[error("{}", translate!("test-error-expected-found", "expected" => .0, "found" => .1))] + ExpectedFound(String, String), + #[error("{}", translate!("test-error-does-not-accept-length", "operator" => .0))] + DoesNotAcceptLength(String), +} + +/// Where in the original argument list an error occurred. +/// +/// Only read when a source snippet is rendered. +#[derive(Debug, Default)] +pub enum ErrorAt { + /// No position could be attributed to the error. + #[default] + Unknown, + /// Zero-based index into the arguments handed to the parser. + Token(usize), + /// The first argument equal to this value. + Value(OsString), +} + +/// A parse or evaluation error, together with the position it points at. +#[derive(Debug, thiserror::Error)] +#[error("{kind}")] +pub struct ParseError { + pub kind: ParseErrorKind, + pub at: ErrorAt, +} + +impl From for ParseError { + fn from(kind: ParseErrorKind) -> Self { + Self { + kind, + at: ErrorAt::Unknown, + } + } +} + +impl ParseError { + /// An error pointing at the argument with index `index`. + pub fn at_token(kind: ParseErrorKind, index: usize) -> Self { + Self { + kind, + at: ErrorAt::Token(index), + } + } + + /// An error pointing at the first argument equal to `token`. + pub fn at_value(kind: ParseErrorKind, token: &OsStr) -> Self { + Self { + kind, + at: ErrorAt::Value(token.to_os_string()), + } + } } /// A Result type for parsing test expressions diff --git a/src/uu/test/src/faccessat.rs b/src/uu/test/src/faccessat.rs new file mode 100644 index 00000000000..a7e51434737 --- /dev/null +++ b/src/uu/test/src/faccessat.rs @@ -0,0 +1,88 @@ +// 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 (vars) egid euid accessat EACCESS OPNOTSUPP NOSYS XOTH XGRP XUSR + +use rustix::fs::Access; +use rustix::io; +#[cfg(not(any(windows, target_os = "wasi")))] +use rustix::process::{getegid, geteuid}; +use std::ffi::OsStr; +use std::fs::OpenOptions; + +/// Check `-r`, `-w` and `-x` with the process's effective credentials. +/// +/// Let the OS account for supplementary groups, ACLs and privileged users. +#[cfg(not(any(windows, target_os = "wasi")))] +pub(crate) fn effective_access(path: &OsStr, access: Access) -> bool { + use rustix::fs::{AtFlags, CWD, accessat}; + + #[cfg(target_os = "android")] + let flags = AtFlags::empty(); + + #[cfg(not(target_os = "android"))] + let flags = AtFlags::EACCESS; + + match accessat(CWD, path, access, flags) { + Ok(()) => true, + Err(io::Errno::NOSYS) | Err(io::Errno::OPNOTSUPP) => { + effective_access_fallback(path, access) + } + Err(_) => false, + } +} + +fn effective_access_fallback(path: &OsStr, access: Access) -> bool { + use rustix::fs::Mode; + use rustix::fs::stat; + use rustix::process::getgroups; + if access.contains(Access::EXEC_OK) { + let Ok(st) = stat(path) else { + return false; + }; + + let mode = Mode::from_raw_mode(st.st_mode); + let euid = geteuid().as_raw(); + let egid = getegid().as_raw(); + + let executable = if euid == 0 { + mode.intersects(Mode::XUSR | Mode::XGRP | Mode::XOTH) + } else if st.st_uid == euid { + mode.contains(Mode::XUSR) + } else { + let in_group = st.st_gid == egid + || getgroups() + .is_ok_and(|groups| groups.iter().any(|gid| gid.as_raw() == st.st_gid)); + + if in_group { + mode.contains(Mode::XGRP) + } else { + mode.contains(Mode::XOTH) + } + }; + + if !executable { + return false; + } + } + + if access.intersects(Access::READ_OK | Access::WRITE_OK) { + let mut options = OpenOptions::new(); + + options + .read(access.contains(Access::READ_OK)) + .write(access.contains(Access::WRITE_OK)); + + if options.open(path).is_err() { + return false; + } + } + + if access == Access::EXISTS { + return stat(path).is_ok(); + } + + true +} diff --git a/src/uu/test/src/parser.rs b/src/uu/test/src/parser.rs index f391752512a..397f017cf5a 100644 --- a/src/uu/test/src/parser.rs +++ b/src/uu/test/src/parser.rs @@ -3,442 +3,524 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (grammar) BOOLOP STRLEN FILETEST FILEOP INTOP STRINGOP ; (vars) LParen StrlenOp +// spell-checker:ignore (grammar) BOOL_OP INT_OP STRING_OP FILE_OP UNARY_OP lparen rparen nargs use std::ffi::{OsStr, OsString}; -use std::iter::Peekable; - -use super::error::{ParseError, ParseResult}; +use super::error::{ParseError, ParseErrorKind, ParseResult}; use uucore::display::Quotable; -/// Represents one of the binary comparison operators for strings, integers, or files -#[derive(Debug, PartialEq, Eq)] -pub enum Operator { - String(OsString), - Int(OsString), - File(OsString), +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum BinaryOp { + StrEq, + StrNe, + StrLt, + StrGt, + IntEq, + IntNe, + IntLt, + IntLe, + IntGt, + IntGe, + FileEf, + FileNt, + FileOt, } -/// Represents one of the unary test operators for strings or files -#[derive(Debug, PartialEq, Eq)] -pub enum UnaryOperator { - StrlenOp(OsString), - FiletestOp(OsString), +impl BinaryOp { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::StrEq => "=", + Self::StrNe => "!=", + Self::StrLt => "<", + Self::StrGt => ">", + Self::IntEq => "-eq", + Self::IntNe => "-ne", + Self::IntLt => "-lt", + Self::IntLe => "-le", + Self::IntGt => "-gt", + Self::IntGe => "-ge", + Self::FileEf => "-ef", + Self::FileNt => "-nt", + Self::FileOt => "-ot", + } + } } -/// Represents a parsed token from a test expression -#[derive(Debug, PartialEq, Eq)] -pub enum Symbol { - LParen, - Bang, - BoolOp(OsString), - Literal(OsString), - Op(Operator), - UnaryOp(UnaryOperator), - None, +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum UnaryOp { + BlockSpecial, + CharacterSpecial, + Directory, + Exists, + Regular, + GroupIdFlag, + GroupOwns, + SymLink, + Sticky, + ModifiedSinceRead, + UserOwns, + Fifo, + Readable, + NonEmpty, + Socket, + Tty, + UserIdFlag, + Writable, + Executable, + StrNonEmpty, + StrEmpty, } -impl Symbol { - /// Create a new Symbol from an [`OsString`]. - /// - /// Returns `Symbol::None` in place of None - fn new(token: Option) -> Self { - match token { - Some(s) => match s.to_str() { - Some(t) => match t { - "(" => Self::LParen, - "!" => Self::Bang, - "-a" | "-o" => Self::BoolOp(s), - "=" | "==" | "!=" | "<" | ">" => Self::Op(Operator::String(s)), - "-eq" | "-ge" | "-gt" | "-le" | "-lt" | "-ne" => Self::Op(Operator::Int(s)), - "-ef" | "-nt" | "-ot" => Self::Op(Operator::File(s)), - "-n" | "-z" => Self::UnaryOp(UnaryOperator::StrlenOp(s)), - "-b" | "-c" | "-d" | "-e" | "-f" | "-g" | "-G" | "-h" | "-k" | "-L" | "-N" - | "-O" | "-p" | "-r" | "-s" | "-S" | "-t" | "-u" | "-w" | "-x" => { - Self::UnaryOp(UnaryOperator::FiletestOp(s)) - } - _ => Self::Literal(s), - }, - None => Self::Literal(s), - }, - None => Self::None, - } - } +#[derive(Clone, Copy, Debug)] +pub(crate) enum Operand<'a> { + Value(&'a OsStr), + Length(&'a OsStr), +} - /// Convert this Symbol into a [`Symbol::Literal`], useful for cases where - /// test treats an operator as a string operand (test has no reserved - /// words). - /// - /// # Panics - /// - /// Panics if `self` is [`Symbol::None`] - fn into_literal(self) -> Self { - Self::Literal(match self { - Self::LParen => OsString::from("("), - Self::Bang => OsString::from("!"), - Self::BoolOp(s) - | Self::Literal(s) - | Self::Op(Operator::String(s) | Operator::Int(s) | Operator::File(s)) - | Self::UnaryOp(UnaryOperator::StrlenOp(s) | UnaryOperator::FiletestOp(s)) => s, - Self::None => panic!(), - }) - } +pub(crate) trait Evaluator { + fn unary(&mut self, op: UnaryOp, arg: &OsStr) -> ParseResult; + fn binary(&mut self, op: BinaryOp, lhs: Operand<'_>, rhs: Operand<'_>) -> ParseResult; } -/// Implement Display trait for Symbol to make it easier to print useful errors. -/// We will try to match the format in which the symbol appears in the input. -impl std::fmt::Display for Symbol { - /// Format a Symbol for printing - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match &self { - Self::LParen => OsStr::new("("), - Self::Bang => OsStr::new("!"), - Self::BoolOp(s) - | Self::Literal(s) - | Self::Op(Operator::String(s) | Operator::Int(s) | Operator::File(s)) - | Self::UnaryOp(UnaryOperator::StrlenOp(s) | UnaryOperator::FiletestOp(s)) => { - OsStr::new(s) - } - Self::None => OsStr::new("None"), - }; - write!(f, "{}", s.quote()) - } +fn bytes(s: &OsStr) -> &[u8] { + s.as_encoded_bytes() +} + +fn token_is(s: &OsStr, token: &[u8]) -> bool { + bytes(s) == token +} + +fn is_bang(s: &OsStr) -> bool { + token_is(s, b"!") +} + +fn is_lparen(s: &OsStr) -> bool { + token_is(s, b"(") +} + +fn is_rparen(s: &OsStr) -> bool { + token_is(s, b")") +} + +fn is_and(s: &OsStr) -> bool { + token_is(s, b"-a") } -/// Recursive descent parser for test, which converts a list of [`OsString`]s -/// (typically command line arguments) into a stack of Symbols in postfix -/// order. +fn is_or(s: &OsStr) -> bool { + token_is(s, b"-o") +} + +fn is_length(s: &OsStr) -> bool { + token_is(s, b"-l") +} + +fn is_two_byte_switch(s: &OsStr) -> bool { + let b = bytes(s); + b.len() == 2 && b[0] == b'-' && b[1] != 0 +} + +fn binary_op(s: &OsStr) -> Option { + Some(match bytes(s) { + b"=" | b"==" => BinaryOp::StrEq, + b"!=" => BinaryOp::StrNe, + b"<" => BinaryOp::StrLt, + b">" => BinaryOp::StrGt, + b"-eq" => BinaryOp::IntEq, + b"-ne" => BinaryOp::IntNe, + b"-lt" => BinaryOp::IntLt, + b"-le" => BinaryOp::IntLe, + b"-gt" => BinaryOp::IntGt, + b"-ge" => BinaryOp::IntGe, + b"-ef" => BinaryOp::FileEf, + b"-nt" => BinaryOp::FileNt, + b"-ot" => BinaryOp::FileOt, + _ => return None, + }) +} + +fn unary_op(s: &OsStr) -> Option { + Some(match bytes(s) { + b"-b" => UnaryOp::BlockSpecial, + b"-c" => UnaryOp::CharacterSpecial, + b"-d" => UnaryOp::Directory, + b"-e" => UnaryOp::Exists, + b"-f" => UnaryOp::Regular, + b"-g" => UnaryOp::GroupIdFlag, + b"-G" => UnaryOp::GroupOwns, + b"-h" | b"-L" => UnaryOp::SymLink, + b"-k" => UnaryOp::Sticky, + b"-N" => UnaryOp::ModifiedSinceRead, + b"-O" => UnaryOp::UserOwns, + b"-p" => UnaryOp::Fifo, + b"-r" => UnaryOp::Readable, + b"-s" => UnaryOp::NonEmpty, + b"-S" => UnaryOp::Socket, + b"-t" => UnaryOp::Tty, + b"-u" => UnaryOp::UserIdFlag, + b"-w" => UnaryOp::Writable, + b"-x" => UnaryOp::Executable, + b"-n" => UnaryOp::StrNonEmpty, + b"-z" => UnaryOp::StrEmpty, + _ => return None, + }) +} + +/// Parser for `test` expressions. /// -/// Grammar: +/// `test` has special, historical meanings for expressions with one to four +/// arguments. Longer expressions use the following grammar, with the usual +/// precedence from strongest to weakest: `!`, `-a`, then `-o`. /// -/// EXPR → TERM | EXPR BOOLOP EXPR -/// TERM → ( EXPR ) -/// TERM → ! EXPR -/// TERM → UOP str -/// UOP → STRLEN | FILETEST -/// TERM → str OP str -/// TERM → str | 𝜖 -/// OP → STRINGOP | INTOP | FILEOP -/// STRINGOP → = | == | != -/// INTOP → -eq | -ge | -gt | -le | -lt | -ne -/// FILEOP → -ef | -nt | -ot -/// STRLEN → -n | -z -/// FILETEST → -b | -c | -d | -e | -f | -g | -G | -h | -k | -L | -N | -O | -p | -/// -r | -s | -S | -t | -u | -w | -x -/// BOOLOP → -a | -o +/// ```text +/// EXPR := OR_EXPR +/// OR_EXPR := AND_EXPR ( "-o" AND_EXPR )* +/// AND_EXPR := TERM ( "-a" TERM )* +/// TERM := "!"* ATOM +/// ATOM := "(" EXPR ")" +/// | VALUE OP VALUE +/// | "-l" VALUE INT_OP VALUE +/// | VALUE INT_OP "-l" VALUE +/// | UNARY_OP VALUE +/// | VALUE +/// OP := STRING_OP | INT_OP | FILE_OP +/// STRING_OP := "=" | "==" | "!=" | "<" | ">" +/// INT_OP := "-eq" | "-ne" | "-lt" | "-le" | "-gt" | "-ge" +/// FILE_OP := "-ef" | "-nt" | "-ot" +/// UNARY_OP := "-n" | "-z" | FILE_TEST +/// FILE_TEST := "-b" | "-c" | "-d" | "-e" | "-f" | "-g" | "-G" | "-h" +/// | "-k" | "-L" | "-N" | "-O" | "-p" | "-r" | "-s" | "-S" +/// | "-t" | "-u" | "-w" | "-x" +///``` /// -#[derive(Debug)] -struct Parser { - tokens: Peekable>, - pub stack: Vec, +/// The grammar is only a description of the long-expression path. Operators +/// are recognized according to their position, so tokens such as `!`, `-a`, +/// `-o`, and `(` can still be literal operands in the short forms. Parsing and +/// evaluation happen together, directly from the argument list; this preserves +/// `test`'s requirement to evaluate both sides of `-a` and `-o`. +struct Parser<'a, E> { + args: &'a [OsString], + pos: usize, + eval: E, } -impl Parser { - /// Construct a new Parser from a `Vec` of tokens. - fn new(tokens: Vec) -> Self { - Self { - tokens: tokens.into_iter().peekable(), - stack: vec![], - } +impl<'a, E: Evaluator> Parser<'a, E> { + fn arg(&self, index: usize) -> &'a OsStr { + self.args[index].as_os_str() } - /// Fetch the next token from the input stream as a Symbol. - fn next_token(&mut self) -> Symbol { - Symbol::new(self.tokens.next()) + #[cold] + fn missing_after_last(&self) -> ParseError { + let index = self.args.len().saturating_sub(1); + let value = self + .args + .get(index) + .map_or_else(|| "''".to_owned(), |s| s.quote().to_string()); + ParseError::at_token(ParseErrorKind::MissingArgument(value), index) } - /// Consume the next token & verify that it matches the provided value. - fn expect(&mut self, value: &str) -> ParseResult<()> { - match self.next_token() { - Symbol::Literal(s) if s == value => Ok(()), - _ => Err(ParseError::Expected(value.quote().to_string())), + fn advance_required(&mut self) -> ParseResult<()> { + self.pos += 1; + if self.pos >= self.args.len() { + Err(self.missing_after_last()) + } else { + Ok(()) } } - /// Peek at the next token from the input stream, returning it as a Symbol. - /// The stream is unchanged and will return the same Symbol on subsequent - /// calls to `next()` or `peek()`. - fn peek(&mut self) -> Symbol { - Symbol::new(self.tokens.peek().cloned()) + fn eval_one(&mut self) -> bool { + let result = !self.arg(self.pos).is_empty(); + self.pos += 1; + result } - /// Test if the next token in the stream is a BOOLOP (-a or -o), without - /// removing the token from the stream. - fn peek_is_boolop(&mut self) -> bool { - matches!(self.peek(), Symbol::BoolOp(_)) - } + fn eval_two(&mut self) -> ParseResult { + if is_bang(self.arg(self.pos)) { + self.pos += 1; + return Ok(!self.eval_one()); + } - /// Parse an expression. - /// - /// EXPR → TERM | EXPR BOOLOP EXPR - fn expr(&mut self) -> ParseResult<()> { - if !self.peek_is_boolop() { - self.term()?; + // In this form every two-byte -X is treated as a unary operator first. + // That is why an unknown switch is an error here, not a string literal. + if is_two_byte_switch(self.arg(self.pos)) { + return self.eval_unary(); } - self.maybe_boolop()?; - Ok(()) + + Err(self.missing_after_last()) } - /// Parse a term token and possible subsequent symbols: "(", "!", UOP, - /// literal, or None. - fn term(&mut self) -> ParseResult<()> { - let symbol = self.next_token(); - - match symbol { - Symbol::LParen => self.lparen()?, - Symbol::Bang => self.bang()?, - 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)?, + fn eval_three(&mut self) -> ParseResult { + if let Some(op) = binary_op(self.arg(self.pos + 1)) { + return self.eval_binary(false, op); } - Ok(()) - } - /// Parse a (possibly) parenthesized expression. - /// - /// test has no reserved keywords, so "(" will be interpreted as a literal - /// in certain cases: - /// - /// * when found at the end of the token stream - /// * when followed by a binary operator that is not _itself_ interpreted - /// as a literal - /// - fn lparen(&mut self) -> ParseResult<()> { - // Look ahead up to 3 tokens to determine if the lparen is being used - // as a grouping operator or should be treated as a literal string - let peek3: Vec = self - .tokens - .clone() - .take(3) - .map(|token| Symbol::new(Some(token))) - .collect(); - - match peek3.as_slice() { - // case 1: lparen is a literal when followed by nothing - [] => { - self.literal(Symbol::LParen.into_literal())?; - Ok(()) - } + if is_bang(self.arg(self.pos)) { + self.advance_required()?; + return Ok(!self.eval_two()?); + } - // case 2: error if end of stream is `( ` - [symbol] => Err(ParseError::MissingArgument(format!("{symbol}"))), - - // case 3: `( uop )` → parenthesized unary operation; - // this case ensures we don’t get confused by `( -f ) )` - // or `( -f ( )`, for example - [Symbol::UnaryOp(_), _, Symbol::Literal(s)] if s == ")" => { - let symbol = self.next_token(); - self.uop(symbol); - self.expect(")")?; - Ok(()) - } + if is_lparen(self.arg(self.pos)) && is_rparen(self.arg(self.pos + 2)) { + self.pos += 1; + let result = self.eval_one(); + self.pos += 1; + return Ok(result); + } - // case 4: binary comparison of literal lparen, e.g. `( != )` - [Symbol::Op(_), Symbol::Literal(s)] | [Symbol::Op(_), Symbol::Literal(s), _] - if s == ")" => - { - self.literal(Symbol::LParen.into_literal())?; - Ok(()) - } + if is_and(self.arg(self.pos + 1)) + || is_or(self.arg(self.pos + 1)) + || token_is(self.arg(self.pos + 1), b">") + || token_is(self.arg(self.pos + 1), b"<") + { + return self.eval_expr(); + } - // case 5: after handling the prior cases, any single token inside - // parentheses is a literal, e.g. `( -f )` - [_, Symbol::Literal(s)] | [_, Symbol::Literal(s), _] if s == ")" => { - let symbol = self.next_token(); - self.literal(symbol)?; - self.expect(")")?; - Ok(()) - } + let index = self.pos + 1; + Err(ParseError::at_token( + ParseErrorKind::BinaryOperatorExpected(self.arg(index).quote().to_string()), + index, + )) + } - // case 6: two binary ops in a row, treat the first op as a literal - [Symbol::Op(_), Symbol::Op(_), _] => { - let symbol = self.next_token(); - self.literal(symbol)?; - self.expect(")")?; - Ok(()) - } + /// Apply the historical one- to four-argument rules before using the + /// precedence-based parser for longer expressions. + fn eval_by_arity(&mut self, nargs: usize) -> ParseResult { + match nargs { + 1 => Ok(self.eval_one()), + 2 => self.eval_two(), + 3 => self.eval_three(), + 4 => { + if is_bang(self.arg(self.pos)) { + self.advance_required()?; + return Ok(!self.eval_three()?); + } - // case 7: if earlier cases didn’t match, `( op …` - // indicates binary comparison of literal lparen with - // anything _except_ ")" (case 4) - [Symbol::Op(_), _] | [Symbol::Op(_), _, _] => { - self.literal(Symbol::LParen.into_literal())?; - Ok(()) - } + if is_lparen(self.arg(self.pos)) && is_rparen(self.arg(self.pos + 3)) { + self.pos += 1; + let result = self.eval_two()?; + self.pos += 1; + return Ok(result); + } - // Otherwise, lparen indicates the start of a parenthesized - // expression - _ => { - self.expr()?; - self.expect(")")?; - Ok(()) + self.eval_expr() } + _ => self.eval_expr(), } } - /// Parse a (possibly) negated expression. - /// - /// Example cases: - /// - /// * `! =`: negate the result of the implicit string length test of `=` - /// * `! = foo`: compare the literal strings `!` and `foo` - /// * `! = = str`: negate comparison of literal `=` and `str` - /// * `!`: bang followed by nothing is literal - /// * `! EXPR`: negate the result of the expression - /// - /// Combined Boolean & negation: - /// - /// * `! ( EXPR ) [BOOLOP EXPR]`: negate the parenthesized expression only - /// * `! UOP str BOOLOP EXPR`: negate the unary subexpression - /// * `! str BOOLOP str`: negate the entire Boolean expression - /// * `! str BOOLOP EXPR BOOLOP EXPR`: negate the value of the first `str` term - /// - fn bang(&mut self) -> ParseResult<()> { - match self.peek() { - Symbol::Op(_) | Symbol::BoolOp(_) => { - // we need to peek ahead one more token to disambiguate the first - // three cases listed above - let peek2 = Symbol::new(self.tokens.clone().nth(1)); - - match peek2 { - // case 1: `! ` - // case 3: `! = OP str` - Symbol::Op(_) | Symbol::None => { - // op is literal - let op = self.next_token().into_literal(); - self.literal(op)?; - self.stack.push(Symbol::Bang); - } - // case 2: ` OP str [BOOLOP EXPR]`. - _ => { - // bang is literal; parsing continues with op - self.literal(Symbol::Bang.into_literal())?; - self.maybe_boolop()?; - } - } + /// Parse a long expression using `-a` precedence over `-o`. + fn eval_expr(&mut self) -> ParseResult { + if self.pos >= self.args.len() { + return Err(self.missing_after_last()); + } + self.eval_or() + } + + fn eval_or(&mut self) -> ParseResult { + let mut result = false; + loop { + // `test` evaluates both sides of -o, even when the result is known. + result |= self.eval_and()?; + if self.pos >= self.args.len() || !is_or(self.arg(self.pos)) { + return Ok(result); } + self.pos += 1; + } + } - // bang followed by nothing is literal - Symbol::None => self.stack.push(Symbol::Bang.into_literal()), - - _ => { - // peek ahead up to 4 tokens to determine if we need to negate - // the entire expression or just the first term - let peek4: Vec = self - .tokens - .clone() - .take(4) - .map(|token| Symbol::new(Some(token))) - .collect(); - - if let [Symbol::Literal(_), Symbol::BoolOp(_), Symbol::Literal(_)] = - peek4.as_slice() - { - // we peeked ahead 4 but there were only 3 tokens left - self.expr()?; - self.stack.push(Symbol::Bang); - } else { - self.term()?; - self.stack.push(Symbol::Bang); - } + fn eval_and(&mut self) -> ParseResult { + let mut result = true; + loop { + // `test` evaluates both sides of -a, even when the result is known. + result &= self.eval_term()?; + if self.pos >= self.args.len() || !is_and(self.arg(self.pos)) { + return Ok(result); } + self.pos += 1; } - Ok(()) } - /// Peek at the next token and parse it as a BOOLOP or string literal, - /// as appropriate. - fn maybe_boolop(&mut self) -> ParseResult<()> { - if self.peek_is_boolop() { - let symbol = self.next_token(); + /// Parse a term, consuming leading negations and then an atom. + fn eval_term(&mut self) -> ParseResult { + let mut negate = false; + while self.pos < self.args.len() && is_bang(self.arg(self.pos)) { + self.advance_required()?; + negate = !negate; + } + + if self.pos >= self.args.len() { + return Err(self.missing_after_last()); + } - // BoolOp by itself interpreted as Literal - if let Symbol::None = self.peek() { - self.literal(symbol.into_literal())?; + let remaining = self.args.len() - self.pos; + let value = if is_lparen(self.arg(self.pos)) { + self.eval_parenthesized()? + } else if remaining >= 4 + && is_length(self.arg(self.pos)) + && binary_op(self.arg(self.pos + 2)).is_some() + { + let op = binary_op(self.arg(self.pos + 2)).unwrap(); + self.eval_binary(true, op)? + } else if remaining >= 3 { + if let Some(op) = binary_op(self.arg(self.pos + 1)) { + self.eval_binary(false, op)? + } else if self.is_general_unary_start() { + self.eval_unary()? } else { - self.boolop(symbol)?; - self.maybe_boolop()?; + self.eval_literal() } - } - Ok(()) + } else if self.is_general_unary_start() { + self.eval_unary()? + } else { + self.eval_literal() + }; + + Ok(negate ^ value) } - /// Parse a Boolean expression. - /// - /// Logical and (-a) has higher precedence than or (-o), so in an - /// expression like `foo -o '' -a ''`, the and subexpression is evaluated - /// first. - fn boolop(&mut self, op: Symbol) -> ParseResult<()> { - if op == Symbol::BoolOp(OsString::from("-a")) { - self.term()?; - } else { - self.expr()?; + fn is_general_unary_start(&self) -> bool { + if !is_two_byte_switch(self.arg(self.pos)) { + return false; } - self.stack.push(op); - Ok(()) + let b = bytes(self.arg(self.pos)); + b[1] != b'a' && b[1] != b'o' } - /// Parse a (possible) unary argument test (string length or file - /// attribute check). - /// - /// If a UOP is followed by nothing it is interpreted as a literal string. - fn uop(&mut self, op: Symbol) { - match self.next_token() { - Symbol::None => self.stack.push(op.into_literal()), - symbol => { - self.stack.push(symbol.into_literal()); - self.stack.push(op); + fn eval_literal(&mut self) -> bool { + let value = !self.arg(self.pos).is_empty(); + self.pos += 1; + value + } + + /// Parse a parenthesized expression while preserving short-form arity + /// rules inside the parentheses. + fn eval_parenthesized(&mut self) -> ParseResult { + self.advance_required()?; + + // For short parenthesized expressions the arity rules still matter. + // Once there are more than four arguments, the normal parser takes over. + let mut nargs = 1usize; + // A closing parenthesis can be the right-hand string operand in + // `( ( != ) )`, so do not mistake it for the group's delimiter. + let right_paren_is_operand = self.pos + 3 < self.args.len() + && is_lparen(self.arg(self.pos)) + && binary_op(self.arg(self.pos + 1)).is_some() + && is_rparen(self.arg(self.pos + 2)) + && is_rparen(self.arg(self.pos + 3)); + while self.pos + nargs < self.args.len() + && (!is_rparen(self.arg(self.pos + nargs)) || (right_paren_is_operand && nargs == 2)) + { + if nargs == 4 { + nargs = self.args.len() - self.pos; + break; } + nargs += 1; + } + + let result = self.eval_by_arity(nargs)?; + if self.pos >= self.args.len() { + return Err(ParseError::at_token( + ParseErrorKind::Expected(OsStr::new(")").quote().to_string()), + self.args.len().saturating_sub(1), + )); + } + if !is_rparen(self.arg(self.pos)) { + return Err(ParseError::at_token( + ParseErrorKind::ExpectedFound( + OsStr::new(")").quote().to_string(), + self.arg(self.pos).quote().to_string(), + ), + self.pos, + )); } + self.pos += 1; + Ok(result) } - /// Parse a string literal, optionally followed by a comparison operator - /// and a second string literal. - fn literal(&mut self, token: Symbol) -> ParseResult<()> { - self.stack.push(token.into_literal()); + fn eval_unary(&mut self) -> ParseResult { + let op_index = self.pos; + let op_token = self.arg(op_index); + let Some(op) = unary_op(op_token) else { + return Err(ParseError::at_token( + ParseErrorKind::UnaryOperatorExpected(op_token.quote().to_string()), + op_index, + )); + }; - // EXPR → str OP str - if let Symbol::Op(_) = self.peek() { - let op = self.next_token(); + self.advance_required()?; + let arg = self.arg(self.pos); + self.pos += 1; + self.eval.unary(op, arg) + } - match self.next_token() { - Symbol::None => { - return Err(ParseError::MissingArgument(format!("{op}"))); + fn eval_binary(&mut self, lhs_is_length: bool, op: BinaryOp) -> ParseResult { + let start = self.pos; + let op_index = start + if lhs_is_length { 2 } else { 1 }; + let rhs_index = op_index + 1; + let rhs_is_length = op_index + 2 < self.args.len() && is_length(self.arg(rhs_index)); + + // A right-hand -l consumes its string before the operator kind is + // considered. Keep that oddity because it is visible in expressions. + self.pos = op_index + if rhs_is_length { 3 } else { 2 }; + + match op { + BinaryOp::IntEq + | BinaryOp::IntNe + | BinaryOp::IntLt + | BinaryOp::IntLe + | BinaryOp::IntGt + | BinaryOp::IntGe => { + let lhs = if lhs_is_length { + Operand::Length(self.arg(start + 1)) + } else { + Operand::Value(self.arg(start)) + }; + let rhs = if rhs_is_length { + Operand::Length(self.arg(op_index + 2)) + } else { + Operand::Value(self.arg(rhs_index)) + }; + self.eval.binary(op, lhs, rhs) + } + BinaryOp::FileEf | BinaryOp::FileNt | BinaryOp::FileOt => { + if lhs_is_length || rhs_is_length { + return Err(ParseError::at_token( + ParseErrorKind::DoesNotAcceptLength(op.as_str().to_owned()), + op_index, + )); } - token => self.stack.push(token.into_literal()), + self.eval.binary( + op, + Operand::Value(self.arg(start)), + Operand::Value(self.arg(rhs_index)), + ) + } + BinaryOp::StrEq | BinaryOp::StrNe | BinaryOp::StrLt | BinaryOp::StrGt => { + let lhs_index = if lhs_is_length { start + 1 } else { start }; + self.eval.binary( + op, + Operand::Value(self.arg(lhs_index)), + Operand::Value(self.arg(rhs_index)), + ) } - - self.stack.push(op); } - Ok(()) } - /// Parser entry point: parse the token stream `self.tokens`, storing the - /// resulting `Symbol` stack in `self.stack`. - fn parse(&mut self) -> ParseResult<()> { - self.expr()?; + fn finish(mut self) -> ParseResult { + if self.args.is_empty() { + return Ok(false); + } - match self.tokens.next() { - Some(token) => Err(ParseError::ExtraArgument(token.quote().to_string())), - None => Ok(()), + let result = self.eval_by_arity(self.args.len())?; + if self.pos != self.args.len() { + return Err(ParseError::at_token( + ParseErrorKind::ExtraArgument(self.arg(self.pos).quote().to_string()), + self.pos, + )); } + Ok(result) } } -/// Parse the token stream `args`, returning a `Symbol` stack representing the -/// operations to perform in postfix order. -pub fn parse(args: Vec) -> ParseResult> { - let mut p = Parser::new(args); - p.parse()?; - Ok(p.stack) +pub(crate) fn evaluate(args: &[OsString], eval: E) -> ParseResult { + Parser { args, pos: 0, eval }.finish() } diff --git a/src/uu/test/src/platform/mod.rs b/src/uu/test/src/platform/mod.rs new file mode 100644 index 00000000000..c69b72b3f67 --- /dev/null +++ b/src/uu/test/src/platform/mod.rs @@ -0,0 +1,16 @@ +// 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. + +#[cfg(target_os = "wasi")] +pub use self::wasi::{path, same_file}; +#[cfg(windows)] +pub use self::windows::{ + fd_is_terminal, is_executable, is_readable, is_writable, owned_by_current_token, same_file, +}; + +#[cfg(target_os = "wasi")] +mod wasi; +#[cfg(windows)] +mod windows; diff --git a/src/uu/test/src/platform/wasi.rs b/src/uu/test/src/platform/wasi.rs new file mode 100644 index 00000000000..6c006042ac6 --- /dev/null +++ b/src/uu/test/src/platform/wasi.rs @@ -0,0 +1,65 @@ +// 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::ffi::{CString, OsStr}; +use std::fs; + +use crate::{PathCondition, modified_since_read}; + +/// WASI has no uid/gid and no permission bits, so the conditions that depend +/// on them (`-u`, `-g`, `-k`, `-O`, `-G`, `-x`) are always false, and a file +/// that can be stat'ed counts as readable. The special file types are only +/// reachable through the unstable `wasi_ext` feature, so they are false too. +/// Everything the platform does report - file type, size, timestamps - +/// behaves as it does elsewhere. +pub fn path(path: &OsStr, condition: &PathCondition) -> bool { + let metadata = if condition == &PathCondition::SymLink { + fs::symlink_metadata(path) + } else { + fs::metadata(path) + }; + + let Ok(metadata) = metadata else { + return false; + }; + + let file_type = metadata.file_type(); + + match condition { + PathCondition::Directory => file_type.is_dir(), + PathCondition::Exists | PathCondition::Readable => true, + PathCondition::ExistsModifiedLastRead => modified_since_read(&metadata), + PathCondition::Regular => file_type.is_file(), + PathCondition::SymLink => file_type.is_symlink(), + PathCondition::NonEmpty => metadata.len() > 0, + PathCondition::Writable => !metadata.permissions().readonly(), + PathCondition::BlockSpecial + | PathCondition::CharacterSpecial + | PathCondition::Fifo + | PathCondition::Socket + | PathCondition::Executable + | PathCondition::GroupIdFlag + | PathCondition::GroupOwns + | PathCondition::Sticky + | PathCondition::UserIdFlag + | PathCondition::UserOwns => false, + } +} + +/// Whether `a` and `b` are the same file, the condition behind `-ef`. +/// `std::os::wasi`'s dev/ino accessors are unstable, so go through `stat`. +pub fn same_file(a: &OsStr, b: &OsStr) -> bool { + fn stat(path: &OsStr) -> Option { + // WASI paths are UTF-8; anything else cannot name a file here. + let path = CString::new(path.to_str()?).ok()?; + let mut stat = unsafe { std::mem::zeroed::() }; + (unsafe { libc::stat(path.as_ptr(), &raw mut stat) } == 0).then_some(stat) + } + + match (stat(a), stat(b)) { + (Some(a), Some(b)) => a.st_dev == b.st_dev && a.st_ino == b.st_ino, + _ => false, + } +} diff --git a/src/uu/test/src/platform/windows.rs b/src/uu/test/src/platform/windows.rs new file mode 100644 index 00000000000..e8da4161f50 --- /dev/null +++ b/src/uu/test/src/platform/windows.rs @@ -0,0 +1,392 @@ +// 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 (vars) DACL PSECURITY PSID + +use std::ffi::{OsStr, OsString}; +use std::fs::{Metadata, OpenOptions}; +use std::io::{self, IsTerminal}; +use std::os::windows::fs::OpenOptionsExt; +use std::os::windows::io::{AsHandle, OwnedHandle}; +use std::path::Path; +use std::sync::LazyLock; +use uucore::fs::{FileInformation, infos_refer_to_same_file}; +use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED; +use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, GENERIC_MAPPING, GROUP_SECURITY_INFORMATION, + OWNER_SECURITY_INFORMATION, SecurityImpersonation, TOKEN_DUPLICATE, TOKEN_QUERY, +}; +use windows_sys::Win32::Storage::FileSystem::{ + FILE_ALL_ACCESS, FILE_FLAG_BACKUP_SEMANTICS, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, + FILE_GENERIC_WRITE, +}; + +/// Confines the unsafe security calls: results come back as [`io::Error`] and +/// SIDs only as [`Sid`]s borrowed from the buffer that owns them. +mod sys { + use std::ffi::OsStr; + use std::io; + use std::marker::PhantomData; + use std::os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}; + use std::ptr; + use uucore::wide::ToWide; + use windows_sys::Win32::Foundation::{ERROR_SUCCESS, HANDLE, LocalFree}; + use windows_sys::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + AccessCheck, DuplicateToken, EqualSid, GENERIC_MAPPING, GROUP_SECURITY_INFORMATION, + GetTokenInformation, MapGenericMask, OBJECT_SECURITY_INFORMATION, + OWNER_SECURITY_INFORMATION, PRIVILEGE_SET, PSECURITY_DESCRIPTOR, PSID, + SECURITY_IMPERSONATION_LEVEL, TOKEN_ACCESS_MASK, TokenOwner, TokenPrimaryGroup, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + /// A SID borrowed from the structure that owns its memory. + #[derive(Clone, Copy)] + pub struct Sid<'a>(PSID, PhantomData<&'a ()>); + + impl PartialEq for Sid<'_> { + fn eq(&self, other: &Self) -> bool { + // SAFETY: each SID is valid for as long as its owner lives, which + // the borrows guarantee. + unsafe { EqualSid(self.0, other.0) != 0 } + } + } + + /// A security descriptor allocated by the security API, freed on drop, + /// along with the owner and group SIDs that point into it. + pub struct SecurityDescriptor { + ptr: PSECURITY_DESCRIPTOR, + owner: PSID, + group: PSID, + } + + impl Drop for SecurityDescriptor { + fn drop(&mut self) { + // SAFETY: the pointer comes from GetNamedSecurityInfoW; freeing a + // null pointer is a no-op. + unsafe { LocalFree(self.ptr) }; + } + } + + impl SecurityDescriptor { + pub fn owner(&self) -> Option> { + (!self.owner.is_null()).then_some(Sid(self.owner, PhantomData)) + } + + pub fn group(&self) -> Option> { + (!self.group.is_null()).then_some(Sid(self.group, PhantomData)) + } + } + + pub fn named_security_info( + path: &OsStr, + info: OBJECT_SECURITY_INFORMATION, + ) -> io::Result { + let wide_path = path.to_wide_null(); + let mut descriptor = SecurityDescriptor { + ptr: ptr::null_mut(), + owner: ptr::null_mut(), + group: ptr::null_mut(), + }; + let owner = if info & OWNER_SECURITY_INFORMATION == 0 { + ptr::null_mut() + } else { + &raw mut descriptor.owner + }; + let group = if info & GROUP_SECURITY_INFORMATION == 0 { + ptr::null_mut() + } else { + &raw mut descriptor.group + }; + // SAFETY: `wide_path` is NUL-terminated and outlives the call, and each + // out pointer is either valid or null. + let status = unsafe { + GetNamedSecurityInfoW( + wide_path.as_ptr(), + SE_FILE_OBJECT, + info, + owner, + group, + ptr::null_mut(), + ptr::null_mut(), + &raw mut descriptor.ptr, + ) + }; + if status == ERROR_SUCCESS { + Ok(descriptor) + } else { + Err(io::Error::from_raw_os_error(status as i32)) + } + } + + pub fn open_process_token(access: TOKEN_ACCESS_MASK) -> io::Result { + let mut handle: HANDLE = ptr::null_mut(); + // SAFETY: GetCurrentProcess returns a pseudo handle that needs no + // closing, and `handle` is a valid out pointer. + if unsafe { OpenProcessToken(GetCurrentProcess(), access, &raw mut handle) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: OpenProcessToken succeeded, so `handle` is a fresh owned handle. + Ok(unsafe { OwnedHandle::from_raw_handle(handle) }) + } + + pub fn duplicate_token( + token: BorrowedHandle, + level: SECURITY_IMPERSONATION_LEVEL, + ) -> io::Result { + let mut handle: HANDLE = ptr::null_mut(); + // SAFETY: `token` is a valid handle and `handle` a valid out pointer. + if unsafe { DuplicateToken(token.as_raw_handle(), level, &raw mut handle) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: DuplicateToken succeeded, so `handle` is a fresh owned handle. + Ok(unsafe { OwnedHandle::from_raw_handle(handle) }) + } + + #[derive(Clone, Copy)] + pub enum TokenSidClass { + Owner, + PrimaryGroup, + } + + /// A SID of a token, kept in the buffer it points into. + pub struct TokenSid(Vec); + + impl TokenSid { + pub fn sid(&self) -> Sid<'_> { + // SAFETY: GetTokenInformation filled the buffer with a structure + // whose first field is the SID pointer. + Sid(unsafe { *self.0.as_ptr().cast::() }, PhantomData) + } + } + + pub fn token_sid(token: BorrowedHandle, class: TokenSidClass) -> io::Result { + let class = match class { + TokenSidClass::Owner => TokenOwner, + TokenSidClass::PrimaryGroup => TokenPrimaryGroup, + }; + let token = token.as_raw_handle(); + + let mut size = 0; + // SAFETY: a null buffer of length zero only asks for the size to + // allocate. + unsafe { GetTokenInformation(token, class, ptr::null_mut(), 0, &raw mut size) }; + if size == 0 { + return Err(io::Error::last_os_error()); + } + + // TOKEN_OWNER and TOKEN_PRIMARY_GROUP are a lone SID pointer followed + // by the SID it points at, so the buffer has to be pointer aligned. + let mut buffer = vec![0usize; (size as usize).div_ceil(size_of::())]; + // SAFETY: `buffer` is at least `size` bytes long, as reported above. + let ok = unsafe { + GetTokenInformation( + token, + class, + buffer.as_mut_ptr().cast(), + size, + &raw mut size, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(TokenSid(buffer)) + } + + pub fn access_check( + descriptor: &SecurityDescriptor, + token: BorrowedHandle, + mut desired: u32, + mapping: &GENERIC_MAPPING, + ) -> io::Result { + // SAFETY: both pointers refer to live locals. + unsafe { MapGenericMask(&raw mut desired, mapping) }; + + // One entry is enough: no file right is ever granted by a privilege. + let mut privileges = PRIVILEGE_SET::default(); + let mut privileges_len = size_of::() as u32; + let mut granted = 0; + let mut status = 0; + // SAFETY: the descriptor and token are valid, every out pointer refers + // to a live local, and `privileges_len` is the size of `privileges`. + let ok = unsafe { + AccessCheck( + descriptor.ptr, + token.as_raw_handle(), + desired, + mapping, + &raw mut privileges, + &raw mut privileges_len, + &raw mut granted, + &raw mut status, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(status != 0) + } +} + +/// Whether `sid` is the SID the current process token stamps onto the objects it +/// creates: its owner or, with `group`, its primary group. +/// +/// Those two are the counterparts of the effective UID and GID. They are not +/// always the token user: a process running elevated has the Administrators +/// group as its token owner, and that is what lands in the descriptor of a file +/// it creates, so comparing against the token user would report that you do not +/// own a file you just created. Group membership is not a substitute — it would +/// also match every group you happen to belong to, such as `Everyone`, and +/// report ownership of files that are not yours. +fn matches_token_sid(sid: sys::Sid<'_>, group: bool) -> bool { + let class = if group { + sys::TokenSidClass::PrimaryGroup + } else { + sys::TokenSidClass::Owner + }; + sys::open_process_token(TOKEN_QUERY) + .and_then(|token| sys::token_sid(token.as_handle(), class)) + .is_ok_and(|token_sid| token_sid.sid() == sid) +} + +/// Whether `path` is owned by the current process token, comparing its owner +/// (or, with `group`, its primary group) SID — the Windows analogue of matching +/// `st_uid`/`st_gid` against the effective UID/GID for `-O` and `-G`. +pub fn owned_by_current_token(path: &OsStr, group: bool) -> bool { + let info = if group { + GROUP_SECURITY_INFORMATION + } else { + OWNER_SECURITY_INFORMATION + }; + let Ok(descriptor) = sys::named_security_info(path, info) else { + return false; + }; + let sid = if group { + descriptor.group() + } else { + descriptor.owner() + }; + sid.is_some_and(|sid| matches_token_sid(sid, group)) +} + +/// AccessCheck only evaluates impersonation tokens. +fn impersonation_token() -> io::Result { + let primary = sys::open_process_token(TOKEN_QUERY | TOKEN_DUPLICATE)?; + sys::duplicate_token(primary.as_handle(), SecurityImpersonation) +} + +/// `None` on volumes without ACLs (FAT, exFAT, many network shares), where the +/// question cannot be answered and the caller keeps the permissive answer. +fn access_check(path: &OsStr, access: u32) -> Option { + // AccessCheck needs the owner and group SIDs next to the DACL. + let descriptor = match sys::named_security_info( + path, + OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + ) { + Ok(descriptor) => descriptor, + // Every file right includes READ_CONTROL, so a denied descriptor means + // the file cannot be opened. + Err(e) if e.raw_os_error() == Some(ERROR_ACCESS_DENIED as i32) => return Some(false), + Err(_) => return None, + }; + let token = impersonation_token().ok()?; + let mapping = GENERIC_MAPPING { + GenericRead: FILE_GENERIC_READ, + GenericWrite: FILE_GENERIC_WRITE, + GenericExecute: FILE_GENERIC_EXECUTE, + GenericAll: FILE_ALL_ACCESS, + }; + sys::access_check(&descriptor, token.as_handle(), access, &mapping).ok() +} + +fn has_access(path: &OsStr, access: u32) -> bool { + access_check(path, access).unwrap_or(true) +} + +pub fn is_readable(path: &OsStr) -> bool { + has_access(path, FILE_GENERIC_READ) +} + +/// The read-only attribute blocks writes whatever the DACL says, but NTFS +/// ignores it on directories. +pub fn is_writable(path: &OsStr, metadata: &Metadata) -> bool { + (metadata.is_dir() || !metadata.permissions().readonly()) + && has_access(path, FILE_GENERIC_WRITE) +} + +struct PathExt { + #[allow(dead_code)] + raw: Option>, + parsed: Box<[&'static OsStr]>, // self-referential slice into `raw` +} + +static PATHEXT: LazyLock = LazyLock::new(|| { + let raw = std::env::var_os("PATHEXT").map(OsString::into_boxed_os_str); + + let parsed = raw + // Turn PATHEXT into a &[u8] slice with some fallback values. + .as_deref() + .unwrap_or_else(|| OsStr::new(".COM;.EXE;.BAT;.CMD")) + .as_encoded_bytes() + // Split into individual extensions. + .split(|b| *b == b';') + // Ensure that they look valid: /\..+/ + .filter_map(|s| s.strip_prefix(b".")) + .filter(|s| !s.is_empty()) + // SAFETY: We have only removed ASCII bytes (; and .), which means + // the remaining bytes form a valid OsStr on this platform. + .map(|s| unsafe { OsStr::from_encoded_bytes_unchecked(s) }) + .collect::>() + .into_boxed_slice(); + + // SAFETY: We're going to return a self-referential struct, + // which requires lifetime trickery in Rust. We return a self-referential + // struct, because `PATHEXT` typically contains numerous extensions and + // there's no good reason to have numerous persistent allocations. + let parsed = unsafe { std::mem::transmute::, Box<[&OsStr]>>(parsed) }; + + PathExt { raw, parsed } +}); + +fn has_executable_extension(path: &OsStr) -> bool { + Path::new(path).extension().is_some_and(|extension| { + PATHEXT + .parsed + .iter() + .any(|c| c.eq_ignore_ascii_case(extension)) + }) +} + +/// For a directory this is permission to enter it: FILE_EXECUTE doubles as +/// FILE_TRAVERSE. +pub fn is_executable(path: &OsStr, metadata: &Metadata) -> bool { + (metadata.is_dir() || has_executable_extension(path)) && has_access(path, FILE_GENERIC_EXECUTE) +} + +/// Only the standard streams can be answered for: asking the CRT about a +/// descriptor it never handed out aborts the process. +pub fn fd_is_terminal(fd: i32) -> bool { + match fd { + 0 => io::stdin().is_terminal(), + 1 => io::stdout().is_terminal(), + 2 => io::stderr().is_terminal(), + _ => false, + } +} + +pub fn same_file(a: &OsStr, b: &OsStr) -> bool { + // Asking for no access right leaves nothing a share mode or DACL could + // refuse; BACKUP_SEMANTICS lets a directory be opened. + fn information(path: &OsStr) -> io::Result { + let file = OpenOptions::new() + .access_mode(0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path)?; + FileInformation::from_file(&file) + } + + infos_refer_to_same_file(information(a), information(b)) +} diff --git a/src/uu/test/src/test.rs b/src/uu/test/src/test.rs index 67307925b9a..6ea200b19e4 100644 --- a/src/uu/test/src/test.rs +++ b/src/uu/test/src/test.rs @@ -3,24 +3,42 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (vars) egid euid FiletestOp StrlenOp +// spell-checker:ignore (vars) egid euid faccessat +mod diagnostics; pub(crate) mod error; +#[cfg(not(any(windows, target_os = "wasi")))] +mod faccessat; mod parser; +#[cfg(any(windows, target_os = "wasi"))] +mod platform; + +#[cfg(not(any(windows, target_os = "wasi")))] +use crate::faccessat::effective_access; use clap::Command; -use error::{ParseError, ParseResult}; -use parser::{Operator, Symbol, UnaryOperator, parse}; +use error::{ParseError, ParseErrorKind, ParseResult}; +use parser::{BinaryOp, Evaluator, Operand, UnaryOp, evaluate}; +#[cfg(windows)] +use platform::fd_is_terminal; +#[cfg(target_os = "wasi")] +use platform::path; +#[cfg(not(any(windows, target_os = "wasi")))] +use rustix::fs::Access; +#[cfg(not(any(windows, target_os = "wasi")))] +use rustix::process::{getegid, geteuid}; +use std::cmp::Ordering; use std::ffi::{OsStr, OsString}; use std::fs; +use std::mem::size_of; #[cfg(unix)] use std::os::unix::fs::MetadataExt; use uucore::display::Quotable; use uucore::error::{UResult, USimpleError}; use uucore::format_usage; -#[cfg(not(windows))] -use uucore::process::{getegid, geteuid}; - +#[cfg(not(any(windows, target_os = "wasi")))] +use uucore::fs::mode::{S_ISGID, S_ISUID, S_ISVTX}; +use uucore::i18n::collator::{init_locale_collation, locale_cmp}; use uucore::translate; // The help_usage method replaces util name (the first word) with {}. @@ -69,141 +87,239 @@ pub fn uumain(mut args: impl uucore::Args) -> UResult<()> { // Show actual name with error let _ = uu_app().name("test"); } - let result = parse(args).map(|mut stack| eval(&mut stack))??; + let expression = uucore::diagnostics::capture(&args); + + match evaluate(&args, TestEvaluator) { + Ok(true) => Ok(()), + Ok(false) => Err(1.into()), + Err(e) => Err(uucore::diagnostics::error_after_report( + expression.as_deref(), + e, + diagnostics::render, + )), + } +} - if result { Ok(()) } else { Err(1.into()) } +struct TestEvaluator; + +fn operand_value(operand: Operand<'_>) -> &OsStr { + match operand { + Operand::Value(value) => value, + Operand::Length(_) => unreachable!("length operand passed to non-integer operator"), + } } -/// Evaluate a stack of Symbols, returning the result of the evaluation or -/// an error message if evaluation failed. -fn eval(stack: &mut Vec) -> ParseResult { - macro_rules! pop_literal { - () => { - match stack.pop() { - Some(Symbol::Literal(s)) => s, - _ => panic!(), +impl Evaluator for TestEvaluator { + fn unary(&mut self, op: UnaryOp, arg: &OsStr) -> ParseResult { + Ok(match op { + UnaryOp::BlockSpecial => path(arg, &PathCondition::BlockSpecial), + UnaryOp::CharacterSpecial => path(arg, &PathCondition::CharacterSpecial), + UnaryOp::Directory => path(arg, &PathCondition::Directory), + UnaryOp::Exists => path(arg, &PathCondition::Exists), + UnaryOp::Regular => path(arg, &PathCondition::Regular), + UnaryOp::GroupIdFlag => path(arg, &PathCondition::GroupIdFlag), + UnaryOp::GroupOwns => path(arg, &PathCondition::GroupOwns), + UnaryOp::SymLink => path(arg, &PathCondition::SymLink), + UnaryOp::Sticky => path(arg, &PathCondition::Sticky), + UnaryOp::ModifiedSinceRead => path(arg, &PathCondition::ExistsModifiedLastRead), + UnaryOp::UserOwns => path(arg, &PathCondition::UserOwns), + UnaryOp::Fifo => path(arg, &PathCondition::Fifo), + UnaryOp::Readable => path(arg, &PathCondition::Readable), + UnaryOp::NonEmpty => path(arg, &PathCondition::NonEmpty), + UnaryOp::Socket => path(arg, &PathCondition::Socket), + UnaryOp::Tty => isatty(arg)?, + UnaryOp::UserIdFlag => path(arg, &PathCondition::UserIdFlag), + UnaryOp::Writable => path(arg, &PathCondition::Writable), + UnaryOp::Executable => path(arg, &PathCondition::Executable), + UnaryOp::StrNonEmpty => !arg.is_empty(), + UnaryOp::StrEmpty => arg.is_empty(), + }) + } + + fn binary(&mut self, op: BinaryOp, lhs: Operand<'_>, rhs: Operand<'_>) -> ParseResult { + match op { + BinaryOp::StrEq => Ok(operand_value(lhs) == operand_value(rhs)), + BinaryOp::StrNe => Ok(operand_value(lhs) != operand_value(rhs)), + BinaryOp::StrLt => { + let _ = init_locale_collation(); + Ok(locale_cmp( + operand_value(lhs).as_encoded_bytes(), + operand_value(rhs).as_encoded_bytes(), + ) + .is_lt()) } - }; + BinaryOp::StrGt => { + let _ = init_locale_collation(); + Ok(locale_cmp( + operand_value(lhs).as_encoded_bytes(), + operand_value(rhs).as_encoded_bytes(), + ) + .is_gt()) + } + BinaryOp::IntEq + | BinaryOp::IntNe + | BinaryOp::IntLt + | BinaryOp::IntLe + | BinaryOp::IntGt + | BinaryOp::IntGe => compare_integer_operands(lhs, rhs, op), + BinaryOp::FileEf | BinaryOp::FileNt | BinaryOp::FileOt => files( + operand_value(lhs), + operand_value(rhs), + OsStr::new(op.as_str()), + ), + } } +} - let s = stack.pop(); +/// An integer operand of a comparison, split into a sign and its decimal digits. +/// +/// Keeping the digits as text instead of converting them to a fixed-width +/// integer is what lets operands of any length be compared, matching GNU, which +/// places no limit on the width of the integers `test` accepts. +#[derive(Debug, PartialEq, Eq)] +struct Integer<'a> { + negative: bool, + /// The digits without leading zeros. Empty when the value is zero. + digits: &'a str, +} - match s { - Some(Symbol::Bang) => { - let result = eval(stack)?; +impl<'a> Integer<'a> { + /// Parse an operand of the form `[+-]?[0-9]+`, surrounded by optional + /// whitespace, returning [`None`] when it has any other shape. + /// The [POSIX locale convention](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_05) + /// includes U+000B VERTICAL TAB as whitespace. + fn parse(value: &'a OsStr) -> Option { + let value = value + .to_str()? + .trim_matches(|c: char| c.is_ascii_whitespace() || c == '\u{000b}'); + + // Only ASCII `+`/`-` are sliced off, so this always cuts on a char boundary. + let (negative, digits) = match value.as_bytes().first()? { + b'-' => (true, &value[1..]), + b'+' => (false, &value[1..]), + _ => (false, value), + }; - Ok(!result) + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; } - Some(Symbol::Op(Operator::String(op))) => { - let b = pop_literal!(); - let a = pop_literal!(); - match op.as_encoded_bytes() { - b"!=" => Ok(a != b), - b"<" => Ok(a < b), - b">" => Ok(a > b), - _ => Ok(a == b), + + let digits = digits.trim_start_matches('0'); + + // Zero is neither positive nor negative, so `-0` compares equal to `0`. + Some(Self { + negative: negative && !digits.is_empty(), + digits, + }) + } +} + +impl Ord for Integer<'_> { + fn cmp(&self, other: &Self) -> Ordering { + match (self.negative, other.negative) { + (false, true) => Ordering::Greater, + (true, false) => Ordering::Less, + (negative, _) => { + // Leading zeros are already gone, so the longer run of digits is + // the larger magnitude and equal-length runs order bytewise. + let magnitude = self + .digits + .len() + .cmp(&other.digits.len()) + .then_with(|| self.digits.cmp(other.digits)); + + if negative { + magnitude.reverse() + } else { + magnitude + } } } - Some(Symbol::Op(Operator::Int(op))) => { - let b = pop_literal!(); - let a = pop_literal!(); + } +} - Ok(integers(&a, &b, &op)?) - } - Some(Symbol::Op(Operator::File(op))) => { - let b = pop_literal!(); - let a = pop_literal!(); - Ok(files(&a, &b, &op)?) - } - Some(Symbol::UnaryOp(UnaryOperator::StrlenOp(op))) => { - let s = match stack.pop() { - Some(Symbol::Literal(s)) => s, - Some(Symbol::None) => OsString::from(""), - None => return Ok(true), - _ => return Err(ParseError::MissingArgument(op.quote().to_string())), - }; - - Ok((op == "-z") == s.is_empty()) - } - Some(Symbol::UnaryOp(UnaryOperator::FiletestOp(op))) => { - let op = op.to_str().unwrap(); - - let f = pop_literal!(); - - Ok(match op { - "-b" => path(&f, &PathCondition::BlockSpecial), - "-c" => path(&f, &PathCondition::CharacterSpecial), - "-d" => path(&f, &PathCondition::Directory), - "-e" => path(&f, &PathCondition::Exists), - "-f" => path(&f, &PathCondition::Regular), - "-g" => path(&f, &PathCondition::GroupIdFlag), - "-G" => path(&f, &PathCondition::GroupOwns), - "-h" => path(&f, &PathCondition::SymLink), - "-k" => path(&f, &PathCondition::Sticky), - "-L" => path(&f, &PathCondition::SymLink), - "-N" => path(&f, &PathCondition::ExistsModifiedLastRead), - "-O" => path(&f, &PathCondition::UserOwns), - "-p" => path(&f, &PathCondition::Fifo), - "-r" => path(&f, &PathCondition::Readable), - "-S" => path(&f, &PathCondition::Socket), - "-s" => path(&f, &PathCondition::NonEmpty), - "-t" => isatty(&f)?, - "-u" => path(&f, &PathCondition::UserIdFlag), - "-w" => path(&f, &PathCondition::Writable), - "-x" => path(&f, &PathCondition::Executable), - _ => panic!(), - }) - } - Some(Symbol::Literal(s)) => Ok(!s.is_empty()), - Some(Symbol::None) | None => Ok(false), - Some(Symbol::BoolOp(op)) => { - if (op == "-a" || op == "-o") && stack.len() < 2 { - return Err(ParseError::UnaryOperatorExpected(op.quote().to_string())); - } +impl PartialOrd for Integer<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[derive(Debug)] +enum IntegerOperand<'a> { + Parsed(Integer<'a>), + Length(usize), +} + +fn integer_operand(operand: Operand<'_>) -> ParseResult> { + match operand { + Operand::Length(value) => Ok(IntegerOperand::Length(value.as_encoded_bytes().len())), + Operand::Value(value) => Integer::parse(value) + .map(IntegerOperand::Parsed) + .ok_or_else(|| { + ParseError::at_value( + ParseErrorKind::InvalidInteger(value.quote().to_string()), + value, + ) + }), + } +} - let b = eval(stack)?; - let a = eval(stack)?; +fn integer_cmp_usize(value: &Integer<'_>, other: usize) -> Ordering { + if value.negative { + return Ordering::Less; + } - Ok(if op == "-a" { a && b } else { a || b }) + let mut buf = [0_u8; 3 * size_of::()]; + let mut n = other; + let mut start = buf.len(); + loop { + start -= 1; + buf[start] = b'0' + (n % 10) as u8; + n /= 10; + if n == 0 { + break; } - _ => Err(ParseError::ExpectedValue), } + + let digits = if value.digits.is_empty() { + b"0".as_slice() + } else { + value.digits.as_bytes() + }; + let other = &buf[start..]; + digits + .len() + .cmp(&other.len()) + .then_with(|| digits.cmp(other)) } -/// Operations to compare integers -/// `a` is the left hand side -/// `b` is the left hand side -/// `op` the operation (ex: -eq, -lt, etc) -fn integers(a: &OsStr, b: &OsStr, op: &OsStr) -> ParseResult { - // Parse the two inputs - let a: i128 = a - .to_str() - .map(str::trim) - .and_then(|s| s.parse().ok()) - .ok_or_else(|| ParseError::InvalidInteger(a.quote().to_string()))?; - - let b: i128 = b - .to_str() - .map(str::trim) - .and_then(|s| s.parse().ok()) - .ok_or_else(|| ParseError::InvalidInteger(b.quote().to_string()))?; - - // Do the maths - Ok(match op.to_str() { - Some("-eq") => a == b, - Some("-ne") => a != b, - Some("-gt") => a > b, - Some("-ge") => a >= b, - Some("-lt") => a < b, - Some("-le") => a <= b, - _ => return Err(ParseError::UnknownOperator(op.quote().to_string())), +fn compare_integer_operands(lhs: Operand<'_>, rhs: Operand<'_>, op: BinaryOp) -> ParseResult { + let lhs = integer_operand(lhs)?; + let rhs = integer_operand(rhs)?; + let order = match (&lhs, &rhs) { + (IntegerOperand::Parsed(lhs), IntegerOperand::Parsed(rhs)) => lhs.cmp(rhs), + (IntegerOperand::Parsed(lhs), IntegerOperand::Length(rhs)) => integer_cmp_usize(lhs, *rhs), + (IntegerOperand::Length(lhs), IntegerOperand::Parsed(rhs)) => { + integer_cmp_usize(rhs, *lhs).reverse() + } + (IntegerOperand::Length(lhs), IntegerOperand::Length(rhs)) => lhs.cmp(rhs), + }; + + Ok(match op { + BinaryOp::IntEq => order.is_eq(), + BinaryOp::IntNe => order.is_ne(), + BinaryOp::IntLt => order.is_lt(), + BinaryOp::IntLe => order.is_le(), + BinaryOp::IntGt => order.is_gt(), + BinaryOp::IntGe => order.is_ge(), + _ => unreachable!("non-integer operator passed to integer comparison"), }) } /// Operations to compare files metadata /// `a` is the left hand side /// `b` is the right hand side -/// `op` the operation (ex: -ef, -nt, etc) +/// `op` the operation (ex: -ef, -nt, etc.) fn files(a: &OsStr, b: &OsStr, op: &OsStr) -> ParseResult { let f_a = fs::metadata(a); let f_b = fs::metadata(b); @@ -211,29 +327,57 @@ fn files(a: &OsStr, b: &OsStr, op: &OsStr) -> ParseResult { let result = match (op.to_str(), f_a, f_b) { #[cfg(unix)] (Some("-ef"), Ok(f_a), Ok(f_b)) => f_a.ino() == f_b.ino() && f_a.dev() == f_b.dev(), - #[cfg(not(unix))] - (Some("-ef"), Ok(_), Ok(_)) => unimplemented!(), + #[cfg(any(windows, target_os = "wasi"))] + (Some("-ef"), Ok(_), Ok(_)) => platform::same_file(a, b), (Some("-nt"), Ok(f_a), Ok(f_b)) => f_a.modified().unwrap() > f_b.modified().unwrap(), (Some("-nt"), Ok(_), _) => true, (Some("-ot"), Ok(f_a), Ok(f_b)) => f_a.modified().unwrap() < f_b.modified().unwrap(), (Some("-ot"), _, Ok(_)) => true, (Some("-ef" | "-nt" | "-ot"), _, _) => false, - (_, _, _) => return Err(ParseError::UnknownOperator(op.quote().to_string())), + (_, _, _) => { + return Err(ParseError::at_value( + ParseErrorKind::UnknownOperator(op.quote().to_string()), + op, + )); + } }; Ok(result) } fn isatty(fd: &OsStr) -> ParseResult { - fd.to_str() - .map(str::trim) - .and_then(|s| s.parse().ok()) - .ok_or_else(|| ParseError::InvalidInteger(fd.quote().to_string())) - .map(|i| unsafe { libc::isatty(i) == 1 }) + let value = Integer::parse(fd).ok_or_else(|| { + ParseError::at_value( + ParseErrorKind::InvalidFileDescriptor(fd.quote().to_string()), + fd, + ) + })?; + + if value.negative { + return Ok(false); + } + let descriptor = if value.digits.is_empty() { + 0 + } else { + let Ok(descriptor) = value.digits.parse::() else { + return Ok(false); + }; + if descriptor > i32::MAX as u32 { + return Ok(false); + } + descriptor as i32 + }; + Ok(fd_is_terminal(descriptor)) +} + +#[cfg(not(windows))] +fn fd_is_terminal(fd: i32) -> bool { + // SAFETY: isatty only inspects the descriptor number it is given. + unsafe { libc::isatty(fd) == 1 } } #[derive(Eq, PartialEq)] -enum PathCondition { +pub(crate) enum PathCondition { BlockSpecial, CharacterSpecial, Directory, @@ -254,106 +398,129 @@ enum PathCondition { Executable, } -#[cfg(not(windows))] +/// Whether the file was modified more recently than it was last read, the +/// condition behind `-N`. A timestamp the platform cannot report counts as +/// "not modified since read" rather than aborting. +pub(crate) fn modified_since_read(metadata: &fs::Metadata) -> bool { + matches!( + (metadata.accessed(), metadata.modified()), + (Ok(read), Ok(modified)) if read < modified + ) +} + +#[cfg(not(any(windows, target_os = "wasi")))] fn path(path: &OsStr, condition: &PathCondition) -> bool { - use std::fs::Metadata; use std::os::unix::fs::FileTypeExt; - const S_ISUID: u32 = 0o4000; - const S_ISGID: u32 = 0o2000; - const S_ISVTX: u32 = 0o1000; - - enum Permission { - Read = 0o4, - Write = 0o2, - Execute = 0o1, - } - - let perm = |metadata: Metadata, p: Permission| { - if geteuid() == metadata.uid() { - metadata.mode() & ((p as u32) << 6) != 0 - } else if getegid() == metadata.gid() { - metadata.mode() & ((p as u32) << 3) != 0 + let metadata = || { + if matches!(condition, PathCondition::SymLink) { + fs::symlink_metadata(path) } else { - metadata.mode() & (p as u32) != 0 + fs::metadata(path) } }; - let metadata = if condition == &PathCondition::SymLink { - fs::symlink_metadata(path) - } else { - fs::metadata(path) - }; + match condition { + PathCondition::Readable => effective_access(path, Access::READ_OK), + PathCondition::Writable => effective_access(path, Access::WRITE_OK), + PathCondition::Executable => effective_access(path, Access::EXEC_OK), - let Ok(metadata) = metadata else { - return false; - }; + PathCondition::BlockSpecial => metadata().is_ok_and(|m| m.file_type().is_block_device()), - let file_type = metadata.file_type(); + PathCondition::CharacterSpecial => metadata().is_ok_and(|m| m.file_type().is_char_device()), - match condition { - PathCondition::BlockSpecial => file_type.is_block_device(), - PathCondition::CharacterSpecial => file_type.is_char_device(), - PathCondition::Directory => file_type.is_dir(), - PathCondition::Exists => true, - PathCondition::ExistsModifiedLastRead => { - metadata.accessed().unwrap() < metadata.modified().unwrap() - } - PathCondition::Regular => file_type.is_file(), - PathCondition::GroupIdFlag => metadata.mode() & S_ISGID != 0, - PathCondition::GroupOwns => metadata.gid() == getegid(), - PathCondition::SymLink => metadata.file_type().is_symlink(), - PathCondition::Sticky => metadata.mode() & S_ISVTX != 0, - PathCondition::UserOwns => metadata.uid() == geteuid(), - PathCondition::Fifo => file_type.is_fifo(), - PathCondition::Readable => perm(metadata, Permission::Read), - PathCondition::Socket => file_type.is_socket(), - PathCondition::NonEmpty => metadata.size() > 0, - PathCondition::UserIdFlag => metadata.mode() & S_ISUID != 0, - PathCondition::Writable => perm(metadata, Permission::Write), - PathCondition::Executable => perm(metadata, Permission::Execute), + PathCondition::Directory => metadata().is_ok_and(|m| m.file_type().is_dir()), + + PathCondition::Exists => metadata().is_ok(), + + PathCondition::ExistsModifiedLastRead => metadata().is_ok_and(|m| modified_since_read(&m)), + + PathCondition::Regular => metadata().is_ok_and(|m| m.file_type().is_file()), + + PathCondition::GroupIdFlag => metadata().is_ok_and(|m| m.mode() & S_ISGID != 0), + + PathCondition::GroupOwns => metadata().is_ok_and(|m| m.gid() == getegid().as_raw()), + + PathCondition::SymLink => metadata().is_ok_and(|m| m.file_type().is_symlink()), + + PathCondition::Sticky => metadata().is_ok_and(|m| m.mode() & S_ISVTX != 0), + + PathCondition::UserOwns => metadata().is_ok_and(|m| m.uid() == geteuid().as_raw()), + + PathCondition::Fifo => metadata().is_ok_and(|m| m.file_type().is_fifo()), + + PathCondition::Socket => metadata().is_ok_and(|m| m.file_type().is_socket()), + + PathCondition::NonEmpty => metadata().is_ok_and(|m| m.size() > 0), + + PathCondition::UserIdFlag => metadata().is_ok_and(|m| m.mode() & S_ISUID != 0), } } - #[cfg(windows)] fn path(path: &OsStr, condition: &PathCondition) -> bool { - use std::fs::metadata; + use crate::platform::{is_executable, is_readable, is_writable, owned_by_current_token}; - let Ok(stat) = metadata(path) else { + let metadata = if condition == &PathCondition::SymLink { + fs::symlink_metadata(path) + } else { + fs::metadata(path) + }; + + let Ok(metadata) = metadata else { return false; }; match condition { - PathCondition::BlockSpecial => false, - PathCondition::CharacterSpecial => false, - PathCondition::Directory => stat.is_dir(), + PathCondition::Directory => metadata.is_dir(), PathCondition::Exists => true, - PathCondition::ExistsModifiedLastRead => unimplemented!(), - PathCondition::Regular => stat.is_file(), - PathCondition::GroupIdFlag => false, - PathCondition::GroupOwns => unimplemented!(), - PathCondition::SymLink => false, - PathCondition::Sticky => false, - PathCondition::UserOwns => unimplemented!(), - PathCondition::Fifo => false, - PathCondition::Readable => true, - PathCondition::Socket => false, - PathCondition::NonEmpty => stat.len() > 0, - PathCondition::UserIdFlag => false, - PathCondition::Writable => !stat.permissions().readonly(), - PathCondition::Executable => std::path::Path::new(path) - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| matches!(e, "exe" | "bat" | "cmd" | "com")), + PathCondition::ExistsModifiedLastRead => modified_since_read(&metadata), + PathCondition::GroupOwns => owned_by_current_token(path, true), + PathCondition::UserOwns => owned_by_current_token(path, false), + PathCondition::Regular => metadata.is_file(), + PathCondition::SymLink => metadata.file_type().is_symlink(), + PathCondition::NonEmpty => metadata.len() > 0, + PathCondition::Readable => is_readable(path), + PathCondition::Writable => is_writable(path, &metadata), + PathCondition::Executable => is_executable(path, &metadata), + PathCondition::BlockSpecial + | PathCondition::CharacterSpecial + | PathCondition::Fifo + | PathCondition::GroupIdFlag + | PathCondition::Socket + | PathCondition::Sticky + | PathCondition::UserIdFlag => false, } } -#[cfg(test)] +// Every test here needs a temporary file, and a WASI guest only sees the +// directories it was granted, so there is no temporary directory to use. +#[cfg(all(test, not(target_os = "wasi")))] mod tests { use super::*; use std::{ffi::OsStr, time::UNIX_EPOCH}; use tempfile::NamedTempFile; + #[cfg(target_os = "linux")] + #[test] + fn test_root_access_is_not_owner_mode_bits() { + use std::os::unix::fs::PermissionsExt; + + if geteuid().as_raw() != 0 { + return; + } + + let file = NamedTempFile::new().unwrap(); + fs::set_permissions(file.path(), fs::Permissions::from_mode(0o000)).unwrap(); + let path = file.path().as_os_str(); + + assert!(effective_access(path, Access::READ_OK)); + assert!(effective_access(path, Access::WRITE_OK)); + assert!(!effective_access(path, Access::EXEC_OK)); + + fs::set_permissions(file.path(), fs::Permissions::from_mode(0o001)).unwrap(); + assert!(effective_access(path, Access::EXEC_OK)); + } + #[test] fn test_files_with_unknown_op() { let a = NamedTempFile::new().unwrap(); @@ -366,7 +533,6 @@ mod tests { } #[test] - #[cfg(unix)] fn test_files_with_ef_op() { let a = NamedTempFile::new().unwrap(); let b = NamedTempFile::new().unwrap(); @@ -430,18 +596,257 @@ mod tests { fn test_integer_op() { let a = OsStr::new("18446744073709551616"); let b = OsStr::new("0"); - assert!(!integers(a, b, OsStr::new("-lt")).unwrap()); + assert!( + !compare_integer_operands(Operand::Value(a), Operand::Value(b), BinaryOp::IntLt) + .unwrap() + ); let a = OsStr::new("18446744073709551616"); let b = OsStr::new("0"); - assert!(integers(a, b, OsStr::new("-gt")).unwrap()); + assert!( + compare_integer_operands(Operand::Value(a), Operand::Value(b), BinaryOp::IntGt) + .unwrap() + ); let a = OsStr::new("-1"); let b = OsStr::new("0"); - assert!(integers(a, b, OsStr::new("-lt")).unwrap()); + assert!( + compare_integer_operands(Operand::Value(a), Operand::Value(b), BinaryOp::IntLt) + .unwrap() + ); let a = OsStr::new("42"); let b = OsStr::new("42"); - assert!(integers(a, b, OsStr::new("-eq")).unwrap()); + assert!( + compare_integer_operands(Operand::Value(a), Operand::Value(b), BinaryOp::IntEq) + .unwrap() + ); let a = OsStr::new("42"); let b = OsStr::new("42"); - assert!(!integers(a, b, OsStr::new("-ne")).unwrap()); + assert!( + !compare_integer_operands(Operand::Value(a), Operand::Value(b), BinaryOp::IntNe) + .unwrap() + ); + } + + /// The 71-digit operand reported in the GNU compatibility issue, which is + /// far wider than any fixed-size integer type. + const BIG: &str = "16267277278126277227728782172782882627278282882172762677623672762783782"; + /// `BIG` with its final digit incremented, so the two only differ in the + /// least significant digit. + const BIG_PLUS_ONE: &str = + "16267277278126277227728782172782882627278282882172762677623672762783783"; + /// One digit shorter than `BIG`, so the two differ in width. + const SMALLER: &str = "1626727727812627722772878217278288262727828288217276267762367276278378"; + + #[test] + fn test_integer_op_beyond_i128() { + let big = OsStr::new(BIG); + let big_plus_one = OsStr::new(BIG_PLUS_ONE); + let smaller = OsStr::new(SMALLER); + let one = OsStr::new("1"); + + assert!( + compare_integer_operands(Operand::Value(big), Operand::Value(big), BinaryOp::IntEq) + .unwrap() + ); + assert!( + !compare_integer_operands(Operand::Value(big), Operand::Value(big), BinaryOp::IntNe) + .unwrap() + ); + assert!( + compare_integer_operands(Operand::Value(big), Operand::Value(big), BinaryOp::IntGe) + .unwrap() + ); + assert!( + compare_integer_operands(Operand::Value(big), Operand::Value(big), BinaryOp::IntLe) + .unwrap() + ); + + assert!( + compare_integer_operands(Operand::Value(one), Operand::Value(big), BinaryOp::IntNe) + .unwrap() + ); + assert!( + compare_integer_operands(Operand::Value(one), Operand::Value(big), BinaryOp::IntLt) + .unwrap() + ); + assert!( + compare_integer_operands(Operand::Value(big), Operand::Value(one), BinaryOp::IntGt) + .unwrap() + ); + + // Same width, differing only in the least significant digit. + assert!( + compare_integer_operands( + Operand::Value(big_plus_one), + Operand::Value(big), + BinaryOp::IntGt + ) + .unwrap() + ); + assert!( + compare_integer_operands( + Operand::Value(big), + Operand::Value(big_plus_one), + BinaryOp::IntLt + ) + .unwrap() + ); + assert!( + !compare_integer_operands( + Operand::Value(big), + Operand::Value(big_plus_one), + BinaryOp::IntEq + ) + .unwrap() + ); + + // Differing widths. + assert!( + compare_integer_operands( + Operand::Value(big), + Operand::Value(smaller), + BinaryOp::IntGt + ) + .unwrap() + ); + assert!( + compare_integer_operands( + Operand::Value(smaller), + Operand::Value(big), + BinaryOp::IntLt + ) + .unwrap() + ); + } + + #[test] + fn test_integer_op_beyond_i128_negative() { + let big = OsStr::new(BIG); + let neg_big = + OsStr::new("-16267277278126277227728782172782882627278282882172762677623672762783782"); + let neg_smaller = + OsStr::new("-1626727727812627722772878217278288262727828288217276267762367276278378"); + + assert!( + compare_integer_operands( + Operand::Value(neg_big), + Operand::Value(neg_big), + BinaryOp::IntEq + ) + .unwrap() + ); + assert!( + compare_integer_operands( + Operand::Value(neg_big), + Operand::Value(OsStr::new("0")), + BinaryOp::IntLt + ) + .unwrap() + ); + assert!( + compare_integer_operands( + Operand::Value(neg_big), + Operand::Value(big), + BinaryOp::IntLt + ) + .unwrap() + ); + assert!( + compare_integer_operands( + Operand::Value(big), + Operand::Value(neg_big), + BinaryOp::IntGt + ) + .unwrap() + ); + + // A wider negative number is the smaller of the two. + assert!( + compare_integer_operands( + Operand::Value(neg_big), + Operand::Value(neg_smaller), + BinaryOp::IntLt + ) + .unwrap() + ); + assert!( + compare_integer_operands( + Operand::Value(neg_smaller), + Operand::Value(neg_big), + BinaryOp::IntGt + ) + .unwrap() + ); + } + + #[test] + fn test_integer_parse_normalizes_sign_and_leading_zeros() { + // Zero carries no sign, so `-0` and `0` are the same value. + assert_eq!( + Integer::parse(OsStr::new("-0")), + Integer::parse(OsStr::new("0")) + ); + assert_eq!( + Integer::parse(OsStr::new("+0")), + Integer::parse(OsStr::new("-0")) + ); + assert_eq!( + Integer::parse(OsStr::new("007")), + Integer::parse(OsStr::new("7")) + ); + assert_eq!( + Integer::parse(OsStr::new("-007")), + Integer::parse(OsStr::new("-7")) + ); + // Surrounding whitespace is ignored. + assert_eq!( + Integer::parse(OsStr::new(" 42 ")), + Integer::parse(OsStr::new("42")) + ); + // Normalization is not limited by width either. + let padded = OsString::from(format!("+00{BIG}")); + assert_eq!(Integer::parse(&padded), Integer::parse(OsStr::new(BIG))); + } + + #[test] + fn test_integer_op_rejects_malformed_operands() { + // Widening the accepted range must not make any of these parse. + // "\u{664}\u{662}" and "\u{ff11}\u{ff12}" are non-ASCII digits, which + // also exercise operands that are not one byte per character. + for operand in [ + "", + "-", + "+", + "++5", + "--5", + "5-", + "+-5", + "1_0", + "0x10", + "1e3", + "123.45", + "4 2", + "\u{664}\u{662}", + "\u{ff11}\u{ff12}", + ] { + let operand = OsStr::new(operand); + assert!( + compare_integer_operands( + Operand::Value(operand), + Operand::Value(OsStr::new("0")), + BinaryOp::IntEq, + ) + .is_err(), + "{operand:?} should not parse as an integer" + ); + assert!( + compare_integer_operands( + Operand::Value(OsStr::new("0")), + Operand::Value(operand), + BinaryOp::IntEq, + ) + .is_err(), + "{operand:?} should not parse as an integer" + ); + } } } diff --git a/src/uu/timeout/Cargo.toml b/src/uu/timeout/Cargo.toml index a4cd53a0de4..c7109c3309f 100644 --- a/src/uu/timeout/Cargo.toml +++ b/src/uu/timeout/Cargo.toml @@ -11,21 +11,18 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/timeout.rs" test = false doctest = false -[dependencies] +[target.'cfg(not(target_os = "fuchsia"))'.dependencies] clap = { workspace = true } -libc = { workspace = true } -uucore = { workspace = true, features = ["parser", "process", "signals"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["parser", "process", "signals"] } -[target.'cfg(unix)'.dependencies] +[target.'cfg(all(unix, not(target_os = "fuchsia")))'.dependencies] +libc = { workspace = true } rustix = { workspace = true, features = ["process"] } [[bin]] @@ -36,6 +33,9 @@ path = "src/main.rs" divan = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bench]] name = "timeout_bench" harness = false diff --git a/src/uu/timeout/benches/timeout_bench.rs b/src/uu/timeout/benches/timeout_bench.rs index 511955f6f0b..0fc06cac41f 100644 --- a/src/uu/timeout/benches/timeout_bench.rs +++ b/src/uu/timeout/benches/timeout_bench.rs @@ -3,13 +3,22 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -#[cfg(unix)] use divan::{Bencher, black_box}; -#[cfg(unix)] use uu_timeout::uumain; -#[cfg(unix)] use uucore::benchmark::run_util_function; +/// First-arg marker that re-runs this bench binary as its own child command, +/// a portable `sleep`/`true` stand-in (`cargo bench` builds no other binary +/// and Windows lacks both in `PATH`). +const CHILD_MARKER: &str = "__timeout_bench_child"; + +fn self_exe() -> String { + std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned() +} + /// Benchmark the fast path where the command exits immediately. #[cfg(unix)] #[divan::bench] @@ -19,6 +28,19 @@ fn timeout_quick_exit(bencher: Bencher) { }); } +/// Benchmark the fast path where the command exits immediately. +#[cfg(windows)] +#[divan::bench] +fn timeout_quick_exit(bencher: Bencher) { + let exe = self_exe(); + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["0.02", &exe, CHILD_MARKER, "0"], + )); + }); +} + /// Benchmark a command that runs longer than the threshold and receives the default signal. #[cfg(unix)] #[divan::bench] @@ -28,7 +50,45 @@ fn timeout_enforced(bencher: Bencher) { }); } +/// Benchmark a command that runs longer than the threshold: timer expiry, +/// job-object tree kill and exit-code plumbing. +#[cfg(windows)] +#[divan::bench] +fn timeout_enforced(bencher: Bencher) { + let exe = self_exe(); + bencher.bench(|| { + black_box(run_util_function( + uumain, + &["0.02", &exe, CHILD_MARKER, "0.2"], + )); + }); +} + +/// Track timeout-firing latency across small durations; a regression from the +/// high-resolution timer (100 ns waitable timer on Windows, POSIX interval +/// timers on unix) to a coarser wait shows up as a step change across the +/// arguments. +#[divan::bench(args = ["0.001", "0.005", "0.02"])] +fn timer_expiry_latency(bencher: Bencher, duration: &str) { + let exe = self_exe(); + bencher.bench(|| { + black_box(run_util_function( + uumain, + &[duration, &exe, CHILD_MARKER, "5"], + )); + }); +} + fn main() { - #[cfg(unix)] + // When re-invoked with CHILD_MARKER, act as a tiny `sleep` replacement. + let mut args = std::env::args().skip(1); + if args.next().as_deref() == Some(CHILD_MARKER) { + if let Some(secs) = args.next().and_then(|s| s.parse::().ok()) + && secs > 0.0 + { + std::thread::sleep(std::time::Duration::from_secs_f64(secs)); + } + return; + } divan::main(); } diff --git a/src/uu/timeout/src/platform/mod.rs b/src/uu/timeout/src/platform/mod.rs new file mode 100644 index 00000000000..100c749e7e4 --- /dev/null +++ b/src/uu/timeout/src/platform/mod.rs @@ -0,0 +1,21 @@ +// 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. + +//! Platform-specific pieces of `timeout`: process-group / job setup, signal +//! sending and signal-forwarding state. The shared control flow in +//! `timeout.rs` only talks to the facade functions re-exported here, which +//! both submodules provide with identical signatures; per-spawn platform +//! state travels through the opaque `SpawnState` returned by `post_spawn` +//! (a job object on Windows, empty on unix). + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub(crate) use unix::*; + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub(crate) use windows::*; diff --git a/src/uu/timeout/src/platform/unix.rs b/src/uu/timeout/src/platform/unix.rs new file mode 100644 index 00000000000..30a05a6af5a --- /dev/null +++ b/src/uu/timeout/src/platform/unix.rs @@ -0,0 +1,144 @@ +// 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) sigstr setpgid sigchld sigwait getpid TTIN TTOU + +use std::io; +use std::os::unix::process::CommandExt; +use std::os::unix::process::ExitStatusExt; +use std::process::Child; + +use rustix::process::{Pid, Signal, getpid, kill_process, setpgid}; +use uucore::process::{ChildExt, timeout_signal_set, unblock_signal}; +use uucore::signals::{install_signal_handler, signal_by_name_or_value}; + +/// Install SIGCHLD handler to ensure waiting for child works even if parent ignored SIGCHLD. +fn install_sigchld() { + extern "C" fn chld(_: core::ffi::c_int) {} + let _ = install_signal_handler(Signal::as_raw(Signal::CHILD), chld); +} + +fn signal_from_raw(sig: i32) -> Option { + if sig <= 0 { + return None; + } + // Fast path: standard named signals (SIGHUP, SIGTERM, SIGKILL, etc.) + if let Some(s) = Signal::from_named_raw(sig) { + return Some(s); + } + // Slow path: realtime signals (SIGRTMIN..=SIGRTMAX). + #[cfg(target_os = "linux")] + { + let rtmin = libc::SIGRTMIN(); + let rtmax = libc::SIGRTMAX(); + if sig >= rtmin && sig <= rtmax { + return Some(unsafe { Signal::from_raw_unchecked(sig) }); + } + } + + None +} + +/// Configure our own process group, the child's spawn attributes and the +/// blocked signal set (consumed by `wait_or_timeout` via `sigwait`), right +/// before the child is spawned. +pub(crate) fn prepare( + cmd_builder: &mut std::process::Command, + foreground: bool, + signal: usize, +) -> io::Result<()> { + if !foreground { + let _ = setpgid(Pid::from_raw(0), Pid::from_raw(0)); + } + + { + #[cfg(target_os = "linux")] + let death_sig = signal_from_raw(signal as i32); + #[cfg(not(target_os = "linux"))] + let _ = signal; + let sigpipe_was_ignored = uucore::signals::sigpipe_was_ignored(); + let stdin_was_closed = uucore::signals::stdin_was_closed(); + + unsafe { + cmd_builder.pre_exec(move || { + // Reset terminal signals to default + let _ = libc::signal(Signal::as_raw(Signal::TTIN), libc::SIG_DFL); + let _ = libc::signal(Signal::as_raw(Signal::TTOU), libc::SIG_DFL); + // Preserve SIGPIPE ignore status if parent had it ignored + if sigpipe_was_ignored { + let _ = libc::signal(Signal::as_raw(Signal::PIPE), libc::SIG_IGN); + } + // If stdin was closed before Rust reopened it as /dev/null, close it in child + if stdin_was_closed { + libc::close(libc::STDIN_FILENO); + } + let _ = timeout_signal_set().thread_unblock(); + #[cfg(target_os = "linux")] + let _ = rustix::process::set_parent_process_death_signal(death_sig); + Ok(()) + }); + } + } + + install_sigchld(); + timeout_signal_set().thread_block()?; + Ok(()) +} + +/// Unix keeps no per-spawn platform state; the type exists so the facade +/// signatures match the Windows implementation (which carries a job object). +pub(crate) struct SpawnState; + +/// Nothing to do after spawning on unix. +pub(crate) fn post_spawn(_child: &Child, _foreground: bool) -> SpawnState { + SpawnState +} + +pub(crate) fn send_signal( + process: &mut Child, + signal: usize, + foreground: bool, + _external: Option, + _state: &SpawnState, +) { + // NOTE: GNU timeout doesn't check for errors of signal. + // The subprocess might have exited just after the timeout. + 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); + } +} + +/// The signal the child was terminated by, if it was terminated by a signal. +pub(crate) fn status_signal(status: std::process::ExitStatus) -> Option { + status.signal() +} + +pub(crate) fn preserve_signal_info(signal: core::ffi::c_int) -> core::ffi::c_int { + // This is needed because timeout is expected to preserve the exit + // status of its child. It is not the case that utilities have a + // single simple exit code, that's an illusion some shells + // provide. Instead exit status is really two numbers: + // + // - An exit code if the program ran to completion + // + // - A signal number if the program was terminated by a signal + // + // 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. + if let Some(sig) = signal_from_raw(signal) { + let _ = unblock_signal(sig); + let _ = kill_process(getpid(), sig); + } + signal +} diff --git a/src/uu/timeout/src/platform/windows.rs b/src/uu/timeout/src/platform/windows.rs new file mode 100644 index 00000000000..0621dc66812 --- /dev/null +++ b/src/uu/timeout/src/platform/windows.rs @@ -0,0 +1,111 @@ +// 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 catchable forwardable + +//! Windows implementation of `timeout`'s platform facade, built on the signal +//! emulation in [`uucore::process`] (which maps POSIX signal numbers to +//! Windows primitives). Non-foreground mode spawns the child in a new console +//! process group tracked by a Job Object, so a lethal signal kills the whole +//! tree and `INT`/`QUIT` arrive as a catchable `CTRL_BREAK_EVENT`. + +use std::io; +use std::process::Child; + +use uucore::process::{ + Job, configure_process_group, enable_ctrl_forwarding, send_signal_to_console_group, + send_signal_to_process, send_signal_to_tree, +}; + +const SIGNAL_INT: usize = 2; +const SIGNAL_QUIT: usize = 3; + +/// Per-spawn platform state, threaded through [`send_signal`]: the job object +/// tracking the child's process tree (non-foreground mode only; also `None` +/// when job assignment failed and we degraded to per-process signalling). +/// Stack ownership means repeated in-process invocations of `uumain` +/// (benchmarks, fuzzing) can never signal a previous run's job. +pub(crate) struct SpawnState { + job: Option, +} + +/// Configure the child's spawn attributes and console-event forwarding, right +/// before the child is spawned. +pub(crate) fn prepare( + cmd_builder: &mut std::process::Command, + foreground: bool, + _signal: usize, +) -> io::Result<()> { + // Record Ctrl-C/Ctrl-Break/console-close as forwardable signal numbers + // instead of dying to them (the unix code blocks a signal set here). + enable_ctrl_forwarding()?; + + if !foreground { + // The analog of unix setpgid(0, 0): the child leads its own console + // group, so the console's Ctrl-C no longer reaches it directly and + // CTRL_BREAK can target exactly its group. + configure_process_group(cmd_builder); + } + Ok(()) +} + +/// Put the freshly spawned child in a job so a lethal signal can terminate +/// its entire process tree. Degrades silently to per-process signalling when +/// jobs are unavailable (mirroring the ignored `setpgid` result on unix). +/// +/// A child that spawns a grandchild before the assignment completes escapes +/// the job; the window is sub-millisecond and unix has the analogous escape +/// (a child can leave the process group via setpgid). +pub(crate) fn post_spawn(child: &Child, foreground: bool) -> SpawnState { + let job = if foreground { + None + } else { + Job::new().ok().filter(|job| job.assign(child).is_ok()) + }; + SpawnState { job } +} + +/// `external` is the console event that interrupted the wait, when this send +/// forwards that event rather than a timeout expiry. +pub(crate) fn send_signal( + process: &mut Child, + signal: usize, + foreground: bool, + external: Option, + state: &SpawnState, +) { + // GNU timeout ignores signal-send errors (the child may have just exited), + // hence the discarded results below. + if foreground { + if matches!(signal, SIGNAL_INT | SIGNAL_QUIT) && external == Some(signal) { + // A foreground child shares our console group: the console already + // delivered this externally received event to it, and re-sending + // cannot be targeted without hitting ourselves. + return; + } + let _ = send_signal_to_process(process, signal); + } else if matches!(signal, SIGNAL_INT | SIGNAL_QUIT) { + // Deliverable as a catchable CTRL_BREAK to the child's group; without + // a console, fall back to termination so the signal is never lost. + if send_signal_to_console_group(process.id(), signal).is_err() { + let _ = send_signal_to_tree(process, state.job.as_ref(), signal); + } + } else { + let _ = send_signal_to_tree(process, state.job.as_ref(), signal); + } +} + +/// Windows exit statuses carry no signal information: terminated children +/// report their forced `128 + signal` value through `status.code()` instead. +pub(crate) fn status_signal(_status: std::process::ExitStatus) -> Option { + None +} + +/// No two-channel exit status exists on Windows; the exit code already +/// carries the signal information (`128 + signal`), so there is nothing to +/// preserve by re-signalling ourselves like the unix implementation does. +pub(crate) fn preserve_signal_info(signal: i32) -> i32 { + signal +} diff --git a/src/uu/timeout/src/status.rs b/src/uu/timeout/src/status.rs index 70fa2c09726..f89bbf32c1f 100644 --- a/src/uu/timeout/src/status.rs +++ b/src/uu/timeout/src/status.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + //! Exit status codes produced by `timeout`. + use uucore::error::UError; /// Enumerates the exit statuses produced by `timeout`. diff --git a/src/uu/timeout/src/timeout.rs b/src/uu/timeout/src/timeout.rs index 6646540d86e..f694c11d5b2 100644 --- a/src/uu/timeout/src/timeout.rs +++ b/src/uu/timeout/src/timeout.rs @@ -5,25 +5,23 @@ // spell-checker:ignore (ToDO) tstr sigstr cmdname setpgid sigchld getpid TTIN TTOU +#![cfg(not(target_os = "fuchsia"))] + +mod platform; mod status; use crate::status::ExitStatus; use clap::{Arg, ArgAction, Command}; +use std::ffi::{OsStr, OsString}; 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; use uucore::display::Quotable; use uucore::error::{UResult, USimpleError, UUsageError}; use uucore::parser::parse_time; -use uucore::process::ChildExt; -use uucore::signals::install_signal_handler; +use uucore::process::{ChildExt, TimeoutRet}; use uucore::translate; -use rustix::process::{Pid, Signal, getpid, kill_process, setpgid}; -#[cfg(unix)] -use std::os::unix::process::CommandExt; use uucore::{ format_usage, signals::{signal_by_name_or_value, signal_list_name_by_value}, @@ -49,7 +47,7 @@ struct Config { preserve_status: bool, verbose: bool, - command: Vec, + command: Vec, } impl Config { @@ -87,9 +85,9 @@ impl Config { let verbose = options.get_flag(options::VERBOSE); let command = options - .get_many::(options::COMMAND) + .get_many::(options::COMMAND) .unwrap() - .map(String::from) + .cloned() .collect::>(); Ok(Self { @@ -171,56 +169,16 @@ pub fn uu_app() -> Command { .required(true) .action(ArgAction::Append) .help(translate!("timeout-help-command")) - .value_hint(clap::ValueHint::CommandName), + .value_hint(clap::ValueHint::CommandName) + .value_parser(clap::value_parser!(OsString)), ) .trailing_var_arg(true) .infer_long_args(true) .after_help(translate!("timeout-after-help")) } -/// 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 _ = install_signal_handler(Signal::as_raw(Signal::CHILD), chld); -} - -/// 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: atomic::AtomicI32 = atomic::AtomicI32::new(0); - -/// 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 sigpipe_ignored = uucore::signals::sigpipe_was_ignored(); - - for sig in [ - Signal::ALARM, - Signal::INT, - Signal::QUIT, - Signal::HUP, - Signal::TERM, - Signal::PIPE, - Signal::USR1, - Signal::USR2, - ] { - if sig == Signal::PIPE && sigpipe_ignored { - continue; // Skip SIGPIPE if it was ignored by parent - } - let _ = install_signal_handler(Signal::as_raw(sig), handle_signal); - } - - if let Some(sig) = signal_from_raw(term_signal as i32) { - let _ = install_signal_handler(Signal::as_raw(sig), handle_signal); - } -} - /// Report that a signal is being sent if the verbose flag is set. -fn report_if_verbose(signal: usize, cmd: &str, verbose: bool) { +fn report_if_verbose(signal: usize, cmd: &OsStr, verbose: bool) { if verbose { let s = if signal == 0 { "0".to_string() @@ -237,43 +195,6 @@ fn report_if_verbose(signal: usize, cmd: &str, verbose: bool) { } } -fn signal_from_raw(sig: i32) -> Option { - if sig <= 0 { - return None; - } - // Fast path: standard named signals (SIGHUP, SIGTERM, SIGKILL, etc.) - if let Some(s) = Signal::from_named_raw(sig) { - return Some(s); - } - // Slow path: realtime signals (SIGRTMIN..=SIGRTMAX). - #[cfg(target_os = "linux")] - { - let rtmin = libc::SIGRTMIN(); - let rtmax = libc::SIGRTMAX(); - if sig >= rtmin && sig <= rtmax { - return Some(unsafe { Signal::from_raw_unchecked(sig) }); - } - } - - None -} - -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. - 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); - } -} - /// Wait for a child process and send a kill signal if it does not terminate. /// /// This function waits for the child `process` for the time period @@ -296,18 +217,18 @@ fn send_signal(process: &mut Child, signal: usize, foreground: bool) { /// the process after that signal is sent. fn wait_or_kill_process( process: &mut Child, - cmd: &str, + cmd: &OsStr, duration: Duration, preserve_status: bool, foreground: bool, verbose: bool, + spawn_state: &platform::SpawnState, ) -> std::io::Result { - // ignore `SIGTERM` here - match process.wait_or_timeout(duration, None) { - Ok(Some(status)) => { + match process.wait_or_timeout(duration, true) { + Ok(TimeoutRet::Exited(status)) => { if preserve_status { let exit_code = status.code().unwrap_or_else(|| { - status.signal().unwrap_or_else(|| { + platform::status_signal(status).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() @@ -318,10 +239,11 @@ fn wait_or_kill_process( Ok(ExitStatus::CommandTimedOut.into()) } } - Ok(None) => { + // GNU timeout also kills on signals other than SIGTERM. + Ok(TimeoutRet::TimedOut) | Ok(TimeoutRet::Interrupted(_)) => { let signal = signal_by_name_or_value("KILL").unwrap(); report_if_verbose(signal, cmd, verbose); - send_signal(process, signal, foreground); + platform::send_signal(process, signal, foreground, None, spawn_state); process.wait()?; Ok(ExitStatus::SignalSent(signal).into()) } @@ -329,34 +251,8 @@ fn wait_or_kill_process( } } -#[cfg(unix)] -fn preserve_signal_info(signal: libc::c_int) -> libc::c_int { - // This is needed because timeout is expected to preserve the exit - // status of its child. It is not the case that utilities have a - // single simple exit code, that's an illusion some shells - // provide. Instead exit status is really two numbers: - // - // - An exit code if the program ran to completion - // - // - A signal number if the program was terminated by a signal - // - // 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. - if let Some(sig) = signal_from_raw(signal) { - let _ = kill_process(getpid(), sig); - } - signal -} - -#[cfg(not(unix))] -fn preserve_signal_info(signal: libc::c_int) -> libc::c_int { - // Do nothing - signal -} - fn timeout( - cmd: &[String], + cmd: &[OsString], duration: Duration, signal: usize, kill_after: Option, @@ -364,10 +260,6 @@ fn timeout( preserve_status: bool, verbose: bool, ) -> UResult<()> { - if !foreground { - let _ = setpgid(Pid::from_raw(0), Pid::from_raw(0)); - } - let mut cmd_builder = process::Command::new(&cmd[0]); cmd_builder .args(&cmd[1..]) @@ -375,40 +267,12 @@ fn timeout( .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); - #[cfg(unix)] - { - #[cfg(target_os = "linux")] - let death_sig = signal_from_raw(signal as i32); - let sigpipe_was_ignored = uucore::signals::sigpipe_was_ignored(); - let stdin_was_closed = uucore::signals::stdin_was_closed(); - - unsafe { - cmd_builder.pre_exec(move || { - // Reset terminal signals to default - let _ = libc::signal(Signal::as_raw(Signal::TTIN), libc::SIG_DFL); - let _ = libc::signal(Signal::as_raw(Signal::TTOU), libc::SIG_DFL); - // Preserve SIGPIPE ignore status if parent had it ignored - if sigpipe_was_ignored { - let _ = libc::signal(Signal::as_raw(Signal::PIPE), libc::SIG_IGN); - } - // 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")] - let _ = rustix::process::set_parent_process_death_signal(death_sig); - Ok(()) - }); - } - } - - install_sigchld(); - install_signal_handlers(signal); + platform::prepare(&mut cmd_builder, foreground, signal) + .map_err(|err| USimpleError::new(ExitStatus::TimeoutFailed.into(), err.to_string()))?; 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(), _ => ExitStatus::CannotInvoke.into(), }; USimpleError::new( @@ -417,39 +281,42 @@ fn timeout( ) })?; + let spawn_state = platform::post_spawn(process, foreground); + // Wait for the child process for the specified time period. // - // If the process exits within the specified time period (the - // `Ok(Some(_))` arm), then return the appropriate status code. + // If the process exits within the specified time period, + // then we return the appropriate status code. + // + // If the wait is interrupted by an external signal, + // then we forward it to the child. // - // If the process does not exit within that time (the `Ok(None)` - // arm) and `kill_after` is specified, then try sending `SIGKILL`. + // If the duration elapses naturally, we send `signal`; + // likewise for `kill_after`. // // TODO The structure of this block is extremely similar to the // 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)) => { + match process.wait_or_timeout(duration, false) { + Ok(TimeoutRet::Exited(status)) => { let exit_code = status.code().unwrap_or_else(|| { - status - .signal() - .map_or_else(|| ExitStatus::TimeoutFailed.into(), preserve_signal_info) + platform::status_signal(status).map_or_else( + || ExitStatus::TimeoutFailed.into(), + platform::preserve_signal_info, + ) }); Err(exit_code.into()) } - Ok(None) => { - 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 { + Ok(TimeoutRet::TimedOut) => { + report_if_verbose(signal, &cmd[0], verbose); + platform::send_signal(process, signal, foreground, None, &spawn_state); + + // KILL cannot be caught, so a later kill is pointless and the + // exit status reports the signal instead of the timeout. + let sent_kill = signal_by_name_or_value("KILL") == Some(signal); + if let Some(kill_after) = kill_after + && !sent_kill + { return match wait_or_kill_process( process, &cmd[0], @@ -457,6 +324,7 @@ fn timeout( preserve_status, foreground, verbose, + &spawn_state, ) { Ok(status) => Err(status.into()), Err(e) => Err(USimpleError::new( @@ -467,28 +335,56 @@ fn timeout( } 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 + if preserve_status { + let exit_code = status .code() .or_else(|| { - status - .signal() + platform::status_signal(status) .map(|s| ExitStatus::SignalSent(s as usize).into()) }) - .unwrap_or(ExitStatus::CommandTimedOut.into()) - .into()) + .unwrap_or_else(|| ExitStatus::CommandTimedOut.into()); + Err(exit_code.into()) + } else if sent_kill { + Err(ExitStatus::SignalSent(signal).into()) } else { Err(ExitStatus::CommandTimedOut.into()) } } + Ok(TimeoutRet::Interrupted(signal_to_send)) => { + report_if_verbose(signal_to_send, &cmd[0], verbose); + platform::send_signal( + process, + signal_to_send, + foreground, + Some(signal_to_send), + &spawn_state, + ); + + if let Some(kill_after) = kill_after { + return match wait_or_kill_process( + process, + &cmd[0], + kill_after, + preserve_status, + foreground, + verbose, + &spawn_state, + ) { + Ok(status) => Err(status.into()), + Err(e) => Err(USimpleError::new( + ExitStatus::TimeoutFailed.into(), + e.to_string(), + )), + }; + } + + process.wait()?; + Err(ExitStatus::SignalSent(signal_to_send).into()) + } Err(_) => { // We're going to return ERR_EXIT_STATUS regardless of // whether `send_signal()` succeeds or fails - send_signal(process, signal, foreground); + platform::send_signal(process, signal, foreground, None, &spawn_state); Err(ExitStatus::TimeoutFailed.into()) } } diff --git a/src/uu/touch/Cargo.toml b/src/uu/touch/Cargo.toml index 447f795d1bf..cc7685f5384 100644 --- a/src/uu/touch/Cargo.toml +++ b/src/uu/touch/Cargo.toml @@ -1,4 +1,5 @@ # spell-checker:ignore datetime + [package] name = "uu_touch" description = "touch ~ (uutils) change FILE timestamps" @@ -12,35 +13,37 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/touch.rs" doctest = false [dependencies] -filetime = { workspace = true } clap = { workspace = true } +filetime = { workspace = true } +fluent = { workspace = true } jiff = { workspace = true } parse_datetime = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true, features = ["libc", "parser"] } -fluent = { workspace = true } [dev-dependencies] tempfile = { workspace = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } + +[target.'cfg(any(unix, target_os = "wasi"))'.dependencies] rustix = { workspace = true, features = ["fs"] } -[target.'cfg(target_os = "windows")'.dependencies] +[target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = [ "Win32_Storage_FileSystem", "Win32_Foundation", ] } +[lints] +workspace = true + [[bin]] name = "touch" path = "src/main.rs" diff --git a/src/uu/touch/locales/en-US.ftl b/src/uu/touch/locales/en-US.ftl index e454ea8ade2..9109319ecb7 100644 --- a/src/uu/touch/locales/en-US.ftl +++ b/src/uu/touch/locales/en-US.ftl @@ -26,3 +26,4 @@ touch-error-unable-to-parse-date = Unable to parse date: { $date } touch-error-windows-stdout-path-failed = GetFinalPathNameByHandleW failed with code { $code } touch-error-invalid-filetime = Source has invalid access or modification time: { $time } touch-error-reference-file-inaccessible = failed to get attributes of { $path }: { $error } +touch-error-stdout-unsupported = touch - (stdout) is not supported on WASI diff --git a/src/uu/touch/locales/fr-FR.ftl b/src/uu/touch/locales/fr-FR.ftl index e99fb8774fe..4f9b91bbb08 100644 --- a/src/uu/touch/locales/fr-FR.ftl +++ b/src/uu/touch/locales/fr-FR.ftl @@ -27,3 +27,4 @@ touch-error-unable-to-parse-date = Impossible d'analyser la date : { $date } touch-error-windows-stdout-path-failed = GetFinalPathNameByHandleW a échoué avec le code { $code } touch-error-invalid-filetime = La source a un temps d'accès ou de modification invalide : { $time } touch-error-reference-file-inaccessible = échec d'obtention des attributs de { $path } : { $error } +touch-error-stdout-unsupported = touch - (sortie standard) n'est pas pris en charge sur WASI diff --git a/src/uu/touch/src/error.rs b/src/uu/touch/src/error.rs index 47823cde93d..7d1b651e9fd 100644 --- a/src/uu/touch/src/error.rs +++ b/src/uu/touch/src/error.rs @@ -4,16 +4,16 @@ // file that was distributed with this source code. // spell-checker:ignore (misc) uioerror + use filetime::FileTime; use std::path::PathBuf; -use thiserror::Error; use uucore::display::Quotable; use uucore::error::{UError, UIoError}; use uucore::translate; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum TouchError { - #[error("{}", translate!("touch-error-unable-to-parse-date", "date" => .0.clone()))] + #[error("{}", translate!("touch-error-unable-to-parse-date", "date" => .0))] InvalidDateFormat(String), /// The source time couldn't be converted to a [`jiff::Zoned`] @@ -25,9 +25,13 @@ pub enum TouchError { ReferenceFileInaccessible(PathBuf, std::io::Error), /// An error getting a path to stdout on Windows - #[error("{}", translate!("touch-error-windows-stdout-path-failed", "code" => .0.clone()))] + #[error("{}", translate!("touch-error-windows-stdout-path-failed", "code" => .0))] WindowsStdoutPathError(String), + /// A feature that is not available on the current platform + #[error("{0}")] + UnsupportedPlatformFeature(String), + /// An error encountered on a specific file #[error("{error}")] TouchFileError { diff --git a/src/uu/touch/src/platform/mod.rs b/src/uu/touch/src/platform/mod.rs new file mode 100644 index 00000000000..3a062f6a025 --- /dev/null +++ b/src/uu/touch/src/platform/mod.rs @@ -0,0 +1,16 @@ +// 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. + +#[cfg(target_os = "wasi")] +pub use self::wasi::{pathbuf_from_stdout, set_file_times, set_symlink_file_times}; + +#[cfg(windows)] +pub use self::windows::pathbuf_from_stdout; + +#[cfg(target_os = "wasi")] +mod wasi; + +#[cfg(windows)] +mod windows; diff --git a/src/uu/touch/src/platform/wasi.rs b/src/uu/touch/src/platform/wasi.rs new file mode 100644 index 00000000000..4dc9dfc697d --- /dev/null +++ b/src/uu/touch/src/platform/wasi.rs @@ -0,0 +1,50 @@ +// 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) filetime utimensat + +use filetime::FileTime; +use rustix::fs::{AtFlags, CWD, Timespec, Timestamps, utimensat}; +use std::io::{Error, Result}; +use std::path::{Path, PathBuf}; + +use uucore::translate; + +use crate::error::TouchError; + +/// WASI replacement for `filetime::set_file_times`. +/// +/// The `filetime` crate has an unimplemented stub on `wasm32-wasi`. WASI +/// supports setting both atime and mtime via `utimensat`, which we reach +/// through `rustix`. +pub fn set_file_times(path: &Path, atime: FileTime, mtime: FileTime) -> Result<()> { + set_times(path, atime, mtime, AtFlags::empty()) +} + +/// WASI replacement for `filetime::set_symlink_file_times`. +pub fn set_symlink_file_times(path: &Path, atime: FileTime, mtime: FileTime) -> Result<()> { + set_times(path, atime, mtime, AtFlags::SYMLINK_NOFOLLOW) +} + +fn set_times(path: &Path, atime: FileTime, mtime: FileTime, flags: AtFlags) -> Result<()> { + let timestamps = Timestamps { + last_access: Timespec { + tv_sec: atime.unix_seconds(), + tv_nsec: atime.nanoseconds() as _, + }, + last_modification: Timespec { + tv_sec: mtime.unix_seconds(), + tv_nsec: mtime.nanoseconds() as _, + }, + }; + utimensat(CWD, path, ×tamps, flags).map_err(Error::from) +} + +/// WASI has no way to name the file behind stdout. +pub fn pathbuf_from_stdout() -> std::result::Result { + Err(TouchError::UnsupportedPlatformFeature(translate!( + "touch-error-stdout-unsupported" + ))) +} diff --git a/src/uu/touch/src/platform/windows.rs b/src/uu/touch/src/platform/windows.rs new file mode 100644 index 00000000000..abdc48d5885 --- /dev/null +++ b/src/uu/touch/src/platform/windows.rs @@ -0,0 +1,64 @@ +// 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) lpszfilepath + +use std::io::stdout; +use std::os::windows::prelude::AsRawHandle; +use std::path::PathBuf; + +use windows_sys::Win32::Foundation::{ + ERROR_INVALID_PARAMETER, ERROR_NOT_ENOUGH_MEMORY, ERROR_PATH_NOT_FOUND, HANDLE, MAX_PATH, +}; +use windows_sys::Win32::Storage::FileSystem::{FILE_NAME_OPENED, GetFinalPathNameByHandleW}; + +use uucore::translate; + +use crate::error::TouchError; + +/// Returns a [`PathBuf`] to stdout, using `GetFinalPathNameByHandleW` to +/// attempt to get the path from the stdout handle. +pub fn pathbuf_from_stdout() -> Result { + let handle = stdout().lock().as_raw_handle() as HANDLE; + let mut file_path_buffer: [u16; MAX_PATH as usize] = [0; MAX_PATH as usize]; + + // https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlea#examples + // SAFETY: We transmute the handle to be able to cast *mut c_void into a + // HANDLE (i32) so rustc will let us call GetFinalPathNameByHandleW. The + // reference example code for GetFinalPathNameByHandleW implies that + // it is safe for us to leave lpszfilepath uninitialized, so long as + // the buffer size is correct. We know the buffer size (MAX_PATH) at + // compile time. MAX_PATH is a small number (260) so we can cast it + // to a u32. + let ret = unsafe { + GetFinalPathNameByHandleW( + handle, + file_path_buffer.as_mut_ptr(), + file_path_buffer.len() as u32, + FILE_NAME_OPENED, + ) + }; + + let buffer_size = match ret { + ERROR_PATH_NOT_FOUND | ERROR_NOT_ENOUGH_MEMORY | ERROR_INVALID_PARAMETER => { + return Err(TouchError::WindowsStdoutPathError( + translate!("touch-error-windows-stdout-path-failed", "code" => ret), + )); + } + 0 => { + return Err(TouchError::WindowsStdoutPathError(translate!( + "touch-error-windows-stdout-path-failed", + "code".to_string() => + format!("{}", std::io::Error::last_os_error().raw_os_error().unwrap_or(0)), + ))); + } + e => e as usize, + }; + + // Don't include the null terminator + Ok(String::from_utf16(&file_path_buffer[0..buffer_size]) + .map_err(|e| TouchError::WindowsStdoutPathError(e.to_string()))? + .into()) +} diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index 783d1d35715..2cc9bc875b8 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -3,16 +3,19 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) datelike datetime filetime lpszfilepath mktime strtime timelike utime DATETIME UTIME futimens +// spell-checker:ignore (ToDO) datelike datetime filetime mktime strtime timelike utime DATETIME UTIME futimens // spell-checker:ignore (FORMATS) MMDDhhmm YYYYMMDDHHMM YYMMDDHHMM YYYYMMDDHHMMS CREAT ENXIO RDONLY utimensat pub mod error; +mod platform; use clap::builder::{PossibleValue, ValueParser}; use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; -#[cfg(any(not(unix), target_os = "redox"))] +use filetime::FileTime; +#[cfg(all(any(not(unix), target_os = "redox"), not(target_os = "wasi")))] use filetime::set_file_times; -use filetime::{FileTime, set_symlink_file_times}; +#[cfg(not(target_os = "wasi"))] +use filetime::set_symlink_file_times; use jiff::civil::Time; use jiff::fmt::strtime; use jiff::tz::TimeZone; @@ -41,6 +44,10 @@ use uucore::translate; use uucore::{format_usage, show}; use crate::error::TouchError; +#[cfg(not(unix))] +use crate::platform::pathbuf_from_stdout; +#[cfg(target_os = "wasi")] +use crate::platform::{set_file_times, set_symlink_file_times}; /// Options contains all the possible behaviors and flags for touch. /// @@ -637,9 +644,9 @@ fn update_times( } } -#[cfg(unix)] /// Build a rustix `Timestamps` from the access and modification `FileTime`s, /// preserving the `UTIME_NOW`/`UTIME_OMIT` sentinels in the nanoseconds field. +#[cfg(unix)] fn build_timestamps(atime: FileTime, mtime: FileTime) -> Timestamps { Timestamps { last_access: rustix::fs::Timespec { @@ -653,11 +660,11 @@ fn build_timestamps(atime: FileTime, mtime: FileTime) -> Timestamps { } } -#[cfg(all(unix, not(target_os = "redox")))] /// Set file times by path using `utimensat`, following symlinks. /// /// This never opens the file, so it does not block on special files such as /// FIFOs. +#[cfg(all(unix, not(target_os = "redox")))] fn set_times_by_path(path: &Path, atime: FileTime, mtime: FileTime) -> UResult<()> { let timestamps = build_timestamps(atime, mtime); rustix::fs::utimensat( @@ -670,23 +677,23 @@ fn set_times_by_path(path: &Path, atime: FileTime, mtime: FileTime) -> UResult<( .map_err_context(|| translate!("touch-error-setting-times-of-path", "path" => path.quote())) } -#[cfg(target_os = "redox")] /// Set file times by path on Redox, which lacks `rustix::fs::utimensat`. /// /// Falls back to `filetime::set_file_times`; unlike on other unixes this may /// block on a reader-less FIFO, but Redox has no FIFO support so the FIFO /// edge case the `utimensat` path guards against does not arise here. +#[cfg(target_os = "redox")] fn set_times_by_path(path: &Path, atime: FileTime, mtime: FileTime) -> UResult<()> { set_file_times(path, atime, mtime) .map_err_context(|| translate!("touch-error-setting-times-of-path", "path" => path.quote())) } -#[cfg(unix)] /// Set file times via file descriptor using `futimens`. /// /// This opens the file write-only and uses the POSIX `futimens` call to set /// access and modification times on the open FD (not by path), which also /// triggers `IN_CLOSE_WRITE` on Linux when the FD is closed. +#[cfg(unix)] fn try_futimens_via_write_fd(path: &Path, atime: FileTime, mtime: FileTime) -> std::io::Result<()> { let file = OpenOptions::new() .write(true) @@ -725,6 +732,19 @@ fn stat(path: &Path, follow: bool) -> std::io::Result<(FileTime, FileTime)> { fs::symlink_metadata(path)? }; + // `FileTime::from_last_{access,modification}_time` is unimplemented on + // `wasm32-wasi`, so go through `Metadata::{accessed, modified}` (which + // return `SystemTime`) and convert via `FileTime::from_system_time`. + #[cfg(target_os = "wasi")] + { + let atime = metadata.accessed()?; + let mtime = metadata.modified()?; + Ok(( + FileTime::from_system_time(atime), + FileTime::from_system_time(mtime), + )) + } + #[cfg(not(target_os = "wasi"))] Ok(( FileTime::from_last_access_time(&metadata), FileTime::from_last_modification_time(&metadata), @@ -784,16 +804,16 @@ fn parse_date(ref_zoned: Zoned, s: &str) -> Result { } // "@%s" is "The number of seconds since the Epoch, 1970-01-01 00:00:00 +0000 (UTC). (TZ) (Calculated from mktime(tm).)" - if s.bytes().next() == Some(b'@') { - if let Ok(ts) = &s[1..].parse::() { - return Ok(FileTime::from_unix_time(*ts, 0)); - } + if s.bytes().next() == Some(b'@') + && let Ok(ts) = &s[1..].parse::() + { + return Ok(FileTime::from_unix_time(*ts, 0)); } - if let Ok(parsed) = parse_datetime::parse_datetime_at_date(ref_zoned, s) { - if let Some(zoned) = parsed.into_zoned() { - return Ok(timestamp_to_filetime(zoned.timestamp())); - } + if let Ok(parsed) = parse_datetime::parse_datetime_at_date(ref_zoned, s) + && let Some(zoned) = parsed.into_zoned() + { + return Ok(timestamp_to_filetime(zoned.timestamp())); } Err(TouchError::InvalidDateFormat(s.to_owned())) @@ -864,7 +884,12 @@ fn parse_timestamp(s: &str) -> UResult { // only care about the timestamp anyway. // Tested in gnu/tests/touch/60-seconds if dt.second() == 59 && ts.ends_with(".60") { - dt += 1.second(); + dt = dt.checked_add(1.second()).map_err(|_| { + USimpleError::new( + 1, + translate!("touch-error-invalid-date-format", "date" => s.quote()), + ) + })?; } // Due to daylight saving time switch, local time can jump from 1:59 AM to @@ -883,14 +908,11 @@ fn parse_timestamp(s: &str) -> UResult { Ok(timestamp_to_filetime(local.timestamp())) } -// TODO: this may be a good candidate to put in fsext.rs /// Returns a [`PathBuf`] to stdout. -/// -/// On Windows, uses `GetFinalPathNameByHandleW` to attempt to get the path -/// from the stdout handle. -#[cfg_attr(not(windows), expect(clippy::unnecessary_wraps))] +#[cfg(unix)] +#[expect(clippy::unnecessary_wraps)] fn pathbuf_from_stdout() -> Result { - #[cfg(all(unix, not(target_os = "android")))] + #[cfg(not(target_os = "android"))] { Ok(PathBuf::from("/dev/stdout")) } @@ -898,66 +920,6 @@ fn pathbuf_from_stdout() -> Result { { Ok(PathBuf::from("/proc/self/fd/1")) } - #[cfg(windows)] - { - use std::os::windows::prelude::AsRawHandle; - use windows_sys::Win32::Foundation::{ - ERROR_INVALID_PARAMETER, ERROR_NOT_ENOUGH_MEMORY, ERROR_PATH_NOT_FOUND, GetLastError, - HANDLE, MAX_PATH, - }; - use windows_sys::Win32::Storage::FileSystem::{ - FILE_NAME_OPENED, GetFinalPathNameByHandleW, - }; - - let handle = std::io::stdout().lock().as_raw_handle() as HANDLE; - let mut file_path_buffer: [u16; MAX_PATH as usize] = [0; MAX_PATH as usize]; - - // https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlea#examples - // SAFETY: We transmute the handle to be able to cast *mut c_void into a - // HANDLE (i32) so rustc will let us call GetFinalPathNameByHandleW. The - // reference example code for GetFinalPathNameByHandleW implies that - // it is safe for us to leave lpszfilepath uninitialized, so long as - // the buffer size is correct. We know the buffer size (MAX_PATH) at - // compile time. MAX_PATH is a small number (260) so we can cast it - // to a u32. - let ret = unsafe { - GetFinalPathNameByHandleW( - handle, - file_path_buffer.as_mut_ptr(), - file_path_buffer.len() as u32, - FILE_NAME_OPENED, - ) - }; - - let buffer_size = match ret { - ERROR_PATH_NOT_FOUND | ERROR_NOT_ENOUGH_MEMORY | ERROR_INVALID_PARAMETER => { - return Err(TouchError::WindowsStdoutPathError( - translate!("touch-error-windows-stdout-path-failed", "code" => ret), - )); - } - 0 => { - return Err(TouchError::WindowsStdoutPathError(translate!( - "touch-error-windows-stdout-path-failed", - "code".to_string() => - format!( - "{}", - // SAFETY: GetLastError is thread-safe and has no documented memory unsafety. - unsafe { GetLastError() } - ), - ))); - } - e => e as usize, - }; - - // Don't include the null terminator - Ok(String::from_utf16(&file_path_buffer[0..buffer_size]) - .map_err(|e| TouchError::WindowsStdoutPathError(e.to_string()))? - .into()) - } - #[cfg(target_os = "wasi")] - { - Ok(PathBuf::from("/dev/stdout")) - } } #[cfg(test)] @@ -1043,6 +1005,15 @@ mod tests { } } + // -t 999912312359.60 bumps the leap second one past the very last representable + // instant in jiff's calendar (9999-12-31T23:59:59), which used to overflow the + // civil DateTime year field via an unchecked `+=` and abort the process (SIGABRT) + // instead of returning a normal parse error. + #[test] + fn test_parse_timestamp_leap_second_overflow_does_not_panic() { + assert!(super::parse_timestamp("999912312359.60").is_err()); + } + #[cfg(unix)] #[test] fn test_try_futimens_via_write_fd_sets_times() { diff --git a/src/uu/tr/Cargo.toml b/src/uu/tr/Cargo.toml index 4f083e46849..d17b671ca68 100644 --- a/src/uu/tr/Cargo.toml +++ b/src/uu/tr/Cargo.toml @@ -11,27 +11,30 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/tr.rs" test = false doctest = false [dependencies] -nom = { workspace = true } +bytecount = { workspace = true, features = ["runtime-dispatch-simd"] } clap = { workspace = true } -uucore = { workspace = true, features = ["fs", "signals"] } fluent = { workspace = true } -bytecount = { workspace = true, features = ["runtime-dispatch-simd"] } +memchr = { workspace = true } +nom = { workspace = true } +uucore = { workspace = true, features = ["fs", "signals"] } [dev-dependencies] divan = { workspace = true } rustix = { workspace = true, features = ["stdio", "fs"] } -tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true + [[bin]] name = "tr" path = "src/main.rs" diff --git a/src/uu/tr/benches/tr_bench.rs b/src/uu/tr/benches/tr_bench.rs index 8f751c72581..a26fc3bc11f 100644 --- a/src/uu/tr/benches/tr_bench.rs +++ b/src/uu/tr/benches/tr_bench.rs @@ -73,6 +73,13 @@ mod benches { let data = text_data::generate_by_size(SIZE_MB, 80); bench_tr_with_stdin(bencher, &data, &["-d", "a-z"]); } + + /// Delete a single character (the newlines). + #[divan::bench] + fn tr_delete_single_char(bencher: Bencher) { + let data = text_data::generate_by_size(SIZE_MB, 80); + bench_tr_with_stdin(bencher, &data, &["-d", "\\n"]); + } } fn main() { diff --git a/src/uu/tr/locales/en-US.ftl b/src/uu/tr/locales/en-US.ftl index f91f2de3033..c8a6fa6601f 100644 --- a/src/uu/tr/locales/en-US.ftl +++ b/src/uu/tr/locales/en-US.ftl @@ -13,7 +13,7 @@ tr-error-missing-operand = missing operand tr-error-missing-operand-translating = missing operand after { $set } Two strings must be given when translating. tr-error-missing-operand-deleting-squeezing = missing operand after { $set } - Two strings must be given when deleting and squeezing. + Two strings must be given when both deleting and squeezing repeats. tr-error-extra-operand-deleting-without-squeezing = extra operand { $operand } Only one string may be given when deleting without squeezing repeats. tr-error-extra-operand-simple = extra operand { $operand } @@ -41,3 +41,12 @@ tr-error-complement-more-than-one-unique = when translating with complemented ch string2 must map all characters in the domain to one tr-error-backwards-range = range-endpoints of '{ $start }-{ $end }' are in reverse collating sequence order tr-error-multiple-char-in-equivalence = { $chars }: equivalence class operand must be a single character + +# Diagnostic labels: what the caret points at in a set +tr-diag-label-backwards-range = did you mean '{ $suggestion }'? +tr-diag-label-set1-longer-set2-ends-in-class = this set is longer than SET2 +tr-diag-label-complement-more-than-one-unique = only one character may be complemented to +tr-diag-help-char-class = classes are alnum, alpha, blank, cntrl, digit, graph, lower, print, punct, space, upper and xdigit +tr-diag-help-equivalence = [=c=] stands for every character equivalent to c +tr-diag-help-repeat = [c*N] repeats c N times, [c*] pads SET2 to the length of SET1 +tr-diag-help-backwards-range = a range goes from the lower character to the higher one, as in a-z diff --git a/src/uu/tr/locales/fr-FR.ftl b/src/uu/tr/locales/fr-FR.ftl index 0b59ea133b0..b563485ba17 100644 --- a/src/uu/tr/locales/fr-FR.ftl +++ b/src/uu/tr/locales/fr-FR.ftl @@ -13,7 +13,7 @@ tr-error-missing-operand = opérande manquant tr-error-missing-operand-translating = opérande manquant après { $set } Deux chaînes doivent être données lors de la traduction. tr-error-missing-operand-deleting-squeezing = opérande manquant après { $set } - Deux chaînes doivent être données lors de la suppression et compression. + Deux chaînes doivent être données lors de la suppression et de la compression des répétitions. tr-error-extra-operand-deleting-without-squeezing = opérande supplémentaire { $operand } Une seule chaîne peut être donnée lors de la suppression sans compression des répétitions. tr-error-extra-operand-simple = opérande supplémentaire { $operand } @@ -42,3 +42,12 @@ tr-error-complement-more-than-one-unique = lors de la traduction avec des classe string2 doit mapper tous les caractères du domaine vers un seul tr-error-backwards-range = les points de fin de plage de '{ $start }-{ $end }' sont dans l'ordre inverse de la séquence de collation tr-error-multiple-char-in-equivalence = { $chars } : l'opérande de classe d'équivalence doit être un seul caractère + +# Étiquettes de diagnostic : ce que le caret désigne dans un ensemble +tr-diag-label-backwards-range = vouliez-vous dire '{ $suggestion }' ? +tr-diag-label-set1-longer-set2-ends-in-class = cet ensemble est plus long que SET2 +tr-diag-label-complement-more-than-one-unique = un seul caractère peut être la cible du complément +tr-diag-help-char-class = les classes sont alnum, alpha, blank, cntrl, digit, graph, lower, print, punct, space, upper et xdigit +tr-diag-help-equivalence = [=c=] désigne tout caractère équivalent à c +tr-diag-help-repeat = [c*N] répète c N fois, [c*] complète SET2 à la longueur de SET1 +tr-diag-help-backwards-range = un intervalle va du caractère le plus petit au plus grand, comme a-z diff --git a/src/uu/tr/src/diagnostics.rs b/src/uu/tr/src/diagnostics.rs new file mode 100644 index 00000000000..f225f0a00d4 --- /dev/null +++ b/src/uu/tr/src/diagnostics.rs @@ -0,0 +1,96 @@ +// 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. + +//! Maps a [`SequenceError`] onto the part of the set it came from, so that +//! [`uucore::diagnostics`] can render it with a caret. + +use std::ffi::OsString; + +use uucore::diagnostics::Snapshot; +use uucore::translate; + +use crate::operation::{BadSequence, SequenceError}; + +/// Render `err` against `args` — the whole argument list, program name +/// included — where `sets` are the set operands as typed. +/// +/// Returns `false` when the set cannot be found among the arguments, in which +/// case the caller should fall back to the plain one-line message. +pub fn render(args: &[OsString], sets: &[OsString], err: &SequenceError) -> bool { + let Some(set) = sets.get(usize::from(err.set) - 1) else { + return false; + }; + // Offsets into the set are byte offsets; a set that is not text cannot be + // pointed into. + let Some(set) = set.to_str() else { + return false; + }; + + let Some((label, help)) = describe(&err.error) else { + return false; + }; + // No span means the set as a whole is the problem, so underline all of it. + let span = err.span.clone().unwrap_or(0..set.len()); + + // The sets are positional operands, numbered as they appear; this holds + // because none of tr's options takes a separate value. + let snapshot = Snapshot::with_program(args); + let Some(index) = snapshot.index_of_positional(usize::from(err.set) - 1) else { + return false; + }; + snapshot.render_inside_at( + index, + set, + span, + &err.to_string(), + label.as_deref(), + help.map(|key| translate!(key)).as_deref(), + ) +} + +/// The caret label for `error`, translated, and the advice that goes under +/// it, or `None` for an error that a caret cannot be placed for. +/// +/// Labelled only where a label would add to the message — a fix, or the +/// decoding of an opaque one — per the convention in [`uucore::diagnostics`]. +fn describe(error: &BadSequence) -> Option<(Option, Option<&'static str>)> { + match error { + BadSequence::MissingCharClassName | BadSequence::InvalidCharClass(_) => { + Some((None, Some("tr-diag-help-char-class"))) + } + BadSequence::MissingEquivalentClassChar | BadSequence::MultipleCharInEquivalence(_) => { + Some((None, Some("tr-diag-help-equivalence"))) + } + BadSequence::InvalidRepeatCount(_) | BadSequence::CharRepeatInSet1 => { + Some((None, Some("tr-diag-help-repeat"))) + } + // The one fix is to swap the endpoints; suggest exactly that. + BadSequence::BackwardsRange { end, start } => Some(( + Some(translate!( + "tr-diag-label-backwards-range", + "suggestion" => format!( + "{}-{}", + crate::operation::range_endpoint_to_string(*end), + crate::operation::range_endpoint_to_string(*start) + ) + )), + Some("tr-diag-help-backwards-range"), + )), + BadSequence::MultipleCharRepeatInSet2 + | BadSequence::ClassExceptLowerUpperInSet2 + | BadSequence::ClassInSet2NotMatchedBySet1 => Some((None, None)), + BadSequence::Set1LongerSet2EndsInClass => Some(( + Some(translate!("tr-diag-label-set1-longer-set2-ends-in-class")), + None, + )), + BadSequence::ComplementMoreThanOneUniqueInSet2 => Some(( + Some(translate!("tr-diag-label-complement-more-than-one-unique")), + None, + )), + // Raised against the solved sets rather than what was typed, so there + // is nothing to point a caret at. + BadSequence::EmptySet2WhenNotTruncatingSet1 => None, + } +} diff --git a/src/uu/tr/src/operation.rs b/src/uu/tr/src/operation.rs index 44c28c160c8..36a2acc0728 100644 --- a/src/uu/tr/src/operation.rs +++ b/src/uu/tr/src/operation.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 (strings) anychar combinator Alnum Punct Xdigit alnum punct xdigit cntrl +// spell-checker:ignore (strings) anychar combinator Alnum Punct Xdigit alnum punct xdigit cntrl alpah use crate::unicode_table; use nom::{ @@ -12,7 +12,7 @@ use nom::{ bytes::complete::{tag, take, take_till, take_until}, character::complete::one_of, combinator::{map, map_opt, peek, recognize, value}, - multi::{many_m_n, many0}, + multi::many_m_n, sequence::{delimited, preceded, separated_pair, terminated}, }; use std::{ @@ -20,6 +20,7 @@ use std::{ error::Error, fmt::{Debug, Display}, io::{BufRead, Write}, + ops::Range, }; use uucore::error::{FromIo, UError, UResult}; use uucore::translate; @@ -28,7 +29,9 @@ use uucore::show_warning; /// Common trait for operations that can process chunks of data pub trait ChunkProcessor { - fn process_chunk(&self, input: &[u8], output: &mut Vec); + /// Return the bytes to write: `input` itself when it is left unchanged, + /// `output` otherwise. + fn process_chunk<'a>(&self, input: &'a [u8], output: &'a mut Vec) -> &'a [u8]; } #[derive(Debug, Clone)] @@ -48,6 +51,16 @@ pub enum BadSequence { MultipleCharInEquivalence(String), } +/// A range endpoint, printed the way the shell would show it: as itself for +/// printable ASCII — backslash-escaped where Rust escapes it, so `\` and the +/// quotes come back doubled — and as an octal escape otherwise. +pub(crate) fn range_endpoint_to_string(ut: u32) -> String { + match char::from_u32(ut) { + Some(ch @ '\x20'..='\x7E') => ch.escape_default().to_string(), + _ => format!("\\{ut:03o}"), + } +} + impl Display for BadSequence { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -113,32 +126,63 @@ impl Display for BadSequence { ) } Self::BackwardsRange { end, start } => { - fn end_or_start_to_string(ut: u32) -> String { - match char::from_u32(ut) { - Some(ch @ '\x20'..='\x7E') => ch.escape_default().to_string(), - _ => { - format!("\\{ut:03o}") - } - } - } write!( f, "{}", - translate!("tr-error-backwards-range", "start" => end_or_start_to_string(*start), "end" => end_or_start_to_string(*end)) + translate!("tr-error-backwards-range", "start" => range_endpoint_to_string(*start), "end" => range_endpoint_to_string(*end)) ) } Self::MultipleCharInEquivalence(s) => write!( f, "{}", - translate!("tr-error-multiple-char-in-equivalence", "chars" => s.clone()) + translate!("tr-error-multiple-char-in-equivalence", "chars" => s) ), } } } impl Error for BadSequence {} + impl UError for BadSequence {} +/// A [`BadSequence`] together with where it was written. +/// +/// A set is a small language of its own, so naming the set and the sequence +/// inside it says far more than the message alone: `[:alpah:]` and `[a-Z]` are +/// both "in SET1", but only one character of each is actually wrong. +#[derive(Debug, Clone)] +pub struct SequenceError { + pub error: BadSequence, + /// Which set the problem is in, numbered as the operands are: 1 or 2. + pub set: u8, + /// Byte range inside that set, or `None` when the set as a whole is at + /// fault rather than one sequence in it. + pub span: Option>, +} + +impl SequenceError { + /// A problem with a set taken as a whole. + fn whole_set(error: BadSequence, set: u8) -> Self { + Self { + error, + set, + span: None, + } + } +} + +impl Display for SequenceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // The location is for the caret only; the message reads as it always + // has. + Display::fmt(&self.error, f) + } +} + +impl Error for SequenceError {} + +impl UError for SequenceError {} + #[derive(Debug, Clone, Copy)] pub enum Class { Alnum, @@ -207,6 +251,73 @@ impl Sequence { } } + /// How many characters the sequence expands to, without expanding it: + /// the count of a `[c*n]` repeat can be far too large to materialize. + fn expanded_len(&self) -> usize { + match self { + Self::Char(_) => 1, + Self::CharRange(l, r) => usize::from(*r) - usize::from(*l) + 1, + // A star is only sized once it has been turned into a repeat. + Self::CharStar(_) => 0, + Self::CharRepeat(_, n) => *n, + Self::Class(_) => self.flatten().count(), + } + } + + /// The expanded length of a set. Widened so that repeat counts close to + /// `usize::MAX` still add up exactly and positions keep their order. + fn expanded_len_of(set: &[Self]) -> u128 { + set.iter().map(|s| s.expanded_len() as u128).sum() + } + + /// The characters of a set, in order, as runs of a repeated character. + /// + /// A `[c*n]` repeat is a single run however large `n` is; everything else + /// expands to runs of one character. Empty runs are left out, and a star + /// must have been turned into a repeat first. + fn runs(set: &[Self]) -> impl Iterator + '_ { + set.iter() + .flat_map(|s| -> Box> { + match s { + Self::CharRepeat(c, n) => Box::new(std::iter::once((*c, *n))), + Self::CharStar(_) => Box::new(std::iter::empty()), + _ => Box::new(s.flatten().map(|c| (c, 1))), + } + }) + .filter(|(_, n)| *n > 0) + } + + /// The number of characters in a set of runs, widened like `expanded_len_of`. + fn runs_len(runs: &[(u8, usize)]) -> u128 { + runs.iter().map(|(_, n)| *n as u128).sum() + } + + /// The characters a set of runs is made of, as a sorted list without duplicates. + fn unique_chars(runs: &[(u8, usize)]) -> Vec { + let mut uniques: Vec = runs.iter().map(|(c, _)| *c).collect(); + uniques.sort_unstable(); + uniques.dedup(); + uniques + } + + /// The complement of the characters that a set holds, or that its first + /// `len` positions hold, one run per character. + fn complement_of_prefix(set: &[Self], len: Option) -> Vec<(u8, usize)> { + let mut present = [false; 256]; + let mut remaining = len; + for (c, n) in Self::runs(set) { + if remaining == Some(0) { + break; + } + present[usize::from(c)] = true; + remaining = remaining.map(|left| left.saturating_sub(n as u128)); + } + (0..=u8::MAX) + .filter(|c| !present[usize::from(*c)]) + .map(|c| (c, 1)) + .collect() + } + // Hide all the nasty sh*t in here pub fn solve_set_characters( set1_str: &[u8], @@ -214,17 +325,28 @@ impl Sequence { complement_flag: bool, truncate_set1_flag: bool, translating: bool, - ) -> Result<(Vec, Vec), BadSequence> { + ) -> Result<(Vec, Vec), SequenceError> { let is_char_star = |s: &&Self| -> bool { matches!(s, Self::CharStar(_)) }; - let set1 = Self::from_str(set1_str)?; + let set1 = Self::parse_set(set1_str).map_err(|(error, span)| SequenceError { + error, + set: 1, + span: Some(span), + })?; if set1.iter().filter(is_char_star).count() != 0 { - return Err(BadSequence::CharRepeatInSet1); + return Err(SequenceError::whole_set(BadSequence::CharRepeatInSet1, 1)); } - let mut set2 = Self::from_str(set2_str)?; + let mut set2 = Self::parse_set(set2_str).map_err(|(error, span)| SequenceError { + error, + set: 2, + span: Some(span), + })?; if set2.iter().filter(is_char_star).count() > 1 { - return Err(BadSequence::MultipleCharRepeatInSet2); + return Err(SequenceError::whole_set( + BadSequence::MultipleCharRepeatInSet2, + 2, + )); } if translating @@ -233,25 +355,28 @@ impl Sequence { && !matches!(x, Self::Class(Class::Upper | Class::Lower)) }) { - return Err(BadSequence::ClassExceptLowerUpperInSet2); + return Err(SequenceError::whole_set( + BadSequence::ClassExceptLowerUpperInSet2, + 2, + )); } - let mut set1_solved: Vec = set1.iter().flat_map(Self::flatten).collect(); - if complement_flag { - set1_solved = (0..=u8::MAX).filter(|x| !set1_solved.contains(x)).collect(); - } - let set1_len = set1_solved.len(); + // Neither set is expanded character by character: a `[c*n]` repeat can + // be far too large for that. Both are handled as runs of one character + // instead, and only the lengths are ever computed in full. + let mut set1_runs: Vec<(u8, usize)> = if complement_flag { + Self::complement_of_prefix(&set1, None) + } else { + Self::runs(&set1).collect() + }; + let set1_len = Self::runs_len(&set1_runs); - let set2_len = set2 - .iter() - .filter_map(|s| match s { - Self::CharStar(_) => None, - r => Some(r), - }) - .flat_map(Self::flatten) - .count(); + // A star has no length of its own until it is turned into a repeat, + // so this is the length of everything else in set2. + let set2_fixed_len = Self::expanded_len_of(&set2); - let star_compensate_len = set1_len.saturating_sub(set2_len); + let star_compensate_len = + usize::try_from(set1_len.saturating_sub(set2_fixed_len)).unwrap_or(usize::MAX); //Replace CharStar with CharRepeat set2 = set2 .iter() @@ -265,40 +390,32 @@ impl Sequence { // For every upper/lower in set2, there must be an upper/lower in set1 at the same position. The position is calculated by expanding everything before the upper/lower in both sets for (set2_pos, set2_item) in set2.iter().enumerate() { if matches!(set2_item, Self::Class(_)) { - let mut set2_part_solved_len = 0; - if set2_pos >= 1 { - set2_part_solved_len = - set2.iter().take(set2_pos).flat_map(Self::flatten).count(); - } + let set2_part_solved_len = Self::expanded_len_of(&set2[..set2_pos]); let mut class_matches = false; for (set1_pos, set1_item) in set1.iter().enumerate() { - if matches!(set1_item, Self::Class(_)) { - let mut set1_part_solved_len = 0; - if set1_pos >= 1 { - set1_part_solved_len = - set1.iter().take(set1_pos).flat_map(Self::flatten).count(); - } - - if set1_part_solved_len == set2_part_solved_len { - class_matches = true; - break; - } + if matches!(set1_item, Self::Class(_)) + && Self::expanded_len_of(&set1[..set1_pos]) == set2_part_solved_len + { + class_matches = true; + break; } } if !class_matches { - return Err(BadSequence::ClassInSet2NotMatchedBySet1); + return Err(SequenceError::whole_set( + BadSequence::ClassInSet2NotMatchedBySet1, + 2, + )); } } } - let set2_solved: Vec<_> = set2.iter().flat_map(Self::flatten).collect(); + let set2_runs: Vec<(u8, usize)> = Self::runs(&set2).collect(); + let set2_len = Self::runs_len(&set2_runs); // Calculate the set of unique characters in set2 - let mut set2_uniques = set2_solved.clone(); - set2_uniques.sort_unstable(); - set2_uniques.dedup(); + let set2_uniques = Self::unique_chars(&set2_runs); let set1_has_class = set1.iter().any(|x| matches!(x, Self::Class(_))); // If the complement flag is used in translate mode, only one unique @@ -308,64 +425,131 @@ impl Sequence { if set1_has_class && translating && complement_flag - && (set2_uniques.len() > 1 || set2_solved.len() > set1_len) + && (set2_uniques.len() > 1 || set2_len > set1_len) { - return Err(BadSequence::ComplementMoreThanOneUniqueInSet2); + return Err(SequenceError::whole_set( + BadSequence::ComplementMoreThanOneUniqueInSet2, + 2, + )); } - if set2_solved.len() < set1_solved.len() { + if set2_len < set1_len { if truncate_set1_flag { if complement_flag && set1_has_class { // GNU applies -t before complementing a character class. // That means we must first truncate the expanded, non-complemented // source set, then complement the truncated prefix to recover the // final translation domain. - let truncated_set1: Vec<_> = set1 - .iter() - .flat_map(Self::flatten) - .take(set2_solved.len()) - .collect(); - set1_solved = (0..=u8::MAX) - .filter(|x| !truncated_set1.contains(x)) - .collect(); + set1_runs = Self::complement_of_prefix(&set1, Some(set2_len)); // After expansion the complemented domain may be larger than set2. // Re-check the complement validity constraint. - if set2_uniques.len() > 1 || set1_solved.len() > set2_solved.len() { - return Err(BadSequence::ComplementMoreThanOneUniqueInSet2); + if set2_uniques.len() > 1 || set1_runs.len() as u128 > set2_len { + return Err(SequenceError::whole_set( + BadSequence::ComplementMoreThanOneUniqueInSet2, + 2, + )); } - } else { - set1_solved.truncate(set2_solved.len()); } + // Otherwise set1 is cut to the length of set2 while pairing below. } else if matches!( set2.last().copied(), Some(Self::Class(Class::Upper | Class::Lower)) ) { - return Err(BadSequence::Set1LongerSet2EndsInClass); + return Err(SequenceError::whole_set( + BadSequence::Set1LongerSet2EndsInClass, + 1, + )); } } + // Line the two sets up position by position, one run at a time. A run + // of one character in set1 maps that character to every character it + // lines up with in set2, and the last mapping wins, so the pair at the + // end of each run boundary is all that is kept: the pairs in between + // repeat it. Once set2 runs out, set1 is either cut (-t) or the rest + // of it maps to the last character of set2. + let fallback = set2_runs.last().map(|(c, _)| *c); + let mut set1_solved = Vec::new(); + let mut set2_solved = Vec::new(); + let mut set2_runs = set2_runs.into_iter(); + let mut pending = set2_runs.next(); + 'pairing: for (c1, mut n1) in set1_runs { + while n1 > 0 { + let Some((c2, n2)) = pending else { + if truncate_set1_flag { + break 'pairing; + } + set1_solved.push(c1); + set2_solved.extend(fallback); + break; + }; + set1_solved.push(c1); + set2_solved.push(c2); + let step = n1.min(n2); + n1 -= step; + pending = if n2 > step { + Some((c2, n2 - step)) + } else { + set2_runs.next() + }; + } + } + // What is left of set2 is still part of it: with -s, the characters + // to squeeze come from all of set2. + set2_solved.extend(pending.into_iter().chain(set2_runs).map(|(c, _)| c)); + Ok((set1_solved, set2_solved)) } } impl Sequence { - pub fn from_str(input: &[u8]) -> Result, BadSequence> { - many0(alt(( - Self::parse_char_range, - Self::parse_char_star, - Self::parse_char_repeat, - Self::parse_class, - Self::parse_char_equal, - // NOTE: This must be the last one - map(Self::parse_backslash_or_char_with_warning, |s| { - Ok(Self::Char(s)) - }), - ))) - .parse(input) - .map(|(_, r)| r) - .unwrap() - .into_iter() - .collect::, _>>() + /// Parse a set, reporting which part of it a bad sequence occupies. + /// + /// The alternatives are tried in the same order as before; the loop only + /// replaces `many0` so that how much each sequence consumed is still known + /// once it turns out to be wrong. Like `many0`, it consumes the whole set + /// even past a bad sequence: parsing the tail emits the warnings — an + /// ambiguous octal escape, invalid UTF-8 — that it always has. + fn parse_set(input: &[u8]) -> Result, (BadSequence, Range)> { + let mut result = Vec::new(); + let mut first_error = None; + let mut rest = input; + while !rest.is_empty() { + let start = input.len() - rest.len(); + let parsed = alt(( + Self::parse_char_star, + Self::parse_char_repeat, + Self::parse_class, + Self::parse_char_equal, + Self::parse_char_range, + // NOTE: This must be the last one + map(Self::parse_backslash_or_char_with_warning, |s| { + Ok(Self::Char(s)) + }), + )) + .parse(rest); + // The last alternative accepts any single byte, so this only + // happens on input the loop has already run out of. + let Ok((next, sequence)) = parsed else { break }; + // `many0` refuses an alternative that matched nothing rather than + // loop on it forever; none of the ones above can, but the loop is + // no place to find out if one ever does. + if next.len() == rest.len() { + break; + } + let end = input.len() - next.len(); + match sequence { + Ok(sequence) => result.push(sequence), + Err(error) => { + first_error.get_or_insert((error, start..end)); + } + } + rest = next; + } + match first_error { + Some(error) => Err(error), + None => Ok(result), + } } fn parse_octal(input: &[u8]) -> IResult<&[u8], u8> { @@ -627,13 +811,20 @@ fn set_to_bitmap(set: &[u8]) -> [bool; 256] { #[derive(Debug)] pub struct DeleteOperation { - pub(crate) delete_table: [bool; 256], + pub(crate) keep_table: [bool; 256], + /// The byte to delete, when it is the only one. + single_delete: Option, } impl DeleteOperation { pub fn new(set: Vec) -> Self { + use crate::simd::find_single_change; + + let keep_table = set_to_bitmap(&set).map(|delete| !delete); + let single_delete = find_single_change(&keep_table, |_, &keep| !keep).map(|(b, _)| b); Self { - delete_table: set_to_bitmap(&set), + keep_table, + single_delete, } } } @@ -641,27 +832,20 @@ impl DeleteOperation { impl SymbolTranslator for DeleteOperation { fn translate(&mut self, current: u8) -> Option { // keep if not present in the delete set - (!self.delete_table[current as usize]).then_some(current) + self.keep_table[current as usize].then_some(current) } } impl ChunkProcessor for DeleteOperation { - fn process_chunk(&self, input: &[u8], output: &mut Vec) { - use crate::simd::{find_single_change, process_single_delete}; + fn process_chunk<'a>(&self, input: &'a [u8], output: &'a mut Vec) -> &'a [u8] { + use crate::simd::{process_delete, process_single_delete}; - // Check if this is single character deletion - if let Some((delete_char, _)) = - find_single_change(&self.delete_table, |_, &should_delete| should_delete) - { - process_single_delete(input, output, delete_char); + if let Some(delete_char) = self.single_delete { + process_single_delete(input, output, delete_char, &self.keep_table) } else { // Standard deletion - output.extend( - input - .iter() - .filter(|&&b| !self.delete_table[b as usize]) - .copied(), - ); + process_delete(input, output, &self.keep_table); + output } } } @@ -669,6 +853,8 @@ impl ChunkProcessor for DeleteOperation { #[derive(Debug)] pub struct TranslateOperation { pub(crate) translation_table: [u8; 256], + /// The byte to replace and its replacement, when it is the only one. + single_change: Option<(u8, u8)>, } impl TranslateOperation { @@ -685,14 +871,26 @@ impl TranslateOperation { translation_table[from as usize] = to; } - Ok(Self { translation_table }) + Ok(Self::from_table(translation_table)) } else if set1.is_empty() && set2.is_empty() { // Identity mapping for empty sets - Ok(Self { translation_table }) + Ok(Self::from_table(translation_table)) } else { + // Raised against the solved sets rather than what was typed, so + // there is nothing to point a caret at. Err(BadSequence::EmptySet2WhenNotTruncatingSet1) } } + + fn from_table(translation_table: [u8; 256]) -> Self { + use crate::simd::find_single_change; + + let single_change = find_single_change(&translation_table, |i, &val| val != i as u8); + Self { + translation_table, + single_change, + } + } } impl SymbolTranslator for TranslateOperation { @@ -702,18 +900,16 @@ impl SymbolTranslator for TranslateOperation { } impl ChunkProcessor for TranslateOperation { - fn process_chunk(&self, input: &[u8], output: &mut Vec) { - use crate::simd::{find_single_change, process_single_char_replace}; + fn process_chunk<'a>(&self, input: &'a [u8], output: &'a mut Vec) -> &'a [u8] { + use crate::simd::process_single_char_replace; - // Check if this is a simple single-character translation - if let Some((source, target)) = - find_single_change(&self.translation_table, |i, &val| val != i as u8) - { + if let Some((source, target)) = self.single_change { // Use SIMD-optimized single character replacement - process_single_char_replace(input, output, source, target); + process_single_char_replace(input, output, source, target) } else { // Standard translation using table lookup output.extend(input.iter().map(|&b| self.translation_table[b as usize])); + output } } } @@ -785,12 +981,12 @@ where /// Platform-specific flush operation #[inline] pub fn flush_output(output: &mut W) -> UResult<()> { - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] return output .flush() .map_err_context(|| translate!("tr-error-write-error")); - #[cfg(target_os = "windows")] + #[cfg(windows)] match output.flush() { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => { diff --git a/src/uu/tr/src/simd.rs b/src/uu/tr/src/simd.rs index 7bf2427daf6..0f31e242008 100644 --- a/src/uu/tr/src/simd.rs +++ b/src/uu/tr/src/simd.rs @@ -28,16 +28,17 @@ where /// SIMD-optimized single character replacement #[inline] -pub fn process_single_char_replace( - input: &[u8], - output: &mut Vec, +pub fn process_single_char_replace<'a>( + input: &'a [u8], + output: &'a mut Vec, source_char: u8, target_char: u8, -) { +) -> &'a [u8] { let count = bytecount::count(input, source_char); if count == 0 { - output.extend_from_slice(input); - } else if count == input.len() { + return input; + } + if count == input.len() { output.resize(output.len() + input.len(), target_char); } else { output.extend( @@ -46,17 +47,70 @@ pub fn process_single_char_replace( .map(|&b| if b == source_char { target_char } else { b }), ); } + output } /// SIMD-optimized delete operation for single character -pub fn process_single_delete(input: &[u8], output: &mut Vec, delete_char: u8) { +/// +/// `keep` must be false for `delete_char` only. +pub fn process_single_delete<'a>( + input: &'a [u8], + output: &'a mut Vec, + delete_char: u8, + keep: &[bool; 256], +) -> &'a [u8] { let count = bytecount::count(input, delete_char); if count == 0 { - output.extend_from_slice(input); + return input; + } + if count < input.len() / 128 { + // Below one match per 128 bytes, copying the runs between matches + // beats `process_delete`. + let mut start = 0; + for pos in memchr::memchr_iter(delete_char, input) { + output.extend_from_slice(&input[start..pos]); + start = pos + 1; + } + output.extend_from_slice(&input[start..]); } else if count < input.len() { - output.extend(input.iter().filter(|&&b| b != delete_char).copied()); + process_delete(input, output, keep); } // If count == input.len(), all deleted, output nothing + output +} + +/// Append to `output` the bytes of `input` whose `keep` entry is true. +pub fn process_delete(input: &[u8], output: &mut Vec, keep: &[bool; 256]) { + // The index is always below `BLOCK` (a power of two), so the modulo is a + // mask that only serves to drop the bounds check. + const BLOCK: usize = 1024; + // Below one kept byte in `FEW`, a branch per byte is well predicted and + // stores less. + const FEW: usize = 64; + let mut block = [0; BLOCK]; + // Guess for the first block from its start, then go by the previous one. + let start = &input[..input.len().min(256)]; + let mut few_kept = start.iter().filter(|&&b| keep[b as usize]).count() * FEW < start.len(); + for chunk in input.chunks(BLOCK) { + let mut kept = 0; + if few_kept { + // Only store the kept bytes. + for &b in chunk { + if keep[b as usize] { + block[kept % BLOCK] = b; + kept += 1; + } + } + } else { + // Store every byte and only advance past kept ones: no branch. + for &b in chunk { + block[kept % BLOCK] = b; + kept += usize::from(keep[b as usize]); + } + } + output.extend_from_slice(&block[..kept]); + few_kept = kept * FEW < chunk.len(); + } } /// Unified I/O processing for all operations @@ -79,10 +133,10 @@ where }; output_buf.clear(); - processor.process_chunk(&buf[..length], &mut output_buf); + let processed = processor.process_chunk(&buf[..length], &mut output_buf); - if !output_buf.is_empty() { - write_output(output, &output_buf)?; + if !processed.is_empty() { + write_output(output, processed)?; } } @@ -90,14 +144,16 @@ where } /// Helper function to handle platform-specific write operations -#[inline] +// Kept out of line: inlined into `translate_input`, the raw `write` made the +// translator state go to memory on every byte, doubling the time of `tr -s`. +#[inline(never)] pub fn write_output(output: &mut W, buf: &[u8]) -> UResult<()> { - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] return output .write_all(buf) .map_err_context(|| translate!("tr-error-write-error")); - #[cfg(target_os = "windows")] + #[cfg(windows)] match output.write_all(buf) { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => { diff --git a/src/uu/tr/src/tr.rs b/src/uu/tr/src/tr.rs index 58976d3ac06..5aadc652683 100644 --- a/src/uu/tr/src/tr.rs +++ b/src/uu/tr/src/tr.rs @@ -3,6 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +mod diagnostics; mod operation; mod simd; mod unicode_table; @@ -31,6 +32,9 @@ mod options { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let args: Vec = args.collect(); + // Kept for the caret in set diagnostics, which needs the sets as typed. + let set_args = uucore::diagnostics::capture(&args); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; let delete_flag = matches.get_flag(options::DELETE); @@ -42,15 +46,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // pattern API on OsStr let sets: Vec<_> = matches .get_many::(options::SETS) - .into_iter() - .flatten() + .ok_or_else(|| UUsageError::new(1, translate!("tr-error-missing-operand")))? .map(ToOwned::to_owned) .collect(); - if sets.is_empty() { - return Err(UUsageError::new(1, translate!("tr-error-missing-operand"))); - } - let sets_len = sets.len(); if !(delete_flag || squeeze_flag) && sets_len == 1 { @@ -98,19 +97,34 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let stdin = stdin(); let mut locked_stdin = stdin.lock(); - let mut locked_stdout = stdout().lock(); + // Write straight to the file descriptor: `Stdout` is line buffered, which + // costs a search for the last newline and an extra write per chunk. + #[cfg(any(unix, target_os = "wasi"))] + let mut output = uucore::io::RawWriter(stdout()); + #[cfg(not(any(unix, target_os = "wasi")))] + let mut output = stdout().lock(); // According to the man page: translating only happens if deleting or if a second set is given let translating = !delete_flag && sets.len() > 1; let mut sets_iter = sets.iter().map(OsString::as_os_str); - let (set1, set2) = Sequence::solve_set_characters( + let solved = Sequence::solve_set_characters( os_str_as_bytes(sets_iter.next().unwrap_or_default())?, os_str_as_bytes(sets_iter.next().unwrap_or_default())?, complement_flag, // if we are not translating then we don't truncate set1 truncate_set1_flag && translating, translating, - )?; + ); + let (set1, set2) = match solved { + Ok(sets_solved) => sets_solved, + Err(error) => { + return Err(uucore::diagnostics::error_after_report( + set_args.as_deref(), + error, + |args, error| diagnostics::render(args, &sets, error), + )); + } + }; if is_stdin_directory(&stdin) { return Err(USimpleError::new(1, translate!("tr-error-read-directory"))); @@ -122,27 +136,27 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let delete_op = DeleteOperation::new(set1); let squeeze_op = SqueezeOperation::new(set2); let op = delete_op.chain(squeeze_op); - translate_input(&mut locked_stdin, &mut locked_stdout, op)?; + translate_input(&mut locked_stdin, &mut output, op)?; } else { let op = DeleteOperation::new(set1); - process_input(&mut locked_stdin, &mut locked_stdout, &op)?; + process_input(&mut locked_stdin, &mut output, &op)?; } } else if squeeze_flag { if sets_len == 1 { let op = SqueezeOperation::new(set1); - translate_input(&mut locked_stdin, &mut locked_stdout, op)?; + translate_input(&mut locked_stdin, &mut output, op)?; } else { let translate_op = TranslateOperation::new(set1, set2.clone())?; let squeeze_op = SqueezeOperation::new(set2); let op = translate_op.chain(squeeze_op); - translate_input(&mut locked_stdin, &mut locked_stdout, op)?; + translate_input(&mut locked_stdin, &mut output, op)?; } } else { let op = TranslateOperation::new(set1, set2)?; - process_input(&mut locked_stdin, &mut locked_stdout, &op)?; + process_input(&mut locked_stdin, &mut output, &op)?; } - flush_output(&mut locked_stdout)?; + flush_output(&mut output)?; Ok(()) } diff --git a/src/uu/true/Cargo.toml b/src/uu/true/Cargo.toml index 5f26d04aefd..11d3efca6ec 100644 --- a/src/uu/true/Cargo.toml +++ b/src/uu/true/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/true.rs" test = false @@ -21,8 +18,8 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true } [[bin]] name = "true" @@ -32,6 +29,9 @@ path = "src/main.rs" divan = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bench]] name = "true_bench" harness = false diff --git a/src/uu/true/src/true.rs b/src/uu/true/src/true.rs index 6e259d17f21..c8d41b8b59c 100644 --- a/src/uu/true/src/true.rs +++ b/src/uu/true/src/true.rs @@ -2,37 +2,46 @@ // // 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 std::io::Write; -use uucore::{crate_version, translate}; +use std::io::{self, Write as _}; +use uucore::error::strip_errno; +use uucore::{crate_version, show_error, translate}; // uucore::main does not support no-result pub fn uumain(mut args: impl uucore::Args) -> i32 { + #[cold] + #[inline(never)] + fn cold_error(e: io::Error) -> i32 { + if e.kind() != io::ErrorKind::BrokenPipe { + // Try to display this error. + show_error!("{}", strip_errno(&e)); + // Mirror GNU options. When failing to print warnings or version flags, then we exit + // with FAIL. This avoids allocation some error information which may result in yet + // other types of failure. + return 1; + } + 0 + } + // skip binary name let (Some(flag), None) = (args.nth(1), args.next()) else { return 0; }; - let error = if flag == "--help" { + let res = if flag == "--help" { uu_app().print_help() } else if flag == "--version" { // avoid uu_app for smaller binary size - writeln!(std::io::stdout(), "true {}", crate_version!()) + writeln!(io::stdout(), "true {}", crate_version!()) } else { return 0; }; - if let Err(print_fail) = error - && print_fail.kind() != std::io::ErrorKind::BrokenPipe - { - // Try to display this error. - let _ = writeln!(std::io::stderr(), "true: {print_fail}"); - // Mirror GNU options. When failing to print warnings or version flags, then we exit - // with FAIL. This avoids allocation some error information which may result in yet - // other types of failure. - return 1; + match res { + Ok(()) => 0, + Err(e) => cold_error(e), } - 0 } pub fn uu_app() -> Command { diff --git a/src/uu/truncate/Cargo.toml b/src/uu/truncate/Cargo.toml index b954022ac7e..a4c532e3c74 100644 --- a/src/uu/truncate/Cargo.toml +++ b/src/uu/truncate/Cargo.toml @@ -11,17 +11,20 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/truncate.rs" doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["parser-size"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["fs", "parser-size"] } + +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true [[bin]] name = "truncate" diff --git a/src/uu/truncate/locales/en-US.ftl b/src/uu/truncate/locales/en-US.ftl index ce6ad3ce7f2..b1b0b2a7f3f 100644 --- a/src/uu/truncate/locales/en-US.ftl +++ b/src/uu/truncate/locales/en-US.ftl @@ -18,13 +18,12 @@ truncate-after-help = SIZE is an integer with an optional prefix and optional un '%' => round up to multiple of # Help messages -truncate-help-io-blocks = treat SIZE as the number of I/O blocks of the file rather than bytes (NOT IMPLEMENTED) +truncate-help-io-blocks = treat SIZE as the number of I/O blocks of the file rather than bytes truncate-help-no-create = do not create files that do not exist truncate-help-reference = base the size of each file on the size of RFILE truncate-help-size = set or adjust the size of each file according to SIZE, which is in bytes unless --io-blocks is specified # Error messages -truncate-error-missing-file-operand = missing file operand truncate-error-cannot-open-no-device = cannot open { $filename } for writing: No such device or address truncate-error-cannot-open-for-writing = cannot open { $filename } for writing truncate-error-invalid-number = Invalid number: { $error } @@ -33,3 +32,4 @@ truncate-error-division-by-zero = division by zero truncate-error-cannot-stat-no-such-file = cannot stat { $filename }: No such file or directory truncate-error-value-too-large = Value too large for defined data type truncate-error-value-too-large-arg = { $arg }: Value too large for defined data type +truncate-error-io-block-mul-overflow = overflow in { $num } * { $factor } byte blocks diff --git a/src/uu/truncate/locales/fr-FR.ftl b/src/uu/truncate/locales/fr-FR.ftl index 6b1f8271192..96a2b19abaf 100644 --- a/src/uu/truncate/locales/fr-FR.ftl +++ b/src/uu/truncate/locales/fr-FR.ftl @@ -18,13 +18,12 @@ truncate-after-help = TAILLE est un entier avec un préfixe optionnel et une uni '%' => arrondir vers le haut au multiple de # Messages d'aide -truncate-help-io-blocks = traiter TAILLE comme le nombre de blocs I/O du fichier plutôt que des octets (NON IMPLÉMENTÉ) +truncate-help-io-blocks = traiter TAILLE comme le nombre de blocs I/O du fichier plutôt que des octets truncate-help-no-create = ne pas créer les fichiers qui n'existent pas truncate-help-reference = baser la taille de chaque fichier sur la taille de RFICHIER truncate-help-size = définir ou ajuster la taille de chaque fichier selon TAILLE, qui est en octets sauf si --io-blocks est spécifié # Messages d'erreur -truncate-error-missing-file-operand = opérande de fichier manquant truncate-error-cannot-open-no-device = impossible d'ouvrir { $filename } en écriture : Aucun périphérique ou adresse de ce type truncate-error-cannot-open-for-writing = impossible d'ouvrir { $filename } en écriture truncate-error-invalid-number = Nombre invalide : { $error } diff --git a/src/uu/truncate/src/truncate.rs b/src/uu/truncate/src/truncate.rs index bdb2af011ab..867f9c264e4 100644 --- a/src/uu/truncate/src/truncate.rs +++ b/src/uu/truncate/src/truncate.rs @@ -12,15 +12,18 @@ use std::io::ErrorKind; #[cfg(unix)] use std::os::unix::fs::FileTypeExt; use std::path::Path; +use uucore::diagnostics::OptionValue; use uucore::display::Quotable; -use uucore::error::{FromIo, UResult, USimpleError, UUsageError}; +use uucore::error::{FromIo, UResult, USimpleError}; use uucore::format_usage; use uucore::show_if_err; use uucore::translate; -use uucore::parser::parse_size::{ParseSizeError, Parser, allow_list_with_all_suffixes}; +use uucore::parser::parse_size::{ + ParseSizeError, Parser, allow_list_with_all_suffixes, size_offset, +}; -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] enum TruncateMode { Absolute(u64), Extend(u64), @@ -38,6 +41,35 @@ enum SizeCalculationError { } impl TruncateMode { + fn scaled_by(&self, factor: u64) -> Result { + let scale = |size: u64| { + size.checked_mul(factor) + .ok_or(SizeCalculationError::Overflow) + }; + + Ok(match self { + Self::Absolute(size) => Self::Absolute(scale(*size)?), + Self::Extend(size) => Self::Extend(scale(*size)?), + Self::Reduce(size) => Self::Reduce(scale(*size)?), + Self::AtMost(size) => Self::AtMost(scale(*size)?), + Self::AtLeast(size) => Self::AtLeast(scale(*size)?), + Self::RoundDown(size) => Self::RoundDown(scale(*size)?), + Self::RoundUp(size) => Self::RoundUp(scale(*size)?), + }) + } + + fn value(&self) -> u64 { + match self { + Self::Absolute(size) + | Self::Extend(size) + | Self::Reduce(size) + | Self::AtMost(size) + | Self::AtLeast(size) + | Self::RoundDown(size) + | Self::RoundUp(size) => *size, + } + } + /// Compute a target size in bytes for this truncate mode. /// /// `fsize` is the size of the reference file, in bytes. @@ -119,28 +151,31 @@ pub mod options { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let args: Vec = args.collect(); + // Kept for the caret in size diagnostics, which needs the size as typed. + let diag_args = uucore::diagnostics::capture(&args); let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; let files: Vec = matches .get_many::(options::ARG_FILES) .map(|v| v.cloned().collect()) - .unwrap_or_default(); - - if files.is_empty() { - return Err(UUsageError::new( - 1, - translate!("truncate-error-missing-file-operand"), - )); - } + .expect("ARG_FILES should be required by clap"); let io_blocks = matches.get_flag(options::IO_BLOCKS); let no_create = matches.get_flag(options::NO_CREATE); let reference = matches - .get_one::(options::REFERENCE) - .map(String::from); + .get_one::(options::REFERENCE) + .map(OsString::from); let size = matches.get_one::(options::SIZE).map(String::from); - truncate(&files, no_create, io_blocks, reference, size) + truncate( + &files, + no_create, + io_blocks, + reference, + size, + diag_args.as_deref(), + ) } pub fn uu_app() -> Command { @@ -149,12 +184,15 @@ pub fn uu_app() -> Command { .about(translate!("truncate-about")) .override_usage(format_usage(&translate!("truncate-usage"))) .after_help(translate!("truncate-after-help")) - .infer_long_args(true); + .infer_long_args(true) + // GNU lets a later -s override an earlier one. + .args_override_self(true); uucore::clap_localization::configure_localized_command(cmd) .arg( Arg::new(options::IO_BLOCKS) .short('o') .long(options::IO_BLOCKS) + .requires(options::SIZE) .help(translate!("truncate-help-io-blocks")) .action(ArgAction::SetTrue), ) @@ -172,7 +210,8 @@ pub fn uu_app() -> Command { .required_unless_present(options::SIZE) .help(translate!("truncate-help-reference")) .value_name("RFILE") - .value_hint(clap::ValueHint::FilePath), + .value_hint(clap::ValueHint::FilePath) + .value_parser(clap::value_parser!(OsString)), ) .arg( Arg::new(options::SIZE) @@ -216,9 +255,25 @@ fn do_file_truncate(filename: &Path, create: bool, size: u64) -> UResult<()> { ) } +/// Block size for file in question, or if file does not yet exist, for the +/// parent directory of the file. +fn io_block_size(path: &Path, metadata: Option<&std::fs::Metadata>) -> u64 { + metadata.map_or_else( + || { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + uucore::fs::sane_blksize::sane_blksize_from_path(parent) + }, + uucore::fs::sane_blksize::sane_blksize_from_metadata, + ) +} + fn file_truncate( filename: &OsString, no_create: bool, + io_blocks: bool, reference_size: Option, mode: &TruncateMode, size_argument: Option<&str>, @@ -226,7 +281,8 @@ fn file_truncate( let path = Path::new(filename); // Get the length of the file. - let file_size = match metadata(path) { + let file_metadata = metadata(path); + let file_size = match file_metadata.as_ref() { Ok(metadata) => { // A pipe has no length. Do this check here to avoid duplicate `stat()` syscall. #[cfg(unix)] @@ -247,6 +303,23 @@ fn file_truncate( // 2. The size of the file to be truncated if no reference has been provided. let actual_reference_size = reference_size.unwrap_or(file_size); + let mode = if io_blocks { + let factor = io_block_size(path, file_metadata.as_ref().ok()); + mode.scaled_by(factor).map_err(|error| match error { + SizeCalculationError::Overflow => USimpleError::new( + 1, + translate!( + "truncate-error-io-block-mul-overflow", + "num" => mode.value(), + "factor" => factor + ), + ), + SizeCalculationError::DivisionByZero => unreachable!(), + })? + } else { + mode.clone() + }; + let truncate_size = mode .to_size(actual_reference_size) .map_err(|error| match error { @@ -267,15 +340,30 @@ fn file_truncate( } })?; + // Reject a size above i64::MAX up front like GNU. + if truncate_size > i64::MAX as u64 { + let error = match size_argument { + None => translate!("truncate-error-value-too-large"), + Some(arg) => { + translate!("truncate-error-value-too-large-arg", "arg" => arg.quote()) + } + }; + return Err(USimpleError::new( + 1, + translate!("truncate-error-invalid-number", "error" => error), + )); + } + do_file_truncate(path, !no_create, truncate_size) } fn truncate( filenames: &[OsString], no_create: bool, - _io_blocks: bool, // TODO: implement handling - reference: Option, + io_blocks: bool, + reference: Option, size: Option, + diag_args: Option<&[OsString]>, ) -> UResult<()> { let reference_size = match reference { Some(reference_path) => { @@ -298,9 +386,15 @@ fn truncate( let mode = match size_string { Some(string) => match parse_mode_and_size(string) { Err(error) => { - return Err(USimpleError::new( - 1, - translate!("truncate-error-invalid-number", "error" => error), + let message = translate!("truncate-error-invalid-number", "error" => &error); + return Err(error.size_value_error( + diag_args, + &OptionValue::new(string, 's', "size"), + // The parser never saw the mode character; the caret has + // to count it back in. + size_offset(string, is_modifier), + &message, + USimpleError::new(1, message.clone()), )); } Ok(mode) => mode, @@ -322,6 +416,7 @@ fn truncate( show_if_err!(file_truncate( filename, no_create, + io_blocks, reference_size, &mode, size_string, @@ -360,6 +455,11 @@ fn parse_mode_and_size(size_string: &str) -> Result Result TruncateMode::Absolute, }) } else { - Err(ParseSizeError::ParseFailure(size_string.to_string())) + Err(ParseSizeError::ParseFailure("''".to_string())) } } @@ -387,6 +487,21 @@ mod tests { use crate::SizeCalculationError; use crate::TruncateMode; use crate::parse_mode_and_size; + use crate::{is_modifier, size_offset}; + + /// The offset a caret counts has to be the one the parser skipped, or the + /// underline lands beside what went wrong. + #[test] + fn size_offset_counts_what_the_parser_stripped() { + let offset = |s| size_offset(s, is_modifier); + assert_eq!(offset("5x"), 0); + assert_eq!(offset("+5x"), 1); + // Only the first modifier is stripped; the second is part of the size. + assert_eq!(offset("//5x"), 1); + // The parser trims before looking for a modifier. + assert_eq!(offset(" +5x"), 3); + assert_eq!(offset(" 5x"), 2); + } #[test] fn test_parse_mode_and_size() { @@ -429,6 +544,18 @@ mod tests { ); } + #[test] + fn test_scale_mode_by_io_block_size() { + assert_eq!( + TruncateMode::Extend(2).scaled_by(4096), + Ok(TruncateMode::Extend(8192)) + ); + assert_eq!( + TruncateMode::Absolute(u64::MAX).scaled_by(4096), + Err(SizeCalculationError::Overflow) + ); + } + #[test] fn test_round_up_when_file_smaller_than_size() { // fsize < size: must round up to size itself diff --git a/src/uu/tsort/Cargo.toml b/src/uu/tsort/Cargo.toml index 8f05185ba7d..fa31eccccb2 100644 --- a/src/uu/tsort/Cargo.toml +++ b/src/uu/tsort/Cargo.toml @@ -12,9 +12,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/tsort.rs" test = false @@ -23,12 +20,16 @@ doctest = false [dependencies] clap = { workspace = true } fluent = { workspace = true } -string-interner = { workspace = true } +hashbrown = { workspace = true } +memchr = { workspace = true } +rustc-hash = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true } -rustc-hash = { workspace = true } -[target.'cfg(unix)'.dependencies] +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = ["Win32_Storage_FileSystem"] } + +[target.'cfg(any(unix, target_os = "wasi"))'.dependencies] rustix = { workspace = true, features = ["fs"] } [[bin]] @@ -39,6 +40,9 @@ path = "src/main.rs" divan = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bench]] name = "tsort_bench" harness = false diff --git a/src/uu/tsort/benches/tsort_bench.rs b/src/uu/tsort/benches/tsort_bench.rs index 28395cffd0e..854340fea07 100644 --- a/src/uu/tsort/benches/tsort_bench.rs +++ b/src/uu/tsort/benches/tsort_bench.rs @@ -5,7 +5,7 @@ use divan::{Bencher, black_box}; use uu_tsort::uumain; -use uucore::benchmark::{run_util_function, setup_test_file}; +use uucore::benchmark::{get_bench_args, setup_test_file}; /// Generate topological sort test data - linear chain fn generate_linear_chain(num_nodes: usize) -> Vec { @@ -118,11 +118,10 @@ fn generate_wide_dag(num_nodes: usize) -> Vec { fn tsort_linear_chain(bencher: Bencher, num_nodes: usize) { let data = generate_linear_chain(num_nodes); let file_path = setup_test_file(&data); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &[file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark tree-like DAG structures @@ -130,11 +129,10 @@ fn tsort_linear_chain(bencher: Bencher, num_nodes: usize) { fn tsort_tree_dag(bencher: Bencher, (depth, branching): (usize, usize)) { let data = generate_tree_dag(depth, branching); let file_path = setup_test_file(&data); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &[file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark complex DAG with cross-dependencies @@ -142,11 +140,10 @@ fn tsort_tree_dag(bencher: Bencher, (depth, branching): (usize, usize)) { fn tsort_complex_dag(bencher: Bencher, num_nodes: usize) { let data = generate_complex_dag(num_nodes); let file_path = setup_test_file(&data); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &[file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark wide DAG with many parallel chains @@ -155,11 +152,10 @@ fn tsort_complex_dag(bencher: Bencher, num_nodes: usize) { fn tsort_wide_dag(bencher: Bencher, num_nodes: usize) { let data = generate_wide_dag(num_nodes); let file_path = setup_test_file(&data); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &[file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /* @@ -189,11 +185,10 @@ fn generate_input_parsing_heavy(num_edges: usize) -> Vec { fn tsort_input_parsing_heavy(bencher: Bencher, num_edges: usize) { let data = generate_input_parsing_heavy(num_edges); let file_path = setup_test_file(&data); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &[file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_path])) + .bench_values(|args| black_box(uumain(args))); } */ diff --git a/src/uu/tsort/locales/en-US.ftl b/src/uu/tsort/locales/en-US.ftl index 41a36a08fa9..be0b1c37590 100644 --- a/src/uu/tsort/locales/en-US.ftl +++ b/src/uu/tsort/locales/en-US.ftl @@ -3,7 +3,6 @@ tsort-about = Topological sort the strings in FILE. Useful for scheduling and determining execution order. If FILE is not passed in, stdin is used instead. tsort-usage = tsort [OPTIONS] FILE -tsort-error-is-dir = read error: Is a directory tsort-error-odd = input contains an odd number of tokens tsort-error-loop = input contains a loop: tsort-error-extra-operand = extra operand { $operand } diff --git a/src/uu/tsort/locales/fr-FR.ftl b/src/uu/tsort/locales/fr-FR.ftl index e43aab26aed..749788a9cee 100644 --- a/src/uu/tsort/locales/fr-FR.ftl +++ b/src/uu/tsort/locales/fr-FR.ftl @@ -3,7 +3,6 @@ tsort-about = Tri topologique des chaînes présentes dans FILE. Utile pour la planification et la détermination de l'ordre d'exécution. Si FILE n'est pas fourni, l'entrée standard (stdin) est utilisée. tsort-usage = tsort [OPTIONS] FILE -tsort-error-is-dir = erreur de lecture : c'est un répertoire tsort-error-odd = l'entrée contient un nombre impair de jetons tsort-error-loop = l'entrée contient une boucle : tsort-error-extra-operand = opérande supplémentaire { $operand } diff --git a/src/uu/tsort/src/error.rs b/src/uu/tsort/src/error.rs new file mode 100644 index 00000000000..186bc35e409 --- /dev/null +++ b/src/uu/tsort/src/error.rs @@ -0,0 +1,41 @@ +// 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; + +use uucore::{display::Quotable as _, translate}; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum Error { + /// Error while reading input. + #[error(transparent)] + Read(#[from] ReadError), + + /// Error while writing output. + #[error("{message}: {0}", message = translate!("common-write-error"))] + Write(io::Error), + + /// The graph contains a cycle. + #[error("{input}: {message}", input = .0, message = translate!("tsort-error-loop"))] + Loop(String), +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ReadError { + /// The input file is actually a directory. + #[error("{input}: {read_error}: {message}", input = .0.maybe_quote(), read_error = translate!("common-read-error"), message = translate!("error-is-a-directory-text"))] + 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), + + /// Wrapper for bubbling up input IO errors. + #[error("{message}: {0}", message = translate!("common-read-error"))] + Io(io::Error), +} diff --git a/src/uu/tsort/src/interner.rs b/src/uu/tsort/src/interner.rs new file mode 100644 index 00000000000..50317e4a9f2 --- /dev/null +++ b/src/uu/tsort/src/interner.rs @@ -0,0 +1,117 @@ +// 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 interner Interner + +use hashbrown::HashTable; +use rustc_hash::FxHasher; +use std::hash::Hasher; + +pub type Sym = usize; + +/// Interns arbitrary byte strings. +/// +/// During interning: +/// +/// &[u8] -> Sym +/// Sym -> &[u8] +/// +/// Once `finish_interning()` is called, the hash table used for +/// `&[u8] -> Sym` lookups is dropped. `Sym -> &[u8]` resolution remains +/// available for the lifetime of the interner. +pub struct ByteInterner { + /// Needed only while new values are being interned. + table: Option>, + + /// All interned byte strings packed contiguously. + bytes: Vec, + + /// Boundaries into `bytes`. + /// + /// Symbol `n` corresponds to: + /// + /// bytes[offsets[n]..offsets[n + 1]] + /// + /// The initial zero is a sentinel, so the number of symbols is + /// `offsets.len() - 1`. + offsets: Vec, +} + +impl Default for ByteInterner { + fn default() -> Self { + Self { + table: Some(HashTable::new()), + bytes: Vec::new(), + offsets: vec![0], + } + } +} + +impl ByteInterner { + #[inline] + fn hash(value: &[u8]) -> u64 { + let mut hasher = FxHasher::default(); + hasher.write(value); + hasher.finish() + } + + #[inline] + pub fn get_or_intern(&mut self, value: &[u8]) -> Sym { + let hash = Self::hash(value); + + let Self { + table, + bytes, + offsets, + } = self; + + let table = table + .as_mut() + .expect("cannot intern values after finish_interning()"); + + // Check whether this byte sequence is already interned. + if let Some(&sym) = table.find(hash, |&sym| { + let start = offsets[sym]; + let end = offsets[sym + 1]; + + &bytes[start..end] == value + }) { + return sym; + } + + // Allocate a new symbol and append the bytes directly to the arena. + let sym = offsets.len() - 1; + + bytes.extend_from_slice(value); + offsets.push(bytes.len()); + + // HashTable stores only the symbol. If it needs to resize, hashes + // for existing entries are reconstructed from the byte arena. + table.insert_unique(hash, sym, |&sym| { + let start = offsets[sym]; + let end = offsets[sym + 1]; + + Self::hash(&bytes[start..end]) + }); + + sym + } + + #[inline] + pub fn resolve(&self, sym: Sym) -> Option<&[u8]> { + let start = *self.offsets.get(sym)?; + let end = *self.offsets.get(sym + 1)?; + + Some(&self.bytes[start..end]) + } + + /// Drop the `bytes -> Sym` lookup structure. + /// + /// Call this after all input has been parsed and no more values will + /// be interned. + pub fn finish_interning(&mut self) { + drop(self.table.take()); + } +} diff --git a/src/uu/tsort/src/parser.rs b/src/uu/tsort/src/parser.rs new file mode 100644 index 00000000000..5b2b427111a --- /dev/null +++ b/src/uu/tsort/src/parser.rs @@ -0,0 +1,69 @@ +// 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 memchr::memchr3; +use std::io::{self, BufRead}; + +pub fn for_each_token(mut reader: R, mut f: F) -> io::Result<()> +where + R: BufRead, + F: FnMut(&[u8]), +{ + let mut pending = Vec::new(); + + loop { + let buf = reader.fill_buf()?; + + if buf.is_empty() { + if !pending.is_empty() { + f(&pending); + } + return Ok(()); + } + + let mut pos = 0; + + while pos < buf.len() { + if pending.is_empty() { + // Skip whitespace before the next token. + while buf.get(pos).is_some_and(|&b| is_delimiter(b)) { + pos += 1; + } + + if pos == buf.len() { + break; + } + } + + if let Some(i) = memchr3(b' ', b'\t', b'\n', &buf[pos..]) { + let end = pos + i; + + if pending.is_empty() { + // Fast path: token is entirely inside this buffer. + f(&buf[pos..end]); + } else { + // Complete a token that started in an earlier buffer. + pending.extend_from_slice(&buf[pos..end]); + f(&pending); + pending.clear(); + } + + pos = end + 1; + } else { + // Token continues into the next fill_buf() chunk. + pending.extend_from_slice(&buf[pos..]); + break; + } + } + + let consumed = buf.len(); + reader.consume(consumed); + } +} + +#[inline] +fn is_delimiter(byte: u8) -> bool { + matches!(byte, b' ' | b'\t' | b'\n') +} diff --git a/src/uu/tsort/src/tsort.rs b/src/uu/tsort/src/tsort.rs index d59cefada0c..fd48eb0b3bd 100644 --- a/src/uu/tsort/src/tsort.rs +++ b/src/uu/tsort/src/tsort.rs @@ -3,26 +3,28 @@ // 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 espidf // spell-checker:ignore (libs) interner +mod error; +mod interner; +mod parser; + use clap::{Arg, ArgAction, Command}; use rustc_hash::FxHashMap; use std::collections::VecDeque; use std::collections::hash_map::Entry; use std::ffi::OsString; +use std::fmt; use std::fs::File; +use std::fs::OpenOptions; use std::io::{self, BufRead, BufReader, BufWriter, Write}; -use string_interner::StringInterner; -use string_interner::backend::BucketBackend; -use thiserror::Error; use uucore::display::Quotable; -use uucore::error::{UError, UResult, USimpleError}; +use uucore::error::{FromIo, UError, UResult, USimpleError}; use uucore::{format_usage, show, translate}; -// short types for switching interning behavior on the fly. -type Sym = string_interner::symbol::SymbolUsize; -type Interner = StringInterner, rustc_hash::FxBuildHasher>; +use crate::error::{Error, ReadError}; +use crate::interner::{ByteInterner, Sym}; mod options { pub const FILE: &str = "file"; @@ -51,27 +53,60 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { // 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 mut options: OpenOptions; // some platforms cannot catch this as read error. Needs additional cost by stat #[cfg(windows)] { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_SEQUENTIAL_SCAN; let input = std::path::Path::new(input); if input.is_dir() { - return Err(TsortError::IsDir(input.to_string_lossy().to_string()).into()); + return Err( + Error::Read(ReadError::IsDir(input.to_string_lossy().to_string())).into(), + ); } + // advise the OS we will access the data sequentially if possible (windows) + options = File::options() + .custom_flags(FILE_FLAG_SEQUENTIAL_SCAN) + .clone(); } - let file = File::open(input)?; - // advise the OS we will access the data sequentially if possible - #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] + #[cfg(not(windows))] + { + options = File::options(); + } + let file = options + .read(true) + .open(input) + .map_err_context(|| input.maybe_quote().to_string())?; + + // advise the OS we will access the data sequentially if possible (unix) + #[cfg(all( + any(unix, target_os = "wasi"), + not(any( + target_vendor = "apple", + target_os = "netbsd", + target_os = "openbsd", + target_os = "dragonfly", + target_os = "espidf", + target_os = "haiku", + target_os = "horizon", + target_os = "redox", + target_os = "solaris", + target_os = "vita", + )) + ))] let _ = rustix::fs::fadvise(&file, 0, None, rustix::fs::Advice::Sequential); let reader = BufReader::new(file); process_input(reader, &mut g)?; } - g.run_tsort() + g.run_tsort()?; + Ok(()) } pub fn uu_app() -> Command { @@ -99,66 +134,54 @@ pub fn uu_app() -> Command { ) } -#[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] io::Error), +// Auxiliary struct, just for printing loop nodes via show! macro. +// +// Diagnostics go through Display, so invalid UTF-8 bytes are represented +// lossily here. +#[derive(Debug)] +struct LoopNode<'a>(&'a [u8]); + +impl fmt::Display for LoopNode<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", String::from_utf8_lossy(self.0)) + } } -// Auxiliary struct, just for printing loop nodes via show! macro -#[derive(Debug, Error)] -#[error("{0}")] -struct LoopNode<'a>(&'a str); +impl std::error::Error for LoopNode<'_> {} -impl UError for TsortError {} +impl UError for Error {} impl UError for LoopNode<'_> {} -fn process_input(reader: R, graph: &mut Graph) -> Result<(), TsortError> { +fn process_input(reader: R, graph: &mut Graph) -> Result<(), Error> { 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.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); - - if let Some(from) = pending.take() { - graph.add_edge(from, token_sym); - } else { - pending = Some(token_sym); - } + // with tokens separated by whitespaces (, \t, or \n). + // + // Tokens are kept as raw bytes so invalid UTF-8 can be preserved. + + let result = parser::for_each_token(reader, |token| { + 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 let Err(e) = result { + if e.kind() == io::ErrorKind::IsADirectory { + return Err(ReadError::IsDir(graph.name()).into()); } + return Err(ReadError::Io(e).into()); } + if pending.is_some() { - return Err(TsortError::NumTokensOdd(graph.name())); + return Err(ReadError::NumTokensOdd(graph.name()).into()); } - + graph.interner.finish_interning(); Ok(()) } @@ -191,38 +214,36 @@ impl Node { } struct Graph { - name_sym: Sym, + name: String, nodes: FxHashMap, - interner: Interner, + interner: ByteInterner, } impl Graph { fn new(name: String) -> Self { - let mut interner = Interner::with_hasher(rustc_hash::FxBuildHasher); - let name_sym = interner.get_or_intern(name); Self { - name_sym, - interner, + name, nodes: FxHashMap::default(), + interner: ByteInterner::default(), } } 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() } + self.name.clone() } - 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 get_node_name(&self, node_sym: Sym) -> &[u8] { + self.interner + .resolve(node_sym) + .expect("symbol should be interned") } 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); + let to_node = self.nodes.entry(to).or_default(); to_node.predecessor_count += 1; } @@ -237,6 +258,7 @@ impl Graph { .successor_tokens, v, ); + self.nodes .get_mut(&v) .expect("node is part of the graph") @@ -244,7 +266,7 @@ impl Graph { } /// Implementation of algorithm T from TAOCP (Don. Knuth), vol. 1. - fn run_tsort(&mut self) -> UResult<()> { + fn run_tsort(&mut self) -> Result<(), Error> { let mut independent_nodes_queue: VecDeque = self .nodes .iter() @@ -257,14 +279,20 @@ impl Graph { }) .collect(); - // Sort by resolved string for deterministic output + // Sort by name for deterministic output. independent_nodes_queue .make_contiguous() .sort_unstable_by(|a, b| self.get_node_name(*a).cmp(self.get_node_name(*b))); + let mut out = BufWriter::new(io::stdout().lock()); + while !self.nodes.is_empty() { let v = self.find_next_node(&mut independent_nodes_queue); - writeln!(out, "{}", self.get_node_name(v))?; + + // Write the node exactly as it appeared in the input, followed by a newline + out.write_all(self.get_node_name(v)).map_err(Error::Write)?; + writeln!(out).map_err(Error::Write)?; + 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 @@ -272,15 +300,20 @@ impl Graph { .nodes .get_mut(&successor_name) .expect("node is part of the graph"); + successor_node.predecessor_count -= 1; + if successor_node.predecessor_count == 0 { independent_nodes_queue.push_back(successor_name); } } } } + + out.flush().map_err(Error::Write)?; Ok(()) } + pub fn indegree(&self, sym: Sym) -> Option { self.nodes.get(&sym).map(|data| data.predecessor_count) } @@ -309,25 +342,32 @@ impl Graph { fn find_and_break_cycle(&mut self, frontier: &mut VecDeque) { let cycle = self.detect_cycle(); - show!(TsortError::Loop(self.name())); + + show!(Error::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).expect("node is part of the graph") == 0 { frontier.push_back(v); } } fn detect_cycle(&self) -> Vec { - // Sort by resolved string for deterministic output + // Sort by name 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 = FxHashMap::default(); let mut stack = Vec::with_capacity(self.nodes.len()); + for &node in &nodes { if self.dfs(node, &mut visited, &mut stack) { let (loop_entry, _) = stack.pop().expect("loop is not empty"); @@ -339,6 +379,7 @@ impl Graph { .collect(); } } + unreachable!("detect_cycle is expected to be called only on graphs with cycles"); } @@ -354,6 +395,7 @@ impl Graph { .get(&node) .map_or(&[], |n: &Node| &n.successor_tokens), )); + let state = *visited.entry(node).or_insert(VisitedState::Opened); if state == VisitedState::Closed { @@ -374,6 +416,7 @@ impl Graph { Entry::Vacant(v) => { // first visit of the node v.insert(VisitedState::Opened); + stack.push(( next_node, self.nodes @@ -381,10 +424,12 @@ impl Graph { .map_or(&[], |n| &n.successor_tokens), )); } + Entry::Occupied(o) => { if *o.get() == VisitedState::Opened { - // 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. + // 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; } diff --git a/src/uu/tty/Cargo.toml b/src/uu/tty/Cargo.toml index 321ac2f082b..4aef685dcf5 100644 --- a/src/uu/tty/Cargo.toml +++ b/src/uu/tty/Cargo.toml @@ -11,20 +11,17 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/tty.rs" test = false doctest = false -[dependencies] +[target.'cfg(not(target_os = "fuchsia"))'.dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["fs"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["fs"] } -[target.'cfg(unix)'.dependencies] +[target.'cfg(all(unix, not(target_os = "fuchsia")))'.dependencies] rustix = { workspace = true, features = ["fs", "termios"] } [target.'cfg(windows)'.dependencies] @@ -33,6 +30,9 @@ windows-sys = { workspace = true, features = [ "Win32_Foundation", ] } +[lints] +workspace = true + [[bin]] name = "tty" path = "src/main.rs" diff --git a/src/uu/tty/src/tty.rs b/src/uu/tty/src/tty.rs index 504e34a88b2..21ff1eef961 100644 --- a/src/uu/tty/src/tty.rs +++ b/src/uu/tty/src/tty.rs @@ -5,12 +5,12 @@ // spell-checker:ignore (ToDO) ttyname filedesc +#![cfg(not(target_os = "fuchsia"))] + use clap::{Arg, ArgAction, Command}; use std::io::{IsTerminal, Write}; -use uucore::error::{UResult, set_exit_code}; -use uucore::format_usage; - -use uucore::translate; +use uucore::error::{UResult, set_exit_code, strip_errno}; +use uucore::{format_usage, show_error, translate}; mod options { pub const SILENT: &str = "silent"; @@ -41,10 +41,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let name = rustix::termios::ttyname(std::io::stdin(), Vec::with_capacity(8)); #[cfg(unix)] let write_result = if let Ok(name) = name { - use std::os::unix::ffi::OsStrExt; - use uucore::display::OsWrite; - let os_name = std::ffi::OsStr::from_bytes(name.as_bytes()); - stdout.write_all_os(os_name) + let mut buf = name.as_bytes().to_vec(); + buf.push(b'\n'); + stdout.write_all(&buf) } else { set_exit_code(1); writeln!(stdout, "{}", translate!("tty-not-a-tty")) @@ -57,7 +56,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { set_exit_code(1); writeln!(stdout, "{}", translate!("tty-not-a-tty")) }; - #[cfg(target_os = "windows")] + #[cfg(windows)] let write_result = { use std::os::windows::io::AsHandle; let stdin = std::io::stdin(); @@ -74,16 +73,15 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } }; - if write_result.is_err() || stdout.flush().is_err() { - // Don't return to prevent a panic later when another flush is attempted - // because the `uucore_procs::main` macro inserts a flush after execution for every utility. - std::process::exit(3); + if let Err(e) = write_result { + show_error!("{}", strip_errno(&e)); + set_exit_code(3); } Ok(()) } -#[cfg(target_os = "windows")] +#[cfg(windows)] fn file_name(handle: std::os::windows::io::BorrowedHandle) -> Option { // This code is adapted from rust's standard library // https://github.com/rust-lang/rust/blob/0424cc16731e6141a18077f8ccde77ba148d9649/library/std/src/sys/io/is_terminal/windows.rs#L25 diff --git a/src/uu/uname/Cargo.toml b/src/uu/uname/Cargo.toml index 1e7986e715a..f6e164892ac 100644 --- a/src/uu/uname/Cargo.toml +++ b/src/uu/uname/Cargo.toml @@ -11,19 +11,19 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/uname.rs" test = false doctest = false [dependencies] -platform-info = { workspace = true } clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +platform-info = { workspace = true } +uucore = { workspace = true } + +[lints] +workspace = true [[bin]] name = "uname" diff --git a/src/uu/uname/locales/en-US.ftl b/src/uu/uname/locales/en-US.ftl index b985518427d..f4293b94a2b 100644 --- a/src/uu/uname/locales/en-US.ftl +++ b/src/uu/uname/locales/en-US.ftl @@ -18,3 +18,15 @@ uname-help-machine = print the machine hardware name. uname-help-os = print the operating system name. uname-help-processor = print the processor type (non-portable) uname-help-hardware-platform = print the hardware platform (non-portable) +uname-help-all-labeled = like -a, print all information but one item per line, labeled. + +# Labels for --all-labeled +# Labels for --all-labeled; { $value } is the system-provided field value. +uname-label-kernel-name = Kernel name: { $value } +uname-label-nodename = Node name: { $value } +uname-label-kernel-release = Kernel release: { $value } +uname-label-kernel-version = Kernel version: { $value } +uname-label-machine = Machine: { $value } +uname-label-processor = Processor: { $value } +uname-label-hardware-platform = Hardware platform: { $value } +uname-label-os = Operating system: { $value } diff --git a/src/uu/uname/locales/fr-FR.ftl b/src/uu/uname/locales/fr-FR.ftl index fc48f3f8ba6..5ffeba4513e 100644 --- a/src/uu/uname/locales/fr-FR.ftl +++ b/src/uu/uname/locales/fr-FR.ftl @@ -18,3 +18,14 @@ uname-help-machine = affiche le nom du matériel de la machine. uname-help-os = affiche le nom du système d'exploitation. uname-help-processor = affiche le type de processeur (non portable) uname-help-hardware-platform = affiche la plateforme matérielle (non portable) +uname-help-all-labeled = comme -a, affiche toutes les informations mais une par ligne, avec étiquette. + +# Étiquettes pour --all-labeled +uname-label-kernel-name = Nom du noyau : { $value } +uname-label-nodename = Nom du nœud : { $value } +uname-label-kernel-release = Version du noyau : { $value } +uname-label-kernel-version = Version détaillée du noyau : { $value } +uname-label-machine = Machine : { $value } +uname-label-processor = Processeur : { $value } +uname-label-hardware-platform = Plateforme matérielle : { $value } +uname-label-os = Système d'exploitation : { $value } diff --git a/src/uu/uname/src/uname.rs b/src/uu/uname/src/uname.rs index 1439bd6f7f2..884979b8cfe 100644 --- a/src/uu/uname/src/uname.rs +++ b/src/uu/uname/src/uname.rs @@ -3,13 +3,13 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (API) nodename osname sysname (options) mnrsv mnrsvo +// spell-checker:ignore (API) nodename osname sysname (options) mnrsv mnrsvo mnrsvoA use std::ffi::{OsStr, OsString}; use clap::{Arg, ArgAction, Command}; use platform_info::{PlatformInfo, PlatformInfoAPI, UNameAPI}; -use uucore::display::println_verbatim; +use uucore::display::{print_verbatim, println_verbatim}; use uucore::translate; use uucore::{ error::{UResult, USimpleError}, @@ -18,6 +18,7 @@ use uucore::{ pub mod options { pub static ALL: &str = "all"; + pub static ALL_LABELED: &str = "all-labeled"; pub static KERNEL_NAME: &str = "kernel-name"; pub static NODENAME: &str = "nodename"; pub static KERNEL_VERSION: &str = "kernel-version"; @@ -58,10 +59,35 @@ impl UNameOutput { .join(OsStr::new(" ")) } + fn display_labeled(&self) -> OsString { + let mut out = OsString::new(); + for (label, value) in [ + ("uname-label-kernel-name", self.kernel_name.as_ref()), + ("uname-label-nodename", self.nodename.as_ref()), + ("uname-label-kernel-release", self.kernel_release.as_ref()), + ("uname-label-kernel-version", self.kernel_version.as_ref()), + ("uname-label-machine", self.machine.as_ref()), + ("uname-label-processor", self.processor.as_ref()), + ( + "uname-label-hardware-platform", + self.hardware_platform.as_ref(), + ), + ("uname-label-os", self.os.as_ref()), + ] { + let Some(value) = value else { continue }; + out.push(OsStr::new( + &translate!(label, "value" => value.to_string_lossy()), + )); + out.push("\n"); + } + out + } + pub fn new(opts: &Options) -> UResult { let uname = PlatformInfo::new() .map_err(|_e| USimpleError::new(1, translate!("uname-error-cannot-get-system-name")))?; let none = !(opts.all + || opts.all_labeled || opts.kernel_name || opts.nodename || opts.kernel_release @@ -71,21 +97,28 @@ impl UNameOutput { || opts.processor || opts.hardware_platform); - let kernel_name = - (opts.kernel_name || opts.all || none).then(|| uname.sysname().to_owned()); + let kernel_name = (opts.kernel_name || opts.all || opts.all_labeled || none) + .then(|| uname.sysname().to_owned()); - let nodename = (opts.nodename || opts.all).then(|| uname.nodename().to_owned()); + let nodename = + (opts.nodename || opts.all || opts.all_labeled).then(|| uname.nodename().to_owned()); - let kernel_release = (opts.kernel_release || opts.all).then(|| uname.release().to_owned()); + let kernel_release = (opts.kernel_release || opts.all || opts.all_labeled) + .then(|| uname.release().to_owned()); - let kernel_version = (opts.kernel_version || opts.all).then(|| uname.version().to_owned()); + let kernel_version = (opts.kernel_version || opts.all || opts.all_labeled) + .then(|| uname.version().to_owned()); - let machine = (opts.machine || opts.all).then(|| uname.machine().to_owned()); + let machine = + (opts.machine || opts.all || opts.all_labeled).then(|| uname.machine().to_owned()); - let os = (opts.os || opts.all).then(|| uname.osname().to_owned()); + let os = (opts.os || opts.all || opts.all_labeled).then(|| uname.osname().to_owned()); // This option is unsupported on modern Linux systems // See: https://lists.gnu.org/archive/html/bug-coreutils/2005-09/msg00063.html + // + // -a and -A omit an unknown processor or hardware platform, and since we never + // determine either one, they only ever show up when explicitly requested. let processor = opts.processor.then(|| translate!("uname-unknown").into()); // This option is unsupported on modern Linux systems @@ -109,6 +142,7 @@ impl UNameOutput { pub struct Options { pub all: bool, + pub all_labeled: bool, pub kernel_name: bool, pub nodename: bool, pub kernel_version: bool, @@ -125,6 +159,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let options = Options { all: matches.get_flag(options::ALL), + all_labeled: matches.get_flag(options::ALL_LABELED), kernel_name: matches.get_flag(options::KERNEL_NAME), nodename: matches.get_flag(options::NODENAME), kernel_release: matches.get_flag(options::KERNEL_RELEASE), @@ -135,8 +170,13 @@ 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()) - .map_err(|e| USimpleError::new(1, e.to_string()))?; + if options.all_labeled { + print_verbatim(output.display_labeled().as_os_str()) + .map_err(|e| USimpleError::new(1, e.to_string()))?; + } else { + println_verbatim(output.display().as_os_str()) + .map_err(|e| USimpleError::new(1, e.to_string()))?; + } Ok(()) } @@ -154,6 +194,13 @@ pub fn uu_app() -> Command { .help(translate!("uname-help-all")) .action(ArgAction::SetTrue), ) + .arg( + Arg::new(options::ALL_LABELED) + .short('A') + .long(options::ALL_LABELED) + .help(translate!("uname-help-all-labeled")) + .action(ArgAction::SetTrue), + ) .arg( Arg::new(options::KERNEL_NAME) .short('s') diff --git a/src/uu/unexpand/Cargo.toml b/src/uu/unexpand/Cargo.toml index 0b2463b7038..2b107526b64 100644 --- a/src/uu/unexpand/Cargo.toml +++ b/src/uu/unexpand/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/unexpand.rs" doctest = false @@ -21,15 +18,20 @@ doctest = false [dependencies] clap = { workspace = true } fluent = { workspace = true } -rustix = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true } +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))'.dependencies] +rustix = { workspace = true, features = ["fs"] } + [dev-dependencies] divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bin]] name = "unexpand" path = "src/main.rs" diff --git a/src/uu/unexpand/benches/unexpand_bench.rs b/src/uu/unexpand/benches/unexpand_bench.rs index 1f9c19469c7..8d2e29f0d48 100644 --- a/src/uu/unexpand/benches/unexpand_bench.rs +++ b/src/uu/unexpand/benches/unexpand_bench.rs @@ -5,7 +5,7 @@ use divan::{Bencher, black_box}; use uu_unexpand::uumain; -use uucore::benchmark::{create_test_file, run_util_function}; +use uucore::benchmark::{create_test_file, get_bench_args}; /// Generate text data with leading spaces (typical unexpand use case) fn generate_indented_text(num_lines: usize) -> Vec { @@ -25,11 +25,10 @@ fn unexpand_many_lines(bencher: Bencher, num_lines: usize) { let temp_dir = tempfile::tempdir().unwrap(); let data = generate_indented_text(num_lines); let file_path = create_test_file(&data, temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &[file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark large file with spaces (tests performance on large files) @@ -42,11 +41,10 @@ fn unexpand_large_file(bencher: Bencher, size_mb: usize) { let num_lines = (size_mb * 1024 * 1024) / line_size; let data = generate_indented_text(num_lines); let file_path = create_test_file(&data, temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &[file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/unexpand/locales/en-US.ftl b/src/uu/unexpand/locales/en-US.ftl index 3a2a2092840..881990c35e2 100644 --- a/src/uu/unexpand/locales/en-US.ftl +++ b/src/uu/unexpand/locales/en-US.ftl @@ -11,6 +11,6 @@ unexpand-help-no-utf8 = interpret input file as 8-bit ASCII rather than UTF-8 # Error messages unexpand-error-invalid-character = tab size contains invalid character(s): { $char } unexpand-error-tab-size-cannot-be-zero = tab size cannot be 0 -unexpand-error-tab-size-too-large = tab stop value is too large +unexpand-error-tab-size-too-large = tab stop is too large unexpand-error-tab-sizes-must-be-ascending = tab sizes must be ascending unexpand-error-is-directory = { $path }: Is a directory diff --git a/src/uu/unexpand/locales/fr-FR.ftl b/src/uu/unexpand/locales/fr-FR.ftl index 44ad09a73d5..9a88abcafb3 100644 --- a/src/uu/unexpand/locales/fr-FR.ftl +++ b/src/uu/unexpand/locales/fr-FR.ftl @@ -11,6 +11,6 @@ unexpand-help-no-utf8 = interpréter le fichier d'entrée comme ASCII 8-bit plut # Messages d'erreur unexpand-error-invalid-character = la taille de tabulation contient des caractères invalides : { $char } unexpand-error-tab-size-cannot-be-zero = la taille de tabulation ne peut pas être 0 -unexpand-error-tab-size-too-large = la valeur d'arrêt de tabulation est trop grande +unexpand-error-tab-size-too-large = l'arrêt de tabulation est trop grand unexpand-error-tab-sizes-must-be-ascending = les tailles de tabulation doivent être croissantes unexpand-error-is-directory = { $path } : Est un répertoire diff --git a/src/uu/unexpand/src/unexpand.rs b/src/uu/unexpand/src/unexpand.rs index 9bc4bbfb287..33b906d58e4 100644 --- a/src/uu/unexpand/src/unexpand.rs +++ b/src/uu/unexpand/src/unexpand.rs @@ -3,24 +3,22 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) nums aflag scol prevtab amode ctype cwidth nbytes lastcol pctype Preprocess +// spell-checker:ignore (ToDO) nums aflag scol prevtab amode ctype cwidth nbytes lastcol pctype Preprocess iswblank use clap::{Arg, ArgAction, Command}; use std::ffi::OsString; use std::fs::File; use std::io::{self, BufReader, BufWriter, Read, Stdin, Stdout, Write, stdin, stdout}; use std::num::IntErrorKind; -use std::path::Path; -use std::str::from_utf8; -use thiserror::Error; +use uucore::char_width::char_info_at; use uucore::display::Quotable; -use uucore::error::{FromIo, UError, UResult, USimpleError, set_exit_code}; +use uucore::error::{FromIo, UError, UResult, set_exit_code}; use uucore::translate; use uucore::{format_usage, show}; const DEFAULT_TABSTOP: usize = 8; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum ParseError { #[error("{}", translate!("unexpand-error-invalid-character", "char" => _0.quote()))] InvalidCharacter(String), @@ -48,7 +46,8 @@ fn parse_tab_num(word: &str, allow_zero: bool) -> Result { } fn parse_tabstops(s: &str) -> Result { - let words = s.split(','); + // GNU accepts a blank (space or tab) or a comma as tab-list separators. + let words = s.split([' ', '\t', ',']); let mut nums = Vec::new(); let mut increment_size: Option = None; @@ -296,20 +295,24 @@ impl Read for Input { } fn open(path: &OsString) -> UResult> { - let filename = Path::new(path); - if filename.is_dir() { - Err(USimpleError::new( - 1, - translate!("unexpand-error-is-directory", "path" => filename.maybe_quote()), - )) - } else if path == "-" { - Ok(BufReader::new(Input::Stdin(stdin()))) - } else { - let f = File::open(path).map_err_context(|| path.maybe_quote().to_string())?; - #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] - let _ = rustix::fs::fadvise(&f, 0, None, rustix::fs::Advice::Sequential); - Ok(BufReader::new(Input::File(f))) + // some platforms show different read error + #[cfg(any(target_os = "wasi", windows))] + { + let filename = std::path::Path::new(path); + if filename.is_dir() { + return Err(uucore::error::USimpleError::new( + 1, + translate!("unexpand-error-is-directory", "path" => filename.maybe_quote()), + )); + } + } + if path == "-" { + return Ok(BufReader::new(Input::Stdin(stdin()))); } + let f = File::open(path).map_err_context(|| path.maybe_quote().to_string())?; + #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] + let _ = rustix::fs::fadvise(&f, 0, None, rustix::fs::Advice::Sequential); + Ok(BufReader::new(Input::File(f))) } fn next_tabstop(tab_config: &TabConfig, col: usize) -> Option { @@ -376,19 +379,47 @@ fn write_tabs( && (print_state.leading || ai && print_state.pctype == CharType::Tab)) { while let Some(nts) = next_tabstop(tab_config, print_state.scol) { - if print_state.col < print_state.scol + nts { + let target = print_state.scol + nts; + if print_state.col < target { + break; + } + // A tab may absorb a wide blank that lies fully within it, but not + // one that straddles the tab stop; stop converting before it. + if print_state + .pending_wide + .iter() + .any(|&(start, width, _)| start < target && target < start + width) + { break; } output.write_all(b"\t")?; - print_state.scol += nts; + print_state.scol = target; } } + // Fill the remaining columns. Wide blanks that were not absorbed into a tab + // are emitted verbatim; everything else is a plain space. + let mut wide_idx = 0; while print_state.col > print_state.scol { - output.write_all(b" ")?; - print_state.scol += 1; + while wide_idx < print_state.pending_wide.len() + && print_state.pending_wide[wide_idx].0 < print_state.scol + { + wide_idx += 1; + } + if wide_idx < print_state.pending_wide.len() + && print_state.pending_wide[wide_idx].0 == print_state.scol + { + let (_, width, bytes) = &print_state.pending_wide[wide_idx]; + output.write_all(bytes)?; + print_state.scol += width; + wide_idx += 1; + } else { + output.write_all(b" ")?; + print_state.scol += 1; + } } + print_state.pending_wide.clear(); Ok(()) } @@ -400,12 +431,30 @@ enum CharType { Other, } -fn next_char_info(utf8: bool, buf: &[u8], byte: usize) -> (CharType, usize, usize) { +/// A multibyte blank is a horizontal space other than ASCII space/tab, i.e. a +/// character for which `iswblank` is true in a UTF-8 locale. Such a blank +/// participates in tab conversion (its columns can be absorbed into a tab), so +/// it must be classified as [`CharType::Space`]. The set is the Unicode space +/// separators minus the non-breaking ones (U+00A0, U+2007, U+202F). +fn is_multibyte_blank(c: char) -> bool { + matches!(c, + '\u{1680}' + | '\u{2000}'..='\u{2006}' + | '\u{2008}'..='\u{200A}' + | '\u{205F}' + | '\u{3000}') +} + +/// Classify the character at `buf[byte]` for tab conversion, returning its +/// [`CharType`] along with its display width and byte length. Unlike +/// [`char_info_at`], the first element is a classification rather than the +/// decoded `char`. +fn classify_char_at(utf8: bool, buf: &[u8], byte: usize) -> (CharType, usize, usize) { use CharType::{Backspace, Other, Space, Tab}; let b = buf[byte]; if b.is_ascii() { return match b { - b' ' => (Space, 0, 1), + b' ' => (Space, 1, 1), b'\t' => (Tab, 0, 1), b'\x08' => (Backspace, 0, 1), _ => (Other, 1, 1), @@ -413,14 +462,11 @@ fn next_char_info(utf8: bool, buf: &[u8], byte: usize) -> (CharType, usize, usiz } if utf8 { - let nbytes = char::from(b).len_utf8(); - // don't overrun the buffer because of invalid UTF-8 - if buf - .get(byte..byte + nbytes) - .is_some_and(|s| from_utf8(s).is_ok()) - { - return (Other, nbytes, nbytes); + let (c, width, nbytes) = char_info_at(buf, byte); + if c.is_some_and(is_multibyte_blank) { + return (Space, width, nbytes); } + return (Other, width, nbytes); } (Other, 1, 1) } @@ -436,6 +482,10 @@ struct PrintState { scol: usize, leading: bool, pctype: CharType, + // Multibyte blanks buffered but not yet written, as (start column, display + // width, bytes). They are absorbed into a tab when a run reaches a tab stop + // and otherwise emitted verbatim by write_tabs. Empty for ASCII-only input. + pending_wide: Vec<(usize, usize, Vec)>, } impl PrintState { @@ -445,6 +495,7 @@ impl PrintState { self.scol = 0; self.leading = true; self.pctype = CharType::Other; + self.pending_wide.clear(); } } @@ -477,36 +528,49 @@ fn unexpand_buf( // We can only fast forward if we don't need to calculate col/scol if let Some(b'\n') = buf.last() { - // Fast path for leading spaces in non-UTF8 mode: count consecutive spaces/tabs at start - if !options.utf8 && !options.aflag && print_state.leading { - // In default mode (not -a), we only convert leading spaces - // So we can batch process them and then copy the rest - while byte < buf.len() { - match buf[byte] { - b' ' => { - print_state.col += 1; - byte += 1; - } + // Fast path: count consecutive leading spaces/tabs at the start of the + // line. Leading whitespace is ASCII, so this is independent of UTF-8 + // mode; the rest of the line is copied verbatim in default mode. It does + // not apply once a finite last column is in play (multiple tab stops), + // where the general loop stops converting past that column. + if !options.aflag && print_state.leading && lastcol == 0 { + // In default mode (not -a), we only convert leading spaces, so batch + // over them and then copy the rest of the line. Compute the column + // into a local without touching state: if the first non-blank is a + // multibyte start it may be a blank that needs the general loop, so + // bail out to it instead of committing here. + let mut end = 0; + let mut col = print_state.col; + let mut saw_tab = false; + while end < buf.len() { + match buf[end] { + b' ' => col += 1, b'\t' => { - print_state.col += next_tabstop(tab_config, print_state.col).unwrap_or(1); - byte += 1; - print_state.pctype = CharType::Tab; + col += next_tabstop(tab_config, col).unwrap_or(1); + saw_tab = true; } _ => break, } + end += 1; } + if end == buf.len() || buf[end].is_ascii() { + print_state.col = col; + if saw_tab { + print_state.pctype = CharType::Tab; + } - // If we found spaces/tabs, write them as tabs - if byte > 0 { - write_tabs(output, tab_config, print_state, options.aflag)?; - } + // If we found spaces/tabs, write them as tabs + if end > 0 { + write_tabs(output, tab_config, print_state, options.aflag)?; + } - // Write the rest of the line directly (no more tab conversion needed) - if byte < buf.len() { - print_state.leading = false; - output.write_all(&buf[byte..])?; + // Write the rest of the line directly (no more tab conversion needed) + if end < buf.len() { + print_state.leading = false; + output.write_all(&buf[end..])?; + } + return Ok(()); } - return Ok(()); } } @@ -520,15 +584,25 @@ fn unexpand_buf( } // figure out how big the next char is, if it's UTF-8 - let (ctype, cwidth, nbytes) = next_char_info(options.utf8, buf, byte); + let (ctype, cwidth, nbytes) = classify_char_at(options.utf8, buf, byte); // now figure out how many columns this char takes up, and maybe print it let tabs_buffered = print_state.leading || options.aflag; match ctype { CharType::Space | CharType::Tab => { + // A buffered multibyte blank must be remembered so it can be + // emitted verbatim if its run is not converted to a tab. + if tabs_buffered && ctype == CharType::Space && nbytes > 1 { + print_state.pending_wide.push(( + print_state.col, + cwidth, + buf[byte..byte + nbytes].to_vec(), + )); + } + // compute next col, but only write space or tab chars if not buffering print_state.col += if ctype == CharType::Space { - 1 + cwidth } else { next_tabstop(tab_config, print_state.col).unwrap_or(1) }; @@ -577,25 +651,22 @@ fn unexpand_file( scol: 0, leading: true, pctype: CharType::Other, + pending_wide: Vec::new(), }; - loop { - match input.read(buf) { - Ok(0) => break, - Ok(n) => { - for line in buf[..n].split_inclusive(|b| *b == b'\n') { - unexpand_buf(line, output, options, lastcol, tab_config, &mut print_state)?; - if let Some(b'\n') = line.last() { - print_state.new_line(); - } - } + while let n @ 1.. = input + .read(buf) + .map_err(|e| e.map_err_context(|| file.maybe_quote().to_string()) as Box)? + { + for line in buf[..n].split_inclusive(|b| *b == b'\n') { + unexpand_buf(line, output, options, lastcol, tab_config, &mut print_state)?; + if let Some(b'\n') = line.last() { + print_state.new_line(); } - Err(e) => return Err(e.map_err_context(|| file.maybe_quote().to_string())), } } // write out anything remaining - write_tabs(output, tab_config, &mut print_state, options.aflag)?; - Ok(()) + write_tabs(output, tab_config, &mut print_state, options.aflag) } fn unexpand(options: &Options) -> UResult<()> { diff --git a/src/uu/uniq/Cargo.toml b/src/uu/uniq/Cargo.toml index 8c365d507b3..65edb1d78aa 100644 --- a/src/uu/uniq/Cargo.toml +++ b/src/uu/uniq/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/uniq.rs" test = false @@ -21,13 +18,16 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["parser"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["parser"] } [dev-dependencies] divan = { workspace = true } uucore = { workspace = true, features = ["benchmark", "parser"] } +[lints] +workspace = true + [[bin]] name = "uniq" path = "src/main.rs" diff --git a/src/uu/uniq/benches/uniq_bench.rs b/src/uu/uniq/benches/uniq_bench.rs index 780ecff154c..5f405baa69b 100644 --- a/src/uu/uniq/benches/uniq_bench.rs +++ b/src/uu/uniq/benches/uniq_bench.rs @@ -5,7 +5,7 @@ use divan::{Bencher, black_box}; use uu_uniq::uumain; -use uucore::benchmark::{run_util_function, setup_test_file}; +use uucore::benchmark::{get_bench_args, run_util_function, setup_test_file}; /// Generate data with many consecutive duplicate lines /// This directly tests the core optimization of PR #8703 - avoiding allocations when comparing lines @@ -37,11 +37,10 @@ fn uniq_heavy_duplicates(bencher: Bencher, num_lines: usize) { let duplicates_per_group = num_lines / num_groups; let data = generate_duplicate_heavy_data(num_groups, duplicates_per_group); let file_path = setup_test_file(&data); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &[file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark 2: Mixed duplicates with counting @@ -52,11 +51,10 @@ fn uniq_with_count(bencher: Bencher, num_lines: usize) { let num_groups = num_lines / 100; let data = generate_duplicate_heavy_data(num_groups, 100); let file_path = setup_test_file(&data); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-c", file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-c", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark 3: Case-insensitive comparison with duplicates @@ -101,11 +99,10 @@ fn uniq_case_insensitive(bencher: Bencher, num_lines: usize) { } let file_path = setup_test_file(&data); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-i", file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-i", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark 4: `-w N` over duplicate-heavy input, for both a narrow N (1) diff --git a/src/uu/uniq/locales/en-US.ftl b/src/uu/uniq/locales/en-US.ftl index 3106270cba9..c24e8acc1dd 100644 --- a/src/uu/uniq/locales/en-US.ftl +++ b/src/uu/uniq/locales/en-US.ftl @@ -40,5 +40,3 @@ uniq-error-all-repeated-badoption = invalid argument 'badoption' for '--all-repe uniq-error-counts-and-repeated-meaningless = printing all duplicated lines and repeat counts is meaningless Try 'uniq --help' for more information. - -uniq-error-could-not-open = Could not open { $path } diff --git a/src/uu/uniq/locales/fr-FR.ftl b/src/uu/uniq/locales/fr-FR.ftl index 094af7001bb..61b70f93bee 100644 --- a/src/uu/uniq/locales/fr-FR.ftl +++ b/src/uu/uniq/locales/fr-FR.ftl @@ -39,4 +39,3 @@ uniq-error-all-repeated-badoption = argument invalide 'badoption' pour '--all-re uniq-error-counts-and-repeated-meaningless = afficher toutes les lignes dupliquées et les nombres de répétitions n'a pas de sens Essayez 'uniq --help' pour plus d'informations. -uniq-error-could-not-open = Impossible d'ouvrir { $path } diff --git a/src/uu/uniq/src/uniq.rs b/src/uu/uniq/src/uniq.rs index 2355da2a20f..34c0c04b5b7 100644 --- a/src/uu/uniq/src/uniq.rs +++ b/src/uu/uniq/src/uniq.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore badoption CTYPE + use clap::{ Arg, ArgAction, ArgMatches, Command, builder::ValueParser, error::ContextKind, error::Error, error::ErrorKind, @@ -55,6 +57,7 @@ struct Uniq { slice_stop: Option, ignore_case: bool, zero_terminated: bool, + is_c_locale: bool, } #[derive(Default)] @@ -63,14 +66,6 @@ struct LineMeta { key_end: usize, } -macro_rules! write_line_terminator { - ($writer:expr, $line_terminator:expr) => { - $writer - .write_all(&[$line_terminator]) - .map_err_context(|| translate!("uniq-error-write-line-terminator")) - }; -} - impl Uniq { pub fn write_uniq(&self, mut reader: impl BufRead, mut writer: impl Write) -> UResult<()> { let mut first_line_printed = false; @@ -87,17 +82,20 @@ impl Uniq { let mut next_buf = Vec::with_capacity(1024); let mut next_meta = LineMeta::default(); + let mut line_out = Vec::with_capacity(1024); - loop { - if !Self::read_line(&mut reader, &mut next_buf, line_terminator)? { - break; - } - + while Self::read_line(&mut reader, &mut next_buf, line_terminator)? { self.build_meta(&next_buf, &mut next_meta); if self.keys_are_equal(¤t_buf, ¤t_meta, &next_buf, &next_meta) { if self.all_repeated { - self.write_line(writer, ¤t_buf, group_count, first_line_printed)?; + self.write_line( + writer, + &mut line_out, + ¤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 +105,13 @@ impl Uniq { if (group_count == 1 && !self.repeats_only) || (group_count > 1 && !self.uniques_only) { - self.write_line(writer, ¤t_buf, group_count, first_line_printed)?; + self.write_line( + writer, + &mut line_out, + ¤t_buf, + group_count, + first_line_printed, + )?; first_line_printed = true; } std::mem::swap(&mut current_buf, &mut next_buf); @@ -118,13 +122,21 @@ impl Uniq { } if (group_count == 1 && !self.repeats_only) || (group_count > 1 && !self.uniques_only) { - self.write_line(writer, ¤t_buf, group_count, first_line_printed)?; + self.write_line( + writer, + &mut line_out, + ¤t_buf, + group_count, + first_line_printed, + )?; first_line_printed = true; } if (self.delimiters == Delimiters::Append || self.delimiters == Delimiters::Both) && first_line_printed { - write_line_terminator!(writer, line_terminator)?; + writer + .write_all(&[line_terminator]) + .map_err_context(|| translate!("uniq-error-write-line-terminator"))?; } writer .flush() @@ -202,7 +214,7 @@ impl Uniq { if remainder.is_empty() { return key_start; } - if Self::is_c_locale() { + if self.is_c_locale { // for C or POSIX we count bytes key_start + remainder.len().min(limit) } else if let Ok(valid) = std::str::from_utf8(remainder) { @@ -264,31 +276,31 @@ impl Uniq { fn write_line( &self, writer: &mut impl Write, + line_out: &mut Vec, line: &[u8], count: usize, first_line_printed: bool, ) -> UResult<()> { let line_terminator = self.get_line_terminator(); + line_out.clear(); + if self.should_print_delimiter(count, first_line_printed) { - write_line_terminator!(writer, line_terminator)?; + line_out.push(line_terminator); } - let mut count_buf = [0u8; Self::COUNT_PREFIX_BUF_SIZE]; - if self.show_counts { - // Call the associated function (no &self) after the refactor above. + let mut count_buf = [0u8; Self::COUNT_PREFIX_BUF_SIZE]; let prefix = Self::build_count_prefix(count, &mut count_buf); - writer - .write_all(prefix) - .map_err_context(|| translate!("uniq-error-write-error"))?; + line_out.extend_from_slice(prefix); } - writer - .write_all(line) - .map_err_context(|| translate!("uniq-error-write-error"))?; + line_out.extend_from_slice(line); + line_out.push(line_terminator); - write_line_terminator!(writer, line_terminator) + writer + .write_all(line_out) + .map_err_context(|| translate!("uniq-error-write-error")) } const COUNT_PREFIX_WIDTH: usize = 7; @@ -365,6 +377,7 @@ fn handle_obsolete(args: impl uucore::Args) -> (Vec, Option, Op let mut skip_chars_old = None; let mut preceding_long_opt_req_value = false; let mut preceding_short_opt_req_value = false; + let mut after_double_dash = false; let filtered_args = args .filter_map(|os_slice| { @@ -374,6 +387,7 @@ fn handle_obsolete(args: impl uucore::Args) -> (Vec, Option, Op &mut skip_chars_old, &mut preceding_long_opt_req_value, &mut preceding_short_opt_req_value, + &mut after_double_dash, ) }) .collect(); @@ -393,9 +407,19 @@ fn filter_args( skip_chars_old: &mut Option, preceding_long_opt_req_value: &mut bool, preceding_short_opt_req_value: &mut bool, + after_double_dash: &mut bool, ) -> Option { let filter: Option; if let Some(slice) = os_slice.to_str() { + if *after_double_dash { + // Past `--` everything is an operand, so `uniq -- -1` names a file + // rather than skipping a field. + return Some(OsString::from(slice)); + } + if slice == "--" { + *after_double_dash = true; + return Some(OsString::from(slice)); + } if should_extract_obs_skip_fields( slice, *preceding_long_opt_req_value, @@ -606,6 +630,8 @@ fn handle_extract_obs_skip_chars( /// Unfortunately these overrides are necessary, since several GNU tests /// for `uniq` hardcode and require the exact wording of the error message /// and it is not compatible with how Clap formats and displays those error messages. +#[cold] +#[inline(never)] fn map_clap_errors(clap_error: Error) -> Box { let footer = translate!("uniq-error-try-help"); let override_arg_conflict = translate!("uniq-error-group-mutually-exclusive") + "\n" + &footer; @@ -680,6 +706,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { slice_stop: opt_parsed(options::CHECK_CHARS, &matches)?, ignore_case: matches.get_flag(options::IGNORE_CASE), zero_terminated: matches.get_flag(options::ZERO_TERMINATED), + is_c_locale: Uniq::is_c_locale(), }; if uniq.show_counts && uniq.all_repeated { @@ -701,6 +728,8 @@ pub fn uu_app() -> Command { .about(translate!("uniq-about")) .override_usage(format_usage(&translate!("uniq-usage"))) .infer_long_args(true) + // GNU lets a later -f/-s/-w override an earlier one. + .args_override_self(true) .after_help(translate!("uniq-after-help")); uucore::clap_localization::configure_localized_command(cmd) .arg( @@ -712,7 +741,9 @@ pub fn uu_app() -> Command { .value_name("delimit-method") .num_args(0..=1) .default_missing_value("none") - .require_equals(true), + .require_equals(true) + // Let the final occurrence select the delimiter method. + .overrides_with(options::ALL_REPEATED), ) .arg( Arg::new(options::GROUP) @@ -822,10 +853,8 @@ fn get_delimiter(matches: &ArgMatches) -> Delimiters { fn open_input_file(in_file_name: Option<&OsStr>) -> UResult> { Ok(match in_file_name { Some(path) if path != "-" => { - let in_file = File::open(path).map_err_context( - || translate!("uniq-error-could-not-open", "path" => path.maybe_quote()), - )?; - Box::new(BufReader::new(in_file)) + let in_file = File::open(path).map_err_context(|| path.maybe_quote().to_string())?; + Box::new(BufReader::with_capacity(OUTPUT_BUFFER_CAPACITY, in_file)) } _ => Box::new(stdin().lock()), }) @@ -835,9 +864,7 @@ fn open_input_file(in_file_name: Option<&OsStr>) -> UResult> { fn open_output_file(out_file_name: Option<&OsStr>) -> UResult> { Ok(match out_file_name { Some(path) if path != "-" => { - let out_file = File::create(path).map_err_context( - || translate!("uniq-error-could-not-open", "path" => path.maybe_quote()), - )?; + let out_file = File::create(path).map_err_context(|| path.maybe_quote().to_string())?; Box::new(BufWriter::with_capacity(OUTPUT_BUFFER_CAPACITY, out_file)) } _ => Box::new(BufWriter::with_capacity( diff --git a/src/uu/unlink/Cargo.toml b/src/uu/unlink/Cargo.toml index a22f40a4a21..421d727ae1f 100644 --- a/src/uu/unlink/Cargo.toml +++ b/src/uu/unlink/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/unlink.rs" test = false @@ -21,8 +18,11 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true } + +[lints] +workspace = true [[bin]] name = "unlink" diff --git a/src/uu/unlink/src/unlink.rs b/src/uu/unlink/src/unlink.rs index 188b6ad2d7f..534051dac92 100644 --- a/src/uu/unlink/src/unlink.rs +++ b/src/uu/unlink/src/unlink.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use std::ffi::OsString; use std::fs::remove_file; use std::path::Path; diff --git a/src/uu/uptime/Cargo.toml b/src/uu/uptime/Cargo.toml index 145bc430e8a..37c818dfb9b 100644 --- a/src/uu/uptime/Cargo.toml +++ b/src/uu/uptime/Cargo.toml @@ -13,12 +13,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - -[features] -feat_systemd_logind = ["uucore/feat_systemd_logind"] - [lib] path = "src/uptime.rs" test = false @@ -26,10 +20,16 @@ doctest = false [dependencies] clap = { workspace = true } -thiserror = { workspace = true } -uucore = { workspace = true, features = ["libc", "utmpx", "uptime"] } fluent = { workspace = true } jiff = { workspace = true } +thiserror = { workspace = true } +uucore = { workspace = true, features = ["libc", "utmpx", "uptime"] } + +[features] +feat_systemd_logind = ["uucore/feat_systemd_logind"] + +[lints] +workspace = true [[bin]] name = "uptime" diff --git a/src/uu/uptime/src/main.rs b/src/uu/uptime/src/main.rs index ec30c0d5cb1..5823b728626 100644 --- a/src/uu/uptime/src/main.rs +++ b/src/uu/uptime/src/main.rs @@ -3,4 +3,4 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -uucore::bin!(uu_uptime); +uucore::bin!(uu_uptime, no_flush); diff --git a/src/uu/uptime/src/platform/mod.rs b/src/uu/uptime/src/platform/mod.rs new file mode 100644 index 00000000000..0425bc19616 --- /dev/null +++ b/src/uu/uptime/src/platform/mod.rs @@ -0,0 +1,21 @@ +// 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. + +//! Platform-specific pieces of `uptime`: the utmp-file operand (which only +//! exists where utmp files do) and the boot-time source for `--since`. The +//! shared control flow in `uptime.rs` only talks to the facade functions +//! re-exported here, which both submodules provide with identical +//! signatures; on Windows the utmp-file facades degenerate to +//! identity/`None`. + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub(crate) use unix::*; + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub(crate) use windows::*; diff --git a/src/uu/uptime/src/platform/unix.rs b/src/uu/uptime/src/platform/unix.rs new file mode 100644 index 00000000000..21f4b8a837b --- /dev/null +++ b/src/uu/uptime/src/platform/unix.rs @@ -0,0 +1,182 @@ +// 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 loadavg nusers upsecs utmpxname couldnt + +//! Unix implementation of `uptime`'s platform facade: the utmp-file operand +//! and the utmpx-derived boot time for `--since` (on OpenBSD, which has no +//! utmpx binding, boot time comes from [`get_uptime`] directly). + +use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint, builder::ValueParser}; +use std::ffi::OsString; +use std::io::{Write, stdout}; +use uucore::error::UResult; +#[cfg(not(any(target_os = "openbsd", target_os = "android")))] +use uucore::libc::time_t; +use uucore::translate; +use uucore::uptime::get_uptime; +#[cfg(not(any(target_os = "openbsd", target_os = "android")))] +use uucore::utmpx::{BOOT_TIME, USER_PROCESS, Utmpx}; + +use crate::{UptimeError, options, print_loadavg, print_nusers, print_time, print_uptime}; + +/// Register platform-only CLI arguments: the utmp file operand, which only +/// makes sense where utmp files exist. +pub(crate) fn add_platform_args(cmd: Command) -> Command { + cmd.arg( + Arg::new(options::PATH) + .help(translate!("uptime-help-path")) + .action(ArgAction::Set) + .num_args(0..=1) + .value_parser(ValueParser::os_string()) + .value_hint(ValueHint::AnyPath), + ) +} + +/// Run `uptime` against a user-supplied utmp file if the file operand was +/// given, or return `None` to fall through to the default system sources. +pub(crate) fn maybe_uptime_from_file(matches: &ArgMatches) -> Option> { + matches + .get_one::(options::PATH) + .map(uptime_with_file) +} + +/// The system uptime in seconds, for `--since`: derived from the utmpx +/// `BOOT_TIME` record where utmpx is available (on OpenBSD, from +/// [`get_uptime`] directly). +pub(crate) fn system_uptime_seconds() -> UResult { + #[cfg(not(any(target_os = "openbsd", target_os = "android")))] + { + let (boot_time, _) = process_utmpx(None); + get_uptime(boot_time) + } + #[cfg(any(target_os = "openbsd", target_os = "android"))] + get_uptime(None) +} + +fn uptime_with_file(file_path: &OsString) -> UResult<()> { + use std::fs; + use std::os::unix::fs::FileTypeExt; + use uucore::error::set_exit_code; + use uucore::show_error; + + // Uptime will print loadavg and time to stderr unless we encounter an extra operand. + let mut non_fatal_error = false; + + // process_utmpx_from_file() doesn't detect or report failures, we check if the path is valid + // before proceeding with more operations. + let md_res = fs::metadata(file_path); + if let Ok(md) = md_res { + if md.is_dir() { + show_error!("{}", UptimeError::TargetIsDir); + non_fatal_error = true; + set_exit_code(1); + } + if md.file_type().is_fifo() { + show_error!("{}", UptimeError::TargetIsFifo); + non_fatal_error = true; + set_exit_code(1); + } + } else if let Err(e) = md_res { + non_fatal_error = true; + set_exit_code(1); + show_error!("{}", UptimeError::IoErr(e)); + } + // utmpxname() returns an -1 , when filename doesn't end with 'x' or its too long. + // Reference: `` + + #[cfg(target_vendor = "apple")] + { + use std::os::unix::ffi::OsStrExt; + let bytes = file_path.as_os_str().as_bytes(); + + if bytes.last() != Some(&b'x') { + show_error!("{}", translate!("uptime-error-couldnt-get-boot-time")); + print_time()?; + write!(stdout(), "{}", translate!("uptime-output-unknown-uptime"))?; + print_nusers(Some(0))?; + print_loadavg()?; + set_exit_code(1); + return Ok(()); + } + } + + if non_fatal_error { + print_time()?; + write!(stdout(), "{}", translate!("uptime-output-unknown-uptime"))?; + print_nusers(Some(0))?; + print_loadavg()?; + return Ok(()); + } + + print_time()?; + let user_count; + + #[cfg(not(any(target_os = "openbsd", target_os = "android")))] + { + let (boot_time, count) = process_utmpx(Some(file_path)); + if let Some(time) = boot_time { + print_uptime(Some(time))?; + } else { + show_error!("{}", translate!("uptime-error-couldnt-get-boot-time")); + set_exit_code(1); + + write!(stdout(), "{}", translate!("uptime-output-unknown-uptime"))?; + } + user_count = count; + } + + #[cfg(any(target_os = "openbsd", target_os = "android"))] + { + let upsecs = get_uptime(None)?; + if upsecs >= 0 { + print_uptime(Some(upsecs))?; + } else { + show_error!("{}", translate!("uptime-error-couldnt-get-boot-time")); + set_exit_code(1); + + write!(stdout(), "{}", translate!("uptime-output-unknown-uptime"))?; + } + #[cfg(target_os = "openbsd")] + { + user_count = + uucore::uptime::get_nusers(file_path.to_str().expect("invalid utmp path file")); + } + #[cfg(target_os = "android")] + { + user_count = 0; + } + } + + print_nusers(Some(user_count))?; + print_loadavg()?; + + Ok(()) +} + +#[cfg(not(any(target_os = "openbsd", target_os = "android")))] +fn process_utmpx(file: Option<&OsString>) -> (Option, usize) { + let mut nusers = 0; + let mut boot_time = None; + + let records = match file { + Some(f) => Utmpx::iter_all_records_from(f), + None => Utmpx::iter_all_records(), + }; + + for line in records { + match line.record_type() { + x if x == USER_PROCESS => nusers += 1, + x if x == BOOT_TIME => { + let dt = line.login_time(); + if dt.unix_timestamp() > 0 { + boot_time = Some(dt.unix_timestamp() as time_t); + } + } + _ => (), + } + } + (boot_time, nusers) +} diff --git a/src/uu/uptime/src/platform/windows.rs b/src/uu/uptime/src/platform/windows.rs new file mode 100644 index 00000000000..0b63b374929 --- /dev/null +++ b/src/uu/uptime/src/platform/windows.rs @@ -0,0 +1,28 @@ +// 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. + +//! Windows implementation of `uptime`'s platform facade. There are no utmp +//! files on Windows, so the file-operand facades degenerate, and `--since` +//! reads the uptime directly from [`uucore::uptime::get_uptime`] +//! (`GetTickCount64`). + +use clap::{ArgMatches, Command}; +use uucore::error::UResult; +use uucore::uptime::get_uptime; + +/// No platform-only CLI arguments on Windows (no utmp files). +pub(crate) fn add_platform_args(cmd: Command) -> Command { + cmd +} + +/// The utmp file operand does not exist on Windows; never handled here. +pub(crate) fn maybe_uptime_from_file(_matches: &ArgMatches) -> Option> { + None +} + +/// The system uptime in seconds, for `--since`. +pub(crate) fn system_uptime_seconds() -> UResult { + get_uptime(None) +} diff --git a/src/uu/uptime/src/uptime.rs b/src/uu/uptime/src/uptime.rs index 2c610409851..17bbcc3b7c1 100644 --- a/src/uu/uptime/src/uptime.rs +++ b/src/uu/uptime/src/uptime.rs @@ -3,37 +3,30 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore getloadavg behaviour loadavg uptime upsecs updays upmins uphours boottime nusers utmpxname gettime clockid couldnt +// spell-checker:ignore behaviour loadavg nusers + +mod platform; use clap::{Arg, ArgAction, Command}; -#[cfg(unix)] -use clap::{ValueHint, builder::ValueParser}; use jiff::tz::TimeZone; use jiff::{Timestamp, ToSpan}; -#[cfg(unix)] -use std::ffi::OsString; use std::io::{self, Write, stdout}; -use thiserror::Error; use uucore::error::{UError, UResult}; use uucore::format_usage; use uucore::libc::time_t; use uucore::translate; use uucore::uptime::{ OutputFormat, format_nusers, get_formatted_loadavg, get_formatted_nusers, get_formatted_time, - get_formatted_uptime, get_uptime, + get_formatted_uptime, }; -#[cfg(unix)] -#[cfg(not(target_os = "openbsd"))] -use uucore::utmpx::{BOOT_TIME, USER_PROCESS, Utmpx}; - pub mod options { pub static SINCE: &str = "since"; pub static PATH: &str = "path"; pub static PRETTY: &str = "pretty"; } -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum UptimeError { // io::Error wrapper #[error("{}", translate!("uptime-error-io", "error" => format!("{}", .0)))] @@ -54,20 +47,16 @@ impl UError for UptimeError { pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - #[cfg(unix)] - let file_path = matches.get_one::(options::PATH); - #[cfg(windows)] - let file_path = None; - 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 { - default_uptime() + return uptime_since(); } + if matches.get_flag(options::PRETTY) { + return pretty_print_uptime(); + } + if let Some(result) = platform::maybe_uptime_from_file(&matches) { + return result; + } + default_uptime() } pub fn uu_app() -> Command { @@ -88,128 +77,19 @@ pub fn uu_app() -> Command { .long(options::SINCE) .help(translate!("uptime-help-since")) .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::PRETTY) + .short('p') + .long(options::PRETTY) + .help(translate!("uptime-help-pretty")) + .action(ArgAction::SetTrue), ); - #[cfg(unix)] - cmd.arg( - Arg::new(options::PATH) - .help(translate!("uptime-help-path")) - .action(ArgAction::Set) - .num_args(0..=1) - .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)] -fn uptime_with_file(file_path: &OsString) -> UResult<()> { - use std::fs; - use std::os::unix::fs::FileTypeExt; - use uucore::error::set_exit_code; - use uucore::show_error; - - // Uptime will print loadavg and time to stderr unless we encounter an extra operand. - let mut non_fatal_error = false; - - // process_utmpx_from_file() doesn't detect or report failures, we check if the path is valid - // before proceeding with more operations. - let md_res = fs::metadata(file_path); - if let Ok(md) = md_res { - if md.is_dir() { - show_error!("{}", UptimeError::TargetIsDir); - non_fatal_error = true; - set_exit_code(1); - } - if md.file_type().is_fifo() { - show_error!("{}", UptimeError::TargetIsFifo); - non_fatal_error = true; - set_exit_code(1); - } - } else if let Err(e) = md_res { - non_fatal_error = true; - set_exit_code(1); - show_error!("{}", UptimeError::IoErr(e)); - } - // utmpxname() returns an -1 , when filename doesn't end with 'x' or its too long. - // Reference: `` - - #[cfg(target_os = "macos")] - { - use std::os::unix::ffi::OsStrExt; - let bytes = file_path.as_os_str().as_bytes(); - - if bytes[bytes.len() - 1] != b'x' { - show_error!("{}", translate!("uptime-error-couldnt-get-boot-time")); - print_time()?; - write!(stdout(), "{}", translate!("uptime-output-unknown-uptime"))?; - print_nusers(Some(0))?; - print_loadavg()?; - set_exit_code(1); - return Ok(()); - } - } - - if non_fatal_error { - print_time()?; - write!(stdout(), "{}", translate!("uptime-output-unknown-uptime"))?; - print_nusers(Some(0))?; - print_loadavg()?; - return Ok(()); - } - - print_time()?; - let user_count; - - #[cfg(not(target_os = "openbsd"))] - { - let (boot_time, count) = process_utmpx(Some(file_path)); - if let Some(time) = boot_time { - print_uptime(Some(time))?; - } else { - show_error!("{}", translate!("uptime-error-couldnt-get-boot-time")); - set_exit_code(1); - - write!(stdout(), "{}", translate!("uptime-output-unknown-uptime"))?; - } - user_count = count; - } - - #[cfg(target_os = "openbsd")] - { - let upsecs = get_uptime(None)?; - if upsecs >= 0 { - print_uptime(Some(upsecs))?; - } else { - show_error!("{}", translate!("uptime-error-couldnt-get-boot-time")); - set_exit_code(1); - - write!(stdout(), "{}", translate!("uptime-output-unknown-uptime"))?; - } - user_count = - uucore::uptime::get_nusers(file_path.to_str().expect("invalid utmp path file")); - } - - print_nusers(Some(user_count))?; - print_loadavg()?; - - Ok(()) + platform::add_platform_args(cmd) } fn uptime_since() -> UResult<()> { - #[cfg(unix)] - #[cfg(not(target_os = "openbsd"))] - let uptime = { - let (boot_time, _) = process_utmpx(None); - get_uptime(boot_time)? - }; - #[cfg(any(windows, target_os = "openbsd"))] - let uptime = get_uptime(None)?; + let uptime = platform::system_uptime_seconds()?; let since_date = (Timestamp::now() - uptime.seconds()).to_zoned(TimeZone::system()); writeln!(stdout(), "{}", since_date.strftime("%Y-%m-%d %H:%M:%S"))?; @@ -227,44 +107,21 @@ fn default_uptime() -> UResult<()> { Ok(()) } +/// Prints the load average with its leading separator, or just the line ending +/// where load averages are unavailable (e.g. Windows), as GNU does. #[inline] fn print_loadavg() -> UResult<()> { if let Ok(s) = get_formatted_loadavg() { - writeln!(stdout(), "{s}")?; + write!(stdout(), ", {s}")?; } + writeln!(stdout())?; Ok(()) } -#[cfg(unix)] -#[cfg(not(target_os = "openbsd"))] -fn process_utmpx(file: Option<&OsString>) -> (Option, usize) { - let mut nusers = 0; - let mut boot_time = None; - - let records = match file { - Some(f) => Utmpx::iter_all_records_from(f), - None => Utmpx::iter_all_records(), - }; - - for line in records { - match line.record_type() { - x if x == USER_PROCESS => nusers += 1, - x if x == BOOT_TIME => { - let dt = line.login_time(); - if dt.unix_timestamp() > 0 { - boot_time = Some(dt.unix_timestamp() as time_t); - } - } - _ => (), - } - } - (boot_time, nusers) -} - fn print_nusers(nusers: Option) -> UResult<()> { write!( stdout(), - "{}, ", + "{}", match nusers { None => { get_formatted_nusers() @@ -279,7 +136,7 @@ fn print_nusers(nusers: Option) -> UResult<()> { } fn print_time() -> UResult<()> { - write!(stdout(), " {} ", get_formatted_time())?; + write!(stdout(), " {} ", get_formatted_time())?; Ok(()) } @@ -287,7 +144,7 @@ fn print_uptime(boot_time: Option) -> UResult<()> { let localized_text = translate!("uptime-output-up-text"); let uptime_message = get_formatted_uptime(boot_time, OutputFormat::HumanReadable)?; - write!(stdout(), "{localized_text} {uptime_message}, ")?; + write!(stdout(), "{localized_text} {uptime_message}, ")?; Ok(()) } diff --git a/src/uu/users/Cargo.toml b/src/uu/users/Cargo.toml index 46871bb5629..ca70debe352 100644 --- a/src/uu/users/Cargo.toml +++ b/src/uu/users/Cargo.toml @@ -13,25 +13,29 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - -[features] -feat_systemd_logind = ["uucore/feat_systemd_logind"] - [lib] path = "src/users.rs" test = false doctest = false -[dependencies] -clap = { workspace = true } +[target.'cfg(any(target_vendor = "apple", target_os = "cygwin", target_os = "freebsd", target_os = "linux", target_os = "netbsd"))'.dependencies] uucore = { workspace = true, features = ["utmpx"] } + +[target.'cfg(any(target_vendor = "apple", target_os = "cygwin", target_os = "freebsd", target_os = "linux", target_os = "netbsd", target_os = "openbsd"))'.dependencies] +clap = { workspace = true } fluent = { workspace = true } +uucore = { workspace = true, features = [] } [target.'cfg(target_os = "openbsd")'.dependencies] utmp-classic = { workspace = true } +[features] +feat_systemd_logind = ["uucore/feat_systemd_logind"] + +[lints] +workspace = true + [[bin]] name = "users" path = "src/main.rs" +required-features = ["uucore/default"] diff --git a/src/uu/users/src/users.rs b/src/uu/users/src/users.rs index 5d78b17f9a7..e71281548cf 100644 --- a/src/uu/users/src/users.rs +++ b/src/uu/users/src/users.rs @@ -5,6 +5,15 @@ // spell-checker:ignore (paths) wtmp +#![cfg(any( + target_vendor = "apple", + target_os = "cygwin", + target_os = "freebsd", + target_os = "linux", + target_os = "netbsd", + target_os = "openbsd" +))] + use std::ffi::OsString; use std::io::{Write, stdout}; use std::path::Path; @@ -55,10 +64,9 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { host: _, time: _, } = entry + && !user.is_empty() { - if !user.is_empty() { - users.push(user); - } + users.push(user); } } }; diff --git a/src/uu/vdir/Cargo.toml b/src/uu/vdir/Cargo.toml index 9a193f50c4f..db151884242 100644 --- a/src/uu/vdir/Cargo.toml +++ b/src/uu/vdir/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/vdir.rs" test = false @@ -21,8 +18,14 @@ doctest = false [dependencies] clap = { workspace = true, features = ["env"] } -uucore = { workspace = true, features = ["entries", "fs", "quoting-style"] } uu_ls = { workspace = true } +uucore = { workspace = true, features = ["entries", "fs", "quoting-style"] } + +[features] +diagnostics = ["uucore/diagnostics"] + +[lints] +workspace = true [[bin]] name = "vdir" diff --git a/src/uu/vdir/src/vdir.rs b/src/uu/vdir/src/vdir.rs index 7cf9fdf9b2c..d4411117708 100644 --- a/src/uu/vdir/src/vdir.rs +++ b/src/uu/vdir/src/vdir.rs @@ -6,50 +6,19 @@ use clap::Command; use std::ffi::OsString; use std::path::Path; -use uu_ls::{Config, Format, options}; +use uu_ls::{Config, options}; use uucore::error::UResult; -use uucore::quoting_style::QuotingStyle; use uucore::{format_usage, translate}; #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { let command = uu_app(); - let matches = uucore::clap_localization::handle_clap_result_with_exit_code(command, args, 2)?; + // The arguments are kept for the caret in SIZE diagnostics, which echoes + // the command line. + let (matches, diag_args) = + uucore::clap_localization::handle_clap_result_with_diagnostics(command, args.collect(), 2)?; - let mut default_quoting_style = false; - let mut default_format_style = false; - - // We check if any options on formatting or quoting style have been given. - // If not, we will use dir default formatting and quoting style options - - if !matches.contains_id(options::QUOTING_STYLE) - && !matches.get_flag(options::quoting::C) - && !matches.get_flag(options::quoting::ESCAPE) - && !matches.get_flag(options::quoting::LITERAL) - { - default_quoting_style = true; - } - if !matches.contains_id(options::FORMAT) - && !matches.get_flag(options::format::ACROSS) - && !matches.get_flag(options::format::COLUMNS) - && !matches.get_flag(options::format::COMMAS) - && !matches.get_flag(options::format::LONG) - && !matches.get_flag(options::format::LONG_NO_GROUP) - && !matches.get_flag(options::format::LONG_NO_OWNER) - && !matches.get_flag(options::format::LONG_NUMERIC_UID_GID) - && !matches.get_flag(options::format::ONE_LINE) - { - default_format_style = true; - } - - let mut config = Config::from(&matches)?; - - if default_quoting_style { - config.quoting_style = QuotingStyle::C_NO_QUOTES; - } - if default_format_style { - config.format = Format::Long; - } + let config = Config::from_vdir(&matches, diag_args.as_deref())?; let locs = matches .get_many::(options::PATHS) diff --git a/src/uu/wc/Cargo.toml b/src/uu/wc/Cargo.toml index c6f8069b154..be25c406c5a 100644 --- a/src/uu/wc/Cargo.toml +++ b/src/uu/wc/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/wc.rs" test = false @@ -24,23 +21,26 @@ bytecount = { workspace = true, features = ["runtime-dispatch-simd"] } clap = { workspace = true } fluent = { workspace = true } thiserror = { workspace = true } +unicode-width = { workspace = true } uucore = { workspace = true, features = [ "hardware", "parser", "pipes", "quoting-style", ] } -unicode-width = { workspace = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } -rustix = { workspace = true, features = ["fs"] } +rustix = { workspace = true, features = ["fs", "param"] } [dev-dependencies] divan = { workspace = true } tempfile = { workspace = true } uucore = { workspace = true, features = ["benchmark"] } +[lints] +workspace = true + [[bin]] name = "wc" path = "src/main.rs" diff --git a/src/uu/wc/benches/wc_bench.rs b/src/uu/wc/benches/wc_bench.rs index 79f8fbd2fea..6abcb5efd4a 100644 --- a/src/uu/wc/benches/wc_bench.rs +++ b/src/uu/wc/benches/wc_bench.rs @@ -5,7 +5,7 @@ use divan::{Bencher, black_box}; use uu_wc::uumain; -use uucore::benchmark::{create_test_file, run_util_function, text_data}; +use uucore::benchmark::{create_test_file, get_bench_args, text_data}; /// Benchmark different file sizes for byte counting #[divan::bench(args = [1, 500])] //todo: add 10kb to measure splice() overhead @@ -13,11 +13,10 @@ fn wc_bytes_synthetic(bencher: Bencher, size_mb: usize) { let temp_dir = tempfile::tempdir().unwrap(); let data = text_data::generate_by_size(size_mb, 80); let file_path = create_test_file(&data, temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-c", file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-c", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } #[divan::bench(args = [2_000])] @@ -25,11 +24,10 @@ fn wc_words_synthetic(bencher: Bencher, size_mb: usize) { let temp_dir = tempfile::tempdir().unwrap(); let data = text_data::generate_by_size(size_mb, 80); let file_path = create_test_file(&data, temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-w", file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-w", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark combined byte+line counting @@ -38,11 +36,10 @@ fn wc_bytes_lines_synthetic(bencher: Bencher, size_mb: usize) { let temp_dir = tempfile::tempdir().unwrap(); let data = text_data::generate_by_size(size_mb, 80); let file_path = create_test_file(&data, temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-cl", file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-cl", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Test different line lengths impact on performance @@ -51,11 +48,10 @@ fn wc_lines_variable_length(bencher: Bencher, (size_mb, avg_line_len): (usize, u let temp_dir = tempfile::tempdir().unwrap(); let data = text_data::generate_by_size(size_mb, avg_line_len); let file_path = create_test_file(&data, temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-l", file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-l", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark large files by line count - up to 500K lines! @@ -64,11 +60,10 @@ fn wc_lines_large_line_count(bencher: Bencher, num_lines: usize) { let temp_dir = tempfile::tempdir().unwrap(); let data = text_data::generate_by_lines(num_lines, 80); let file_path = create_test_file(&data, temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-l", file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-l", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark character counting on large line counts @@ -77,11 +72,10 @@ fn wc_chars_large_line_count(bencher: Bencher, num_lines: usize) { let temp_dir = tempfile::tempdir().unwrap(); let data = text_data::generate_by_lines(num_lines, 80); let file_path = create_test_file(&data, temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-m", file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-m", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark word counting on large line counts @@ -90,11 +84,10 @@ fn wc_words_large_line_count(bencher: Bencher, num_lines: usize) { let temp_dir = tempfile::tempdir().unwrap(); let data = text_data::generate_by_lines(num_lines, 80); let file_path = create_test_file(&data, temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-w", file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-w", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark default wc (lines, words, bytes) on large line counts @@ -103,11 +96,10 @@ fn wc_default_large_line_count(bencher: Bencher, num_lines: usize) { let temp_dir = tempfile::tempdir().unwrap(); let data = text_data::generate_by_lines(num_lines, 80); let file_path = create_test_file(&data, temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-lwc", file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-lwc", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } /// Benchmark very short vs very long lines with 100K lines @@ -116,11 +108,10 @@ fn wc_lines_extreme_line_lengths(bencher: Bencher, (num_lines, line_len): (usize let temp_dir = tempfile::tempdir().unwrap(); let data = text_data::generate_by_lines(num_lines, line_len); let file_path = create_test_file(&data, temp_dir.path()); - let file_path_str = file_path.to_str().unwrap(); - bencher.bench(|| { - black_box(run_util_function(uumain, &["-l", file_path_str])); - }); + bencher + .with_inputs(|| get_bench_args(&[&"-l", &file_path]).into_iter()) + .bench_values(|args| black_box(uumain(args))); } fn main() { diff --git a/src/uu/wc/src/countable.rs b/src/uu/wc/src/countable.rs index 32d56553879..95d3dc5d0c8 100644 --- a/src/uu/wc/src/countable.rs +++ b/src/uu/wc/src/countable.rs @@ -2,11 +2,13 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + //! Traits and implementations for iterating over lines in a file-like object. //! //! This module provides a [`WordCountable`] trait and implementations //! for some common file-like objects. Use the [`WordCountable::buffered`] //! method to get an iterator over lines of a file-like object. + use std::fs::File; use std::io::{BufRead, BufReader, Read, StdinLock}; diff --git a/src/uu/wc/src/utf8/mod.rs b/src/uu/wc/src/utf8/mod.rs index 399a9f3646e..281cc907bc0 100644 --- a/src/uu/wc/src/utf8/mod.rs +++ b/src/uu/wc/src/utf8/mod.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore Sapin + mod read; pub use read::{BufReadDecoder, BufReadDecoderError}; diff --git a/src/uu/wc/src/utf8/read.rs b/src/uu/wc/src/utf8/read.rs index dabf1a380a2..508fec3ac9c 100644 --- a/src/uu/wc/src/utf8/read.rs +++ b/src/uu/wc/src/utf8/read.rs @@ -2,10 +2,11 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore bytestream + use super::{Incomplete, str}; use std::io::{self, BufRead}; -use thiserror::Error; use uucore::translate; /// Wraps a `std::io::BufRead` buffered byte stream and decode it as UTF-8. @@ -15,7 +16,7 @@ pub struct BufReadDecoder { incomplete: Incomplete, } -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum BufReadDecoderError<'a> { /// Represents one UTF-8 error in the byte stream. /// diff --git a/src/uu/wc/src/wc.rs b/src/uu/wc/src/wc.rs index a7fea0519e9..60d586c873e 100644 --- a/src/uu/wc/src/wc.rs +++ b/src/uu/wc/src/wc.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// cSpell:ignore ilog wc wc's +// spell-checker:ignore ilog wc wc's mod count_fast; mod countable; @@ -23,10 +23,9 @@ use std::{ }; use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser}; -use thiserror::Error; use unicode_width::UnicodeWidthChar; use utf8::{BufReadDecoder, BufReadDecoderError}; -use uucore::{display::Quotable, translate}; +use uucore::{display::Quotable, quoting_style::locale_aware_shell_escape, translate}; use uucore::{ error::{FromIo, UError, UResult}, @@ -271,7 +270,7 @@ impl<'a> Input<'a> { /// Converts input into the form that appears in errors. fn path_display(&self) -> String { match self { - Self::Path(path) => escape_name_wrapper(path.as_os_str()), + Self::Path(path) => locale_aware_shell_escape(path.as_os_str()), Self::Stdin(_) => translate!("wc-standard-input"), } } @@ -304,9 +303,9 @@ fn is_stdin_small_file() -> bool { ) } -#[cfg(not(unix))] /// Windows presents a piped stdin as a "normal file" with a length equal to however many bytes /// have been buffered at the time it's checked. To be safe, we must never assume it's a file. +#[cfg(not(unix))] fn is_stdin_small_file() -> bool { false } @@ -343,7 +342,7 @@ impl TotalWhen { } } -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum WcError { #[error("{}", translate!("wc-error-files-disabled", "extra" => extra.quote()))] FilesDisabled { extra: Cow<'static, OsStr> }, @@ -361,7 +360,7 @@ impl WcError { Some((input, idx)) => { let path = match input { Input::Stdin(_) => STDIN_REPR.into(), - Input::Path(path) => escape_name_wrapper(path.as_os_str()).into(), + Input::Path(path) => locale_aware_shell_escape(path.as_os_str()).into(), }; Self::ZeroLengthFileNameCtx { path, idx } } @@ -779,7 +778,7 @@ fn files0_iter_file<'a>(path: &Path) -> UResult quoting_style::locale_aware_escape_name( path.as_os_str(), - QuotingStyle::SHELL_ESCAPE_QUOTE, + QuotingStyle::SHELL_ESCAPE_ALWAYS, ) .into_string() .expect("All escaped names with the escaping option return valid strings.") @@ -814,7 +813,7 @@ fn files0_iter<'a>( } } Err(e) => Err(e.map_err_context( - || translate!("wc-error-read-error", "path" => escape_name_wrapper(&err_path)), + || translate!("wc-error-read-error", "path" => locale_aware_shell_escape(&err_path)), ) as Box), }), ); @@ -828,12 +827,6 @@ fn files0_iter<'a>( }) } -fn escape_name_wrapper(name: &OsStr) -> String { - quoting_style::locale_aware_escape_name(name, QuotingStyle::SHELL_ESCAPE) - .into_string() - .expect("All escaped names with the escaping option return valid strings.") -} - fn hardware_feature_label(feature: HardwareFeature) -> &'static str { match feature { HardwareFeature::Avx512 => "AVX512F", diff --git a/src/uu/wc/src/word_count.rs b/src/uu/wc/src/word_count.rs index 3c18d692b2c..fce74d23dd8 100644 --- a/src/uu/wc/src/word_count.rs +++ b/src/uu/wc/src/word_count.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use std::cmp::max; use std::ops::{Add, AddAssign}; diff --git a/src/uu/who/Cargo.toml b/src/uu/who/Cargo.toml index 38adccbd08e..c3c36788106 100644 --- a/src/uu/who/Cargo.toml +++ b/src/uu/who/Cargo.toml @@ -1,6 +1,5 @@ # spell-checker:ignore logind - [package] name = "uu_who" description = "who ~ (uutils) display information about currently logged-in users" @@ -14,12 +13,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - -[features] -feat_systemd_logind = ["uucore/feat_systemd_logind"] - [lib] path = "src/who.rs" test = false @@ -27,9 +20,15 @@ doctest = false [dependencies] clap = { workspace = true } -rustix = { workspace = true } -uucore = { workspace = true, features = ["utmpx"] } fluent = { workspace = true } +rustix = { workspace = true, features = ["fs", "termios"] } +uucore = { workspace = true, features = ["utmpx"] } + +[features] +feat_systemd_logind = ["uucore/feat_systemd_logind"] + +[lints] +workspace = true [[bin]] name = "who" diff --git a/src/uu/who/src/platform/unix.rs b/src/uu/who/src/platform/unix.rs index 8917ac70190..9203da6eb6e 100644 --- a/src/uu/who/src/platform/unix.rs +++ b/src/uu/who/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. -// spell-checker:ignore (ToDO) ttyname hostnames runlevel mesg wtmp statted boottime deadprocs initspawn clockchange curr pidstr exitstr hoststr +// spell-checker:ignore (ToDO) ttyname hostnames runlevel mesg wtmp use crate::options; use crate::uu_app; @@ -25,6 +25,94 @@ fn get_long_usage() -> String { translate!("who-long-usage", "default_file" => utmpx::DEFAULT_FILE) } +/// Which kinds of accounting record are worth reporting. +#[derive(Default)] +struct Selection { + /// The record left by the last system boot. + boot: bool, + /// The records of processes that have since exited. + exited: bool, + /// The login processes still waiting for someone to sign in. + login_slots: bool, + /// The processes that init spawned. + init_children: bool, + /// The record left by the most recent clock adjustment. + clock_change: bool, + /// The record holding the current runlevel. + runlevel: bool, + /// Ordinary user sessions. + sessions: bool, +} + +impl Selection { + /// True when no selecting option was given at all, including `--users`. + /// Such an invocation falls back to reporting user sessions. + fn is_default(&self) -> bool { + !(self.boot + || self.exited + || self.login_slots + || self.init_children + || self.clock_change + || self.runlevel + || self.sessions) + } +} + +/// Which columns each row carries. +#[derive(Default)] +struct Layout { + /// Prepend a header row naming the columns. + header: bool, + /// The column reporting whether the terminal accepts messages: `+` when it + /// does, `-` when it does not, `?` when the terminal cannot be queried. + write_state: bool, + /// How long the terminal has been quiet. + idle: bool, + /// How the process ended and with what status. + exit: bool, + /// Drop everything but the name, line and time columns. + terse: bool, +} + +/// The events that are reported from something other than a live session. +#[derive(Clone, Copy)] +enum Event { + Boot, + ClockChange, + #[cfg(target_os = "linux")] + Runlevel, + LoginSlot, + InitChild, + Exited, +} + +/// One output line, before the columns are padded out. +struct Row<'a> { + user: &'a str, + write_state: char, + line: &'a str, + time: &'a str, + idle: &'a str, + pid: &'a str, + note: &'a str, + exit: &'a str, +} + +impl Default for Row<'_> { + fn default() -> Self { + Self { + user: "", + write_state: ' ', + line: "", + time: "", + idle: "", + pid: "", + note: "", + exit: "", + } + } +} + pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app().after_help(get_long_usage()), args)?; @@ -34,83 +122,43 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .map(|v| v.map(ToString::to_string).collect()) .unwrap_or_default(); - // If true, attempt to canonicalize hostnames via a DNS lookup. - let do_lookup = matches.get_flag(options::LOOKUP); - - // If true, display only a list of usernames and count of - // the users logged on. - // Ignored for 'who am i'. - let short_list = matches.get_flag(options::COUNT); - let all = matches.get_flag(options::ALL); + let flag = |name: &str| all || matches.get_flag(name); + + let mut select = Selection { + boot: flag(options::BOOT), + exited: flag(options::DEAD), + login_slots: flag(options::LOGIN), + init_children: flag(options::PROCESS), + clock_change: flag(options::TIME), + runlevel: flag(options::RUNLEVEL), + sessions: matches.get_flag(options::USERS), + }; - // If true, display a line at the top describing each field. - let include_heading = matches.get_flag(options::HEADING); - - // If true, display a '+' for each user if mesg y, a '-' if mesg n, - // or a '?' if their tty cannot be statted. - let include_mesg = all || matches.get_flag(options::MESG); - - // If true, display the last boot time. - let need_boottime = all || matches.get_flag(options::BOOT); - - // If true, display dead processes. - let need_deadprocs = all || matches.get_flag(options::DEAD); - - // If true, display processes waiting for user login. - let need_login = all || matches.get_flag(options::LOGIN); - - // If true, display processes started by init. - let need_initspawn = all || matches.get_flag(options::PROCESS); - - // If true, display the last clock change. - let need_clockchange = all || matches.get_flag(options::TIME); - - // If true, display the current runlevel. - let need_runlevel = all || matches.get_flag(options::RUNLEVEL); - - let use_defaults = !(all - || need_boottime - || need_deadprocs - || need_login - || need_initspawn - || need_runlevel - || need_clockchange - || matches.get_flag(options::USERS)); - - // If true, display user processes. - let need_users = all || matches.get_flag(options::USERS) || use_defaults; - - // If true, display the hours:minutes since each user has touched - // the keyboard, or "." if within the last minute, or "old" if - // not within the last day. - let include_idle = need_deadprocs || need_login || need_runlevel || need_users; - - // If true, display process termination & exit status. - let include_exit = need_deadprocs; - - // If true, display only name, line, and time fields. - let short_output = !include_exit && use_defaults; - - // If true, display info only for the controlling tty. - let my_line_only = matches.get_flag(options::ONLY_HOSTNAME_USER) || files.len() == 2; + // With no selecting option the report falls back to user sessions, and the + // narrower row shape that goes with them. + let defaulted = select.is_default(); + select.sessions |= all || defaulted; + + let layout = Layout { + header: matches.get_flag(options::HEADING), + write_state: flag(options::MESG), + // The idle column is only meaningful for records tied to a terminal. + idle: select.exited || select.login_slots || select.runlevel || select.sessions, + exit: select.exited, + terse: !select.exited && defaulted, + }; let mut who = Who { - do_lookup, - short_list, - short_output, - include_idle, - include_heading, - include_mesg, - include_exit, - need_boottime, - need_deadprocs, - need_login, - need_initspawn, - need_clockchange, - need_runlevel, - need_users, - my_line_only, + // Resolve each recorded host to its canonical name before printing it. + resolve_hosts: matches.get_flag(options::LOOKUP), + // Print just the login names followed by a total, instead of one row + // per record. Carries no meaning in the `who am i` form. + names_only: matches.get_flag(options::COUNT), + // Report only the session attached to the invoking terminal. + own_terminal_only: matches.get_flag(options::ONLY_HOSTNAME_USER) || files.len() == 2, + select, + layout, args: files, }; @@ -119,41 +167,28 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } struct Who { - do_lookup: bool, - short_list: bool, - short_output: bool, - include_idle: bool, - include_heading: bool, - include_mesg: bool, - include_exit: bool, - need_boottime: bool, - need_deadprocs: bool, - need_login: bool, - need_initspawn: bool, - need_clockchange: bool, - need_runlevel: bool, - need_users: bool, - my_line_only: bool, + resolve_hosts: bool, + names_only: bool, + own_terminal_only: bool, + select: Selection, + layout: Layout, args: Vec, } -fn idle_string<'a>(when: i64, boottime: i64) -> Cow<'a, str> { +/// Render how long a terminal has been quiet: `hours:minutes`, `.` when under a +/// minute, and the localized `old` past a day or before the given boot time. +fn format_idle<'a>(when: i64, since_boot: i64) -> Cow<'a, str> { thread_local! { static NOW: time::OffsetDateTime = time::OffsetDateTime::now_local().unwrap(); } NOW.with(|n| { let now = n.unix_timestamp(); - if boottime < when && now - 24 * 3600 < when && when <= now { - let seconds_idle = now - when; - if seconds_idle < 60 { + if since_boot < when && now - 24 * 3600 < when && when <= now { + let quiet_for = now - when; + if quiet_for < 60 { " . ".into() } else { - format!( - "{:02}:{:02}", - seconds_idle / 3600, - (seconds_idle % 3600) / 60 - ) - .into() + format!("{:02}:{:02}", quiet_for / 3600, (quiet_for % 3600) / 60).into() } } else { translate!("who-idle-old").into() @@ -161,10 +196,10 @@ fn idle_string<'a>(when: i64, boottime: i64) -> Cow<'a, str> { }) } -fn time_string(ut: &UtmpxRecord) -> String { +fn format_timestamp(ut: &UtmpxRecord) -> String { const FORMAT_DESCRIPTION_VERSION: usize = 2; - let time_format: Vec = if ["LC_ALL", "LC_TIME", "LANG"] + let pattern: Vec = if ["LC_ALL", "LC_TIME", "LANG"] .into_iter() .find_map(std::env::var_os) .as_deref() @@ -182,7 +217,7 @@ fn time_string(ut: &UtmpxRecord) -> String { ) .unwrap() }; - ut.login_time().format(&time_format).unwrap() + ut.login_time().format(&pattern).unwrap() } fn current_tty() -> String { @@ -192,280 +227,246 @@ fn current_tty() -> String { } impl Who { - #[allow(clippy::cognitive_complexity)] fn exec(&mut self) -> UResult<()> { - #[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() } else { utmpx::DEFAULT_FILE }; - if self.short_list { - let users = utmpx::Utmpx::iter_all_records_from(f) - .filter(UtmpxRecord::is_user_process) - .map(|ut| ut.user()) - .collect::>(); - println!("{}", users.join(" ")); - println!("{}", translate!("who-user-count", "count" => users.len())); - } else { - let records = utmpx::Utmpx::iter_all_records_from(f); + if self.names_only { + return self.emit_names(f); + } - if self.include_heading { - self.print_heading()?; - } - let cur_tty = if self.my_line_only { - current_tty() - } else { - String::new() - }; + let records = utmpx::Utmpx::iter_all_records_from(f); - for ut in records { - if !self.my_line_only || cur_tty == ut.tty_device() { - if self.need_users && ut.is_user_process() { - self.print_user(&ut)?; - } else { - match ut.record_type() { - rt if self.need_runlevel && run_level_chk(rt) => { - if cfg!(target_os = "linux") { - self.print_runlevel(&ut)?; - } - } - x if x == utmpx::BOOT_TIME && self.need_boottime => { - self.print_boottime(&ut)?; - } - x if x == utmpx::NEW_TIME && self.need_clockchange => { - self.print_clockchange(&ut)?; - } - x if x == utmpx::INIT_PROCESS && self.need_initspawn => { - self.print_initspawn(&ut)?; - } - x if x == utmpx::LOGIN_PROCESS && self.need_login => { - self.print_login(&ut)?; - } - x if x == utmpx::DEAD_PROCESS && self.need_deadprocs => { - self.print_deadprocs(&ut)?; - } - _ => {} - } - } - } + if self.layout.header { + self.emit_header()?; + } + let cur_tty = if self.own_terminal_only { + current_tty() + } else { + String::new() + }; - if ut.record_type() == utmpx::BOOT_TIME {} + for ut in records { + if self.own_terminal_only && cur_tty != ut.tty_device() { + continue; + } + if self.select.sessions && ut.is_user_process() { + self.emit_session(&ut)?; + } else if let Some(event) = self.event_for(&ut) { + self.emit_event(&ut, event)?; } } Ok(()) } - #[inline] - fn print_runlevel(&self, ut: &UtmpxRecord) -> UResult<()> { - let last = (ut.pid() / 256) as u8 as char; - let curr = (ut.pid() % 256) as u8 as char; - let runlevel_line = translate!("who-runlevel", "level" => curr); - let comment = - translate!("who-runlevel-last", "last" => (if last == 'N' { 'S' } else { 'N' })); - - self.print_line( - "", - ' ', - &runlevel_line, - &time_string(ut), - "", - "", - if last.is_control() { "" } else { &comment }, - "", + /// The `-q` report: every login name on one line, then the total. + fn emit_names(&self, path: &str) -> UResult<()> { + let users = utmpx::Utmpx::iter_all_records_from(path) + .filter(UtmpxRecord::is_user_process) + .map(|ut| ut.user()) + .collect::>(); + // `println!` panics on a write error; the rest of this file surfaces + // it through `?` instead so the caller can report it and exit + // non-zero, matching GNU (#13388). + writeln!(stdout(), "{}", users.join(" "))?; + writeln!( + stdout(), + "{}", + translate!("who-user-count", "count" => users.len()) )?; Ok(()) } - #[inline] - fn print_clockchange(&self, ut: &UtmpxRecord) -> UResult<()> { - self.print_line( - "", - ' ', - &translate!("who-clock-change"), - &time_string(ut), - "", - "", - "", - "", - )?; - Ok(()) - } + /// Map a record to the event it stands for, or `None` when that kind was + /// not selected. + fn event_for(&self, ut: &UtmpxRecord) -> Option { + let rt = ut.record_type(); - #[inline] - fn print_login(&self, ut: &UtmpxRecord) -> UResult<()> { - let comment = translate!("who-login-id", "id" => ut.terminal_suffix()); - let pidstr = format!("{}", ut.pid()); - self.print_line( - &translate!("who-login"), - ' ', - &ut.tty_device(), - &time_string(ut), - "", - &pidstr, - &comment, - "", - )?; - Ok(()) - } + #[cfg(target_os = "linux")] + if self.select.runlevel && rt == utmpx::RUN_LVL { + return Some(Event::Runlevel); + } - #[inline] - fn print_deadprocs(&self, ut: &UtmpxRecord) -> UResult<()> { - let comment = translate!("who-login-id", "id" => ut.terminal_suffix()); - let pidstr = format!("{}", ut.pid()); - let e = ut.exit_status(); - let exitstr = translate!("who-dead-exit-status", "term" => e.0, "exit" => e.1); - self.print_line( - "", - ' ', - &ut.tty_device(), - &time_string(ut), - "", - &pidstr, - &comment, - &exitstr, - )?; - Ok(()) + match rt { + utmpx::BOOT_TIME if self.select.boot => Some(Event::Boot), + utmpx::NEW_TIME if self.select.clock_change => Some(Event::ClockChange), + utmpx::INIT_PROCESS if self.select.init_children => Some(Event::InitChild), + utmpx::LOGIN_PROCESS if self.select.login_slots => Some(Event::LoginSlot), + utmpx::DEAD_PROCESS if self.select.exited => Some(Event::Exited), + _ => None, + } } - #[inline] - fn print_initspawn(&self, ut: &UtmpxRecord) -> UResult<()> { - let comment = translate!("who-login-id", "id" => ut.terminal_suffix()); - let pidstr = format!("{}", ut.pid()); - self.print_line( - "", - ' ', - &ut.tty_device(), - &time_string(ut), - "", - &pidstr, - &comment, - "", - )?; - Ok(()) - } + fn emit_event(&self, ut: &UtmpxRecord, event: Event) -> UResult<()> { + let time = format_timestamp(ut); + let pid = format!("{}", ut.pid()); + let note = translate!("who-login-id", "id" => ut.terminal_suffix()); - #[inline] - fn print_boottime(&self, ut: &UtmpxRecord) -> UResult<()> { - self.print_line( - "", - ' ', - &translate!("who-system-boot"), - &time_string(ut), - "", - "", - "", - "", - )?; - Ok(()) + // Held outside the match so the borrows below outlive it. + #[cfg(target_os = "linux")] + let runlevel_line; + #[cfg(target_os = "linux")] + let runlevel_note; + let exit; + + let row = match event { + Event::Boot => Row { + line: &translate!("who-system-boot"), + time: &time, + ..Row::default() + }, + Event::ClockChange => Row { + line: &translate!("who-clock-change"), + time: &time, + ..Row::default() + }, + #[cfg(target_os = "linux")] + Event::Runlevel => { + let last = (ut.pid() / 256) as u8 as char; + let level = (ut.pid() % 256) as u8 as char; + runlevel_line = translate!("who-runlevel", "level" => level); + // Like GNU, a previous level of 'N' is reported as 'S'; any + // other previous level is reported as-is. + runlevel_note = translate!("who-runlevel-last", "last" => (if last == 'N' { 'S' } else { last })); + Row { + line: &runlevel_line, + time: &time, + note: if last.is_control() { + "" + } else { + &runlevel_note + }, + ..Row::default() + } + } + Event::LoginSlot => Row { + user: &translate!("who-login"), + line: &ut.tty_device(), + time: &time, + pid: &pid, + note: ¬e, + ..Row::default() + }, + Event::InitChild => Row { + line: &ut.tty_device(), + time: &time, + pid: &pid, + note: ¬e, + ..Row::default() + }, + Event::Exited => { + let e = ut.exit_status(); + exit = translate!("who-dead-exit-status", "term" => e.0, "exit" => e.1); + Row { + line: &ut.tty_device(), + time: &time, + pid: &pid, + note: ¬e, + exit: &exit, + ..Row::default() + } + } + }; + + self.emit_row(&row) } - fn print_user(&self, ut: &UtmpxRecord) -> UResult<()> { + fn emit_session(&self, ut: &UtmpxRecord) -> UResult<()> { let mut p = PathBuf::from("/dev"); p.push(ut.tty_device().as_str()); - let mesg; - let last_change; - if let Ok(meta) = p.metadata() { - #[cfg(all( - not(target_os = "android"), - not(target_os = "freebsd"), - not(target_vendor = "apple") - ))] - let iwgrp = S_IWGRP; - #[cfg(any(target_os = "android", target_os = "freebsd", target_vendor = "apple"))] - let iwgrp = S_IWGRP as u32; - mesg = if meta.mode() & iwgrp == 0 { '-' } else { '+' }; - last_change = meta.atime(); - } else { - mesg = '?'; - last_change = 0; - } + // A terminal that cannot be stat'ed reports an unknown write state and + // an unknown idle time rather than failing the whole listing. + let (write_state, last_touched) = match p.metadata() { + Ok(meta) => { + #[cfg(all( + not(target_vendor = "apple"), + not(target_os = "android"), + not(target_os = "freebsd") + ))] + let iwgrp = S_IWGRP; + #[cfg(any(target_vendor = "apple", target_os = "android", target_os = "freebsd"))] + let iwgrp = S_IWGRP as u32; + let state = if meta.mode() & iwgrp == 0 { '-' } else { '+' }; + (state, meta.atime()) + } + Err(_) => ('?', 0), + }; - let idle = if last_change == 0 { + let idle = if last_touched == 0 { " ?".into() } else { - idle_string(last_change, 0) + format_idle(last_touched, 0) }; - let s = if self.do_lookup { + let host = if self.resolve_hosts { ut.canon_host().map_err_context(|| { let host = ut.host(); translate!("who-canonicalize-error", "host" => host.split(':').next().unwrap_or(&host).quote()) - .to_string() })? } else { ut.host() }; - let hoststr = if s.is_empty() { s } else { format!("({s})") }; - - self.print_line( - ut.user().as_ref(), - mesg, - ut.tty_device().as_ref(), - time_string(ut).as_str(), - idle.as_ref(), - format!("{}", ut.pid()).as_str(), - hoststr.as_str(), - "", - )?; + let note = if host.is_empty() { + host + } else { + format!("({host})") + }; + + self.emit_row(&Row { + user: &ut.user(), + write_state, + line: &ut.tty_device(), + time: &format_timestamp(ut), + idle: &idle, + pid: &format!("{}", ut.pid()), + note: ¬e, + exit: "", + })?; Ok(()) } - #[allow(clippy::too_many_arguments)] - fn print_line( - &self, - user: &str, - state: char, - line: &str, - time: &str, - idle: &str, - pid: &str, - comment: &str, - exit: &str, - ) -> UResult<()> { - let mut buf = String::with_capacity(64); - let msg = vec![' ', state].into_iter().collect::(); + fn emit_row(&self, row: &Row) -> UResult<()> { + // Width of "%b %e %H:%M" under LC_ALL=C. + const TIME_WIDTH: usize = 3 + 2 + 2 + 1 + 2; - write!(buf, "{user:<8}").unwrap(); - if self.include_mesg { - buf.push_str(&msg); + let mut buf = String::with_capacity(64); + write!(buf, "{:<8}", row.user).unwrap(); + if self.layout.write_state { + buf.push(' '); + buf.push(row.write_state); } - write!(buf, " {line:<12}").unwrap(); - // "%b %e %H:%M" (LC_ALL=C) - let time_size = 3 + 2 + 2 + 1 + 2; - write!(buf, " {time:10}").unwrap(); + write!(buf, " {:>10}", row.pid).unwrap(); } - write!(buf, " {comment:<8}").unwrap(); - if self.include_exit { - write!(buf, " {exit:<12}").unwrap(); + write!(buf, " {:<8}", row.note).unwrap(); + if self.layout.exit { + write!(buf, " {:<12}", row.exit).unwrap(); } writeln!(stdout(), "{}", buf.trim_end())?; Ok(()) } #[inline] - fn print_heading(&self) -> UResult<()> { - self.print_line( - &translate!("who-heading-name"), - ' ', - &translate!("who-heading-line"), - &translate!("who-heading-time"), - &translate!("who-heading-idle"), - &translate!("who-heading-pid"), - &translate!("who-heading-comment"), - &translate!("who-heading-exit"), - )?; + fn emit_header(&self) -> UResult<()> { + self.emit_row(&Row { + user: &translate!("who-heading-name"), + write_state: ' ', + line: &translate!("who-heading-line"), + time: &translate!("who-heading-time"), + idle: &translate!("who-heading-idle"), + pid: &translate!("who-heading-pid"), + note: &translate!("who-heading-comment"), + exit: &translate!("who-heading-exit"), + })?; Ok(()) } } diff --git a/src/uu/whoami/Cargo.toml b/src/uu/whoami/Cargo.toml index 5613c5f6e18..0fe360668bd 100644 --- a/src/uu/whoami/Cargo.toml +++ b/src/uu/whoami/Cargo.toml @@ -11,9 +11,6 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/whoami.rs" test = false @@ -21,16 +18,22 @@ doctest = false [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["entries", "process"] } fluent = { workspace = true } +uucore = { workspace = true, features = ["entries"] } + +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true, features = ["process"] } -[target.'cfg(target_os = "windows")'.dependencies] +[target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = [ "Win32_NetworkManagement_NetManagement", "Win32_System_WindowsProgramming", "Win32_Foundation", ] } +[lints] +workspace = true + [[bin]] name = "whoami" path = "src/main.rs" diff --git a/src/uu/whoami/src/main.rs b/src/uu/whoami/src/main.rs index 0c9ee7f689c..7a6c9b9a1c1 100644 --- a/src/uu/whoami/src/main.rs +++ b/src/uu/whoami/src/main.rs @@ -3,4 +3,4 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -uucore::bin!(uu_whoami); +uucore::bin!(uu_whoami, no_flush); diff --git a/src/uu/whoami/src/platform/unix.rs b/src/uu/whoami/src/platform/unix.rs index 9310a6de0a2..6970ddd6e31 100644 --- a/src/uu/whoami/src/platform/unix.rs +++ b/src/uu/whoami/src/platform/unix.rs @@ -6,10 +6,10 @@ use std::ffi::OsString; use std::io; +use rustix::process::geteuid; use uucore::entries::uid2usr; -use uucore::process::geteuid; pub fn get_username() -> io::Result { // uid2usr should arguably return an OsString but currently doesn't - uid2usr(geteuid()).map(Into::into) + uid2usr(geteuid().as_raw()).map(Into::into) } diff --git a/src/uu/yes/Cargo.toml b/src/uu/yes/Cargo.toml index 374e3d36da9..31a916c1524 100644 --- a/src/uu/yes/Cargo.toml +++ b/src/uu/yes/Cargo.toml @@ -11,20 +11,22 @@ edition.workspace = true rust-version.workspace = true readme.workspace = true -[lints] -workspace = true - [lib] path = "src/yes.rs" doctest = false [dependencies] clap = { workspace = true } -itertools = { workspace = true } fluent = { workspace = true } -rustix = { workspace = true, features = ["stdio", "pipe"] } +itertools = { workspace = true } uucore = { workspace = true, features = ["fs", "pipes"] } +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +rustix = { workspace = true, features = ["stdio", "pipe"] } + +[lints] +workspace = true + [[bin]] name = "yes" path = "src/main.rs" diff --git a/src/uu/yes/src/yes.rs b/src/uu/yes/src/yes.rs index 2bc9e8507a9..581867341fd 100644 --- a/src/uu/yes/src/yes.rs +++ b/src/uu/yes/src/yes.rs @@ -31,8 +31,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { repeat_content_to_capacity(&mut buffer); match exec(&buffer) { Ok(()) => Ok(()), - // On Windows, silently handle broken pipe since there's no SIGPIPE - #[cfg(windows)] + // On Windows and WASI, silently handle broken pipe since there's no SIGPIPE + #[cfg(any(windows, target_os = "wasi"))] Err(err) if err.kind() == io::ErrorKind::BrokenPipe => Ok(()), Err(err) => Err(USimpleError::new( 1, @@ -96,22 +96,24 @@ pub fn exec(bytes: &[u8]) -> io::Result<()> { let stdout = rustix::stdio::stdout(); // improve throughput let _ = rustix::pipe::fcntl_setpipe_size(stdout, MAX_ROOTLESS_PIPE_SIZE); - // GNU catches all strace injections for zero-copy syscalls except for 1st one (checking support of it) // tee() cannot control offset. We can do tee only if original bytes.len() is multiple of PIPE_BUF, // but it is slower than mixing splice even it reduces syscalls... - let bytes_len = bytes.len(); - if let Ok((p_read, mut p_write)) = pipe::() + if let bytes_len @ ..=MAX_ROOTLESS_PIPE_SIZE = bytes.len() + && let Ok((p_read, mut p_write)) = pipe::() && p_write.write_all(bytes).is_ok() && let Ok((broker_read, broker_write)) = pipe::() + // GNU catches all strace injections for splice expect for 1st one (checking support of it) + // do same things for tee too which is enough for actual usage && Ok(bytes_len) == tee(&p_read, &broker_write, MAX_ROOTLESS_PIPE_SIZE) - && uucore::pipes::drain_pipe(&broker_read, &stdout, bytes_len)?.is_ok() + && let Ok(spliced_first) = splice(&broker_read, &stdout, bytes_len) { - // fallback from tee() is possible since we did not send anything to stdout yet - while let Ok(mut remain) = tee(&p_read, &broker_write, MAX_ROOTLESS_PIPE_SIZE) { - debug_assert!(remain == bytes_len, "splice should cleanup pipe"); + let mut remain = bytes_len - spliced_first; + loop { while remain > 0 { remain -= splice(&broker_read, &stdout, remain)?; } + remain = tee(&p_read, &broker_write, MAX_ROOTLESS_PIPE_SIZE)?; + debug_assert!(remain == bytes_len, "splice should cleanup pipe"); } } diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index f797b5dc1e9..e5a178879ac 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -1,4 +1,4 @@ -# spell-checker:ignore (features) bigdecimal zerocopy extendedbigdecimal tzdb zoneinfo logind libcrypto +# spell-checker:ignore (features) extendedbigdecimal tzdb zoneinfo logind libcrypto [package] name = "uucore" @@ -15,19 +15,16 @@ version.workspace = true [package.metadata.docs.rs] all-features = true -[lints] -workspace = true - [lib] path = "src/lib/lib.rs" [dependencies] -bstr = { workspace = true, optional = true } +ariadne = { workspace = true, optional = true } +base64-simd = { workspace = true, optional = true } clap = { workspace = true } -uucore_procs = { workspace = true } -unit-prefix = { workspace = true, optional = true } -dns-lookup = { workspace = true, optional = true } -dunce = { version = "1.0.4", optional = true } +data-encoding = { workspace = true, optional = true } +data-encoding-macro = { workspace = true, optional = true } +dunce = { workspace = true, optional = true } glob = { workspace = true, optional = true } itertools = { workspace = true, optional = true } jiff = { workspace = true, optional = true, features = [ @@ -35,23 +32,18 @@ jiff = { workspace = true, optional = true, features = [ "tzdb-zoneinfo", "tzdb-concatenated", ] } -rustc-hash = { workspace = true } -rustix = { workspace = true, features = ["fs", "net", "pipe", "process"] } -time = { workspace = true, optional = true, features = [ - "formatting", - "local-offset", - "macros", -] } -# * "problem" dependencies (pinned) -data-encoding = { version = "2.6", optional = true } -data-encoding-macro = { version = "0.1.15", optional = true } -z85 = { version = "3.0.5", optional = true } -base64-simd = { version = "0.8", optional = true } libc = { workspace = true, optional = true } -os_display = "0.1.3" +os_display = { workspace = true } +rustc-hash = { workspace = true } +# Not optional: os_display already pulls unicode-width into every uucore build, +# so making it a direct dependency here is free and keeps char_width available +# on all targets (a feature-gated optional dep failed to activate on wasm). +unicode-width = { workspace = true } +unit-prefix = { workspace = true, optional = true } +uucore_procs = { workspace = true } +z85 = { workspace = true, optional = true } # Benchmark dependencies (optional) -divan = { workspace = true, optional = true } tempfile = { workspace = true, optional = true } digest = { workspace = true, optional = true } @@ -95,42 +87,61 @@ thiserror = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +[target.'cfg(any(target_os = "freebsd", target_os = "hurd", target_os = "linux", target_os = "android", target_os = "macos", target_os = "netbsd"))'.dependencies] +xattr = { version = "1.3.1", optional = true } + [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] selinux = { workspace = true, optional = true } +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "cygwin"))'.dependencies] +bstr = { workspace = true, optional = true } + +[target.'cfg(any(target_vendor = "apple", target_os = "cygwin", target_os = "freebsd", target_os = "linux", target_os = "netbsd"))'.dependencies] +dns-lookup = { workspace = true, optional = true } + +[target.'cfg(any(unix, windows, target_os = "wasi"))'.dependencies] +rustix = { workspace = true, optional = true } + [target.'cfg(unix)'.dependencies] -nix = { workspace = true, features = [ - "dir", - "fs", - "poll", - "signal", - "uio", - "user", - "zerocopy", +# utmpx is unix-only, so its dependencies must not be pulled into other targets +# (the uptime feature enables utmpx and now builds on windows too). +nix = { workspace = true, optional = true } +time = { workspace = true, optional = true, features = [ + "formatting", + "local-offset", + "macros", ] } -# OpenSSL is only used on Unix targets. On Windows, enabling the `openssl` -# feature is a no-op and the pure-Rust digest implementations are used. -openssl = { workspace = true, optional = true } walkdir = { workspace = true, optional = true } -xattr = { workspace = true, optional = true } [target.'cfg(target_os = "linux")'.dependencies] procfs = { workspace = true, optional = true } -[target.'cfg(target_os = "windows")'.dependencies] -wild = "2.2.1" -winapi-util = { workspace = true, optional = true } +[target.'cfg(windows)'.dependencies] +wild = { workspace = true } windows-sys = { workspace = true, optional = true, default-features = false, features = [ "Wdk_System_SystemInformation", + "Win32_Security", "Win32_Storage_FileSystem", "Win32_Foundation", + "Win32_Globalization", + "Win32_NetworkManagement_WNet", + "Win32_System_Console", + "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Ioctl", + "Win32_System_JobObjects", "Win32_System_RemoteDesktop", "Win32_System_SystemInformation", + "Win32_System_Threading", "Win32_System_WindowsProgramming", ] } +[target.'cfg(not(target_env = "msvc"))'.dependencies] +openssl = { workspace = true, features = ["vendored"], optional = true } +# vendored openssl with MSVC needs complex setup... +[target.'cfg(target_env = "msvc")'.dependencies] +openssl = { workspace = true, features = ["aws-lc"], optional = true } + [target.'cfg(target_os = "openbsd")'.dependencies] utmp-classic = { workspace = true, optional = true } @@ -139,13 +150,14 @@ default = ["signals"] # * non-default features backup-control = [] colors = [] -checksum = ["quoting-style", "sum", "base64-simd"] +diagnostics = ["dep:ariadne"] +checksum = ["base64-simd", "quoting-style", "rustix/fs", "sum"] encoding = ["data-encoding", "data-encoding-macro", "z85", "base64-simd"] -entries = ["libc"] +entries = ["libc", "rustix/fs", "rustix/process"] extendedbigdecimal = ["bigdecimal", "num-traits"] fast-inc = [] -fs = ["dunce", "libc", "winapi-util", "windows-sys"] -fsext = ["libc", "windows-sys", "bstr"] +fs = ["dunce", "libc", "rustix/fs", "rustix/std", "windows-sys"] +fsext = ["libc", "windows-sys", "bstr", "wide"] fsxattr = ["xattr", "itertools", "libc"] hardware = [] lines = [] @@ -161,7 +173,7 @@ format = [ ] i18n-all = ["i18n-charmap", "i18n-collator", "i18n-decimal", "i18n-datetime"] i18n-charmap = ["i18n-common"] -i18n-common = ["icu_locale"] +i18n-common = ["icu_locale", "windows-sys"] i18n-collator = ["i18n-common", "icu_collator"] i18n-decimal = ["i18n-common", "icu_decimal", "icu_provider"] i18n-datetime = [ @@ -171,24 +183,24 @@ i18n-datetime = [ "jiff-icu", "jiff", ] -mode = ["libc"] -perms = ["entries", "libc", "walkdir"] -buf-copy = [] +mode = ["libc", "rustix/process"] +perms = ["entries", "libc", "safe-traversal", "walkdir"] +buf-copy = ["pipes"] parser-num = ["extendedbigdecimal", "num-traits"] parser-size = ["parser-num", "procfs"] parser-glob = ["glob"] parser = ["parser-num", "parser-size", "parser-glob"] -pipes = ["fs"] -process = ["libc"] +pipes = ["fs", "rustix/pipe"] +process = ["libc", "nix/user", "rustix/process", "windows-sys"] proc-info = ["tty", "walkdir"] quoting-style = ["i18n-common"] ranges = [] ringbuffer = [] -safe-copy = [] -safe-traversal = ["libc"] +safe-copy = ["rustix/fs"] +safe-traversal = ["libc", "nix/fs", "nix/dir", "nix/user", "rustix/fs"] selinux = ["dep:selinux"] smack = ["xattr"] -signals = [] +signals = ["nix/fs", "nix/signal", "nix/poll"] sum = [ "digest", "hex", @@ -215,5 +227,8 @@ version-cmp = [] wide = [] tty = [] time = ["jiff"] -uptime = ["jiff", "libc", "windows-sys", "utmpx", "utmp-classic"] -benchmark = ["divan", "itertools", "tempfile"] +uptime = ["jiff", "libc", "rustix/time", "utmp-classic", "utmpx", "windows-sys"] +benchmark = ["itertools", "tempfile"] + +[lints] +workspace = true diff --git a/src/uucore/build.rs b/src/uucore/build.rs index 284e796de6b..a9a7d09e6b6 100644 --- a/src/uucore/build.rs +++ b/src/uucore/build.rs @@ -22,6 +22,7 @@ pub fn main() -> Result<(), Box> { writeln!(embedded_file)?; // Generate optimized lookup function instead of HashMap + writeln!(embedded_file, "#[expect(clippy::match_same_arms)]")?; // needed for dir and vdir aliases for ls writeln!( embedded_file, "pub fn get_embedded_locale(key: &str) -> Option<&'static str> {{" @@ -160,9 +161,30 @@ fn embed_single_utility_locale( project_root.join(format!("src/uucore/locales/{locale}.ftl")) })?; + embed_error_locales(embedded_file, project_root, locales_to_embed)?; + Ok(()) } +/// Embed the strings only an error path asks for. +/// +/// They are embedded like any other component but parsed only when something +/// actually looks one up, so the common bundle every utility builds at startup +/// stays small. +/// +/// # Errors +/// +/// Returns an error if writing to the `embedded_file` fails. +fn embed_error_locales( + embedded_file: &mut File, + project_root: &Path, + locales_to_embed: &(String, Option), +) -> Result<(), Box> { + embed_component_locales(embedded_file, locales_to_embed, "uucore-errors", |locale| { + project_root.join(format!("src/uucore/locales/errors/{locale}.ftl")) + }) +} + /// Embed locale files for all utilities (multicall binary). /// /// # Errors @@ -210,6 +232,8 @@ fn embed_all_utility_locales( project_root.join(format!("src/uucore/locales/{locale}.ftl")) })?; + embed_error_locales(embedded_file, project_root, locales_to_embed)?; + embedded_file.flush()?; Ok(()) } @@ -236,11 +260,24 @@ fn embed_static_utility_locales( return Ok(()); // nothing to scan }; + // Which utilities we embed here depends on the crates unpacked next to us + // in the registry, i.e. on what the consumer depends on. Cargo only knows + // about the files we declare, so without watching the consumer's lock file + // a utility added after the first build would keep printing raw message + // ids until uucore is rebuilt by hand. + if let Some(lock_file) = consumer_lock_file(env::var_os("OUT_DIR").as_deref()) { + println!("cargo:rerun-if-changed={}", lock_file.display()); + } + // First, try to embed uucore locales - critical for common translations like "Usage:" embed_component_locales(embedded_file, locales_to_embed, "uucore", |locale| { Path::new(&manifest_dir).join(format!("locales/{locale}.ftl")) })?; + embed_component_locales(embedded_file, locales_to_embed, "uucore-errors", |locale| { + Path::new(&manifest_dir).join(format!("locales/errors/{locale}.ftl")) + })?; + // Collect and sort for deterministic builds let mut entries: Vec<_> = std::fs::read_dir(registry_dir)? .filter_map(Result::ok) @@ -249,25 +286,33 @@ fn embed_static_utility_locales( for entry in entries { let file_name = entry.file_name(); - if let Some(dir_name) = file_name.to_str() { + if let Some(dir_name) = file_name.to_str() && // Match uu_- - #[expect(clippy::collapsible_if)] - if let Some((util_part, _)) = dir_name.split_once('-') { - if let Some(util_name) = util_part.strip_prefix("uu_") { - embed_component_locales( - embedded_file, - locales_to_embed, - util_name, - |locale| entry.path().join(format!("locales/{locale}.ftl")), - )?; - } - } + let Some((util_part, _)) = dir_name.split_once('-') && + let Some(util_name) = util_part.strip_prefix("uu_") + { + embed_component_locales(embedded_file, locales_to_embed, util_name, |locale| { + entry.path().join(format!("locales/{locale}.ftl")) + })?; } } Ok(()) } +/// Find the `Cargo.lock` of the project we are being built for, by walking up +/// from `OUT_DIR` (`//build/uucore-/out`) until a lock +/// file shows up next to a target directory. +/// +/// Returns `None` when there is none to watch, e.g. for a build with a target +/// directory outside of the project. +fn consumer_lock_file(out_dir: Option<&std::ffi::OsStr>) -> Option { + Path::new(out_dir?) + .ancestors() + .map(|dir| dir.join("Cargo.lock")) + .find(|lock| lock.is_file()) +} + /// Determines which locales to embed into the binary. /// /// To support localized messages in installed binaries (e.g., via `cargo install`), @@ -327,11 +372,12 @@ fn embed_locale_file( embedded_file, " // Locale for {component} ({locale})" )?; - // Determine if we need a hash. If content contains ", we need r#""# - let delimiter = if content.contains('"') { "#" } else { "" }; + // Never a raw string: `r#"..."#` ends at `"#`, so content holding that + // pair closes the literal and the rest is compiled as Rust. `{:?}` + // escapes every quote and has no terminator the content can reach. writeln!( embedded_file, - " \"{locale_key}\" => Some(r{delimiter}\"{content}\"{delimiter})," + " {locale_key:?} => Some({content:?})," )?; // Tell Cargo to rerun if this file changes @@ -369,17 +415,16 @@ where for entry in std::fs::read_dir(locale_dir)? { let entry = entry?; let path = entry.path(); - #[expect(clippy::collapsible_if)] - if path.extension().is_some_and(|e| e == "ftl") { - if let Some(locale) = path.file_stem().and_then(|s| s.to_str()) { - embed_locale_file( - embedded_file, - &path, - &format!("{component_name}/{locale}.ftl"), - locale, - component_name, - )?; - } + if path.extension().is_some_and(|e| e == "ftl") + && let Some(locale) = path.file_stem().and_then(|s| s.to_str()) + { + embed_locale_file( + embedded_file, + &path, + &format!("{component_name}/{locale}.ftl"), + locale, + component_name, + )?; } } } @@ -415,6 +460,21 @@ where mod tests { use super::*; + #[test] + fn consumer_lock_file_found_above_target_dir() { + let tmp = std::env::temp_dir().join(format!("uucore-build-{}", std::process::id())); + let out_dir = tmp.join("target/debug/build/uucore-1234/out"); + std::fs::create_dir_all(&out_dir).unwrap(); + assert_eq!(consumer_lock_file(Some(out_dir.as_os_str())), None); + + let lock = tmp.join("Cargo.lock"); + std::fs::write(&lock, "").unwrap(); + assert_eq!(consumer_lock_file(Some(out_dir.as_os_str())), Some(lock)); + + assert_eq!(consumer_lock_file(None), None); + std::fs::remove_dir_all(&tmp).unwrap(); + } + #[test] fn get_locales_to_embed_no_lang() { let (en_locale, system_locale) = get_locales_to_embed(None); diff --git a/src/uucore/locales/en-US.ftl b/src/uucore/locales/en-US.ftl index f408b4ee69e..d4aa0eb952d 100644 --- a/src/uucore/locales/en-US.ftl +++ b/src/uucore/locales/en-US.ftl @@ -7,6 +7,7 @@ common-tip = tip common-usage = Usage common-help = help common-version = version +common-read-error = read error common-write-error = write error # Common clap error messages @@ -29,8 +30,11 @@ help-flag-version = Print version information error-io = I/O error error-permission-denied = Permission denied error-file-not-found = No such file or directory +error-no-such-process = No such process error-invalid-argument = Invalid argument -error-is-a-directory = { $file }: Is a directory +error-is-a-directory-text = Is a directory +error-is-a-directory = { $file }: { error-is-a-directory-text } +error-too-many-symlink-levels = Too many levels of symbolic links # Common actions action-copying = copying @@ -67,7 +71,6 @@ safe-traversal-directory = # checksum-related messages checksum-no-properly-formatted = { $checksum_file }: no properly formatted checksum lines found checksum-no-file-verified = { $checksum_file }: no file was verified -checksum-error-failed-to-read-input = failed to read input checksum-bad-format = { $count -> [1] { $count } line is improperly formatted *[other] { $count } lines are improperly formatted @@ -80,8 +83,20 @@ checksum-failed-open-file = { $count -> [1] { $count } listed file could not be read *[other] { $count } listed files could not be read } -checksum-error-algo-bad-format = { $file }: { $line }: improperly formatted { $algo } checksum line # uudoc tldr examples messages uudoc-tldr-attribution = The examples are provided by the [tldr-pages project](https://tldr.sh) under the [CC BY 4.0 License](https://github.com/tldr-pages/tldr/blob/main/LICENSE.md). uudoc-tldr-disclaimer = Please note that, as uutils is a work in progress, some examples might fail. + +# Symbolic mode parsing messages +mode-error-unexpected-end = unexpected end of mode +mode-error-invalid-operator = invalid operator (expected +, -, or =, but found { $operator }) + +# Diagnostic labels: what the caret points at in a mode +mode-diag-label-missing-operator = this clause says who, but not what to change +mode-diag-label-invalid-number = not an octal mode +mode-diag-help-syntax = a mode is either octal, as in 644, or clauses such as u+rwx,go-w + +# Shared recursive chown/chgrp/chmod diagnostics (uucore::perms) +perms-cannot-access = cannot access { $file }: { $error } +perms-cannot-access-replaced = cannot access { $file }: replaced while it was being processed diff --git a/src/uucore/locales/errors/en-US.ftl b/src/uucore/locales/errors/en-US.ftl new file mode 100644 index 00000000000..5720f768ecd --- /dev/null +++ b/src/uucore/locales/errors/en-US.ftl @@ -0,0 +1,58 @@ +# Strings only an error path ever asks for: the messages of the shared +# parsers and the labels a caret carries. They live apart from the common +# uucore strings because every utility parses those at startup, and almost +# no run ever needs one of these. + +checksum-error-algo-bad-format = { $file }: { $line }: improperly formatted { $algo } checksum line +# Format string parsing messages (printf, seq, env, ...) +format-error-invalid-spec = %{ $spec }: invalid conversion specification +format-error-too-many-specs = format '{ $format }' has too many % directives +format-error-no-spec = format '{ $format }' has no % directive +format-error-ends-with-percent = format { $format } ends in % +format-error-invalid-precision = invalid precision: '{ $precision }' +format-error-wrong-spec-type = wrong % directive type was given +format-error-write = write error: { $error } +format-error-no-more-arguments = no more arguments +format-error-invalid-argument = invalid argument +format-error-missing-hex = missing hexadecimal number in escape +format-error-invalid-universal-character = invalid universal character name \{ $escape }{ $digits } + +# The word ariadne heads the advice line of a caret report with +diagnostics-help-label = Help + +# Diagnostic label shared by the utilities whose arguments are an expression +diagnostics-label-expression-complete = the expression was already complete here + +# Checksum errors (cksum, md5sum, sha*sum, b2sum) +checksum-error-raw-multiple-files = the --raw option is not supported with multiple files +checksum-error-check-only-flag = the --{ $flag } option is meaningful only when verifying checksums +checksum-error-length-required = --length required for { $algorithm } +checksum-error-invalid-length = invalid length: { $length } +checksum-error-length-too-big-for-blake = maximum digest length for { $algorithm } is 512 bits +checksum-error-length-not-multiple-of-8 = length is not a multiple of 8 +checksum-error-invalid-length-for-sha = digest length for { $algorithm } must be 224, 256, 384, or 512 +checksum-error-length-required-for-sha = --algorithm={ $algorithm } requires specifying --length 224, 256, 384, or 512 +checksum-error-length-only-for-blake2b-sha2-sha3 = --length is only supported with --algorithm blake2b, sha2, or sha3 +checksum-error-binary-text-conflict = the --binary and --text options are meaningless when verifying checksums +checksum-error-text-without-untagged = --text mode is only supported with --untagged +checksum-error-tag-check = the --tag option is meaningless when verifying checksums +checksum-error-text-after-tag = --tag does not support --text mode +checksum-error-algorithm-not-supported-with-check = --check is not supported with --algorithm={"{"}bsd,sysv,crc,crc32b{"}"} +checksum-error-combine-multiple-algorithms = You cannot combine multiple hash algorithms! +checksum-error-need-algorithm-to-hash = Needs an algorithm to hash with. + Use --help for more information. +checksum-error-unknown-algorithm = unknown algorithm: { $algorithm }: clap should have prevented this case + +# Diagnostic labels: what the caret points at in a SIZE +size-diag-label-invalid-suffix = not a known unit +size-diag-label-too-big = this number is too large to use +size-diag-help-syntax = a size is a number and an optional unit: K, M, G and so on for 1024, KB, MB, GB for 1000 + +# Diagnostic labels: what the caret points at in a list of ranges. What a zero +# bound got wrong depends on what the range counts, so each utility says that +# in its own words. +range-diag-label-too-large = this number is too large +range-diag-label-inverted = this range ends before it starts + +# Invalid QUOTING_STYLE env var +invalid-quoting-style-env-var = ignoring invalid value of environment variable QUOTING_STYLE: { $invalid } diff --git a/src/uucore/locales/errors/fr-FR.ftl b/src/uucore/locales/errors/fr-FR.ftl new file mode 100644 index 00000000000..28642d75ea7 --- /dev/null +++ b/src/uucore/locales/errors/fr-FR.ftl @@ -0,0 +1,58 @@ +# Chaînes que seul un chemin d'erreur demande : les messages des analyseurs +# partagés et les étiquettes portées par un caret. Elles sont séparées des +# chaînes uucore communes, que chaque utilitaire analyse au démarrage alors +# que presque aucune exécution n'en a besoin. + +checksum-error-algo-bad-format = { $file }: { $line }: ligne invalide pour { $algo } +# Messages d'analyse des chaînes de format (printf, seq, env, ...) +format-error-invalid-spec = %{ $spec } : spécification de conversion invalide +format-error-too-many-specs = le format '{ $format }' a trop de directives % +format-error-no-spec = le format '{ $format }' n'a pas de directive % +format-error-ends-with-percent = le format { $format } se termine par % +format-error-invalid-precision = précision invalide : '{ $precision }' +format-error-wrong-spec-type = type de directive % incorrect +format-error-write = erreur d'écriture : { $error } +format-error-no-more-arguments = plus d'arguments +format-error-invalid-argument = argument invalide +format-error-missing-hex = nombre hexadécimal manquant dans l'échappement +format-error-invalid-universal-character = nom de caractère universel invalide \{ $escape }{ $digits } + +# Le mot en tête de la ligne de conseil d'un rapport avec caret +diagnostics-help-label = Aide{" "} + +# Étiquette de diagnostic partagée par les utilitaires dont les arguments forment une expression +diagnostics-label-expression-complete = l'expression était déjà complète ici + +# Erreurs de somme de contrôle (cksum, md5sum, sha*sum, b2sum) +checksum-error-raw-multiple-files = l'option --raw n'est pas prise en charge avec plusieurs fichiers +checksum-error-check-only-flag = l'option --{ $flag } n'a de sens que lors de la vérification de sommes de contrôle +checksum-error-length-required = --length est requis pour { $algorithm } +checksum-error-invalid-length = longueur invalide : { $length } +checksum-error-length-too-big-for-blake = la longueur maximale d'empreinte pour { $algorithm } est de 512 bits +checksum-error-length-not-multiple-of-8 = la longueur n'est pas un multiple de 8 +checksum-error-invalid-length-for-sha = la longueur d'empreinte pour { $algorithm } doit être 224, 256, 384 ou 512 +checksum-error-length-required-for-sha = --algorithm={ $algorithm } nécessite de préciser --length 224, 256, 384 ou 512 +checksum-error-length-only-for-blake2b-sha2-sha3 = --length n'est pris en charge qu'avec --algorithm blake2b, sha2 ou sha3 +checksum-error-binary-text-conflict = les options --binary et --text n'ont pas de sens lors de la vérification de sommes de contrôle +checksum-error-text-without-untagged = le mode --text n'est pris en charge qu'avec --untagged +checksum-error-tag-check = l'option --tag n'a pas de sens lors de la vérification de sommes de contrôle +checksum-error-text-after-tag = --tag ne prend pas en charge le mode --text +checksum-error-algorithm-not-supported-with-check = --check n'est pas pris en charge avec --algorithm={"{"}bsd,sysv,crc,crc32b{"}"} +checksum-error-combine-multiple-algorithms = Vous ne pouvez pas combiner plusieurs algorithmes de hachage ! +checksum-error-need-algorithm-to-hash = Un algorithme de hachage est nécessaire. + Utilisez --help pour plus d'informations. +checksum-error-unknown-algorithm = algorithme inconnu : { $algorithm } : clap aurait dû empêcher ce cas + +# Étiquettes de diagnostic : ce que le caret désigne dans une TAILLE +size-diag-label-invalid-suffix = unité inconnue +size-diag-label-too-big = ce nombre est trop grand pour être utilisé +size-diag-help-syntax = une taille est un nombre suivi d'une unité facultative : K, M, G et ainsi de suite pour 1024, KB, MB, GB pour 1000 + +# Étiquettes de diagnostic : ce que le caret désigne dans une liste +# d'intervalles. Ce qu'une borne nulle a de faux dépend de ce que l'intervalle +# compte, donc chaque utilitaire le dit avec ses propres mots. +range-diag-label-too-large = ce nombre est trop grand +range-diag-label-inverted = cet intervalle se termine avant de commencer + +# variable d'environnement QUOTING_STYLE invalide +invalid-quoting-style-env-var = valeur invalide de la variable d'environnement QUOTING_STYLE ignorée : { $invalid } diff --git a/src/uucore/locales/fr-FR.ftl b/src/uucore/locales/fr-FR.ftl index 507a536cc6e..2b4ea26658c 100644 --- a/src/uucore/locales/fr-FR.ftl +++ b/src/uucore/locales/fr-FR.ftl @@ -8,7 +8,7 @@ common-usage = Utilisation common-help = aide common-version = version common-write-error = erreur d'écriture - +common-read-error = erreur de lecture # Messages d'erreur clap communs clap-error-unexpected-argument = { $error_word } : argument inattendu '{ $arg }' trouvé clap-error-unexpected-argument-simple = argument inattendu @@ -29,8 +29,11 @@ help-flag-version = Afficher les informations de version error-io = Erreur E/S error-permission-denied = Permission refusée error-file-not-found = Aucun fichier ou répertoire de ce type +error-no-such-process = Aucun processus de ce type error-invalid-argument = Argument invalide -error-is-a-directory = { $file }: Est un répertoire +error-is-a-directory-text = Est un répertoire +error-is-a-directory = { $file }: { error-is-a-directory-text } +error-too-many-symlink-levels = Trop de niveaux de liens symboliques # Actions communes action-copying = copie @@ -61,7 +64,6 @@ safe-traversal-directory = # Messages relatifs au module checksum checksum-no-properly-formatted = { $checksum_file }: aucune ligne correctement formattée n'a été trouvée checksum-no-file-verified = { $checksum_file }: aucun fichier n'a été vérifié -checksum-error-failed-to-read-input = échec de la lecture de l'entrée checksum-bad-format = { $count -> [1] { $count } ligne invalide *[other] { $count } lignes invalides @@ -74,8 +76,16 @@ checksum-failed-open-file = { $count -> [1] { $count } fichier passé n'a pas pu être lu *[other] { $count } fichiers passés n'ont pas pu être lu } -checksum-error-algo-bad-format = { $file }: { $line }: ligne invalide pour { $algo } # Messages uudoc pour les exemples tldr uudoc-tldr-attribution = Les exemples sont fournis par le [projet tldr-pages](https://tldr.sh) sous la [licence CC BY 4.0](https://github.com/tldr-pages/tldr/blob/main/LICENSE.md). uudoc-tldr-disclaimer = Veuillez noter que, uutils étant en cours de développement, certains exemples peuvent échouer. + +# Messages d'analyse des modes symboliques +mode-error-unexpected-end = fin de mode inattendue +mode-error-invalid-operator = opérateur invalide (+, - ou = attendu, mais { $operator } trouvé) + +# Étiquettes de diagnostic : ce que le caret désigne dans un mode +mode-diag-label-missing-operator = cette clause indique qui, mais pas quoi changer +mode-diag-label-invalid-number = n'est pas un mode octal +mode-diag-help-syntax = un mode est soit octal, comme 644, soit des clauses comme u+rwx,go-w diff --git a/src/uucore/src/lib/features.rs b/src/uucore/src/lib/features.rs index e0e99aee6b0..83dff9de82d 100644 --- a/src/uucore/src/lib/features.rs +++ b/src/uucore/src/lib/features.rs @@ -2,20 +2,32 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// features ~ feature-gated modules (core/bundler file) -// + // spell-checker:ignore (features) extendedbigdecimal logind +// features ~ feature-gated modules (core/bundler file) + #[cfg(feature = "backup-control")] pub mod backup_control; #[cfg(feature = "benchmark")] pub mod benchmark; #[cfg(feature = "buf-copy")] pub mod buf_copy; +pub mod char_width; #[cfg(feature = "checksum")] pub mod checksum; #[cfg(feature = "colors")] pub mod colors; +#[cfg(feature = "diagnostics")] +pub mod diagnostics; +// Without the feature, a no-op stand-in keeps the API — and its callers — +// compiling; they all fall back to their plain one-line messages. +#[cfg(not(feature = "diagnostics"))] +#[path = "features/diagnostics_stub.rs"] +pub mod diagnostics; +// The part of the diagnostics that is not a no-op without the feature: both +// `diagnostics` above re-export it rather than each carrying a copy. +mod diagnostics_boundary; #[cfg(feature = "encoding")] pub mod encoding; #[cfg(feature = "extendedbigdecimal")] @@ -24,7 +36,7 @@ pub mod extendedbigdecimal; pub mod fast_inc; #[cfg(feature = "format")] pub mod format; -#[cfg(feature = "fs")] +#[cfg(all(feature = "fs", any(unix, windows, target_os = "wasi")))] pub mod fs; #[cfg(feature = "fsext")] pub mod fsext; @@ -66,41 +78,67 @@ pub mod mode; pub mod entries; #[cfg(all(unix, feature = "perms"))] pub mod perms; -#[cfg(all( - any(target_os = "linux", target_os = "android"), - any(feature = "pipes", feature = "buf-copy") -))] +#[cfg(all(feature = "pipes", any(target_os = "linux", target_os = "android")))] pub mod pipes; #[cfg(all(target_os = "linux", feature = "proc-info"))] pub mod proc_info; -#[cfg(all(unix, feature = "process"))] +#[cfg(all(any(unix, windows), feature = "process"))] pub mod process; #[cfg(all(unix, feature = "safe-copy"))] pub mod safe_copy; -#[cfg(all(unix, not(target_os = "redox")))] +#[cfg(all(feature = "safe-traversal", unix, not(target_os = "redox")))] pub mod safe_traversal; #[cfg(all(target_os = "linux", feature = "tty"))] pub mod tty; -#[cfg(all(unix, feature = "fsxattr"))] +#[cfg(all( + feature = "fsxattr", + any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "netbsd" + ) +))] pub mod fsxattr; #[cfg(feature = "hardware")] pub mod hardware; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] pub mod selinux; -#[cfg(all(unix, not(target_os = "fuchsia"), feature = "signals"))] +#[cfg(all( + feature = "signals", + any( + windows, + target_vendor = "apple", + target_os = "aix", + target_os = "android", + target_os = "cygwin", + target_os = "freebsd", + target_os = "hurd", + target_os = "illumos", + target_os = "linux", + target_os = "netbsd", + target_os = "openbsd", + target_os = "redox", + target_os = "solaris" + ) +))] pub mod signals; #[cfg(all(feature = "smack", target_os = "linux"))] pub mod smack; #[cfg(feature = "feat_systemd_logind")] pub mod systemd_logind; #[cfg(all( - unix, - not(target_os = "android"), - not(target_os = "fuchsia"), - not(target_os = "openbsd"), - not(target_os = "redox"), - feature = "utmpx" + feature = "utmpx", + any( + target_vendor = "apple", + target_os = "cygwin", + target_os = "freebsd", + target_os = "linux", + target_os = "netbsd" + ) ))] pub mod utmpx; // ** windows-only diff --git a/src/uucore/src/lib/features/backup_control.rs b/src/uucore/src/lib/features/backup_control.rs index 34f73521061..a47ff84f374 100644 --- a/src/uucore/src/lib/features/backup_control.rs +++ b/src/uucore/src/lib/features/backup_control.rs @@ -2,6 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + +// spell-checker:ignore backupopt + //! Implement GNU-style backup functionality. //! //! This module implements the backup functionality as described in the [GNU @@ -60,7 +63,7 @@ //! "command", "--backup=t", "--suffix=bak~" //! ]); //! -//! let backup_mode = match backup_control::determine_backup_mode(&matches) { +//! let backup_mode = match backup_control::determine_backup_mode(std::env::var("VERSION_CONTROL").ok(), &matches) { //! Err(e) => { //! show!(e); //! return; @@ -79,11 +82,10 @@ //! } //! ``` -// spell-checker:ignore backupopt - use crate::{ display::Quotable, error::{UError, UResult}, + fs::FileInformation, }; use clap::ArgMatches; use std::{ @@ -245,7 +247,7 @@ pub mod arguments { /// /// 1. From the '-S' or '--suffix' CLI argument, if present /// 2. From the "SIMPLE_BACKUP_SUFFIX" environment variable, if present -/// 3. By using the default '~' if none of the others apply, or if they contained slashes +/// 3. By using the default '~' if none of the others apply, or if they are empty or contain slashes /// /// This function directly takes [`ArgMatches`] as argument and looks for /// the '-S' and '--suffix' arguments itself. @@ -256,7 +258,7 @@ pub fn determine_backup_suffix(matches: &ArgMatches) -> String { } else { env::var("SIMPLE_BACKUP_SUFFIX").unwrap_or_else(|_| DEFAULT_BACKUP_SUFFIX.to_owned()) }; - if suffix.contains('/') { + if suffix.is_empty() || suffix.contains('/') { DEFAULT_BACKUP_SUFFIX.to_owned() } else { suffix @@ -309,7 +311,7 @@ pub fn determine_backup_suffix(matches: &ArgMatches) -> String { /// "command", "-b", "--backup=t" /// ]); /// -/// let backup_mode = backup_control::determine_backup_mode(&matches).unwrap(); +/// let backup_mode = backup_control::determine_backup_mode(std::env::var("VERSION_CONTROL").ok(), &matches).unwrap(); /// assert_eq!(backup_mode, BackupMode::Numbered) /// } /// ``` @@ -332,7 +334,7 @@ pub fn determine_backup_suffix(matches: &ArgMatches) -> String { /// "command", "-b", "--backup=n" /// ]); /// -/// let backup_mode = backup_control::determine_backup_mode(&matches); +/// let backup_mode = backup_control::determine_backup_mode(std::env::var("VERSION_CONTROL").ok(), &matches); /// /// assert!(backup_mode.is_err()); /// let err = backup_mode.unwrap_err(); @@ -341,7 +343,7 @@ pub fn determine_backup_suffix(matches: &ArgMatches) -> String { /// show!(err); /// } /// ``` -pub fn determine_backup_mode(matches: &ArgMatches) -> UResult { +pub fn determine_backup_mode(env_ctl: Option, matches: &ArgMatches) -> UResult { if matches.contains_id(arguments::OPT_BACKUP) { // Use method to determine the type of backups to make. When this option // is used but method is not specified, then the value of the @@ -350,7 +352,7 @@ pub fn determine_backup_mode(matches: &ArgMatches) -> UResult { if let Some(method) = matches.get_one::(arguments::OPT_BACKUP) { // Second argument is for the error string that is returned. match_method(method, "backup type") - } else if let Ok(method) = env::var("VERSION_CONTROL") { + } else if let Some(method) = env_ctl { // Second argument is for the error string that is returned. match_method(&method, "$VERSION_CONTROL") } else { @@ -361,7 +363,7 @@ pub fn determine_backup_mode(matches: &ArgMatches) -> UResult { // the short form of this option, -b does not accept any argument. // if VERSION_CONTROL is not set then using -b is equivalent to // using --backup=existing. - if let Ok(method) = env::var("VERSION_CONTROL") { + if let Some(method) = env_ctl { match_method(&method, "$VERSION_CONTROL") } else { Ok(BackupMode::Existing) @@ -369,7 +371,7 @@ pub fn determine_backup_mode(matches: &ArgMatches) -> UResult { } else if matches.contains_id(arguments::OPT_SUFFIX) { // Suffix option is enough to determine mode even if --backup is not set. // If VERSION_CONTROL is not set, the default backup type is 'existing'. - if let Ok(method) = env::var("VERSION_CONTROL") { + if let Some(method) = env_ctl { match_method(&method, "$VERSION_CONTROL") } else { Ok(BackupMode::Existing) @@ -462,31 +464,89 @@ fn existing_backup_path>(path: &Path, suffix: S) -> PathBuf { simple_backup_path(path, suffix.as_ref()) } -/// Returns true if the source file is likely to be the simple backup file for the target file. +/// Returns true if backing up `target` would destroy `source`. +/// +/// Backing up renames `target` onto its backup name. When `source` *is* that +/// backup, the rename clobbers it and the operation has nothing left to read, +/// leaving both files empty. GNU refuses instead (`source_is_dst_backup` in +/// `copy.c`), and `cp`, `mv` and `install` all need the same answer. +/// +/// The mode is part of the question, not the caller's business: numbered +/// backups never reuse an existing name, and [`BackupMode::None`] performs no +/// rename at all, so neither can destroy anything. Both return false here so no +/// caller has to remember the rule - getting that gate wrong per-utility is +/// exactly how `mv` came to miss `--backup=existing`. /// /// # Arguments /// /// * `source` - A Path reference that holds the source (backup) file path. /// * `target` - A Path reference that holds the target file path. /// * `suffix` - Str that holds the backup suffix. +/// * `mode` - The backup mode in effect. +/// * `dereference` - Whether the source is resolved through symlinks, matching +/// how the calling utility opens it (`false` for `mv`, `true` for `cp`/`install`). /// /// # Examples /// /// ``` +/// use std::fs; /// use std::path::Path; -/// use uucore::backup_control::source_is_target_backup; -/// let source = Path::new("data.txt~"); -/// let target = Path::new("data.txt"); -/// let suffix = String::from("~"); -/// -/// assert_eq!(source_is_target_backup(&source, &target, &suffix), true); +/// use uucore::backup_control::{BackupMode, backup_would_destroy_source}; +/// +/// let dir = tempfile::tempdir().unwrap(); +/// let target = dir.path().join("data.txt"); +/// let source = dir.path().join("data.txt~"); +/// fs::write(&target, "").unwrap(); +/// fs::write(&source, "").unwrap(); +/// +/// // `./data.txt~` and `data.txt~` name the same file, so both are caught. +/// assert!(backup_would_destroy_source( +/// &source, &target, "~", BackupMode::Simple, false +/// )); +/// +/// // A numbered backup picks a fresh name, so the source is safe. +/// assert!(!backup_would_destroy_source( +/// &source, &target, "~", BackupMode::Numbered, false +/// )); /// ``` /// -pub fn source_is_target_backup(source: &Path, target: &Path, suffix: &str) -> bool { - let source_filename = source.as_os_str(); +pub fn backup_would_destroy_source( + source: &Path, + target: &Path, + suffix: &str, + mode: BackupMode, + dereference: bool, +) -> bool { + if matches!(mode, BackupMode::None | BackupMode::Numbered) { + return false; + } + // GNU gates on the file names first: only a source whose final component is + // the target's final component plus the suffix can be clobbered by the + // backup rename. This keeps unrelated files that merely happen to share an + // inode (a hard link, say) from being refused. + let (Some(source_base), Some(target_base)) = (source.file_name(), target.file_name()) else { + return false; + }; + let mut expected_base = target_base.to_owned(); + expected_base.push(suffix); + if source_base != expected_base { + return false; + } + + // Then compare the files themselves rather than how they were spelled, so + // `a~`, `./a~` and an absolute path are all recognized. If the backup does + // not exist yet there is nothing to destroy. let mut target_backup_filename = target.as_os_str().to_owned(); target_backup_filename.push(suffix); - source_filename == target_backup_filename + let target_backup = PathBuf::from(target_backup_filename); + + match ( + FileInformation::from_path(source, dereference), + FileInformation::from_path(&target_backup, true), + ) { + (Ok(source_info), Ok(backup_info)) => source_info == backup_info, + _ => false, + } } // @@ -495,20 +555,7 @@ pub fn source_is_target_backup(source: &Path, target: &Path, suffix: &str) -> bo #[cfg(test)] mod tests { use super::*; - // Required to instantiate mutex in shared context use clap::Command; - use std::sync::Mutex; - - // The mutex is required here as by default all tests are run as separate - // threads under the same parent process. As environment variables are - // specific to processes (and thus shared among threads), data races *will* - // occur if no precautions are taken. Thus we have all tests that rely on - // environment variables lock this empty mutex to ensure they don't access - // it concurrently. - static TEST_MUTEX: Mutex<()> = Mutex::new(()); - - // Environment variable for "VERSION_CONTROL" - static ENV_VERSION_CONTROL: &str = "VERSION_CONTROL"; fn make_app() -> Command { Command::new("command") @@ -520,55 +567,40 @@ mod tests { // Defaults to --backup=existing #[test] fn test_backup_mode_short_only() { - let _dummy = TEST_MUTEX.lock().unwrap(); let matches = make_app().get_matches_from(vec!["command", "-b"]); - - let result = determine_backup_mode(&matches).unwrap(); - + let result = determine_backup_mode(None, &matches).unwrap(); assert_eq!(result, BackupMode::Existing); } // --backup takes precedence over -b #[test] fn test_backup_mode_long_preferred_over_short() { - let _dummy = TEST_MUTEX.lock().unwrap(); let matches = make_app().get_matches_from(vec!["command", "-b", "--backup=none"]); - - let result = determine_backup_mode(&matches).unwrap(); - + let result = determine_backup_mode(None, &matches).unwrap(); assert_eq!(result, BackupMode::None); } // --backup can be passed without an argument #[test] fn test_backup_mode_long_without_args_no_env() { - let _dummy = TEST_MUTEX.lock().unwrap(); let matches = make_app().get_matches_from(vec!["command", "--backup"]); - - let result = determine_backup_mode(&matches).unwrap(); - + let result = determine_backup_mode(None, &matches).unwrap(); assert_eq!(result, BackupMode::Existing); } // --backup can be passed with an argument only #[test] fn test_backup_mode_long_with_args() { - let _dummy = TEST_MUTEX.lock().unwrap(); let matches = make_app().get_matches_from(vec!["command", "--backup=simple"]); - - let result = determine_backup_mode(&matches).unwrap(); - + let result = determine_backup_mode(None, &matches).unwrap(); assert_eq!(result, BackupMode::Simple); } // --backup errors on invalid argument #[test] fn test_backup_mode_long_with_args_invalid() { - let _dummy = TEST_MUTEX.lock().unwrap(); let matches = make_app().get_matches_from(vec!["command", "--backup=foobar"]); - - let result = determine_backup_mode(&matches); - + let result = determine_backup_mode(None, &matches); assert!(result.is_err()); let text = format!("{}", result.unwrap_err()); assert!(text.contains("invalid argument 'foobar' for 'backup type'")); @@ -577,11 +609,8 @@ mod tests { // --backup errors on ambiguous argument #[test] fn test_backup_mode_long_with_args_ambiguous() { - let _dummy = TEST_MUTEX.lock().unwrap(); let matches = make_app().get_matches_from(vec!["command", "--backup=n"]); - - let result = determine_backup_mode(&matches); - + let result = determine_backup_mode(None, &matches); assert!(result.is_err()); let text = format!("{}", result.unwrap_err()); assert!(text.contains("ambiguous argument 'n' for 'backup type'")); @@ -590,122 +619,82 @@ mod tests { // --backup accepts shortened arguments (si for simple) #[test] fn test_backup_mode_long_with_arg_shortened() { - let _dummy = TEST_MUTEX.lock().unwrap(); let matches = make_app().get_matches_from(vec!["command", "--backup=si"]); - - let result = determine_backup_mode(&matches).unwrap(); - + let result = determine_backup_mode(None, &matches).unwrap(); assert_eq!(result, BackupMode::Simple); } // -b doesn't ignores the "VERSION_CONTROL" environment variable #[test] fn test_backup_mode_short_does_not_ignore_env() { - let _dummy = TEST_MUTEX.lock().unwrap(); - unsafe { env::set_var(ENV_VERSION_CONTROL, "numbered") }; let matches = make_app().get_matches_from(vec!["command", "-b"]); - - let result = determine_backup_mode(&matches).unwrap(); - + let result = determine_backup_mode(Some("numbered".into()), &matches).unwrap(); assert_eq!(result, BackupMode::Numbered); - unsafe { env::remove_var(ENV_VERSION_CONTROL) }; } // --backup can be passed without an argument, but reads env var if existent #[test] fn test_backup_mode_long_without_args_with_env() { - let _dummy = TEST_MUTEX.lock().unwrap(); - unsafe { env::set_var(ENV_VERSION_CONTROL, "none") }; let matches = make_app().get_matches_from(vec!["command", "--backup"]); - - let result = determine_backup_mode(&matches).unwrap(); - + let result = determine_backup_mode(Some("none".into()), &matches).unwrap(); assert_eq!(result, BackupMode::None); - unsafe { env::remove_var(ENV_VERSION_CONTROL) }; } // --backup errors on invalid VERSION_CONTROL env var #[test] fn test_backup_mode_long_with_env_var_invalid() { - let _dummy = TEST_MUTEX.lock().unwrap(); - unsafe { env::set_var(ENV_VERSION_CONTROL, "foobar") }; let matches = make_app().get_matches_from(vec!["command", "--backup"]); - - let result = determine_backup_mode(&matches); - + let result = determine_backup_mode(Some("foobar".into()), &matches); assert!(result.is_err()); let text = format!("{}", result.unwrap_err()); assert!(text.contains("invalid argument 'foobar' for '$VERSION_CONTROL'")); - unsafe { env::remove_var(ENV_VERSION_CONTROL) }; } // --backup errors on ambiguous VERSION_CONTROL env var #[test] fn test_backup_mode_long_with_env_var_ambiguous() { - let _dummy = TEST_MUTEX.lock().unwrap(); - unsafe { env::set_var(ENV_VERSION_CONTROL, "n") }; let matches = make_app().get_matches_from(vec!["command", "--backup"]); - - let result = determine_backup_mode(&matches); - + let result = determine_backup_mode(Some("n".into()), &matches); assert!(result.is_err()); let text = format!("{}", result.unwrap_err()); assert!(text.contains("ambiguous argument 'n' for '$VERSION_CONTROL'")); - unsafe { env::remove_var(ENV_VERSION_CONTROL) }; } // --backup accepts shortened env vars (si for simple) #[test] fn test_backup_mode_long_with_env_var_shortened() { - let _dummy = TEST_MUTEX.lock().unwrap(); - unsafe { env::set_var(ENV_VERSION_CONTROL, "si") }; let matches = make_app().get_matches_from(vec!["command", "--backup"]); - - let result = determine_backup_mode(&matches).unwrap(); - + let result = determine_backup_mode(Some("si".into()), &matches).unwrap(); assert_eq!(result, BackupMode::Simple); - unsafe { env::remove_var(ENV_VERSION_CONTROL) }; } // Using --suffix without --backup defaults to --backup=existing #[test] fn test_backup_mode_suffix_without_backup_option() { - let _dummy = TEST_MUTEX.lock().unwrap(); let matches = make_app().get_matches_from(vec!["command", "--suffix", ".bak"]); - - let result = determine_backup_mode(&matches).unwrap(); - + let result = determine_backup_mode(None, &matches).unwrap(); assert_eq!(result, BackupMode::Existing); } // Using --suffix without --backup uses env var if existing #[test] fn test_backup_mode_suffix_without_backup_option_with_env_var() { - let _dummy = TEST_MUTEX.lock().unwrap(); - unsafe { env::set_var(ENV_VERSION_CONTROL, "numbered") }; let matches = make_app().get_matches_from(vec!["command", "--suffix", ".bak"]); - - let result = determine_backup_mode(&matches).unwrap(); - + let result = determine_backup_mode(Some("numbered".into()), &matches).unwrap(); assert_eq!(result, BackupMode::Numbered); - unsafe { env::remove_var(ENV_VERSION_CONTROL) }; } #[test] fn test_suffix_takes_hyphen_value() { - let _dummy = TEST_MUTEX.lock().unwrap(); let matches = make_app().get_matches_from(vec!["command", "-b", "--suffix", "-v"]); - let result = determine_backup_suffix(&matches); assert_eq!(result, "-v"); } #[test] fn test_suffix_rejects_path_traversal() { - let _dummy = TEST_MUTEX.lock().unwrap(); let matches = make_app().get_matches_from(vec!["command", "-b", "--suffix", "_/../../dest"]); - let result = determine_backup_suffix(&matches); assert_eq!(result, DEFAULT_BACKUP_SUFFIX); } @@ -745,29 +734,135 @@ mod tests { } #[test] - fn test_source_is_target_backup() { - let source = Path::new("data.txt.bak"); - let target = Path::new("data.txt"); - let suffix = String::from(".bak"); - - assert!(source_is_target_backup(source, target, &suffix)); + fn test_backup_would_destroy_source() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("data.txt"); + let source = dir.path().join("data.txt.bak"); + fs::write(&target, "").unwrap(); + fs::write(&source, "").unwrap(); + + assert!(backup_would_destroy_source( + &source, + &target, + ".bak", + BackupMode::Simple, + false + )); } #[test] fn test_source_is_not_target_backup() { - let source = Path::new("data.txt"); - let target = Path::new("backup.txt"); - let suffix = String::from(".bak"); + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("backup.txt"); + let source = dir.path().join("data.txt"); + fs::write(&target, "").unwrap(); + fs::write(&source, "").unwrap(); - assert!(!source_is_target_backup(source, target, &suffix)); + assert!(!backup_would_destroy_source( + &source, + &target, + ".bak", + BackupMode::Simple, + false + )); + } + + #[test] + fn test_backup_would_destroy_source_with_tilde_suffix() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("example"); + let source = dir.path().join("example~"); + fs::write(&target, "").unwrap(); + fs::write(&source, "").unwrap(); + + assert!(backup_would_destroy_source( + &source, + &target, + "~", + BackupMode::Simple, + false + )); + } + + /// The guard must see through how the operands were spelled. + #[test] + fn test_backup_would_destroy_source_ignores_spelling() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("a"), "").unwrap(); + fs::write(dir.path().join("a~"), "").unwrap(); + + for target in ["a", "./a"] { + let source = dir.path().join("a~"); + let target = dir.path().join(target); + assert!( + backup_would_destroy_source(&source, &target, "~", BackupMode::Simple, false), + "guard failed open for target spelled {}", + target.display() + ); + } } + /// A backup that does not exist yet cannot destroy anything. + #[test] + fn test_backup_would_destroy_source_absent_backup() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("a"); + let source = dir.path().join("a~"); + fs::write(&target, "").unwrap(); + + assert!(!backup_would_destroy_source( + &source, + &target, + "~", + BackupMode::Simple, + false + )); + } + + /// Sharing the backup's inode under a different name is safe: the rename + /// only drops one link, so the data survives. + #[cfg(unix)] #[test] - fn test_source_is_target_backup_with_tilde_suffix() { - let source = Path::new("example~"); - let target = Path::new("example"); - let suffix = String::from("~"); + fn test_backup_would_destroy_source_mode_gate() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("data.txt"); + let source = dir.path().join("data.txt~"); + fs::write(&target, "").unwrap(); + fs::write(&source, "").unwrap(); + + // Modes that rename onto an existing name would destroy the source. + for mode in [BackupMode::Simple, BackupMode::Existing] { + assert!( + backup_would_destroy_source(&source, &target, "~", mode, false), + "{mode:?} should be guarded" + ); + } - assert!(source_is_target_backup(source, target, &suffix)); + // Numbered picks a fresh name; None renames nothing at all. + for mode in [BackupMode::Numbered, BackupMode::None] { + assert!( + !backup_would_destroy_source(&source, &target, "~", mode, false), + "{mode:?} cannot destroy the source" + ); + } + } + + #[test] + fn test_backup_would_destroy_source_hard_link_under_other_name() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("a"); + let backup = dir.path().join("a~"); + let source = dir.path().join("b"); + fs::write(&target, "").unwrap(); + fs::write(&backup, "").unwrap(); + fs::hard_link(&backup, &source).unwrap(); + + assert!(!backup_would_destroy_source( + &source, + &target, + "~", + BackupMode::Simple, + false + )); } } diff --git a/src/uucore/src/lib/features/benchmark.rs b/src/uucore/src/lib/features/benchmark.rs index e2dfac1e72a..c14760e4f64 100644 --- a/src/uucore/src/lib/features/benchmark.rs +++ b/src/uucore/src/lib/features/benchmark.rs @@ -40,6 +40,14 @@ where util_func(os_args.into_iter()) } +/// Prepare benchmark arguments for a utility function +pub fn get_bench_args(args: &[&dyn AsRef]) -> Vec { + // Prepend a dummy program name as argv[0] since clap expects it + std::iter::once("benchmark".into()) + .chain(args.iter().map(Into::into)) + .collect_vec() +} + /// Helper function to set up a temporary test file and leak the temporary directory /// so it persists for the duration of the benchmark pub fn setup_test_file(data: &[u8]) -> PathBuf { diff --git a/src/uucore/src/lib/features/char_width.rs b/src/uucore/src/lib/features/char_width.rs new file mode 100644 index 00000000000..5e4c813d66f --- /dev/null +++ b/src/uucore/src/lib/features/char_width.rs @@ -0,0 +1,78 @@ +// 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 nbytes + +//! Display width and byte length of UTF-8 characters within a byte buffer. + +use std::str::from_utf8; +use unicode_width::UnicodeWidthChar; + +/// Decode the (possibly multibyte) UTF-8 character beginning at `buf[pos]`, +/// returning the character, its display width, and its byte length. +/// +/// The sequence length is taken from the leading byte's bit pattern rather than +/// its value, so 3- and 4-byte characters are measured correctly. A truncated +/// or otherwise invalid sequence resolves to `(None, 1, 1)`, which lets callers +/// resync one byte at a time. +/// +/// # Panics +/// +/// Panics if `pos` is out of bounds for `buf`. +#[must_use] +pub fn char_info_at(buf: &[u8], pos: usize) -> (Option, usize, usize) { + let nbytes = match buf[pos] { + 0xC0..=0xDF => 2, + 0xE0..=0xEF => 3, + 0xF0..=0xF7 => 4, + _ => 1, + }; + buf.get(pos..pos + nbytes) + .and_then(|s| from_utf8(s).ok()) + .and_then(|s| s.chars().next()) + .map_or((None, 1, 1), |c| { + (Some(c), UnicodeWidthChar::width(c).unwrap_or(0), nbytes) + }) +} + +/// Return the display width and byte length of the character at `buf[pos]`. +/// +/// A truncated or otherwise invalid sequence resolves to a single byte of width +/// one. See [`char_info_at`] for the decoding details. +/// +/// # Panics +/// +/// Panics if `pos` is out of bounds for `buf`. +#[must_use] +pub fn char_width_at(buf: &[u8], pos: usize) -> (usize, usize) { + let (_, width, nbytes) = char_info_at(buf, pos); + (width, nbytes) +} + +#[cfg(test)] +mod tests { + use super::{char_info_at, char_width_at}; + + #[test] + fn width_and_length() { + assert_eq!(char_width_at(b"a", 0), (1, 1)); // ASCII + assert_eq!(char_width_at("é".as_bytes(), 0), (1, 2)); // U+00E9, 2-byte width 1 + assert_eq!(char_width_at("\u{0301}".as_bytes(), 0), (0, 2)); // combining mark, width 0 + assert_eq!(char_width_at("\u{3000}".as_bytes(), 0), (2, 3)); // 3-byte width 2 + assert_eq!(char_width_at("\u{1F600}".as_bytes(), 0), (2, 4)); // 4-byte width 2 + assert_eq!(char_width_at(&[0xFF], 0), (1, 1)); // invalid leading byte resyncs + assert_eq!(char_width_at(&[0xE3], 0), (1, 1)); // truncated sequence resyncs + } + + #[test] + fn info_decodes_char() { + assert_eq!( + char_info_at("\u{3000}".as_bytes(), 0), + (Some('\u{3000}'), 2, 3) + ); + assert_eq!(char_info_at(b"a", 0), (Some('a'), 1, 1)); + assert_eq!(char_info_at(&[0xFF], 0), (None, 1, 1)); // invalid resyncs + } +} diff --git a/src/uucore/src/lib/features/checksum/compute.rs b/src/uucore/src/lib/features/checksum/compute.rs index b64c8f388bc..2e32645fb3c 100644 --- a/src/uucore/src/lib/features/checksum/compute.rs +++ b/src/uucore/src/lib/features/checksum/compute.rs @@ -15,6 +15,7 @@ use crate::checksum::{ }; use crate::error::{FromIo, UResult, USimpleError}; use crate::line_ending::LineEnding; +use crate::quoting_style::locale_aware_shell_escape; use crate::sum::DigestOutput; use crate::{show, translate}; @@ -256,7 +257,10 @@ where if filepath.is_dir() { show!(USimpleError::new( 1, - translate!("error-is-a-directory", "file" => filepath.display()) + translate!( + "error-is-a-directory", + "file" => locale_aware_shell_escape(filepath) + ) )); continue; } @@ -271,7 +275,7 @@ where file } Err(err) => { - show!(err.map_err_context(|| filepath.to_string_lossy().into())); + show!(err.map_err_context(|| locale_aware_shell_escape(filepath))); continue; } }; @@ -283,8 +287,13 @@ where // Always compute the "binary" version of the digest, i.e. on Windows, // never handle CRLFs specifically. - let (digest_output, sz) = digest_reader(&mut digest, &mut file, ReadingMode::Binary) - .map_err_context(|| translate!("checksum-error-failed-to-read-input"))?; + let (digest_output, sz) = match digest_reader(&mut digest, &mut file, ReadingMode::Binary) { + Ok(result) => result, + Err(err) => { + show!(err.map_err_context(|| locale_aware_shell_escape(filename))); + continue; + } + }; // Encodes the sum if df is Base64, leaves as-is otherwise. let encode_sum = |sum: DigestOutput, df: DigestFormat| { diff --git a/src/uucore/src/lib/features/checksum/mod.rs b/src/uucore/src/lib/features/checksum/mod.rs index 25f93bb1865..7e5d3b667a9 100644 --- a/src/uucore/src/lib/features/checksum/mod.rs +++ b/src/uucore/src/lib/features/checksum/mod.rs @@ -10,14 +10,13 @@ use std::io::{self, Read}; use std::num::IntErrorKind; use os_display::Quotable; -use thiserror::Error; use crate::error::{UError, UResult, strip_errno}; use crate::sum::{ Blake2b, Blake3, Bsd, CRC32B, Crc, Digest, DigestOutput, DigestWriter, Md5, Sha1, Sha3_224, Sha3_256, Sha3_384, Sha3_512, Sha224, Sha256, Sha384, Sha512, Shake128, Shake256, Sm3, SysV, }; -use crate::{show_error, translate}; +use crate::{show_error, translate, translate_text}; pub mod compute; pub mod validate; @@ -260,12 +259,6 @@ pub struct HashLength { } impl HashLength { - #[must_use] - #[inline] - pub(crate) fn from_bytes(n: usize) -> Self { - Self { bit_len: n * 8 } - } - #[must_use] #[inline] pub fn from_bits(n: usize) -> Self { @@ -419,17 +412,14 @@ impl SizedAlgoKind { pub fn bitlen(&self) -> usize { match self { - Self::Sysv => 512, + Self::Sysv | Self::Sm3 => 512, Self::Bsd => 1024, Self::Crc => 256, Self::Crc32b => 32, Self::Md5 => 128, - Self::Sm3 => 512, Self::Sha1 => 160, - Self::Sha2(len) => len.as_usize(), - Self::Sha3(len) => len.as_usize(), - Self::Blake2b(len) => len.as_bits(), - Self::Blake3(len) => len.as_bits(), + Self::Sha2(len) | Self::Sha3(len) => len.as_usize(), + Self::Blake2b(len) | Self::Blake3(len) => len.as_bits(), Self::Shake128(len) => len.map_or(Shake128::DEFAULT_BIT_SIZE, HashLength::as_bits), Self::Shake256(len) => len.map_or(Shake256::DEFAULT_BIT_SIZE, HashLength::as_bits), } @@ -440,45 +430,45 @@ impl SizedAlgoKind { } } -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum ChecksumError { - #[error("the --raw option is not supported with multiple files")] + #[error("{}", translate!("checksum-error-raw-multiple-files"))] RawMultipleFiles, - #[error("the --{0} option is meaningful only when verifying checksums")] + #[error("{}", translate_text!("checksum-error-check-only-flag", "flag" => _0))] CheckOnlyFlag(String), // --length sanitization errors - #[error("--length required for {}", .0.quote())] + #[error("{}", translate_text!("checksum-error-length-required", "algorithm" => _0.quote()))] LengthRequired(String), - #[error("invalid length: {}", .0.quote())] + #[error("{}", translate_text!("checksum-error-invalid-length", "length" => _0.quote()))] InvalidLength(String), - #[error("maximum digest length for {} is 512 bits", .0.quote())] + #[error("{}", translate_text!("checksum-error-length-too-big-for-blake", "algorithm" => _0.quote()))] LengthTooBigForBlake(String), - #[error("length is not a multiple of 8")] + #[error("{}", translate!("checksum-error-length-not-multiple-of-8"))] LengthNotMultipleOf8, - #[error("digest length for {} must be 224, 256, 384, or 512", .0.quote())] + #[error("{}", translate_text!("checksum-error-invalid-length-for-sha", "algorithm" => _0.quote()))] InvalidLengthForSha(String), - #[error("--algorithm={0} requires specifying --length 224, 256, 384, or 512")] + #[error("{}", translate_text!("checksum-error-length-required-for-sha", "algorithm" => _0))] LengthRequiredForSha(String), - #[error("--length is only supported with --algorithm blake2b, sha2, or sha3")] + #[error("{}", translate!("checksum-error-length-only-for-blake2b-sha2-sha3"))] LengthOnlyForBlake2bSha2Sha3, - #[error("the --binary and --text options are meaningless when verifying checksums")] + #[error("{}", translate!("checksum-error-binary-text-conflict"))] BinaryTextConflict, - #[error("--text mode is only supported with --untagged")] + #[error("{}", translate!("checksum-error-text-without-untagged"))] TextWithoutUntagged, - #[error("the --tag option is meaningless when verifying checksums")] + #[error("{}", translate!("checksum-error-tag-check"))] TagCheck, - #[error("--tag does not support --text mode")] + #[error("{}", translate!("checksum-error-text-after-tag"))] TextAfterTag, - #[error("--check is not supported with --algorithm={{bsd,sysv,crc,crc32b}}")] + #[error("{}", translate!("checksum-error-algorithm-not-supported-with-check"))] AlgorithmNotSupportedWithCheck, - #[error("You cannot combine multiple hash algorithms!")] + #[error("{}", translate!("checksum-error-combine-multiple-algorithms"))] CombineMultipleAlgorithms, - #[error("Needs an algorithm to hash with.\nUse --help for more information.")] + #[error("{}", translate!("checksum-error-need-algorithm-to-hash"))] NeedAlgorithmToHash, - #[error("unknown algorithm: {0}: clap should have prevented this case")] + #[error("{}", translate_text!("checksum-error-unknown-algorithm", "algorithm" => _0))] UnknownAlgorithm(String), #[error("{}: {}", translate!("common-write-error"), strip_errno(.0))] Write(io::Error), @@ -660,6 +650,12 @@ pub fn unescape_filename(filename: &[u8]) -> (Vec, &'static str) { pub fn escape_filename(filename: &OsStr) -> (String, &'static str) { let original = filename.to_string_lossy(); + + // On Windows, backslashes are path separators. Normalize them to forward + // slashes so that the checksum output is portable across platforms. + #[cfg(windows)] + let original = original.replace('\\', "/"); + let escaped = original .replace('\\', "\\\\") .replace('\n', "\\n") @@ -705,7 +701,13 @@ mod tests { assert_eq!(prefix, "\\"); let (escaped, prefix) = escape_filename(OsStr::new("test\\file.txt")); + #[cfg(windows)] + assert_eq!(escaped, "test/file.txt"); + #[cfg(windows)] + assert_eq!(prefix, ""); + #[cfg(not(windows))] assert_eq!(escaped, "test\\\\file.txt"); + #[cfg(not(windows))] assert_eq!(prefix, "\\"); } diff --git a/src/uucore/src/lib/features/checksum/validate.rs b/src/uucore/src/lib/features/checksum/validate.rs index d8938886bd4..fe5aed39249 100644 --- a/src/uucore/src/lib/features/checksum/validate.rs +++ b/src/uucore/src/lib/features/checksum/validate.rs @@ -18,8 +18,8 @@ use crate::checksum::{ AlgoKind, BlakeLength, ChecksumError, HashLength, ReadingMode, ShaLength, SizedAlgoKind, digest_reader, parse_blake_length, unescape_filename, }; -use crate::error::{FromIo, UError, UIoError, UResult, USimpleError}; -use crate::quoting_style::{QuotingStyle, locale_aware_escape_name}; +use crate::error::{FromIo, UError, UResult, USimpleError, strip_errno}; +use crate::quoting_style::locale_aware_shell_escape; use crate::sum::{self, Blake2b, Blake3, DigestOutput}; use crate::{ os_str_as_bytes, os_str_from_bytes, read_os_string_lines, show, show_warning_caps, translate, @@ -221,8 +221,7 @@ impl FileChecksumResult { fn can_display(self, verbose: ChecksumVerbose) -> bool { match self { Self::Ok => verbose.over_quiet(), - Self::Failed => verbose.over_status(), - Self::CantOpen => true, + Self::Failed | Self::CantOpen => verbose.over_status(), } } } @@ -246,9 +245,8 @@ fn write_file_report( verbose: ChecksumVerbose, ) -> io::Result<()> { if result.can_display(verbose) { - let filename = locale_aware_escape_name(filename, QuotingStyle::SHELL_ESCAPE); - // Here, .to_string_lossy() is lossless thanks to the escaping. - writeln!(w, "{}: {result}", filename.to_string_lossy())?; + let filename = locale_aware_shell_escape(filename); + writeln!(w, "{filename}: {result}")?; } Ok(()) } @@ -281,12 +279,13 @@ impl LineFormat { // find the next parenthesis using byte search (not next whitespace) because openssl's // tagged format does not put a space before (filename) - let par_idx = rest.iter().position(|&b| b == b'(')?; - // If the parenthesis is the first character (minus whitespace, which has already been stripped out), then, - // it's not a validly formatted line. - if par_idx == 0 { - return None; - } + let par_idx = rest + .iter() + .position(|&b| b == b'(') + // If the parenthesis is the first character (minus whitespace, which has already been stripped out), then, + // it's not a validly formatted line. + .filter(|b| *b != 0)?; + let sub_case = if rest[par_idx - 1] == b' ' { SubCase::Posix } else { @@ -318,9 +317,23 @@ impl LineFormat { let algo_utf8 = unsafe { String::from_utf8_unchecked(algo.to_vec()) }; // stripping '(' not ' (' since we matched on ( not whitespace because of openssl. let after_paren = rest.get(par_idx + 1..)?; - let (filename, checksum) = match sub_case { - SubCase::Posix => ByteSliceExt::rsplit_once(after_paren, b") = ")?, - SubCase::OpenSSL => ByteSliceExt::rsplit_once(after_paren, b")= ")?, + let (filename, checksum) = if algo_substring.iter().all(u8::is_ascii_hexdigit) { + // This may be the digest of an untagged line with a single space, + // like " (a)= b": only take the exact separators, so + // that such a line still reaches the untagged parser. + match sub_case { + SubCase::Posix => ByteSliceExt::rsplit_once(after_paren, b") = ")?, + SubCase::OpenSSL => ByteSliceExt::rsplit_once(after_paren, b")= ")?, + } + } else { + // The file name ends at the last ')'. Like GNU, accept any blanks, + // or none, on either side of the '=' that follows it. + let (filename, after_name) = ByteSliceExt::rsplit_once(after_paren, b")")?; + let checksum = after_name + .trim_blanks_start() + .strip_prefix(b"=")? + .trim_blanks_start(); + (filename, checksum) }; let checksum_utf8 = Self::validate_checksum_format(checksum)?; @@ -438,6 +451,9 @@ impl LineFormat { trait ByteSliceExt { /// Look for a pattern from right to left, return surrounding parts if found. fn rsplit_once(&self, pattern: &[u8]) -> Option<(&Self, &Self)>; + + /// Remove the leading spaces and tabs. + fn trim_blanks_start(&self) -> &Self; } impl ByteSliceExt for [u8] { @@ -451,6 +467,14 @@ impl ByteSliceExt for [u8] { &self[self.len() - pos..], )) } + + fn trim_blanks_start(&self) -> &Self { + let blanks = self + .iter() + .take_while(|&&b| b == b' ' || b == b'\t') + .count(); + &self[blanks..] + } } /// Hold the data extracted from a checksum line. @@ -553,12 +577,7 @@ fn get_file_to_check( ); }; let print_error = |err: io::Error| { - show!(err.map_err_context(|| { - locale_aware_escape_name(filename, QuotingStyle::SHELL_ESCAPE) - // This is non destructive thanks to the escaping - .to_string_lossy() - .to_string() - })); + show!(err.map_err_context(|| { locale_aware_shell_escape(filename) })); }; match File::open(filename) { Ok(f) => { @@ -592,24 +611,24 @@ fn get_file_to_check( /// Returns a reader to the list of checksums fn get_input_file(filename: &OsStr) -> UResult> { - match File::open(filename) { - Ok(f) => { - if f.metadata()?.is_dir() { - Err(io::Error::other( - translate!("error-is-a-directory", "file" => filename.maybe_quote()), - ) - .into()) - } else { - Ok(Box::new(f)) - } - } - Err(_) => Err(io::Error::other(format!( + let file = File::open(filename).map_err(|e| match e.kind() { + #[cfg(any(target_os = "wasi", windows))] + io::ErrorKind::NotFound => io::Error::other(format!( "{}: {}", filename.maybe_quote(), translate!("error-file-not-found") - )) - .into()), + )), + _ => io::Error::other(format!("{}: {}", filename.maybe_quote(), strip_errno(&e))), + })?; + // some platforms shows different read error + #[cfg(any(target_os = "wasi", windows))] + if file.metadata().is_ok_and(|m| m.is_dir()) { + return Err(io::Error::other( + translate!("error-is-a-directory", "file" => filename.maybe_quote()), + ) + .into()); } + Ok(Box::new(file)) } /// Gets the algorithm name and length from the `LineInfo` if the algo-based format is matched. @@ -620,10 +639,8 @@ fn identify_algo_name_and_length( ) -> Result<(AlgoKind, Option), LineCheckError> { use AlgoKind as ak; let algo_from_line = line_info.algo_name.clone().unwrap_or_default(); - let Ok(line_algo) = AlgoKind::from_cksum(algo_from_line.to_lowercase()) else { - // Unknown algorithm - return Err(LineCheckError::ImproperlyFormatted); - }; + let line_algo = AlgoKind::from_cksum(algo_from_line.to_lowercase()) + .map_err(|_| LineCheckError::ImproperlyFormatted)?; *last_algo = Some(algo_from_line); // check if we are called with XXXsum (example: md5sum) but we detected a @@ -642,10 +659,9 @@ fn identify_algo_name_and_length( let hash_len = if let Some(bitlen) = line_info.algo_bit_len { match line_algo { algo @ (ak::Blake2b | ak::Blake3) => { - match parse_blake_length(algo, BlakeLength::Int(bitlen)) { - Ok(len) => Some(len), - Err(_) => return Err(LineCheckError::ImproperlyFormatted), - } + let len = parse_blake_length(algo, BlakeLength::Int(bitlen)) + .map_err(|_| LineCheckError::ImproperlyFormatted)?; + Some(len) } ak::Sha2 | ak::Sha3 if [224, 256, 384, 512].contains(&bitlen) => { Some(HashLength::from_bits(bitlen)) @@ -687,7 +703,7 @@ fn compute_and_check_digest_from_file( let real_filename_to_check = os_str_from_bytes(&filename_to_check_unescaped)?; // Open the input file - let file_to_check = get_file_to_check(&real_filename_to_check, opts)?; + let file_to_check = get_file_to_check(real_filename_to_check, opts)?; let mut file_reader = BufReader::new(file_to_check); // Read the file and calculate the checksum @@ -700,15 +716,13 @@ fn compute_and_check_digest_from_file( match digest_reader(&mut digest, &mut file_reader, ReadingMode::Binary) { 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() - })); + show!( + err.map_err_context(|| { locale_aware_shell_escape(real_filename_to_check) }) + ); let _ = write_file_report( io::stdout(), - &real_filename_to_check, + real_filename_to_check, FileChecksumResult::CantOpen, opts.verbose, ); @@ -724,7 +738,7 @@ fn compute_and_check_digest_from_file( }; let _ = write_file_report( io::stdout(), - &real_filename_to_check, + real_filename_to_check, FileChecksumResult::from_bool(checksum_correct), opts.verbose, ); @@ -793,7 +807,12 @@ fn process_non_algo_based_line( // bits except when dealing with blake2b, sha2 and sha3, where we will // detect the length. let algo_len = match cli_algo_kind { - ak::Blake2b | ak::Blake3 => Some(HashLength::from_bytes(expected_checksum.len())), + // An over-length digest makes this a malformed line for GNU, not a + // fatal error. + algo @ (ak::Blake2b | ak::Blake3) => Some( + parse_blake_length(algo, BlakeLength::Int(expected_checksum.len() * 8)) + .map_err(|_| LineCheckError::ImproperlyFormatted)?, + ), ak::Sha2 | ak::Sha3 => { // multiplication by 8 to get the number of bits Some( @@ -881,16 +900,13 @@ fn process_checksum_file( // will use the same parser. let mut cached_line_format = None; // last_algo caches the algorithm used in the last line to print a warning - // message for the current line if improperly formatted. + // message for the current line if improperly formatted. Defaults to CRC. // Behavior tested in gnu_cksum_c::test_warn - let mut last_algo = None; + let mut last_algo = Some(AlgoKind::Crc.to_uppercase().to_string()); for (i, line_res) in read_os_string_lines(reader).enumerate() { - let line = line_res.map_err(|e| { - USimpleError::new( - UIoError::from(e).code(), - format!("{}: read error", filename_input.maybe_quote()), - ) + let line = line_res.map_err(|_| { + USimpleError::new(1, format!("{}: read error", filename_input.maybe_quote())) })?; let line_result = process_checksum_line( @@ -951,9 +967,7 @@ fn process_checksum_file( // not a single line correctly formatted found // return an error if res.total_properly_formatted() == 0 { - if opts.verbose.over_status() { - log_no_properly_formatted(filename_display()); - } + log_no_properly_formatted(filename_display()); return Err(FileCheckError::Failed); } @@ -1028,29 +1042,38 @@ mod tests { fn test_algo_based_parser() { #[allow(clippy::type_complexity)] let test_cases: &[(&[u8], Option<(&[u8], Option<&[u8]>, &[u8], &[u8])>)] = &[ + // spell-checker:disable (b"SHA256 (example.txt) = d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2", Some((b"SHA256", None, b"example.txt", b"d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"))), - // cspell:disable (b"BLAKE2b-512 (file) = abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef", Some((b"BLAKE2b", Some(b"512"), b"file", b"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef"))), (b" MD5 (test) = 9e107d9d372bb6826bd81d3542a419d6", Some((b"MD5", None, b"test", b"9e107d9d372bb6826bd81d3542a419d6"))), (b"SHA-1 (anotherfile) = a9993e364706816aba3e25717850c26c9cd0d89d", Some((b"SHA", Some(b"1"), b"anotherfile", b"a9993e364706816aba3e25717850c26c9cd0d89d"))), (b" MD5 (anothertest) = fds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"anothertest", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), - (b" MD5(anothertest2) = fds65dsf46as5df4d6f54asds5d7f7g9", None), + (b" MD5(anothertest2) = fds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"anothertest2", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), (b" MD5(weirdfilename0)= stillfilename)= fds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"weirdfilename0)= stillfilename", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), (b" MD5(weirdfilename1)= )= fds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"weirdfilename1)= ", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), (b" MD5(weirdfilename2) = )= fds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"weirdfilename2) = ", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), (b" MD5 (weirdfilename3)= ) = fds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"weirdfilename3)= ", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), (b" MD5 (weirdfilename4) = ) = fds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"weirdfilename4) = ", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), - (b" MD5(weirdfilename5)= ) = fds65dsf46as5df4d6f54asds5d7f7g9", None), - (b" MD5(weirdfilename6) = ) = fds65dsf46as5df4d6f54asds5d7f7g9", None), - (b" MD5 (weirdfilename7)= )= fds65dsf46as5df4d6f54asds5d7f7g9", None), - (b" MD5 (weirdfilename8) = )= fds65dsf46as5df4d6f54asds5d7f7g9", None), + (b" MD5(weirdfilename5)= ) = fds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"weirdfilename5)= ", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), + (b" MD5(weirdfilename6) = ) = fds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"weirdfilename6) = ", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), + (b" MD5 (weirdfilename7)= )= fds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"weirdfilename7)= ", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), + (b" MD5 (weirdfilename8) = )= fds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"weirdfilename8) = ", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), + // any blanks, or none, around the '=' + (b"MD5 (tabs)\t=\tfds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"tabs", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), + (b"MD5 (none)=fds65dsf46as5df4d6f54asds5d7f7g9", Some((b"MD5", None, b"none", b"fds65dsf46as5df4d6f54asds5d7f7g9"))), + // but no other whitespace next to the '=', and at most one space + // before the '(' + (b"MD5 (f)\x0c= fds65dsf46as5df4d6f54asds5d7f7g9", None), + (b"MD5 (f) =\rfds65dsf46as5df4d6f54asds5d7f7g9", None), + (b"MD5 (f) = fds65dsf46as5df4d6f54asds5d7f7g9", None), + (b"MD5\t(f) = fds65dsf46as5df4d6f54asds5d7f7g9", None), // test for missing algorithm (b"(filename) = fds65dsf46as5df4d6f54asds5d7f7g9", None), (b"filename) = fds65dsf46as5df4d6f54asds5d7f7g9", None), (b"filename = fds65dsf46as5df4d6f54asds5d7f7g9", None), + // spell-checker:enable ]; - // cspell:enable for (input, expected) in test_cases { let line_info = LineFormat::parse_algo_based(input); match expected { @@ -1239,6 +1262,16 @@ mod tests { assert_eq!(line_info.format, LineFormat::AlgoBased); assert!(cached_line_format.is_none()); + // Test an upper-case hex digest followed by a single space: the rest of + // the line looks like the end of a tagged line, but is a file name + let line_single_space_hex = OsString::from("D41D8CD98F00B204E9800998ECF8427E (a)= b"); + let line_info = LineInfo::parse(&line_single_space_hex, &mut cached_line_format).unwrap(); + assert_eq!(line_info.format, LineFormat::SingleSpace); + assert_eq!(line_info.filename, b"(a)= b"); + assert_eq!(line_info.checksum, "D41D8CD98F00B204E9800998ECF8427E"); + + cached_line_format = None; + // Test trailing space after checksum line (should fail) let line_algo_based_leading_space = OsString::from("MD5 (example.txt) = d41d8cd98f00b204e9800998ecf8427e "); diff --git a/src/uucore/src/lib/features/colors.rs b/src/uucore/src/lib/features/colors.rs index 2792de7c5a0..e676c0efc29 100644 --- a/src/uucore/src/lib/features/colors.rs +++ b/src/uucore/src/lib/features/colors.rs @@ -2,7 +2,8 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// cSpell:disable + +// spell-checker:disable //! Provides color handling for `ls` and other utilities. diff --git a/src/uucore/src/lib/features/diagnostics.rs b/src/uucore/src/lib/features/diagnostics.rs new file mode 100644 index 00000000000..791854a00bf --- /dev/null +++ b/src/uucore/src/lib/features/diagnostics.rs @@ -0,0 +1,1151 @@ +// 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 étage replacen + +//! Render an error against the argument list it came from. +//! +//! Utilities whose arguments *are* the expression they evaluate — `test`, `expr` +//! — can only say so much in a single line of stderr. This module echoes the +//! arguments back as a source line and points a caret at the one that is at +//! fault. +//! +//! A utility is expected to map its own error type to an argument index, +//! optionally a label, and optionally a line of advice; everything user-facing +//! is passed in already localized, so this module holds no messages of its +//! own. A label should add something the message does not say — an +//! expectation, or a fix — never restate it: with no label the span is drawn +//! as a bare underline, and the message and advice carry the rest. +//! +//! Rendering only happens when stderr is a terminal, so anything reading our +//! output — a script, a pipe, a test suite — still sees the plain one-line +//! message it always did. `UUTILS_DIAG=always` or `never` overrides that +//! check. +//! +//! ```text +//! tr: range-endpoints of 'y-b' are in reverse collating sequence order +//! ╭─[ tr:1:7 ] +//! │ +//! 1 │ tr qw[y-b] x +//! │ ─┬─ +//! │ ╰─── did you mean 'b-y'? +//! │ +//! │ Help: a range goes from the lower character to the higher one, as in a-z +//! ───╯ +//! ``` + +use std::borrow::Cow; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::fmt::Write as _; +use std::io::IsTerminal; +use std::ops::Range; +use std::sync::OnceLock; + +use ariadne::{CharSet, Color, Config, IndexType, Label, Report, ReportKind, Source}; + +use crate::display::Quotable; +use crate::translate; + +/// The variable that overrides the terminal check. +const MODE_VAR: &str = "UUTILS_DIAG"; + +/// When to render an error against its argument list. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Mode { + /// Let stderr decide. + Auto, + Always, + Never, +} + +impl Mode { + /// What [`MODE_VAR`] asks for. + /// + /// Anything but `always` and `never` is [`Mode::Auto`] rather than an + /// error — this gets exported from shell profiles, and no spelling of it + /// should make a utility fail. + fn from_env(value: Option<&OsStr>) -> Self { + let Some(value) = value.and_then(OsStr::to_str) else { + return Self::Auto; + }; + if value.eq_ignore_ascii_case("always") { + Self::Always + } else if value.eq_ignore_ascii_case("never") { + Self::Never + } else { + Self::Auto + } + } +} + +/// Whether errors should be rendered against their argument list. +/// +/// Callers should check this before doing any work that only a diagnostic needs, +/// so that the default path costs nothing. +/// +/// # Returns +/// +/// What `UUTILS_DIAG` asks for, when it asks. Otherwise `true` when stderr is +/// a terminal — a person is watching, and gets the rich form — and `false` in +/// a script or a pipe, where whatever reads stderr gets the plain message it +/// can grep for. +pub fn enabled() -> bool { + // Read once: this runs before the argument capture of every caret. + static MODE: OnceLock = OnceLock::new(); + match MODE.get_or_init(|| Mode::from_env(env::var_os(MODE_VAR).as_deref())) { + Mode::Always => true, + Mode::Never => false, + Mode::Auto => std::io::stderr().is_terminal(), + } +} + +/// Keep the arguments a diagnostic would point at, as they were typed. +/// +/// Parsing moves, rewrites or consumes the argument list, so a utility that +/// may want a caret has to put a copy aside before that happens. This is the +/// one place that decides whether the copy is worth making. +/// +/// # Arguments +/// +/// * `args` - The arguments to keep, exactly as the caret should echo them. +/// +/// # Returns +/// +/// `None` when diagnostics are off, so that the copy is only paid for when +/// something is going to be rendered. +pub fn capture(args: &[OsString]) -> Option> { + enabled().then(|| args.to_vec()) +} + +/// Keep the arguments a diagnostic would point at, minus the program name. +/// +/// # Arguments +/// +/// * `args` - The whole argument list, `argv[0]` included. The program name is +/// dropped since it is never part of what a caret points at. +/// +/// # Returns +/// +/// As [`capture`], `None` when diagnostics are off. +pub fn operands(args: &[OsString]) -> Option> { + capture(args.get(1..).unwrap_or_default()) +} + +pub use crate::features::diagnostics_boundary::{ + OptionValue, ValueOptions, char_span, floor_boundary, list_items, +}; + +/// The error to raise for something a caret may have just explained. +/// +/// Draws the report when the arguments as typed were kept, and quiets `error` +/// when it did: the report has already said everything the one-line message +/// would, and the exit code is all that is left to carry. Every caret +/// diagnostic ends this way, so it is written once here. +/// +/// # Arguments +/// +/// * `diag_args` - The arguments as typed, program name included, or `None` +/// when they were not kept — as [`capture`] returns them. +/// * `error` - The error to raise when nothing was drawn. It is lent to `draw` +/// rather than moved into it, since it is usually the error the report is +/// about as well. +/// * `draw` - Draws the report against the arguments, and returns `false` when +/// it could not — because the error is not about any one of them, or because +/// none of them turned out to carry what the caret would point at. +pub fn error_after_report>>( + diag_args: Option<&[OsString]>, + error: E, + draw: impl FnOnce(&[OsString], &E) -> bool, +) -> Box { + let reported = diag_args.is_some_and(|args| draw(args, &error)); + crate::error::quiet_if_reported(reported, error) +} + +/// An argument list rendered as a single line, with the position of every +/// argument inside it. +pub struct Snapshot { + /// The arguments joined by spaces, quoted where needed. + text: String, + /// Byte range of each argument inside `text`. + spans: Vec>, + /// The arguments themselves, for [`Snapshot::index_of`]. + args: Vec, + /// Index of the first operand: 1 when argument 0 is the program name. + first_operand: usize, +} + +impl Snapshot { + /// Build a snapshot of an argument list. + /// + /// # Arguments + /// + /// * `args` - The arguments as passed to the utility, without the utility + /// name itself. + pub fn new>(args: &[S]) -> Self { + let mut snapshot = Self::with_capacity(args.len()); + for arg in args { + snapshot.push(arg.as_ref().to_os_string()); + } + snapshot + } + + /// Build a snapshot of an argument list whose first argument is the + /// program name. + /// + /// The program name is shown in the source line — the command reads as it + /// was typed — but it is never matched by any locator, so an operand that + /// happens to share its text cannot be mistaken for it. + /// + /// # Arguments + /// + /// * `args` - The whole argument list, `argv[0]` included. + pub fn with_program>(args: &[S]) -> Self { + let mut snapshot = Self::new(args); + snapshot.first_operand = args.len().min(1); + snapshot + } + + /// Build a snapshot of an argument list held as raw bytes. + /// + /// # Arguments + /// + /// * `args` - The arguments as raw bytes, as produced by + /// [`crate::os_string_to_vec`]. + pub fn from_bytes>(args: &[S]) -> Self { + let mut snapshot = Self::with_capacity(args.len()); + for arg in args { + snapshot.push(match crate::os_str_from_bytes(arg.as_ref()) { + Ok(arg) => arg.to_owned(), + // Only reachable on platforms where `OsStr` is not raw bytes; + // show the argument lossily rather than not at all. + Err(_) => String::from_utf8_lossy(arg.as_ref()).into_owned().into(), + }); + } + snapshot + } + + fn with_capacity(len: usize) -> Self { + Self { + text: String::new(), + spans: Vec::with_capacity(len), + args: Vec::with_capacity(len), + first_operand: 0, + } + } + + fn push(&mut self, arg: OsString) { + if !self.spans.is_empty() { + self.text.push(' '); + } + let start = self.text.len(); + // Offsets are taken from the rendered text rather than from the + // argument itself: quoting changes the length (a non-UTF-8 argument is + // shown as `$'fo\x80o'`) and the caret has to line up with what is + // actually printed. + match arg.to_str() { + // Operators such as `=` or `!=` are shell-special, but quoting them + // here would only obscure the expression. + Some(s) if !s.is_empty() && !s.chars().any(char::is_whitespace) => { + self.text.push_str(s); + } + _ => { + let _ = write!(self.text, "{}", arg.maybe_quote()); + } + } + self.spans.push(start..self.text.len()); + self.args.push(arg); + } + + /// Whether there is anything to point at. + pub fn is_empty(&self) -> bool { + self.spans.is_empty() + } + + /// Index of the first argument equal to `arg`. + /// + /// Useful for errors raised after parsing, which know the value at fault but + /// no longer know where it came from. + /// + /// # Arguments + /// + /// * `arg` - The argument to look for. + /// + /// # Returns + /// + /// The index of the first operand equal to `arg`, or `None` if there is + /// none. A repeated value resolves to its first occurrence, and the + /// program name is never matched. + pub fn index_of(&self, arg: &OsStr) -> Option { + self.args + .iter() + .enumerate() + .skip(self.first_operand) + .find(|(_, candidate)| *candidate == arg) + .map(|(index, _)| index) + } + + /// Index of the argument carrying `operand` as the value of an option. + /// + /// An option's value can be spelled many ways — `-k 2.3q`, `-k2.3q`, + /// `-rk2.3q`, `--key 2.3q`, `--key=2.3q` — and a text search cannot tell + /// the value apart from a file or another option that happens to end with + /// the same characters. This walks the arguments and matches only the + /// shapes an option value can actually take. + /// + /// # Arguments + /// + /// * `operand` - The value at fault, as the parser received it. + /// * `short` - The option's short name (`'k'` for `-k`), if it has one. + /// * `long` - The option's long name (`"key"` for `--key`), if it has one. + /// + /// # Returns + /// + /// The index of the first argument carrying `operand` — the argument + /// itself for a detached value, the combined argument for a glued one — + /// or `None` if no argument does. First match is the right one for + /// repeatable options, since parsing stops at the first failing value. + pub fn index_of_value( + &self, + operand: &str, + short: Option, + long: Option<&str>, + ) -> Option { + // A run of short options the wanted one ends, as in `-r…k`. + let ends_cluster = |arg: &str| { + let Some(short) = short else { return false }; + arg.strip_prefix('-').is_some_and(|cluster| { + cluster.ends_with(short) && cluster.chars().all(char::is_alphanumeric) + }) + }; + // `-k` or `--key`, or a cluster of short options ending in `-…k`. + let is_flag = |arg: &str| { + if let Some(rest) = arg.strip_prefix("--") { + return long == Some(rest); + } + ends_cluster(arg) + }; + // `--key=`. + let is_attached_long = |arg: &str| { + let Some(long) = long else { return false }; + arg.strip_prefix("--") + .and_then(|rest| rest.strip_prefix(long)) + .and_then(|rest| rest.strip_prefix('=')) + == Some(operand) + }; + // `-k`, or glued to a cluster as `-r…k`. An empty + // value cannot be glued: `-k` alone is the flag, not the flag carrying + // nothing, and taking it for the value would point the caret at the + // option instead of the empty argument that follows it. + let is_attached_short = |arg: &str| { + if operand.is_empty() { + return false; + } + let Some(prefix) = arg + .len() + .checked_sub(operand.len()) + .and_then(|end| arg.get(..end)) + else { + return false; + }; + arg.ends_with(operand) && ends_cluster(prefix) + }; + + let args = self.args.iter().map(|arg| arg.to_str()).enumerate(); + let mut previous: Option<&str> = None; + for (index, arg) in args.skip(self.first_operand) { + let Some(arg) = arg else { + previous = None; + continue; + }; + // Everything after a bare `--` is positional, never an option or + // its value. + if arg == "--" { + break; + } + if is_attached_long(arg) + || is_attached_short(arg) + || (arg == operand && previous.is_some_and(is_flag)) + { + return Some(index); + } + previous = Some(arg); + } + None + } + + /// Index of the `n`-th positional argument, counting from zero. + /// + /// An argument is taken as positional when it follows a bare `--` or does + /// not start with `-`; a lone `-` counts as positional. This only holds + /// for utilities none of whose options take a *separate* value — an + /// option's detached value would be miscounted as a positional. + /// + /// # Arguments + /// + /// * `n` - Zero-based rank among the positional arguments. + /// + /// # Returns + /// + /// The index of that positional, or `None` if there are not that many. + pub fn index_of_positional(&self, n: usize) -> Option { + self.index_of_operand(n, &ValueOptions::NONE) + } + + /// Index of the `n`-th operand, stepping over the values of `options`. + /// + /// As [`Snapshot::index_of_positional`], but for a utility whose options + /// take a separate value: `csplit -n 3 file 5` has two operands, not + /// three, and naming `-n` is what keeps the caret off the `3`. + /// + /// # Arguments + /// + /// * `n` - Zero-based rank among the operands. + /// * `options` - The options whose value is a separate argument. + /// + /// # Returns + /// + /// The index of that operand, or `None` if there are not that many. + pub fn index_of_operand(&self, n: usize, options: &ValueOptions) -> Option { + let mut options_ended = false; + let mut skip_value = false; + let mut rank = 0; + + for (index, arg) in self.args.iter().enumerate().skip(self.first_operand) { + if skip_value { + skip_value = false; + continue; + } + if !options_ended { + let bytes = arg.as_encoded_bytes(); + if bytes == b"--" { + options_ended = true; + continue; + } + // A lone `-` is an operand, and so is an argument that is not + // UTF-8, which no option spelling can be. + if let Some(text) = arg.to_str() + && text.starts_with('-') + && text != "-" + { + skip_value = options.takes_next(text); + continue; + } + } + if rank == n { + return Some(index); + } + rank += 1; + } + None + } + + /// Byte range of the argument at `index`. + /// + /// An index past the end means "end of input" and is clamped to the last + /// argument, so the caret never falls outside the text. + fn span_at(&self, index: usize) -> Option> { + self.spans.get(index).or_else(|| self.spans.last()).cloned() + } + + /// Write a report for the argument at `index` to stderr. + /// + /// # Arguments + /// + /// * `index` - Position of the argument at fault. An index past the end + /// points at the last argument. + /// * `message` - The error message, already localized. + /// * `label` - Text placed under the caret, already localized, or `None` + /// for a bare underline. A label should add something the message does + /// not say — an expectation, or a fix — never restate it. + /// * `help` - An optional line of advice, already localized. + /// + /// # Returns + /// + /// `false` if nothing could be rendered, in which case the caller should + /// fall back to a plain one-line message. + pub fn render( + &self, + index: usize, + message: &str, + label: Option<&str>, + help: Option<&str>, + ) -> bool { + let Some(span) = self.span_at(index) else { + return false; + }; + self.report(span, message, label, help) + } + + /// Write a report pointing at `range`, a byte range *inside* `operand`, + /// where `operand` is the tail (or the whole) of the argument at `index`. + /// + /// For utilities whose operands are small languages of their own — a `sort` + /// key, a `chmod` mode — the argument as a whole is rarely the answer; the + /// caret belongs under the one character that broke the parse. The argument + /// is named rather than searched for: the caller has located it with + /// [`Snapshot::index_of`], [`Snapshot::index_of_value`] or + /// [`Snapshot::index_of_positional`], or tracked it itself, so an unrelated + /// argument sharing the operand's text cannot draw the caret away. The + /// operand may sit at the tail of a larger argument, so a key passed as + /// `-k2.3x` and one passed as `-k 2.3x` both point at the same place. + /// + /// Falls back to underlining the whole argument when the operand is not + /// its tail, or when quoting means an offset inside `operand` no longer + /// lines up with what is printed. + /// + /// # Arguments + /// + /// * `index` - Position of the argument carrying `operand`. + /// * `operand` - The operand at fault, as it was parsed. + /// * `range` - Byte range inside `operand` to point at. An empty range + /// marks the character it starts at. + /// * `message` - The error message, already localized. + /// * `label` - Text placed under the caret, already localized, or `None` + /// for a bare underline. A label should add something the message does + /// not say — an expectation, or a fix — never restate it. + /// * `help` - An optional line of advice, already localized. + /// + /// # Returns + /// + /// `false` if nothing could be rendered, in which case the caller should + /// fall back to a plain one-line message. + pub fn render_inside_at( + &self, + index: usize, + operand: &str, + range: Range, + message: &str, + label: Option<&str>, + help: Option<&str>, + ) -> bool { + let Some(span) = self.locate_at(index, operand, range) else { + return false; + }; + self.report(span, message, label, help) + } + + /// Write a report pointing at `range` inside `operand`, where `operand` is + /// the value of an option. + /// + /// The pairing of [`Snapshot::index_of_value`] with + /// [`Snapshot::render_inside_at`] is what almost every option value needs — + /// a `chmod` mode, a `sort` key, a `cut` range, a `head` size — so it is + /// written once here rather than in each utility. + /// + /// # Arguments + /// + /// * `operand` - The value at fault, as the parser received it. + /// * `short` - The option's short name (`'k'` for `-k`), if it has one. + /// * `long` - The option's long name (`"key"` for `--key`), if it has one. + /// * `range` - Byte range inside `operand` to point at. An empty range + /// marks the character it starts at. + /// * `message` - The error message, already localized. + /// * `label` - Text placed under the caret, already localized, or `None` + /// for a bare underline. + /// * `help` - An optional line of advice, already localized. + /// + /// # Returns + /// + /// `false` when no argument carries `operand` as that option's value, or + /// when nothing could be rendered, in which case the caller should fall + /// back to the plain one-line message. + // One more parameter than clippy likes, but every one of them is already + // part of `render_inside_at`; folding them into a struct would only move + // the list to the call site. + #[allow(clippy::too_many_arguments)] + pub fn render_option_value( + &self, + operand: &str, + short: Option, + long: Option<&str>, + range: Range, + message: &str, + label: Option<&str>, + help: Option<&str>, + ) -> bool { + let Some(index) = self.index_of_value(operand, short, long) else { + return false; + }; + self.render_inside_at(index, operand, range, message, label, help) + } + + /// Write a report pointing at `range` inside the value of an option. + /// + /// As [`Snapshot::render_option_value`], for a value that travels with the + /// option it was given to. + /// + /// # Arguments + /// + /// * `option` - The value at fault and the option it came from. + /// * `range` - Byte range inside the value to point at. An empty range + /// marks the character it starts at. + /// * `message` - The error message, already localized. + /// * `label` - Text placed under the caret, already localized, or `None` + /// for a bare underline. + /// * `help` - An optional line of advice, already localized. + pub fn render_option( + &self, + option: &OptionValue, + range: Range, + message: &str, + label: Option<&str>, + help: Option<&str>, + ) -> bool { + self.render_option_value( + &option.value, + option.short, + option.long, + range, + message, + label, + help, + ) + } + + /// Byte range covered by `range` — an offset inside `operand` — within the + /// argument at `index`. + fn locate_at(&self, index: usize, operand: &str, range: Range) -> Option> { + let arg = self.args.get(index)?; + let whole = self.spans[index].clone(); + // An empty operand has nothing of its own to point at, and would be + // found at the end of the argument rather than where it was written. + // The argument as echoed — a bare pair of quotes — is what was typed. + if operand.is_empty() || !arg.as_encoded_bytes().ends_with(operand.as_bytes()) { + return Some(whole); + } + // Where the operand's own text sits in what was printed for this + // argument. An argument holding a space is echoed quoted, so the + // operand no longer ends the span it is drawn in — but the quoting + // only wraps it, and an offset inside it still holds wherever its + // bytes turned up. An argument that had to be escaped to be printed + // — a non-UTF-8 one, or one quoting cannot wrap — does not contain + // them contiguously, and falls back to a plain underline. + let Some(offset) = self.text[whole.clone()].rfind(operand) else { + return Some(whole); + }; + Some(self.locate_operand(whole.start + offset, operand, range)) + } + + /// Byte range covered by `range` — an offset inside `operand` — where + /// `operand` is drawn starting at `base`. + fn locate_operand(&self, base: usize, operand: &str, range: Range) -> Range { + let whole = base..base + operand.len(); + // Offsets come from someone else's parser, so they are only trusted as + // far as the text agrees with them. + let start = self.floor_boundary(base + range.start.min(operand.len())); + let end = self.floor_boundary(base + range.end.clamp(range.start, operand.len())); + if start < end { + return start..end; + } + // An empty range means something is missing rather than wrong; give the + // caret the character it stopped at, or the whole operand if it ran + // out. + let stopped = char_span(&self.text, start); + if start < whole.end && !stopped.is_empty() { + return stopped; + } + whole + } + + /// `offset`, moved back to the nearest character boundary. + fn floor_boundary(&self, offset: usize) -> usize { + floor_boundary(&self.text, offset) + } + + /// Translate the word ariadne heads the advice line with. + /// + /// The advice itself arrives already localized, but ariadne writes its own + /// `Help` in front of it, so a translated report would end on a line half + /// in English. The word is replaced where ariadne wrote it — on the first + /// row below the label, which is the only place it can be — rather than + /// everywhere, since an argument the report echoes is free to contain it + /// too. + /// + /// # Arguments + /// + /// * `body` - The rendered report, its headline already dropped. + /// * `has_help` - Whether an advice line was asked for at all. + fn localize_help(body: &str, has_help: bool) -> Cow<'_, str> { + let localized = translate!("diagnostics-help-label"); + if !has_help || localized == "Help" { + return Cow::Borrowed(body); + } + let mut lines: Vec> = body.lines().map(Cow::Borrowed).collect(); + // Rows 0..=2 are the header, the gutter and the source line; rows 3 + // and 4 belong to the label. + let Some(row) = lines + .iter() + .skip(5) + .position(|line| line.contains("Help")) + .map(|row| row + 5) + else { + // The report is not shaped the way we assumed; hand it back + // untouched rather than rebuilding it around a row we never found. + return Cow::Borrowed(body); + }; + lines[row] = Cow::Owned(lines[row].replacen("Help", &localized, 1)); + Cow::Owned(lines.join("\n") + "\n") + } + + /// Whether `row` is one of the two rows a single label occupies. + /// + /// A report with one label on one source line always has the same shape, + /// once its `Error:` headline has been dropped: the file header, a blank + /// gutter, the source line, then the underline and the arm under it. + fn is_label_row(row: usize) -> bool { + /// Row holding the arguments as they were typed. + const SOURCE: usize = 2; + (SOURCE + 1..=SOURCE + 2).contains(&row) + } + + /// The report ariadne draws for `span`, with its headline dropped. + /// + /// Both [`Self::localize_help`] and [`Self::is_label_row`] count on the + /// rows of what this returns, so it is a step of its own: a test can render + /// a known report and check the shape those two assume, rather than only + /// the finished output. + /// + /// # Returns + /// + /// `None` when ariadne could not render, in which case the caller falls + /// back to the plain one-line message. + fn render_body( + &self, + span: Range, + label: Option<&str>, + help: Option<&str>, + color: bool, + ) -> Option { + let id = crate::util_name(); + let config = Config::default() + // ariadne counts characters unless told otherwise, which would drift + // on multi-byte arguments. + .with_index_type(IndexType::Byte) + .with_color(color) + .with_char_set(CharSet::Unicode); + + // An empty message keeps the underline and the caret; ariadne draws + // nothing at all for a label without a message. + let caret = Label::new((id, span.clone())) + .with_color(Color::Red) + .with_message(label.unwrap_or_default()); + let mut report = Report::build(ReportKind::Error, (id, span)) + .with_config(config) + .with_label(caret); + + if let Some(help) = help { + report = report.with_help(help); + } + + let mut rendered = Vec::new(); + if report + .finish() + .write((id, Source::from(self.text.as_str())), &mut rendered) + .is_err() + { + return None; + } + + // ariadne heads every report with its own line — a hardcoded, untranslated + // "Error:". Drop it and write the utility name instead, so the first line + // reads exactly like the plain one-line form. + let rendered = String::from_utf8_lossy(&rendered); + Some( + rendered + .split_once('\n') + .map_or("", |(_, rest)| rest) + .into(), + ) + } + + fn report( + &self, + span: Range, + message: &str, + label: Option<&str>, + help: Option<&str>, + ) -> bool { + let id = crate::util_name(); + let color = env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal(); + let Some(body) = self.render_body(span, label, help, color) else { + return false; + }; + let body = Self::localize_help(&body, help.is_some()); + let body = body.as_ref(); + if label.is_some() { + eprint!("{id}: {message}\n{body}"); + } else { + // The empty message left an arrow arm pointing at nothing: drop it + // and flatten the tee, so only the underline remains. Only the two + // rows ariadne drew for the label are rewritten — the row above + // them echoes the arguments, and an argument is free to contain a + // `╰` or a `┬` of its own. + let body = body + .lines() + .enumerate() + .filter(|&(row, line)| !(Self::is_label_row(row) && line.contains('╰'))) + .map(|(row, line)| { + if Self::is_label_row(row) { + Cow::Owned(line.replace('┬', "─")) + } else { + Cow::Borrowed(line) + } + }) + .collect::>() + .join("\n"); + eprintln!("{id}: {message}\n{body}"); + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot(args: &[&str]) -> Snapshot { + Snapshot::new(args) + } + + /// Everything but `always` and `never` leaves the terminal check in + /// charge, rather than failing. + #[test] + fn the_mode_variable_decides_only_when_it_is_spelled_out() { + let mode = |value| Mode::from_env(Some(OsStr::new(value))); + + assert_eq!(mode("always"), Mode::Always); + assert_eq!(mode("ALWAYS"), Mode::Always); + assert_eq!(mode("never"), Mode::Never); + assert_eq!(mode("Never"), Mode::Never); + + for undecided in ["auto", "", "yes", "1", "sometimes"] { + assert_eq!(mode(undecided), Mode::Auto, "{undecided:?}"); + } + assert_eq!(Mode::from_env(None), Mode::Auto); + } + + /// `localize_help` and `is_label_row` both address ariadne's output by row + /// number, which only they know is right. Render a report whose shape is + /// known and check it, so that an ariadne that adds or drops a row fails + /// here rather than silently deleting a row of somebody's command line. + #[test] + fn the_rendered_report_has_the_row_shape_the_rewriting_assumes() { + let snap = snapshot(&["-k", "1,0"]); + let body = snap + .render_body(3..6, Some("a label"), Some("some advice"), false) + .unwrap(); + let rows: Vec<&str> = body.lines().collect(); + + // Row 2 echoes the arguments; rewriting it would edit what was typed. + assert!(rows[2].contains("-k 1,0"), "row 2: {:?}", rows[2]); + assert!(!Snapshot::is_label_row(2)); + + // Rows 3 and 4 are the underline and the arm carrying the label. + assert!(Snapshot::is_label_row(3) && Snapshot::is_label_row(4)); + assert!(rows[3].contains('┬'), "row 3: {:?}", rows[3]); + assert!( + rows[4].contains('╰') && rows[4].contains("a label"), + "row 4: {:?}", + rows[4] + ); + + // The advice comes below those, where `localize_help` looks for it. + assert!( + rows.iter().skip(5).any(|row| row.contains("Help")), + "no help row below row 4: {rows:?}" + ); + } + + #[test] + fn spans_cover_each_argument() { + let snap = snapshot(&["a", "=", ""]); + assert_eq!(snap.text, "a = ''"); + assert_eq!(snap.spans, vec![0..1, 2..3, 4..6]); + } + + #[test] + fn empty_argument_list_has_nothing_to_point_at() { + let snap = snapshot(&[]); + assert!(snap.is_empty()); + assert_eq!(snap.text, ""); + assert_eq!(snap.span_at(0), None); + } + + #[test] + fn spans_are_byte_ranges_for_multibyte_arguments() { + let snap = snapshot(&["étage", "!=", "x"]); + let spans = snap.spans.clone(); + assert_eq!(&snap.text[spans[0].clone()], "étage"); + assert_eq!(&snap.text[spans[1].clone()], "!="); + assert_eq!(&snap.text[spans[2].clone()], "x"); + } + + #[test] + fn arguments_with_whitespace_are_quoted() { + let snap = snapshot(&["two words", "-lt", "5"]); + assert_eq!(snap.text, "'two words' -lt 5"); + assert_eq!(&snap.text[snap.span_at(0).unwrap()], "'two words'"); + } + + #[cfg(unix)] + #[test] + fn spans_follow_the_quoted_form_of_invalid_utf8() { + let snap = Snapshot::from_bytes(&[&b"qu\x91x"[..], b"-eq", b"7"]); + let span = snap.span_at(0).unwrap(); + // The caret covers the escaped rendering, not the four raw bytes. + assert_eq!(&snap.text[span.clone()], r"$'qu\x91x'"); + assert!(span.len() > 4); + } + + #[test] + fn byte_arguments_round_trip() { + let snap = Snapshot::from_bytes(&[b"9", b"+", b"4"]); + assert_eq!(snap.text, "9 + 4"); + assert_eq!(snap.index_of(OsStr::new("+")), Some(1)); + assert_eq!(snap.index_of(OsStr::new("4")), Some(2)); + } + + #[cfg(unix)] + #[test] + fn invalid_utf8_arguments_can_still_be_found() { + use std::os::unix::ffi::OsStrExt; + + let snap = Snapshot::from_bytes(&[&b"ba\x80d"[..], b"+", b"1"]); + // A lossy conversion would not compare equal to the raw argument. + assert_eq!(snap.index_of(OsStr::from_bytes(b"ba\x80d")), Some(0)); + } + + #[test] + fn index_past_the_end_clamps_to_the_last_argument() { + let snap = snapshot(&["7", "-lt"]); + assert_eq!(snap.span_at(99), Some(2..5)); + } + + #[test] + fn an_empty_range_still_gets_one_character() { + let snap = snapshot(&["u+rwx,g"]); + let span = snap.locate_at(0, "u+rwx,g", 6..6).unwrap(); + assert_eq!(&snap.text[span], "g"); + } + + #[test] + fn a_range_at_the_end_of_an_operand_falls_back_to_the_operand() { + let snap = snapshot(&["-k1,"]); + // Nothing left to point at, so the caret takes the operand — not the + // `-k` in front of it, which is not what went wrong. + assert_eq!(snap.locate_at(0, "1,", 2..2), Some(2..4)); + assert_eq!(&snap.text[2..4], "1,"); + } + + #[test] + fn offsets_hold_inside_an_operand_the_report_had_to_quote() { + let snap = snapshot(&["a b", "-k1"]); + // The quoting only wraps the operand, so an offset into it still lands + // on the same bytes. + let span = snap.locate_at(0, "a b", 1..2).unwrap(); + assert_eq!(&snap.text[span], " "); + } + + #[test] + fn offsets_inside_a_multibyte_operand_stay_on_character_boundaries() { + let snap = snapshot(&["--from=éx"]); + // Mid-character offset, walked back rather than slicing a code point. + let span = snap.locate_at(0, "éx", 1..3).unwrap(); + assert_eq!(&snap.text[span], "éx"); + } + + #[test] + fn index_of_finds_the_first_match() { + let snap = snapshot(&["dup", "-ef", "dup"]); + assert_eq!(snap.index_of(OsStr::new("dup")), Some(0)); + assert_eq!(snap.index_of(OsStr::new("absent")), None); + } + + #[test] + fn the_program_name_is_never_the_argument_looked_for() { + // `printf printf` prints its own name; the operand is the second one. + let snap = Snapshot::with_program(&["printf", "printf"]); + assert_eq!(snap.index_of(OsStr::new("printf")), Some(1)); + } + + #[test] + fn an_operand_starting_with_a_dash_is_still_found() { + // What `index_of_positional` cannot do: printf takes hyphen values, so + // `-%y` is the format rather than an option. + let snap = Snapshot::with_program(&["printf", "-%y", "arg"]); + assert_eq!(snap.index_of(OsStr::new("-%y")), Some(1)); + assert_eq!(snap.index_of_positional(0), Some(2)); + } + + #[test] + fn a_value_is_found_in_every_spelling_of_its_option() { + for (args, expected) in [ + (&["-k", "2.3q", "f"][..], 1), + (&["-k2.3q", "f"][..], 0), + (&["-rk2.3q", "f"][..], 0), + (&["--key", "2.3q", "f"][..], 1), + (&["--key=2.3q", "f"][..], 0), + ] { + let snap = snapshot(args); + assert_eq!( + snap.index_of_value("2.3q", Some('k'), Some("key")), + Some(expected), + "in {args:?}" + ); + } + } + + #[test] + fn the_program_name_is_never_an_option_value() { + let snap = Snapshot::with_program(&["sort", "-k", "sort"]); + assert_eq!(snap.index_of_value("sort", Some('k'), Some("key")), Some(2)); + } + + #[test] + fn a_file_sharing_the_value_text_is_not_the_value() { + let snap = Snapshot::with_program(&["sort", "data.2.3q", "-k2.3q"]); + assert_eq!(snap.index_of_value("2.3q", Some('k'), Some("key")), Some(2)); + } + + #[test] + fn another_option_sharing_the_value_suffix_is_not_the_value() { + let snap = Snapshot::with_program(&["numfmt", "--delimiter=%q", "--format=%q", "1000"]); + assert_eq!(snap.index_of_value("%q", None, Some("format")), Some(2)); + } + + #[test] + fn a_long_option_is_not_mistaken_for_a_short_cluster() { + // `--am=644` ends in `m` + the operand, but it is not a `-m` cluster. + let snap = snapshot(&["--am=644", "-m644", "d"]); + assert_eq!(snap.index_of_value("644", Some('m'), Some("mode")), Some(1)); + } + + #[test] + fn a_value_after_a_double_dash_is_not_an_option_value() { + let snap = snapshot(&["--", "-k", "2.3q"]); + assert_eq!(snap.index_of_value("2.3q", Some('k'), Some("key")), None); + } + + #[test] + fn an_empty_value_is_the_argument_after_the_flag() { + // `-k` ends in `k` and in the empty operand, but it is the flag. + let snap = snapshot(&["-k", "", "f"]); + assert_eq!(snap.index_of_value("", Some('k'), Some("key")), Some(1)); + } + + #[test] + fn a_missing_value_cannot_be_located() { + let snap = snapshot(&["-x", "unrelated"]); + assert_eq!(snap.index_of_value("2.3q", Some('k'), Some("key")), None); + } + + #[test] + fn positionals_are_counted_across_options() { + let snap = Snapshot::with_program(&["tr", "-c", "ab", "tr"]); + assert_eq!(snap.index_of_positional(0), Some(2)); + assert_eq!(snap.index_of_positional(1), Some(3)); + assert_eq!(snap.index_of_positional(2), None); + } + + #[test] + fn a_double_dash_ends_options_for_positionals() { + let snap = Snapshot::with_program(&["tr", "--", "-d", "x"]); + assert_eq!(snap.index_of_positional(0), Some(2)); + assert_eq!(snap.index_of_positional(1), Some(3)); + } + + #[test] + fn a_lone_dash_is_a_positional() { + let snap = snapshot(&["-c", "-", "x"]); + assert_eq!(snap.index_of_positional(0), Some(1)); + } + + /// A utility with one option of each spelling that takes a value. + const VALUE_OPTIONS: ValueOptions = ValueOptions { + shorts: &['d'], + longs: &["suffix", "delimiter"], + }; + + #[test] + fn an_operand_is_found_past_a_detached_option_value() { + // Counting positionals would stop at the `q` given to `--suffix`. + let snap = Snapshot::with_program(&["numfmt", "--suffix", "q", "1000"]); + assert_eq!(snap.index_of_operand(0, &VALUE_OPTIONS), Some(3)); + assert_eq!(snap.index_of_operand(1, &VALUE_OPTIONS), None); + assert_eq!(snap.index_of_positional(0), Some(2)); + } + + #[test] + fn an_attached_value_does_not_swallow_the_operand() { + for args in [ + &["numfmt", "--suffix=q", "1000"][..], + &["numfmt", "-dq", "1000"][..], + ] { + let snap = Snapshot::with_program(args); + assert_eq!( + snap.index_of_operand(0, &VALUE_OPTIONS), + Some(2), + "in {args:?}" + ); + } + } + + #[test] + fn an_abbreviated_long_option_still_takes_its_value() { + let snap = Snapshot::with_program(&["numfmt", "--suf", "q", "1000"]); + assert_eq!(snap.index_of_operand(0, &VALUE_OPTIONS), Some(3)); + } + + #[test] + fn only_the_last_of_a_short_cluster_takes_the_next_argument() { + // `-zd` ends in the value option, `-dz` does not. + let snap = Snapshot::with_program(&["numfmt", "-zd", ",", "1000"]); + assert_eq!(snap.index_of_operand(0, &VALUE_OPTIONS), Some(3)); + let snap = Snapshot::with_program(&["numfmt", "-dz", "1000"]); + assert_eq!(snap.index_of_operand(0, &VALUE_OPTIONS), Some(2)); + } + + #[test] + fn an_option_value_past_a_double_dash_is_an_operand() { + let snap = Snapshot::with_program(&["numfmt", "--", "--suffix", "q"]); + assert_eq!(snap.index_of_operand(0, &VALUE_OPTIONS), Some(2)); + assert_eq!(snap.index_of_operand(1, &VALUE_OPTIONS), Some(3)); + } + + #[test] + fn an_option_the_table_does_not_name_takes_nothing() { + let snap = Snapshot::with_program(&["numfmt", "--round", "up", "1000"]); + assert_eq!(snap.index_of_operand(0, &VALUE_OPTIONS), Some(2)); + } + + #[test] + fn locate_at_points_inside_the_named_argument() { + for (args, index) in [(&["-k2.3x", "f"][..], 0), (&["-k", "2.3x", "f"][..], 1)] { + let snap = snapshot(args); + let span = snap.locate_at(index, "2.3x", 3..4).unwrap(); + assert_eq!(&snap.text[span], "x", "in {args:?}"); + } + } + + #[test] + fn locate_at_ignores_an_earlier_argument_with_the_same_tail() { + let snap = snapshot(&["data.2.3q", "-k2.3q"]); + let span = snap.locate_at(1, "2.3q", 3..4).unwrap(); + assert_eq!(span, snap.spans[1].start + 5..snap.spans[1].start + 6); + } + + #[test] + fn locate_at_underlines_the_whole_argument_when_the_operand_is_not_its_tail() { + let snap = snapshot(&["ab", "cd"]); + assert_eq!(snap.locate_at(1, "zz", 0..1), Some(3..5)); + } + + #[test] + fn locate_at_past_the_end_cannot_be_located() { + let snap = snapshot(&["ab"]); + assert_eq!(snap.locate_at(5, "ab", 0..1), None); + } +} diff --git a/src/uucore/src/lib/features/diagnostics_boundary.rs b/src/uucore/src/lib/features/diagnostics_boundary.rs new file mode 100644 index 00000000000..b65b26f3356 --- /dev/null +++ b/src/uucore/src/lib/features/diagnostics_boundary.rs @@ -0,0 +1,218 @@ +// 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. + +//! The part of the caret diagnostics that is real even without them. +//! +//! Both [`crate::diagnostics`] and its no-op stand-in re-export these, and both +//! do so for the same reason: a caller locates what a caret would point at — +//! flooring an offset, walking a list, keeping a value next to the option it +//! came from — before it knows whether anything will be drawn, so that much has +//! to work even when the rendering is compiled out. Keeping it here means the +//! two cannot drift. + +use std::ops::Range; + +/// `offset`, moved back to the nearest character boundary of `text`. +/// +/// Offsets handed to the diagnostics are counted in bytes by someone else's +/// parser, which may have been walking an `OsStr` rather than text; one can +/// land inside a multi-byte character. Clamped to the end of `text`. +/// +/// # Arguments +/// +/// * `text` - The string the offset counts into. +/// * `offset` - A byte offset, trusted only as far as `text` agrees with it. +pub fn floor_boundary(text: &str, offset: usize) -> usize { + let offset = offset.min(text.len()); + (0..=offset) + .rev() + .find(|&i| text.is_char_boundary(i)) + .unwrap_or(0) +} + +/// The range covering the character `offset` falls in. +/// +/// Useful for the errors that blame a single character, so that a caret marks +/// the whole of it rather than its first byte. +/// +/// # Arguments +/// +/// * `text` - The string the offset counts into. +/// * `offset` - A byte offset, trusted only as far as `text` agrees with it. +/// +/// # Returns +/// +/// An empty range at the end of `text`, which the renderer reads as "nothing +/// left to point at". +pub fn char_span(text: &str, offset: usize) -> Range { + let start = floor_boundary(text, offset); + match text[start..].chars().next() { + Some(c) => start..start + c.len_utf8(), + None => start..start, + } +} + +/// The value of an option, and the option it was given to. +/// +/// An option's value can be spelled many ways — `-S 1Q`, `-S1Q`, +/// `--buffer-size=1Q` — so a caret pointing inside one has to know which option +/// carried it before it can know which argument to draw under. A utility that +/// may want a caret keeps the value and the two names together from the moment +/// the parse fails until the report is drawn. +#[derive(Debug)] +pub struct OptionValue { + /// The value as typed. + pub value: String, + /// The option's short name, if it has one. + pub short: Option, + /// The option's long name, if it has one. + pub long: Option<&'static str>, +} + +impl OptionValue { + /// The value of an option answering to both a short and a long name. + pub fn new(value: impl Into, short: char, long: &'static str) -> Self { + Self::with_names(value, Some(short), Some(long)) + } + + /// The value of an option that is missing one of the two names, or whose + /// names are only known once the parse has failed — `stat` blames `-c` or + /// `--printf` depending on which one it was given. + pub fn with_names( + value: impl Into, + short: Option, + long: Option<&'static str>, + ) -> Self { + Self { + value: value.into(), + short, + long, + } + } +} + +/// The options whose value is written as a separate argument. +/// +/// An operand cannot be counted off by position alone once a value can sit +/// between the operands: in `csplit -n 3 file 5` the `3` is a value, not the +/// first operand. Only the spellings that put the value in the *next* +/// argument matter; `--name=value` and `-dq` are self-contained. +pub struct ValueOptions<'a> { + /// The short names, without the `-`. + pub shorts: &'a [char], + /// The long names, without the `--`. An unambiguous abbreviation counts + /// too, since `infer_long_args` accepts one; an ambiguous one never gets + /// this far, clap having refused it first. + pub longs: &'a [&'a str], +} + +impl ValueOptions<'_> { + /// For a utility none of whose options takes a separate value, where an + /// operand is simply an argument that does not look like an option. + pub const NONE: Self = Self { + shorts: &[], + longs: &[], + }; + + /// Whether `arg` names one of these options in the spelling that takes the + /// argument after it as its value. + /// + /// # Arguments + /// + /// * `arg` - An argument already known to start with `-` and not to be a + /// lone `-` or a bare `--`. + pub fn takes_next(&self, arg: &str) -> bool { + if let Some(long) = arg.strip_prefix("--") { + // `--name=value` carries its own value. + return !long.contains('=') && self.longs.iter().any(|name| name.starts_with(long)); + } + // In a run of short options, only the last one can carry the value. + arg.strip_prefix('-') + .and_then(|cluster| cluster.chars().next_back()) + .is_some_and(|last| self.shorts.contains(&last)) + } +} + +/// The items of a separated list, each with its byte range inside `list`. +/// +/// A caret pointing at one item of a list — a `dd` conversion flag, a `join` +/// output field — needs to know where that item was written. The list is walked +/// rather than searched for the item's text, which would also match inside an +/// earlier item the wanted one is a prefix of: the `noc` of `nocache,noc`. +/// +/// # Arguments +/// +/// * `list` - The list as typed. +/// * `separators` - The characters it is split on, of any width. +pub fn list_items<'a>( + list: &'a str, + separators: &'a [char], +) -> impl DoubleEndedIterator)> { + let base = list.as_ptr() as usize; + list.split(separators).map(move |item| { + // Every item is a slice of `list`, so its address gives away where it + // was written: no running count to keep, which would have tied the + // spans to walking the list once, in order, past separators of a width + // the count assumed. + let start = item.as_ptr() as usize - base; + (item, start..start + item.len()) + }) +} + +#[cfg(test)] +mod tests { + use super::{char_span, floor_boundary, list_items}; + + #[test] + fn floors_into_a_multibyte_character() { + // "é" is two bytes, so offset 1 is inside it. + assert_eq!(floor_boundary("é", 1), 0); + assert_eq!(floor_boundary("aé", 2), 1); + } + + #[test] + fn clamps_past_the_end() { + assert_eq!(floor_boundary("ab", 9), 2); + } + + #[test] + fn spans_the_whole_character() { + assert_eq!(char_span("aé", 1), 1..3); + assert_eq!(char_span("aé", 2), 1..3); + } + + #[test] + fn spans_nothing_at_the_end() { + assert_eq!(char_span("ab", 2), 2..2); + } + + #[test] + fn spans_every_item_of_a_list() { + let items: Vec<_> = list_items("ab,,cde", &[',']).collect(); + assert_eq!(items, vec![("ab", 0..2), ("", 3..3), ("cde", 4..7)]); + } + + /// The whole point of walking: "sy" also occurs at the start of "sync". + #[test] + fn spans_an_item_an_earlier_one_starts_with() { + let items: Vec<_> = list_items("sync sy", &[' ']).collect(); + assert_eq!(items[1], ("sy", 5..7)); + } + + /// The spans are a property of the list, not of the walk: taking the items + /// out of order, or splitting on a separator that is more than one byte + /// wide, points at the same text. + #[test] + fn spans_an_item_wherever_it_is_reached() { + let items: Vec<_> = list_items("aé§bb§c", &['\u{a7}']).rev().collect(); + assert_eq!(items, vec![("c", 9..10), ("bb", 5..7), ("aé", 0..3)]); + } + + #[test] + fn spans_a_list_of_one() { + let items: Vec<_> = list_items("solo", &[',', ' ']).collect(); + assert_eq!(items, vec![("solo", 0..4)]); + } +} diff --git a/src/uucore/src/lib/features/diagnostics_stub.rs b/src/uucore/src/lib/features/diagnostics_stub.rs new file mode 100644 index 00000000000..aefc3f3f015 --- /dev/null +++ b/src/uucore/src/lib/features/diagnostics_stub.rs @@ -0,0 +1,140 @@ +// 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-op stand-in for the `diagnostics` module, compiled when the +//! `diagnostics` cargo feature is off. +//! +//! It mirrors the real API so callers need no `cfg` of their own: +//! [`enabled`] is always `false`, the locators find nothing and the render +//! methods render nothing, so every caller falls back to the plain one-line +//! message it would print anywhere but a terminal. + +use std::ffi::{OsStr, OsString}; +use std::ops::Range; + +/// Always `false`: rendering is compiled out. +pub fn enabled() -> bool { + false +} + +/// Always `None`: rendering is compiled out. +pub fn capture(_args: &[OsString]) -> Option> { + None +} + +/// Always `None`: rendering is compiled out. +pub fn operands(_args: &[OsString]) -> Option> { + None +} + +// Boundary arithmetic is real even when rendering is not: a caller may floor +// an offset before it knows whether anything will be drawn. It is the one part +// of this module that is not a no-op, so it is shared with the real one rather +// than restated here. +pub use crate::features::diagnostics_boundary::{ + OptionValue, ValueOptions, char_span, floor_boundary, list_items, +}; + +/// Always the error itself: nothing is ever drawn to replace it. +pub fn error_after_report>>( + _diag_args: Option<&[OsString]>, + error: E, + _draw: impl FnOnce(&[OsString], &E) -> bool, +) -> Box { + error.into() +} + +/// A snapshot of nothing: it finds nothing and renders nothing. +/// +/// Deliberately derives nothing the real `Snapshot` does not, so that what a +/// caller may do with one does not depend on whether the feature is on. +pub struct Snapshot; + +#[allow(clippy::unused_self)] +impl Snapshot { + pub fn new>(_args: &[S]) -> Self { + Self + } + + pub fn with_program>(_args: &[S]) -> Self { + Self + } + + pub fn from_bytes>(_args: &[S]) -> Self { + Self + } + + pub fn is_empty(&self) -> bool { + true + } + + pub fn index_of(&self, _arg: &OsStr) -> Option { + None + } + + pub fn index_of_value( + &self, + _operand: &str, + _short: Option, + _long: Option<&str>, + ) -> Option { + None + } + + pub fn index_of_positional(&self, _n: usize) -> Option { + None + } + + pub fn index_of_operand(&self, _n: usize, _options: &ValueOptions) -> Option { + None + } + + pub fn render( + &self, + _index: usize, + _message: &str, + _label: Option<&str>, + _help: Option<&str>, + ) -> bool { + false + } + + pub fn render_inside_at( + &self, + _index: usize, + _operand: &str, + _range: Range, + _message: &str, + _label: Option<&str>, + _help: Option<&str>, + ) -> bool { + false + } + + pub fn render_option( + &self, + _option: &OptionValue, + _range: Range, + _message: &str, + _label: Option<&str>, + _help: Option<&str>, + ) -> bool { + false + } + + #[allow(clippy::too_many_arguments)] + pub fn render_option_value( + &self, + _operand: &str, + _short: Option, + _long: Option<&str>, + _range: Range, + _message: &str, + _label: Option<&str>, + _help: Option<&str>, + ) -> bool { + false + } +} diff --git a/src/uucore/src/lib/features/encoding.rs b/src/uucore/src/lib/features/encoding.rs index db6114d2783..4a6e3394835 100644 --- a/src/uucore/src/lib/features/encoding.rs +++ b/src/uucore/src/lib/features/encoding.rs @@ -23,23 +23,17 @@ pub struct Base64SimdWrapper { impl Base64SimdWrapper { fn decode_with_standard(input: &[u8], output: &mut Vec) -> Result<(), ()> { - match base64_simd::STANDARD.decode_to_vec(input) { - Ok(decoded_bytes) => { - output.extend_from_slice(&decoded_bytes); - Ok(()) - } - Err(_) => Err(()), - } + let decoded_bytes = base64_simd::STANDARD.decode_to_vec(input).map_err(|_| ())?; + output.extend_from_slice(&decoded_bytes); + Ok(()) } fn decode_with_no_pad(input: &[u8], output: &mut Vec) -> Result<(), ()> { - match base64_simd::STANDARD_NO_PAD.decode_to_vec(input) { - Ok(decoded_bytes) => { - output.extend_from_slice(&decoded_bytes); - Ok(()) - } - Err(_) => Err(()), - } + let decoded_bytes = base64_simd::STANDARD_NO_PAD + .decode_to_vec(input) + .map_err(|_| ())?; + output.extend_from_slice(&decoded_bytes); + Ok(()) } pub fn new( @@ -91,9 +85,8 @@ impl SupportsFastDecodeAndEncode for Base64SimdWrapper { return Err(USimpleError::new(1, "error: invalid input")); } - if Self::decode_with_standard(&remaining[..segment_len], output).is_err() { - return Err(USimpleError::new(1, "error: invalid input")); - } + Self::decode_with_standard(&remaining[..segment_len], output) + .map_err(|_| USimpleError::new(1, "error: invalid input"))?; start += segment_len; } else { @@ -106,9 +99,8 @@ impl SupportsFastDecodeAndEncode for Base64SimdWrapper { Self::decode_with_no_pad }; - if decoder(remaining, output).is_err() { - return Err(USimpleError::new(1, "error: invalid input")); - } + decoder(remaining, output) + .map_err(|_| USimpleError::new(1, "error: invalid input"))?; break; } @@ -429,13 +421,8 @@ impl SupportsFastDecodeAndEncode for Z85Wrapper { return Err(USimpleError::new(1, "error: invalid input")); } - let decode_result = match z85::decode(input) { - Ok(ve) => ve, - Err(_de) => { - return Err(USimpleError::new(1, "error: invalid input")); - } - }; - + let decode_result = + z85::decode(input).map_err(|_de| USimpleError::new(1, "error: invalid input"))?; output.extend_from_slice(&decode_result); Ok(()) @@ -474,28 +461,23 @@ impl SupportsFastDecodeAndEncode for EncodingWrapper { // Adapted from `decode` in the "data-encoding" crate fn decode_into_vec(&self, input: &[u8], output: &mut Vec) -> UResult<()> { - let decode_len_result = match self.encoding.decode_len(input.len()) { - Ok(us) => us, - Err(_de) => { - return Err(USimpleError::new(1, "error: invalid input")); - } - }; + let decode_len_result = self + .encoding + .decode_len(input.len()) + .map_err(|_de| USimpleError::new(1, "error: invalid input"))?; let output_len = output.len(); output.resize(output_len + decode_len_result, 0); - match self.encoding.decode_mut(input, &mut (output[output_len..])) { - Ok(us) => { - // See: - // https://docs.rs/data-encoding/latest/data_encoding/struct.Encoding.html#method.decode_mut - // "Returns the length of the decoded output. This length may be smaller than the output length if the input contained padding or ignored characters. The output bytes after the returned length are not initialized and should not be read." - output.truncate(output_len + us); - } - Err(_de) => { - return Err(USimpleError::new(1, "error: invalid input")); - } - } + let us = self + .encoding + .decode_mut(input, &mut (output[output_len..])) + .map_err(|_de| USimpleError::new(1, "error: invalid input"))?; + // See: + // https://docs.rs/data-encoding/latest/data_encoding/struct.Encoding.html#method.decode_mut + // "Returns the length of the decoded output. This length may be smaller than the output length if the input contained padding or ignored characters. The output bytes after the returned length are not initialized and should not be read." + output.truncate(output_len + us); Ok(()) } diff --git a/src/uucore/src/lib/features/entries.rs b/src/uucore/src/lib/features/entries.rs index bf2766d9cef..8507fae9a9b 100644 --- a/src/uucore/src/lib/features/entries.rs +++ b/src/uucore/src/lib/features/entries.rs @@ -32,13 +32,14 @@ //! assert!(entries::Group::locate(root_group).is_ok()); //! ``` -#[cfg(any(target_os = "freebsd", target_vendor = "apple"))] +use core::ffi::{CStr, c_char, c_int}; +#[cfg(any(target_vendor = "apple", target_os = "freebsd"))] use libc::time_t; -use libc::{c_char, c_int, gid_t, uid_t}; use libc::{getgrgid, getgrnam}; use libc::{getpwnam, getpwuid, group, passwd}; +use libc::{gid_t, uid_t}; -use std::ffi::{CStr, CString}; +use std::ffi::CString; use std::io::Error as IOError; use std::io::ErrorKind; use std::io::Result as IOResult; @@ -83,7 +84,7 @@ unsafe extern "C" { pub fn get_groups_gnu(arg_id: Option) -> IOResult> { let groups = rustix::process::getgroups() .map(|g| g.into_iter().map(rustix::fs::Gid::as_raw).collect())?; - let egid = arg_id.unwrap_or_else(crate::features::process::getegid); + let egid = arg_id.unwrap_or_else(|| rustix::process::getegid().as_raw()); Ok(sort_groups(groups, egid)) } @@ -115,22 +116,25 @@ pub struct Passwd { #[expect(clippy::struct_field_names)] pub user_passwd: Option, /// AKA passwd.pw_class - #[cfg(any(target_os = "freebsd", target_vendor = "apple"))] + #[cfg(any(target_vendor = "apple", target_os = "freebsd"))] pub user_access_class: Option, /// AKA passwd.pw_change - #[cfg(any(target_os = "freebsd", target_vendor = "apple"))] + #[cfg(any(target_vendor = "apple", target_os = "freebsd"))] #[expect(clippy::struct_field_names)] pub passwd_change_time: time_t, /// AKA passwd.pw_expire - #[cfg(any(target_os = "freebsd", target_vendor = "apple"))] + #[cfg(any(target_vendor = "apple", target_os = "freebsd"))] pub expiration: time_t, } /// # Safety -/// ptr must point to a valid C string. /// -/// Returns None if ptr is null. -fn cstr2string(ptr: *const c_char) -> Option { +/// If `ptr` is non-null, it must point to a valid, NUL-terminated C string +/// +/// # Returns +/// +/// Returns None if `ptr`` is null. +unsafe fn cstr2string(ptr: *const c_char) -> Option { if ptr.is_null() { None } else { @@ -144,24 +148,24 @@ impl Passwd { /// the function runs. That means PW_LOCK must be held. unsafe fn from_raw(raw: passwd) -> Self { Self { - name: cstr2string(raw.pw_name).expect("passwd without name"), + name: unsafe { cstr2string(raw.pw_name).expect("passwd without name") }, uid: raw.pw_uid, gid: raw.pw_gid, #[cfg(not(all( target_os = "android", any(target_arch = "x86", target_arch = "arm") )))] - user_info: cstr2string(raw.pw_gecos), + user_info: unsafe { cstr2string(raw.pw_gecos) }, #[cfg(all(target_os = "android", any(target_arch = "x86", target_arch = "arm")))] user_info: None, - user_shell: cstr2string(raw.pw_shell), - user_dir: cstr2string(raw.pw_dir), - user_passwd: cstr2string(raw.pw_passwd), - #[cfg(any(target_os = "freebsd", target_vendor = "apple"))] - user_access_class: cstr2string(raw.pw_class), - #[cfg(any(target_os = "freebsd", target_vendor = "apple"))] + user_shell: unsafe { cstr2string(raw.pw_shell) }, + user_dir: unsafe { cstr2string(raw.pw_dir) }, + user_passwd: unsafe { cstr2string(raw.pw_passwd) }, + #[cfg(any(target_vendor = "apple", target_os = "freebsd"))] + user_access_class: unsafe { cstr2string(raw.pw_class) }, + #[cfg(any(target_vendor = "apple", target_os = "freebsd"))] passwd_change_time: raw.pw_change, - #[cfg(any(target_os = "freebsd", target_vendor = "apple"))] + #[cfg(any(target_vendor = "apple", target_os = "freebsd"))] expiration: raw.pw_expire, } } @@ -225,7 +229,7 @@ impl Group { /// the function runs. That means PW_LOCK must be held. unsafe fn from_raw(raw: group) -> Self { Self { - name: cstr2string(raw.gr_name).expect("group without name"), + name: unsafe { cstr2string(raw.gr_name).expect("group without name") }, gid: raw.gr_gid, } } @@ -335,9 +339,11 @@ pub fn grp2gid(name: &str) -> IOResult { #[cfg(test)] mod test { + #[cfg(all(unix, not(target_os = "redox"), feature = "process"))] use super::*; #[test] + #[cfg(all(unix, not(target_os = "redox"), feature = "process"))] fn test_sort_groups() { assert_eq!(sort_groups(vec![1, 2, 3], 4), vec![4, 1, 2, 3]); assert_eq!(sort_groups(vec![1, 2, 3], 3), vec![3, 1, 2]); @@ -347,16 +353,16 @@ mod test { } #[test] + #[cfg(all(unix, not(target_os = "redox"), feature = "process"))] fn test_entries_get_groups_gnu() { if let Ok(mut groups) = rustix::process::getgroups().map(|g| { g.into_iter() .map(rustix::fs::Gid::as_raw) .collect::>() - }) { - if let Some(last) = groups.pop() { - groups.insert(0, last); - assert_eq!(get_groups_gnu(Some(last)).unwrap(), groups); - } + }) && let Some(last) = groups.pop() + { + groups.insert(0, last); + assert_eq!(get_groups_gnu(Some(last)).unwrap(), groups); } } } diff --git a/src/uucore/src/lib/features/extendedbigdecimal.rs b/src/uucore/src/lib/features/extendedbigdecimal.rs index d119da4f77b..0ae4563e5d4 100644 --- a/src/uucore/src/lib/features/extendedbigdecimal.rs +++ b/src/uucore/src/lib/features/extendedbigdecimal.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore bigdecimal extendedbigdecimal biguint + //! An arbitrary precision float that can also represent infinity, NaN, etc. //! //! The finite values are stored as [`BigDecimal`] instances. Because @@ -20,6 +22,7 @@ //! let summand2 = ExtendedBigDecimal::Infinity; //! assert_eq!(summand1 + summand2, ExtendedBigDecimal::Infinity); //! ``` + use std::cmp::Ordering; use std::ops::Add; use std::ops::Neg; @@ -162,21 +165,20 @@ impl Add for ExtendedBigDecimal { fn add(self, other: Self) -> Self { match (self, other) { (Self::BigDecimal(m), Self::BigDecimal(n)) => Self::BigDecimal(m.add(n)), - (Self::BigDecimal(_), Self::MinusInfinity) => Self::MinusInfinity, - (Self::BigDecimal(_), Self::Infinity) => Self::Infinity, + (Self::BigDecimal(_), Self::MinusInfinity) + | (Self::MinusInfinity, Self::BigDecimal(_)) + | (Self::MinusInfinity, Self::MinusInfinity) + | (Self::MinusInfinity, Self::MinusZero) => Self::MinusInfinity, + (Self::BigDecimal(_), Self::Infinity) + | (Self::Infinity, Self::BigDecimal(_)) + | (Self::Infinity, Self::Infinity) + | (Self::Infinity, Self::MinusZero) => Self::Infinity, (Self::BigDecimal(m), Self::MinusZero) => Self::BigDecimal(m), - (Self::Infinity, Self::BigDecimal(_)) => Self::Infinity, - (Self::Infinity, Self::Infinity) => Self::Infinity, - (Self::Infinity, Self::MinusZero) => Self::Infinity, - (Self::Infinity, Self::MinusInfinity) => Self::Nan, - (Self::MinusInfinity, Self::BigDecimal(_)) => Self::MinusInfinity, - (Self::MinusInfinity, Self::MinusInfinity) => Self::MinusInfinity, - (Self::MinusInfinity, Self::MinusZero) => Self::MinusInfinity, - (Self::MinusInfinity, Self::Infinity) => Self::Nan, - (Self::Nan, _) => Self::Nan, - (_, Self::Nan) => Self::Nan, - (Self::MinusNan, _) => Self::MinusNan, - (_, Self::MinusNan) => Self::MinusNan, + (Self::Infinity, Self::MinusInfinity) + | (Self::MinusInfinity, Self::Infinity) + | (Self::Nan, _) + | (_, Self::Nan) => Self::Nan, + (Self::MinusNan, _) | (_, Self::MinusNan) => Self::MinusNan, (Self::MinusZero, other) => other, } } @@ -186,25 +188,25 @@ impl PartialEq for ExtendedBigDecimal { fn eq(&self, other: &Self) -> bool { match (self, other) { (Self::BigDecimal(m), Self::BigDecimal(n)) => m.eq(n), - (Self::BigDecimal(_), Self::MinusInfinity) => false, - (Self::BigDecimal(_), Self::Infinity) => false, - (Self::BigDecimal(_), Self::MinusZero) => false, - (Self::Infinity, Self::BigDecimal(_)) => false, - (Self::Infinity, Self::Infinity) => true, - (Self::Infinity, Self::MinusZero) => false, - (Self::Infinity, Self::MinusInfinity) => false, - (Self::MinusInfinity, Self::BigDecimal(_)) => false, - (Self::MinusInfinity, Self::Infinity) => false, - (Self::MinusInfinity, Self::MinusZero) => false, - (Self::MinusInfinity, Self::MinusInfinity) => true, - (Self::MinusZero, Self::BigDecimal(_)) => false, - (Self::MinusZero, Self::Infinity) => false, - (Self::MinusZero, Self::MinusZero) => true, - (Self::MinusZero, Self::MinusInfinity) => false, - (Self::Nan, _) => false, - (Self::MinusNan, _) => false, - (_, Self::Nan) => false, - (_, Self::MinusNan) => false, + (Self::BigDecimal(_), Self::MinusInfinity) + | (Self::BigDecimal(_), Self::Infinity) + | (Self::BigDecimal(_), Self::MinusZero) + | (Self::Infinity, Self::BigDecimal(_)) + | (Self::Infinity, Self::MinusZero) + | (Self::Infinity, Self::MinusInfinity) + | (Self::MinusInfinity, Self::BigDecimal(_)) + | (Self::MinusInfinity, Self::Infinity) + | (Self::MinusInfinity, Self::MinusZero) + | (Self::MinusZero, Self::BigDecimal(_)) + | (Self::MinusZero, Self::Infinity) + | (Self::MinusZero, Self::MinusInfinity) + | (Self::Nan, _) + | (Self::MinusNan, _) + | (_, Self::Nan) + | (_, Self::MinusNan) => false, + (Self::Infinity, Self::Infinity) + | (Self::MinusInfinity, Self::MinusInfinity) + | (Self::MinusZero, Self::MinusZero) => true, } } } @@ -213,25 +215,22 @@ impl PartialOrd for ExtendedBigDecimal { fn partial_cmp(&self, other: &Self) -> Option { match (self, other) { (Self::BigDecimal(m), Self::BigDecimal(n)) => m.partial_cmp(n), - (Self::BigDecimal(_), Self::MinusInfinity) => Some(Ordering::Greater), - (Self::BigDecimal(_), Self::Infinity) => Some(Ordering::Less), + (Self::BigDecimal(_), Self::MinusInfinity) + | (Self::Infinity, Self::BigDecimal(_)) + | (Self::Infinity, Self::MinusZero) + | (Self::Infinity, Self::MinusInfinity) + | (Self::MinusZero, Self::MinusInfinity) => Some(Ordering::Greater), + (Self::BigDecimal(_), Self::Infinity) + | (Self::MinusInfinity, Self::BigDecimal(_)) + | (Self::MinusInfinity, Self::Infinity) + | (Self::MinusInfinity, Self::MinusZero) + | (Self::MinusZero, Self::Infinity) => Some(Ordering::Less), (Self::BigDecimal(m), Self::MinusZero) => m.partial_cmp(&BigDecimal::zero()), - (Self::Infinity, Self::BigDecimal(_)) => Some(Ordering::Greater), - (Self::Infinity, Self::Infinity) => Some(Ordering::Equal), - (Self::Infinity, Self::MinusZero) => Some(Ordering::Greater), - (Self::Infinity, Self::MinusInfinity) => Some(Ordering::Greater), - (Self::MinusInfinity, Self::BigDecimal(_)) => Some(Ordering::Less), - (Self::MinusInfinity, Self::Infinity) => Some(Ordering::Less), - (Self::MinusInfinity, Self::MinusZero) => Some(Ordering::Less), - (Self::MinusInfinity, Self::MinusInfinity) => Some(Ordering::Equal), + (Self::Infinity, Self::Infinity) + | (Self::MinusZero, Self::MinusZero) + | (Self::MinusInfinity, Self::MinusInfinity) => Some(Ordering::Equal), (Self::MinusZero, Self::BigDecimal(n)) => BigDecimal::zero().partial_cmp(n), - (Self::MinusZero, Self::Infinity) => Some(Ordering::Less), - (Self::MinusZero, Self::MinusZero) => Some(Ordering::Equal), - (Self::MinusZero, Self::MinusInfinity) => Some(Ordering::Greater), - (Self::Nan, _) => None, - (Self::MinusNan, _) => None, - (_, Self::Nan) => None, - (_, Self::MinusNan) => None, + (Self::Nan, _) | (Self::MinusNan, _) | (_, Self::Nan) | (_, Self::MinusNan) => None, } } } diff --git a/src/uucore/src/lib/features/format/argument.rs b/src/uucore/src/lib/features/format/argument.rs index b4eea031384..449735245cf 100644 --- a/src/uucore/src/lib/features/format/argument.rs +++ b/src/uucore/src/lib/features/format/argument.rs @@ -208,28 +208,30 @@ fn extract_value( match p { Ok(v) => v, Err(e) => { - set_exit_code(1); - let input = locale_aware_escape_name(OsStr::new(input), QuotingStyle::C_NO_QUOTES); + let input = locale_aware_escape_name(OsStr::new(input), QuotingStyle::Escape); match e { - ExtendedParserError::Overflow(v) => { - show_error!("{}: Numerical result out of range", input.quote()); - v - } - ExtendedParserError::Underflow(v) => { + ExtendedParserError::Overflow(v) | ExtendedParserError::Underflow(v) => { + set_exit_code(1); show_error!("{}: Numerical result out of range", input.quote()); v } ExtendedParserError::NotNumeric => { + set_exit_code(1); show_error!("{}: expected a numeric value", input.quote()); Default::default() } ExtendedParserError::PartialMatch(v, rest) => { if quote_start { - set_exit_code(0); - show_warning!( - "{rest}: character(s) following character constant have been ignored" - ); + // GNU stays silent about the ignored trailing bytes when + // POSIXLY_CORRECT is set. Only the presence of the variable + // matters, its value is irrelevant. + if std::env::var_os("POSIXLY_CORRECT").is_none() { + show_warning!( + "{rest}: character(s) following character constant have been ignored" + ); + } } else { + set_exit_code(1); show_error!("{}: value not completely converted", input.quote()); } diff --git a/src/uucore/src/lib/features/format/escape.rs b/src/uucore/src/lib/features/format/escape.rs index 2c6a0283a6e..fa46403913c 100644 --- a/src/uucore/src/lib/features/format/escape.rs +++ b/src/uucore/src/lib/features/format/escape.rs @@ -26,6 +26,27 @@ pub enum OctalParsing { ThreeDigits = 3, } +/// Which escape sequences `parse_escape_code` recognizes. +/// +/// `printf` recognizes three sequences that `echo -e` does not: `\"`, and the +/// four- and eight-digit code point escapes `\u` and `\U`. Every other +/// sequence is shared by both, so this only selects whether those three are +/// interpreted or left exactly as written. +#[derive(Clone, Copy)] +pub enum EscapeSet { + /// Interpret `\"`, `\u` and `\U` on top of the shared sequences. + WithUnicodeAndQuote, + /// Only the shared sequences. `\"`, `\u` and `\U` are not escape sequences + /// and are emitted with their backslash, like any other unknown escape. + WithoutUnicodeAndQuote, +} + +impl EscapeSet { + fn has_unicode_and_quote(self) -> bool { + matches!(self, Self::WithUnicodeAndQuote) + } +} + #[derive(Clone, Copy)] enum Base { Oct(OctalParsing), @@ -121,21 +142,22 @@ pub enum EscapeError { pub fn parse_escape_code( rest: &mut &[u8], zero_octal_parsing: OctalParsing, + escape_set: EscapeSet, ) -> Result { if let [c, new_rest @ ..] = rest { // This is for the \NNN syntax for octal sequences. // Note that '0' is intentionally omitted because that // would be the \0NNN syntax. - if let b'1'..=b'7' = c { - if let Some(parsed) = parse_code(rest, Base::Oct(OctalParsing::ThreeDigits)) { - return Ok(EscapedChar::Byte(parsed)); - } + if let b'1'..=b'7' = c + && let Some(parsed) = parse_code(rest, Base::Oct(OctalParsing::ThreeDigits)) + { + return Ok(EscapedChar::Byte(parsed)); } *rest = new_rest; match c { b'\\' => Ok(EscapedChar::Byte(b'\\')), - b'"' => Ok(EscapedChar::Byte(b'"')), + b'"' if escape_set.has_unicode_and_quote() => Ok(EscapedChar::Byte(b'"')), b'a' => Ok(EscapedChar::Byte(b'\x07')), b'b' => Ok(EscapedChar::Byte(b'\x08')), b'c' => Ok(EscapedChar::End), @@ -149,24 +171,24 @@ pub fn parse_escape_code( if let Some(c) = parse_code(rest, Base::Hex) { Ok(EscapedChar::Byte(c)) } else { - Err(FormatError::MissingHex) + Err(FormatError::MissingHex(None)) } } b'0' => Ok(EscapedChar::Byte( parse_code(rest, Base::Oct(zero_octal_parsing)).unwrap_or(b'\0'), )), - b'u' => match parse_unicode(rest, 4) { + b'u' if escape_set.has_unicode_and_quote() => match parse_unicode(rest, 4) { Ok(c) => Ok(EscapedChar::Char(c)), - Err(EscapeError::MissingHexadecimalNumber) => Err(FormatError::MissingHex), + Err(EscapeError::MissingHexadecimalNumber) => Err(FormatError::MissingHex(None)), Err(EscapeError::InvalidCharacters(chars)) => { - Err(FormatError::InvalidCharacter('u', chars)) + Err(FormatError::InvalidCharacter('u', chars, None)) } }, - b'U' => match parse_unicode(rest, 8) { + b'U' if escape_set.has_unicode_and_quote() => match parse_unicode(rest, 8) { Ok(c) => Ok(EscapedChar::Char(c)), - Err(EscapeError::MissingHexadecimalNumber) => Err(FormatError::MissingHex), + Err(EscapeError::MissingHexadecimalNumber) => Err(FormatError::MissingHex(None)), Err(EscapeError::InvalidCharacters(chars)) => { - Err(FormatError::InvalidCharacter('U', chars)) + Err(FormatError::InvalidCharacter('U', chars, None)) } }, c => Ok(EscapedChar::Backslash(*c)), diff --git a/src/uucore/src/lib/features/format/mod.rs b/src/uucore/src/lib/features/format/mod.rs index 1f1c79de556..fa88dfe5903 100644 --- a/src/uucore/src/lib/features/format/mod.rs +++ b/src/uucore/src/lib/features/format/mod.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore extendedbigdecimal //! `printf`-style formatting @@ -37,26 +38,31 @@ pub mod human; pub mod num_format; mod spec; -pub use self::escape::{EscapedChar, OctalParsing}; +pub use self::escape::{EscapeSet, EscapedChar, OctalParsing}; use crate::extendedbigdecimal::ExtendedBigDecimal; pub use argument::{FormatArgument, FormatArguments}; use self::{escape::parse_escape_code, num_format::Formatter}; -use crate::{NonUtf8OsStrError, error::UError}; +use crate::{ + NonUtf8OsStrError, + error::{UError, strip_errno}, + translate, translate_text, +}; pub use spec::Spec; use std::{ error::Error, fmt::Display, io::{Write, stdout}, marker::PhantomData, - ops::ControlFlow, + ops::{ControlFlow, Range}, }; use os_display::Quotable; #[derive(Debug)] pub enum FormatError { - SpecError(Vec), + /// The spec that failed to parse and its byte range in the format string. + SpecError(Vec, Range), IoError(std::io::Error), NoMoreArguments, InvalidArgument(FormatArgument), @@ -67,13 +73,51 @@ pub enum FormatError { /// The format specifier ends with a %, as in `%f%`. EndsWithPercent(Vec), /// The escape sequence `\x` appears without a literal hexadecimal value. - MissingHex, + /// Carries its byte range in the format string, when it came from one. + MissingHex(Option>), /// The hexadecimal characters represent a code point that cannot represent a /// Unicode character (e.g., a surrogate code point) - InvalidCharacter(char, Vec), + /// Carries its byte range in the format string, when it came from one. + InvalidCharacter(char, Vec, Option>), InvalidEncoding(NonUtf8OsStrError), } +impl FormatError { + /// Attach the byte range of the token that raised a parse-time error. + /// + /// Escape errors are constructed where only the escape itself is in + /// sight; the format parser knows where that escape sat and fills the + /// span in here. Errors parsed out of other text — a `%b` argument, an + /// `echo` operand — keep `None`. + fn spanned(self, span: Range) -> Self { + match self { + Self::MissingHex(_) => Self::MissingHex(Some(span)), + Self::InvalidCharacter(c, digits, _) => Self::InvalidCharacter(c, digits, Some(span)), + other => other, + } + } +} + +/// The error for a spec [`Spec::parse`] rejected, carrying the byte range it +/// occupies in `fmt`. +/// +/// The range runs from the `%` that [`Spec::parse`] had already consumed to the +/// end of what it rejected. The caller passes where that `%` is rather than +/// letting this work it out from `slice`, which would go wrong for a `slice` +/// that is not a subslice of `fmt`. +/// +/// # Arguments +/// +/// * `fmt` - The whole format string. +/// * `percent` - The offset in `fmt` of the `%` this spec starts with. +/// * `slice` - The subslice of `fmt` the failed parse returned. +fn spec_error(fmt: &[u8], percent: usize, slice: &[u8]) -> FormatError { + let start = slice.as_ptr() as usize - fmt.as_ptr() as usize; + debug_assert!(start <= fmt.len() && start + slice.len() <= fmt.len()); + debug_assert!(percent < start); + FormatError::SpecError(slice.to_vec(), percent..start + slice.len()) +} + impl Error for FormatError {} impl UError for FormatError {} @@ -91,39 +135,38 @@ impl From for FormatError { impl Display for FormatError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::SpecError(s) => write!( - f, - "%{}: invalid conversion specification", - String::from_utf8_lossy(s) - ), - Self::TooManySpecs(s) => write!( - f, - "format '{}' has too many % directives", - String::from_utf8_lossy(s) - ), - Self::NeedAtLeastOneSpec(s) => write!( - f, - "format '{}' has no % directive", - String::from_utf8_lossy(s) - ), + // Everything quoted below is text the user typed and has to come back + // unchanged, hence translate_text! rather than translate!. + let message = match self { + Self::SpecError(s, _) => { + translate_text!("format-error-invalid-spec", "spec" => String::from_utf8_lossy(s)) + } + Self::TooManySpecs(s) => { + translate_text!("format-error-too-many-specs", "format" => String::from_utf8_lossy(s)) + } + Self::NeedAtLeastOneSpec(s) => { + translate_text!("format-error-no-spec", "format" => String::from_utf8_lossy(s)) + } Self::EndsWithPercent(s) => { - write!(f, "format {} ends in %", String::from_utf8_lossy(s).quote()) + translate_text!("format-error-ends-with-percent", "format" => String::from_utf8_lossy(s).quote()) + } + Self::InvalidPrecision(precision) => { + translate_text!("format-error-invalid-precision", "precision" => precision) } - 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(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"), - Self::InvalidCharacter(escape_char, digits) => write!( - f, - "invalid universal character name \\{escape_char}{}", - String::from_utf8_lossy(digits) + Self::WrongSpecType => translate!("format-error-wrong-spec-type"), + Self::IoError(e) => translate_text!("format-error-write", "error" => strip_errno(e)), + Self::NoMoreArguments => translate!("format-error-no-more-arguments"), + Self::InvalidArgument(_) => translate!("format-error-invalid-argument"), + Self::MissingHex(_) => translate!("format-error-missing-hex"), + Self::InvalidCharacter(escape_char, digits, _) => translate_text!( + "format-error-invalid-universal-character", + "escape" => escape_char, + "digits" => String::from_utf8_lossy(digits) ), - Self::InvalidEncoding(no) => no.fmt(f), - } + Self::InvalidEncoding(no) => return no.fmt(f), + }; + f.write_str(&message) } } @@ -202,10 +245,9 @@ impl FormatItem { args: &mut FormatArguments, ) -> Result, FormatError> { match self { - Self::Spec(spec) => spec.write(writer, args)?, - Self::Char(c) => return c.write(writer).map_err(FormatError::IoError), + Self::Spec(spec) => spec.write(writer, args), + Self::Char(c) => c.write(writer).map_err(FormatError::IoError), } - Ok(ControlFlow::Continue(())) } } @@ -221,16 +263,26 @@ pub fn parse_spec_and_escape( Some(Ok(FormatItem::Char(EscapedChar::Byte(b'%')))) } [b'%', rest @ ..] => { + let percent = fmt.len() - current.len(); current = rest; let spec = match Spec::parse(&mut current) { Ok(spec) => spec, - Err(slice) => return Some(Err(FormatError::SpecError(slice.to_vec()))), + Err(slice) => return Some(Err(spec_error(fmt, percent, slice))), }; Some(Ok(FormatItem::Spec(spec))) } [b'\\', rest @ ..] => { + let start = fmt.len() - current.len(); current = rest; - Some(parse_escape_code(&mut current, OctalParsing::default()).map(FormatItem::Char)) + Some( + parse_escape_code( + &mut current, + OctalParsing::default(), + EscapeSet::WithUnicodeAndQuote, + ) + .map(FormatItem::Char) + .map_err(|e| e.spanned(start..fmt.len() - current.len())), + ) } [c, rest @ ..] => { current = rest; @@ -252,10 +304,11 @@ pub fn parse_spec_only( Some(Ok(FormatItem::Char(b'%'))) } [b'%', rest @ ..] => { + let percent = fmt.len() - current.len(); current = rest; let spec = match Spec::parse(&mut current) { Ok(spec) => spec, - Err(slice) => return Some(Err(FormatError::SpecError(slice.to_vec()))), + Err(slice) => return Some(Err(spec_error(fmt, percent, slice))), }; Some(Ok(FormatItem::Spec(spec))) } @@ -270,6 +323,7 @@ pub fn parse_spec_only( pub fn parse_escape_only( fmt: &[u8], zero_octal_parsing: OctalParsing, + escape_set: EscapeSet, ) -> impl Iterator + '_ { let mut current = fmt; std::iter::from_fn(move || match current { @@ -277,7 +331,7 @@ pub fn parse_escape_only( [b'\\', rest @ ..] => { current = rest; Some( - parse_escape_code(&mut current, zero_octal_parsing) + parse_escape_code(&mut current, zero_octal_parsing, escape_set) .unwrap_or(EscapedChar::Backslash(b'x')), ) } diff --git a/src/uucore/src/lib/features/format/num_format.rs b/src/uucore/src/lib/features/format/num_format.rs index b0977114b6c..cfd01c1f4ac 100644 --- a/src/uucore/src/lib/features/format/num_format.rs +++ b/src/uucore/src/lib/features/format/num_format.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore bigdecimal prec cppreference bignums + //! Utilities for formatting numbers in various formats use bigdecimal::BigDecimal; @@ -278,21 +280,21 @@ impl Formatter<&ExtendedBigDecimal> for Float { // overflow/underflow, so this only guards values built without going // through it; treat them the same way a real float would (as +-inf/0) // rather than erroring. - if let ExtendedBigDecimal::BigDecimal(bd) = &abs { - if i32::try_from(bd.fractional_digit_count()).is_err() { - let overflow = bd.fractional_digit_count().is_negative(); - let abs = if overflow { - ExtendedBigDecimal::Infinity - } else { - ExtendedBigDecimal::zero() - }; - if alignment == NumberAlignment::RightZero { - alignment = NumberAlignment::RightSpace; - } - let s = format_float_non_finite(&abs, self.case); - let sign_indicator = get_sign_indicator(self.positive_sign, negative); - return write_output(writer, sign_indicator, s, self.width, alignment); + if let ExtendedBigDecimal::BigDecimal(bd) = &abs + && i32::try_from(bd.fractional_digit_count()).is_err() + { + let overflow = bd.fractional_digit_count().is_negative(); + let abs = if overflow { + ExtendedBigDecimal::Infinity + } else { + ExtendedBigDecimal::zero() + }; + if alignment == NumberAlignment::RightZero { + alignment = NumberAlignment::RightSpace; } + let s = format_float_non_finite(&abs, self.case); + let sign_indicator = get_sign_indicator(self.positive_sign, negative); + return write_output(writer, sign_indicator, s, self.width, alignment); } let s = if let ExtendedBigDecimal::BigDecimal(bd) = abs { @@ -413,6 +415,9 @@ fn format_float_non_finite(e: &ExtendedBigDecimal, case: Case) -> String { s } +/// Largest precision `format!` accepts; beyond it the formatter panics. +const MAX_FMT_PRECISION: usize = u16::MAX as usize; + fn format_float_decimal( bd: &BigDecimal, precision: Option, @@ -429,6 +434,19 @@ fn format_float_decimal( return format!("{bd:.0}."); } } + // `format!` stores width and precision as `u16` and panics above that, so + // write the digits we have and pad the remainder with zeros ourselves. + if precision > MAX_FMT_PRECISION { + let digits = usize::try_from(bd.fractional_digit_count().max(0)).unwrap_or(usize::MAX); + let written = digits.min(MAX_FMT_PRECISION); + let mut s = format!("{bd:.written$}"); + if written == 0 { + s.push('.'); + } + s.extend(std::iter::repeat_n('0', precision - written)); + return s; + } + format!("{bd:.precision$}") } @@ -749,6 +767,18 @@ fn strip_fractional_zeroes_and_dot(s: &mut String) { } } +fn write_padding(writer: &mut impl Write, byte: u8, mut len: usize) -> std::io::Result<()> { + // One KiB keeps stack use small while amortizing writes for large field widths. + const BUFFER_SIZE: usize = 1024; + let buffer = [byte; BUFFER_SIZE]; + + while len >= BUFFER_SIZE { + writer.write_all(&buffer)?; + len -= BUFFER_SIZE; + } + writer.write_all(&buffer[..len]) +} + fn write_output( mut writer: impl Write, sign_indicator: String, @@ -769,17 +799,18 @@ fn write_output( // Check if the width is too large for formatting super::check_width(remaining_width)?; + let padding = width.saturating_sub(sign_indicator.len().saturating_add(s.len())); + match alignment { - NumberAlignment::Left => write!(writer, "{sign_indicator}{s: { + writer.write_all(sign_indicator.as_bytes())?; + writer.write_all(s.as_bytes())?; + write_padding(&mut writer, b' ', padding) + } NumberAlignment::RightSpace => { - let is_sign = sign_indicator.starts_with('-') || sign_indicator.starts_with('+'); // When sign_indicator is in ['-', '+'] - if is_sign && remaining_width > 0 { - // Make sure sign_indicator is just next to number, e.g. "% +5.1f" 1 ==> $ +1.0 - let s = sign_indicator + s.as_str(); - write!(writer, "{s:>width$}", width = remaining_width + 1) // Since we now add sign_indicator and s together, plus 1 - } else { - write!(writer, "{sign_indicator}{s:>remaining_width$}") - } + write_padding(&mut writer, b' ', padding)?; + writer.write_all(sign_indicator.as_bytes())?; + writer.write_all(s.as_bytes()) } NumberAlignment::RightZero => { // Add the padding after "0x" for hexadecimals @@ -788,8 +819,10 @@ fn write_output( } else { ("", s.as_str()) }; - let remaining_width = remaining_width.saturating_sub(prefix.len()); - write!(writer, "{sign_indicator}{prefix}{rest:0>remaining_width$}") + writer.write_all(sign_indicator.as_bytes())?; + writer.write_all(prefix.as_bytes())?; + write_padding(&mut writer, b'0', padding)?; + writer.write_all(rest.as_bytes()) } } } @@ -840,6 +873,29 @@ mod test { assert_eq!(f(&ExtendedBigDecimal::Infinity), "INF"); } + #[test] + fn decimal_float_precision_beyond_formatter_limit() { + use super::format_float_decimal; + // A precision above u16::MAX cannot go through `format!` directly. + let f = |x: &str, precision| { + format_float_decimal( + &BigDecimal::from_str(x).unwrap(), + Some(precision), + ForceDecimal::No, + ) + }; + + let s = f("3.25", 70_123); + assert_eq!(s.len(), 70_125); + assert!(s.starts_with("3.25")); + assert!(s[4..].bytes().all(|b| b == b'0')); + + let s = f("7", 65_536); + assert_eq!(s.len(), 65_538); + assert!(s.starts_with("7.")); + assert!(s[2..].bytes().all(|b| b == b'0')); + } + #[test] fn decimal_float() { use super::format_float_decimal; diff --git a/src/uucore/src/lib/features/format/spec.rs b/src/uucore/src/lib/features/format/spec.rs index 1af75ae94d4..069d3069ad0 100644 --- a/src/uucore/src/lib/features/format/spec.rs +++ b/src/uucore/src/lib/features/format/spec.rs @@ -6,18 +6,14 @@ // spell-checker:ignore (vars) intmax ptrdiff padlen use super::{ - ExtendedBigDecimal, FormatChar, FormatError, OctalParsing, check_precision, + EscapeSet, ExtendedBigDecimal, FormatChar, FormatError, OctalParsing, check_precision, num_format::{ self, Case, FloatVariant, ForceDecimal, Formatter, NumberAlignment, PositiveSign, Prefix, UnsignedIntVariant, }, parse_escape_only, }; -use crate::{ - format::FormatArguments, - os_str_as_bytes, - quoting_style::{QuotingStyle, locale_aware_escape_name}, -}; +use crate::{format::FormatArguments, os_str_as_bytes, quoting_style::locale_aware_shell_escape}; use std::{io::Write, num::NonZero, ops::ControlFlow}; /// A parsed specification for formatting a value @@ -343,7 +339,8 @@ impl Spec { &self, mut writer: impl Write, args: &mut FormatArguments, - ) -> Result<(), FormatError> { + ) -> Result, FormatError> { + let mut control_flow = ControlFlow::Continue(()); match self { Self::Char { width, @@ -387,11 +384,17 @@ impl Spec { let bytes = os_str_as_bytes(os_str)?; let mut parsed = Vec::::new(); - for c in parse_escape_only(bytes, OctalParsing::ThreeDigits) { + for c in parse_escape_only( + bytes, + OctalParsing::ThreeDigits, + EscapeSet::WithUnicodeAndQuote, + ) { match c.write(&mut parsed)? { ControlFlow::Continue(()) => {} ControlFlow::Break(()) => { - // TODO: This should break the _entire execution_ of printf + // A `\c` inside the argument stops output for the + // rest of the printf invocation, not just this spec. + control_flow = ControlFlow::Break(()); break; } } @@ -399,12 +402,8 @@ impl Spec { writer.write_all(&parsed).map_err(FormatError::IoError) } Self::QuotedString { position } => { - let s = locale_aware_escape_name( - args.next_string(*position), - QuotingStyle::SHELL_ESCAPE, - ); - let bytes = os_str_as_bytes(&s)?; - writer.write_all(bytes).map_err(FormatError::IoError) + let s = locale_aware_shell_escape(args.next_string(*position)); + writer.write_all(s.as_bytes()).map_err(FormatError::IoError) } Self::SignedInt { width, @@ -496,7 +495,8 @@ impl Spec { .fmt(writer, &f) .map_err(FormatError::IoError) } - } + }?; + Ok(control_flow) } } @@ -511,7 +511,8 @@ fn resolve_asterisk_width( Some(CanAsterisk::Asterisk(loc)) => { let nb = args.next_i64(loc); if nb < 0 { - Some((usize::try_from(-(nb as isize)).ok().unwrap_or(0), true)) + // Unsigned arithmetic, so `i64::MIN` (magnitude 2^63) doesn't overflow. + Some((usize::try_from(nb.unsigned_abs()).ok().unwrap_or(0), true)) } else { Some((usize::try_from(nb).ok().unwrap_or(0), false)) } @@ -550,14 +551,31 @@ fn write_padded( if left { writer.write_all(text)?; - write!(writer, "{: padlen$}", "")?; + write_spaces(&mut writer, padlen)?; writer.write_all(text) } .map_err(FormatError::IoError) } +/// Write `n` space bytes directly to `writer`. +/// +/// Unlike `write!(writer, "{: std::io::Result<()> { + const SPACES: [u8; 64] = [b' '; 64]; + let mut remaining = n; + while remaining > 0 { + let chunk = remaining.min(SPACES.len()); + writer.write_all(&SPACES[..chunk])?; + remaining -= chunk; + } + Ok(()) +} + /// Check for a number ending with a '$' fn eat_argument_position(rest: &mut &[u8], index: &mut usize) -> Option { let original_index = *index; @@ -670,6 +688,26 @@ mod tests { ) ); } + + #[test] + fn asterisk_i64_min_width() { + // Regression test for https://github.com/uutils/coreutils/issues/13766 + // |i64::MIN| = 2^63 overflows i64, so the magnitude of a negative + // `*` width must be computed in unsigned arithmetic. + let expected = usize::try_from(i64::MIN.unsigned_abs()).unwrap_or(0); + for arg in [ + FormatArgument::SignedInt(i64::MIN), + FormatArgument::Unparsed(i64::MIN.to_string().into()), + ] { + assert_eq!( + Some((expected, true)), + resolve_asterisk_width( + Some(CanAsterisk::Asterisk(ArgumentLocation::NextArgument)), + &mut FormatArguments::new(&[arg]), + ) + ); + } + } } mod resolve_asterisk_precision { diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index afa87d27729..045ae3a3cef 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -3,13 +3,13 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -//! Set of functions to manage regular files, special files, and links. +// spell-checker:ignore backport Ioctl absolutized linkat symlinkat renameat unlinkat openat urandom NOFOLLOW CLOEXEC RDONLY unguessability -// spell-checker:ignore backport Ioctl absolutized +//! Set of functions to manage regular files, special files, and links. -#[cfg(all(unix, not(target_os = "redox")))] +use crate::translate; +#[cfg(all(unix, not(target_os = "haiku")))] pub use libc::{major, makedev, minor}; -use std::collections::HashSet; use std::collections::VecDeque; use std::env; use std::ffi::{OsStr, OsString}; @@ -18,7 +18,7 @@ use std::fs::read_dir; use std::hash::Hash; use std::io::Stdin; use std::io::{Error, ErrorKind, Result as IOResult}; -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] use std::os::fd::AsFd; #[cfg(unix)] use std::os::unix::fs::MetadataExt; @@ -27,17 +27,25 @@ use std::os::windows::ffi::{OsStrExt, OsStringExt}; #[cfg(windows)] use std::os::windows::io::AsRawHandle; use std::path::{Component, MAIN_SEPARATOR, Path, PathBuf}; -#[cfg(target_os = "windows")] -use winapi_util::AsHandleRef; +#[cfg(unix)] +use std::sync::OnceLock; #[cfg(windows)] use windows_sys::Win32::Foundation::MAX_PATH; #[cfg(windows)] -use windows_sys::Win32::Storage::FileSystem::{GetDiskFreeSpaceW, GetVolumePathNameW}; +use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetDiskFreeSpaceW, GetFileInformationByHandle, GetVolumePathNameW, +}; #[cfg(windows)] use windows_sys::Win32::System::IO::DeviceIoControl; #[cfg(windows)] use windows_sys::Win32::System::Ioctl::FSCTL_SET_SPARSE; +/// Maximum number of symlinks followed while resolving a single path. +/// +/// Matches the limit Linux enforces during path lookup; going past it is +/// reported as "Too many levels of symbolic links". +pub const SYMLINK_FOLLOW_LIMIT: usize = 40; + /// Used to check if the `mode` has its `perm` bit set. /// /// This macro expands to `mode & perm != 0`. @@ -52,24 +60,26 @@ macro_rules! has { /// Information to uniquely identify a file #[derive(Clone)] pub struct FileInformation( - #[cfg(unix)] rustix::fs::Stat, - #[cfg(windows)] winapi_util::file::Information, - // WASI does not have nix::sys::stat, so we store std::fs::Metadata instead. - #[cfg(target_os = "wasi")] fs::Metadata, + #[cfg(any(unix, target_os = "wasi"))] rustix::fs::Stat, + #[cfg(windows)] BY_HANDLE_FILE_INFORMATION, ); impl FileInformation { /// Get information from a currently open file - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] pub fn from_file(file: &impl AsFd) -> IOResult { let stat = rustix::fs::fstat(file)?; Ok(Self(stat)) } /// Get information from a currently open file - #[cfg(target_os = "windows")] - pub fn from_file(file: &impl AsHandleRef) -> IOResult { - let info = winapi_util::file::information(file.as_handle_ref())?; + #[cfg(windows)] + pub fn from_file(file: &impl AsRawHandle) -> IOResult { + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: `info` is a valid pointer to be populated by GetFileInformationByHandle. + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &raw mut info) } == 0 { + return Err(Error::last_os_error()); + } Ok(Self(info)) } @@ -78,7 +88,7 @@ impl FileInformation { /// If `path` points to a symlink and `dereference` is true, information about /// the link's target will be returned. pub fn from_path(path: impl AsRef, dereference: bool) -> IOResult { - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] { let stat = if dereference { rustix::fs::stat(path.as_ref()) @@ -87,10 +97,10 @@ impl FileInformation { }; Ok(Self(stat?)) } - #[cfg(target_os = "windows")] + #[cfg(windows)] { use std::fs::OpenOptions; - use std::os::windows::prelude::*; + use std::os::windows::fs::OpenOptionsExt; let mut open_options = OpenOptions::new(); let mut custom_flags = 0; if !dereference { @@ -102,118 +112,67 @@ impl FileInformation { let file = open_options.read(true).open(path.as_ref())?; Self::from_file(&file) } - // WASI: use std::fs::metadata / symlink_metadata since nix is not available - #[cfg(target_os = "wasi")] - { - let metadata = if dereference { - fs::metadata(path.as_ref()) - } else { - fs::symlink_metadata(path.as_ref()) - }; - Ok(Self(metadata?)) - } } pub fn file_size(&self) -> u64 { - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] { assert!(self.0.st_size >= 0, "File size is negative"); self.0.st_size.try_into().unwrap() } - #[cfg(target_os = "windows")] - { - self.0.file_size() - } - #[cfg(target_os = "wasi")] + #[cfg(windows)] { - self.0.len() + ((self.0.nFileSizeHigh as u64) << 32) | (self.0.nFileSizeLow as u64) } } #[cfg(windows)] pub fn file_index(&self) -> u64 { - self.0.file_index() + ((self.0.nFileIndexHigh as u64) << 32) | (self.0.nFileIndexLow as u64) } pub fn number_of_links(&self) -> u64 { - #[cfg(all( - unix, - not(target_vendor = "apple"), - not(target_os = "aix"), - not(target_os = "android"), - not(target_os = "freebsd"), - not(target_os = "netbsd"), - not(target_os = "openbsd"), - not(target_os = "illumos"), - not(target_os = "solaris"), - not(target_os = "cygwin"), - not(target_arch = "aarch64"), - not(target_arch = "riscv64"), - not(target_arch = "loongarch64"), - not(target_arch = "sparc64"), - target_pointer_width = "64" - ))] - return self.0.st_nlink; - #[cfg(all( - unix, - any( - target_vendor = "apple", - target_os = "android", - target_os = "netbsd", - target_os = "openbsd", - target_os = "illumos", - target_os = "solaris", - target_os = "cygwin", - target_arch = "aarch64", - target_arch = "riscv64", - target_arch = "loongarch64", - target_arch = "sparc64", - not(target_pointer_width = "64") - ) - ))] - 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(any(unix, target_os = "wasi"))] + { + #[cfg(any(target_os = "aix", target_os = "haiku"))] + return self.0.st_nlink.try_into().unwrap(); + #[cfg(not(any(target_os = "aix", target_os = "haiku")))] + #[allow(clippy::useless_conversion)] + return self.0.st_nlink.into(); + } #[cfg(windows)] - return self.0.number_of_links(); - // WASI: nlink is not available in std::fs::Metadata, return 1 - #[cfg(target_os = "wasi")] - return 1; + return self.0.nNumberOfLinks as u64; } - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] pub fn inode(&self) -> u64 { - #[cfg(all(not(any(target_os = "netbsd")), target_pointer_width = "64"))] - return self.0.st_ino; - #[cfg(any(target_os = "netbsd", not(target_pointer_width = "64")))] + #[cfg(target_os = "haiku")] + return self.0.st_ino.try_into().unwrap(); + #[cfg(not(target_os = "haiku"))] #[allow(clippy::useless_conversion)] return self.0.st_ino.into(); } -} -#[cfg(unix)] -impl PartialEq for FileInformation { - fn eq(&self, other: &Self) -> bool { - self.0.st_dev == other.0.st_dev && self.0.st_ino == other.0.st_ino + pub fn dev(&self) -> u64 { + #[cfg(any(unix, target_os = "wasi"))] + return self.0.st_dev as _; + #[cfg(windows)] + return self.0.dwVolumeSerialNumber as _; } } -// WASI: compare by file type and size as a basic heuristic since -// device/inode numbers are not available through std::fs::Metadata. -#[cfg(target_os = "wasi")] +#[cfg(any(unix, target_os = "wasi"))] impl PartialEq for FileInformation { fn eq(&self, other: &Self) -> bool { - self.0.file_type() == other.0.file_type() && self.0.len() == other.0.len() + self.0.st_dev == other.0.st_dev && self.0.st_ino == other.0.st_ino } } -#[cfg(target_os = "windows")] +#[cfg(windows)] impl PartialEq for FileInformation { fn eq(&self, other: &Self) -> bool { - self.0.volume_serial_number() == other.0.volume_serial_number() - && self.0.file_index() == other.0.file_index() + self.0.dwVolumeSerialNumber == other.0.dwVolumeSerialNumber + && self.file_index() == other.file_index() } } @@ -221,20 +180,15 @@ impl Eq for FileInformation {} impl Hash for FileInformation { fn hash(&self, state: &mut H) { - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] { self.0.st_dev.hash(state); self.0.st_ino.hash(state); } - #[cfg(target_os = "windows")] - { - self.0.volume_serial_number().hash(state); - self.0.file_index().hash(state); - } - #[cfg(target_os = "wasi")] + #[cfg(windows)] { - self.0.len().hash(state); - self.0.file_type().is_dir().hash(state); + self.0.dwVolumeSerialNumber.hash(state); + self.file_index().hash(state); } } } @@ -329,11 +283,10 @@ enum OwningComponent { impl OwningComponent { fn as_os_str(&self) -> &OsStr { match self { - Self::Prefix(s) => s.as_os_str(), + Self::Prefix(s) | Self::Normal(s) => s.as_os_str(), Self::RootDir => Component::RootDir.as_os_str(), Self::CurDir => Component::CurDir.as_os_str(), Self::ParentDir => Component::ParentDir.as_os_str(), - Self::Normal(s) => s.as_os_str(), } } } @@ -350,6 +303,22 @@ impl<'a> From> for OwningComponent { } } +/// Confirm that `path` (already known to exist) is a directory, without +/// requiring permission to list its contents. +/// +/// `read_dir` alone would raise the right error for a non-directory, but +/// also requires listing permission on the target, which a plain "is this a +/// directory" check should not need (e.g. `realpath /root/` succeeds for +/// non-root users even though they can't list `/root`). +fn ensure_is_directory(path: &Path) -> IOResult<()> { + if fs::metadata(path)?.is_dir() { + Ok(()) + } else { + read_dir(path)?; + Ok(()) + } +} + /// Return the canonical, absolute form of a path. /// /// This function is a generalization of [`std::fs::canonicalize`] that @@ -382,7 +351,6 @@ pub fn canonicalize>( miss_mode: MissingHandling, res_mode: ResolveMode, ) -> IOResult { - const SYMLINKS_TO_LOOK_FOR_LOOPS: i32 = 20; let original = original.as_ref(); let has_to_be_directory = (miss_mode == MissingHandling::Normal || miss_mode == MissingHandling::Existing) && { @@ -403,7 +371,6 @@ pub fn canonicalize>( let mut parts: VecDeque = path.components().map(Into::into).collect(); let mut result = PathBuf::new(); let mut followed_symlinks = 0; - let mut visited_files = HashSet::new(); while let Some(part) = parts.pop_front() { match part { OwningComponent::Prefix(s) => { @@ -426,21 +393,14 @@ pub fn canonicalize>( for link_part in link_path.components().rev() { parts.push_front(link_part.into()); } - if followed_symlinks < SYMLINKS_TO_LOOK_FOR_LOOPS { - followed_symlinks += 1; - } else { - let file_info = - FileInformation::from_path(result.parent().unwrap(), false).unwrap(); - let mut path_to_follow = PathBuf::new(); - for part in &parts { - path_to_follow.push(part.as_os_str()); - } - if !visited_files.insert((file_info, path_to_follow)) { - return Err(Error::new( - ErrorKind::InvalidInput, - "Too many levels of symbolic links", - )); // TODO use ErrorKind::FilesystemLoop when stable - } + // A fixed cap rather than tracking visited links: loops such + // as `foo -> foo/bar` never revisit the same state. + followed_symlinks += 1; + if followed_symlinks > SYMLINK_FOLLOW_LIMIT { + return Err(Error::new( + ErrorKind::InvalidInput, + translate!("error-too-many-symlink-levels"), + )); // TODO use ErrorKind::FilesystemLoop when stable } result.pop(); } @@ -457,13 +417,13 @@ pub fn canonicalize>( match miss_mode { MissingHandling::Existing => { if has_to_be_directory { - read_dir(&result)?; + ensure_is_directory(&result)?; } } MissingHandling::Normal => { if result.exists() { if has_to_be_directory { - read_dir(&result)?; + ensure_is_directory(&result)?; } } else if let Some(parent) = result.parent() { read_dir(parent)?; @@ -474,8 +434,8 @@ pub fn canonicalize>( Ok(result) } -#[cfg(not(unix))] /// Display the permissions of a file +#[cfg(not(unix))] pub fn display_permissions(metadata: &fs::Metadata, display_file_type: bool) -> String { let write = if metadata.permissions().readonly() { '-' @@ -498,8 +458,8 @@ pub fn display_permissions(metadata: &fs::Metadata, display_file_type: bool) -> } } -#[cfg(unix)] /// Display the permissions of a file +#[cfg(unix)] pub fn display_permissions(metadata: &fs::Metadata, display_file_type: bool) -> String { display_permissions_unix(metadata.mode(), display_file_type) } @@ -566,10 +526,9 @@ fn get_file_display(mode: u32) -> char { } } -// The logic below is more readable written this way. -#[allow(clippy::if_not_else)] -#[allow(clippy::cognitive_complexity)] /// Display the unix permissions of a file +#[allow(clippy::if_not_else, reason = "more readable written this way")] +#[allow(clippy::cognitive_complexity)] pub fn display_permissions_unix(mode: u32, display_file_type: bool) -> String { use mode::{ S_IRGRP, S_IROTH, S_IRUSR, S_ISGID, S_ISUID, S_ISVTX, S_IWGRP, S_IWOTH, S_IWUSR, S_IXGRP, @@ -649,6 +608,119 @@ pub fn infos_refer_to_same_file( info1.is_ok() && info1.ok() == info2.ok() } +/// The identity of `/`, stat'd once per process (like GNU's `get_root_dev_ino`). +#[cfg(unix)] +fn root_file_information() -> Option<&'static FileInformation> { + static ROOT: OnceLock> = OnceLock::new(); + ROOT.get_or_init(|| FileInformation::from_path(Path::new("/"), true).ok()) + .as_ref() +} + +/// Whether `path` *is* `/`, by `(st_dev, st_ino)` rather than by name. +/// +/// A bind mount of `/` (`mount --bind / /mnt`) is a real directory whose path +/// never resolves to `/`, so a name-based `--preserve-root` check misses it; +/// GNU compares dev/ino for the same reason. `dereference` says whether a +/// symlink at `path` is about to be followed (only then does a link to `/` +/// count). Returns `false` if `path` or `/` cannot be stat'd, or off unix. +pub fn path_is_root_dir>(path: P, dereference: bool) -> bool { + #[cfg(unix)] + { + let Some(root) = root_file_information() else { + return false; + }; + FileInformation::from_path(path, dereference).is_ok_and(|info| &info == root) + } + #[cfg(not(unix))] + { + let _ = (path, dereference); + false + } +} + +/// Check if two files are identical by comparing their contents. +/// +/// Returns `Ok(true)` if both files exist, are regular files, and have identical contents. +/// Returns `Ok(false)` if the files differ in size, aren't both regular files, or have different contents. +/// Returns `Err` if an I/O error occurs while opening or reading either file. +/// +/// # Examples +/// +/// ``` +/// use std::io::Write; +/// use tempfile::NamedTempFile; +/// use uucore::fs::are_files_identical; +/// +/// let mut file1 = NamedTempFile::new().unwrap(); +/// let mut file2 = NamedTempFile::new().unwrap(); +/// file1.write_all(b"hello world").unwrap(); +/// file2.write_all(b"hello world").unwrap(); +/// +/// assert!(are_files_identical(file1.path(), file2.path()).unwrap()); +/// ``` +pub fn are_files_identical(path1: impl AsRef, path2: impl AsRef) -> IOResult { + use std::fs::{File, metadata}; + use std::io::{BufReader, ErrorKind, Read}; + + let path1 = path1.as_ref(); + let path2 = path2.as_ref(); + + // First compare file sizes + let metadata1 = metadata(path1)?; + let metadata2 = metadata(path2)?; + + if metadata1.len() != metadata2.len() { + 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)?; + + let mut reader1 = BufReader::new(file1); + let mut reader2 = BufReader::new(file2); + + let mut buffer1 = [0; 8192]; + let mut buffer2 = [0; 8192]; + + loop { + // Read from first file with EINTR retry handling + // This loop retries the read operation if it's interrupted by signals (e.g., SIGUSR1) + // 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() == ErrorKind::Interrupted => {} + result => break result?, + } + }; + + // Read from second file with EINTR retry handling + // 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() == ErrorKind::Interrupted => {} + result => break result?, + } + }; + + if bytes1 != bytes2 { + return Ok(false); + } + + if bytes1 == 0 { + return Ok(true); + } + + if buffer1[..bytes1] != buffer2[..bytes2] { + return Ok(false); + } + } +} + /// Converts absolute `path` to be relative to absolute `to` path. pub fn make_path_relative_to, P2: AsRef>(path: P1, to: P2) -> PathBuf { let path = path.as_ref(); @@ -677,6 +749,7 @@ pub fn make_path_relative_to, P2: AsRef>(path: P1, to: P2) /// Checks if there is a symlink loop in the given path. /// /// A symlink loop is a chain of symlinks where the last symlink points back to one of the previous symlinks in the chain. +/// Like the kernel, a chain longer than [`SYMLINK_FOLLOW_LIMIT`] is treated as a loop. /// /// # Arguments /// @@ -686,23 +759,22 @@ pub fn make_path_relative_to, P2: AsRef>(path: P1, to: P2) /// /// * `bool` - Returns `true` if a symlink loop is detected, `false` otherwise. pub fn is_symlink_loop(path: &Path) -> bool { - let mut visited_symlinks = HashSet::new(); let mut current_path = path.to_path_buf(); - while let (Ok(metadata), Ok(link)) = ( - current_path.symlink_metadata(), - fs::read_link(¤t_path), - ) { + for _ in 0..=SYMLINK_FOLLOW_LIMIT { + let (Ok(metadata), Ok(link)) = ( + current_path.symlink_metadata(), + fs::read_link(¤t_path), + ) else { + return false; + }; if !metadata.file_type().is_symlink() { return false; } - if !visited_symlinks.insert(current_path.clone()) { - return true; - } current_path = link; } - false + true } #[cfg(not(unix))] @@ -723,9 +795,12 @@ pub fn are_hardlinks_to_same_file(_source: &Path, _target: &Path) -> bool { /// * `bool` - Returns `true` if the paths are hard links to the same file, and `false` otherwise. #[cfg(unix)] pub fn are_hardlinks_to_same_file(source: &Path, target: &Path) -> bool { - let (Ok(source_metadata), Ok(target_metadata)) = - (fs::symlink_metadata(source), fs::symlink_metadata(target)) - else { + // The target is usually the one that does not exist, so look it up first + // and return early instead of also querying the source for nothing. + let Ok(target_metadata) = fs::symlink_metadata(target) else { + return false; + }; + let Ok(source_metadata) = fs::symlink_metadata(source) else { return false; }; @@ -749,9 +824,12 @@ pub fn are_hardlinks_or_one_way_symlink_to_same_file(_source: &Path, _target: &P /// * `bool` - Returns `true` if either of above conditions are true, and `false` otherwise. #[cfg(unix)] pub fn are_hardlinks_or_one_way_symlink_to_same_file(source: &Path, target: &Path) -> bool { - let (Ok(source_metadata), Ok(target_metadata)) = - (fs::metadata(source), fs::symlink_metadata(target)) - else { + // As above, look up the target first: if it does not exist, there is + // nothing to compare the source with. + let Ok(target_metadata) = fs::symlink_metadata(target) else { + return false; + }; + let Ok(source_metadata) = fs::metadata(source) else { return false; }; @@ -771,12 +849,21 @@ pub fn are_hardlinks_or_one_way_symlink_to_same_file(source: &Path, target: &Pat pub fn path_ends_with_terminator(path: &Path) -> bool { #[cfg(unix)] use std::os::unix::prelude::OsStrExt; - #[cfg(target_os = "wasi")] + #[cfg(all(target_os = "wasi", target_env = "p1"))] use std::os::wasi::ffi::OsStrExt; - path.as_os_str() + + #[cfg(all(target_os = "wasi", target_env = "p2"))] + return path + .as_os_str() + .as_encoded_bytes() + .last() + .is_some_and(|&byte| byte == b'/'); + #[cfg(not(all(target_os = "wasi", target_env = "p2")))] + return path + .as_os_str() .as_bytes() .last() - .is_some_and(|&byte| byte == b'/') + .is_some_and(|&byte| byte == b'/'); } #[cfg(windows)] @@ -798,13 +885,17 @@ pub fn path_ends_with_terminator(path: &Path) -> bool { /// /// * `bool` - Returns `true` if stdin is a directory, `false` otherwise. pub fn is_stdin_directory(stdin: &Stdin) -> bool { - #[cfg(unix)] + #[cfg(any(unix, all(target_os = "wasi", target_env = "p2")))] { use mode::{S_IFDIR, S_IFMT}; - let mode = rustix::fs::fstat(stdin).unwrap().st_mode as u32; - // We use the S_IFMT mask ala S_ISDIR() to avoid mistaking - // sockets for directories. - mode & S_IFMT == S_IFDIR + if let Ok(stat) = rustix::fs::fstat(stdin) { + #[allow(clippy::unnecessary_cast)] + let mode = stat.st_mode as u32; + // We use the S_IFMT mask ala S_ISDIR() to avoid mistaking + // sockets for directories. + return mode & S_IFMT == S_IFDIR; + } + false } #[cfg(windows)] @@ -817,8 +908,8 @@ pub fn is_stdin_directory(stdin: &Stdin) -> bool { false } - // WASI: stdin is never a directory - #[cfg(target_os = "wasi")] + // WASI P1: stdin is never a directory + #[cfg(all(target_os = "wasi", target_env = "p1"))] { let _ = stdin; false @@ -1002,22 +1093,127 @@ pub fn get_filename(file: &Path) -> Option<&str> { file.file_name().and_then(|filename| filename.to_str()) } -// Redox's libc appears not to include the following utilities +/// Atomically replace `dest` with a new link to `target` — symbolic if +/// `symbolic` is set, hard otherwise. +/// +/// Never unlinks `dest` first, which would briefly free the name for another +/// user to claim. Try the create; if the name is taken, build the link under a +/// random temporary name in the same directory and `renameat(2)` it over. +/// +/// # Errors +/// +/// Returns an error if the link cannot be created, if the parent directory +/// cannot be opened, or if no unique temporary name is available. +pub fn replace_link(target: &Path, dest: &Path, symbolic: bool) -> IOResult<()> { + #[cfg(all(unix, not(target_os = "redox")))] + { + use rustix::fs::{AtFlags, CWD, Mode, OFlags, openat, renameat, unlinkat}; + use std::ffi::OsStr; + use std::io::Read; + use std::os::unix::ffi::OsStrExt; + + // GNU's template is `CuXXXXXX`: a 2-char prefix plus 6 random chars + // from a 62-char alphabet. The ~3% modulo bias per slot is irrelevant + // for an 8-char unguessability budget. + const ALPHABET: &[u8; 62] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + match link_at(target, CWD, dest.as_os_str(), symbolic) { + Err(e) if e.kind() == ErrorKind::AlreadyExists => {} + res => return res, + } -#[cfg(target_os = "redox")] -pub fn major(dev: libc::dev_t) -> libc::c_uint { - (((dev >> 8) & 0xFFF) | ((dev >> 32) & 0xFFFFF000)) as _ + let parent = dest + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let basename = dest + .file_name() + .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "invalid link path"))?; + // No NOFOLLOW: the parent may be a symlink to a directory, which the + // create attempt above already followed. + let dir = openat( + CWD, + parent, + OFlags::DIRECTORY | OFlags::RDONLY | OFlags::CLOEXEC, + Mode::empty(), + )?; + let mut urandom = fs::File::open("/dev/urandom")?; + + for _ in 0..32 { + let mut name = *b"Cu------"; + let mut raw = [0u8; 6]; + urandom.read_exact(&mut raw)?; + for (slot, byte) in name[2..].iter_mut().zip(raw) { + *slot = ALPHABET[byte as usize % ALPHABET.len()]; + } + let tmp = OsStr::from_bytes(&name); + + match link_at(target, &dir, tmp, symbolic) { + Ok(()) => { + let renamed = renameat(&dir, tmp, &dir, basename); + // Renaming onto an existing link to the same inode is a + // no-op, which leaves the temp behind. + let _ = unlinkat(&dir, tmp, AtFlags::empty()); + return renamed.map_err(Into::into); + } + Err(e) if e.kind() == ErrorKind::AlreadyExists => {} + Err(e) => return Err(e), + } + } + Err(Error::new( + ErrorKind::AlreadyExists, + "no unique temporary name available in the destination directory", + )) + } + #[cfg(any(windows, target_os = "redox", target_os = "wasi"))] + { + // No atomic replace available here; this leaves the window described + // above, accepted only where the platform offers nothing better. + match create_link_std(target, dest, symbolic) { + Err(e) if e.kind() == ErrorKind::AlreadyExists => { + fs::remove_file(dest)?; + create_link_std(target, dest, symbolic) + } + res => res, + } + } } -#[cfg(target_os = "redox")] -pub fn minor(dev: libc::dev_t) -> libc::c_uint { - ((dev & 0xFF) | ((dev >> 12) & 0xFFFFF00)) as _ +/// `symlinkat`/`linkat` relative to an open directory. +#[cfg(all(unix, not(target_os = "redox")))] +fn link_at(target: &Path, dir: Fd, name: &OsStr, symbolic: bool) -> IOResult<()> { + use rustix::fs::{AtFlags, CWD, linkat, symlinkat}; + + if symbolic { + symlinkat(target, dir, name).map_err(Into::into) + } else { + // `AtFlags::empty()` matches `std::fs::hard_link`: not dereferenced. + linkat(CWD, target, dir, name, AtFlags::empty()).map_err(Into::into) + } } -#[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(any(windows, target_os = "redox", target_os = "wasi"))] +fn create_link_std(target: &Path, dest: &Path, symbolic: bool) -> IOResult<()> { + if !symbolic { + return fs::hard_link(target, dest); + } + #[cfg(windows)] + { + if target.is_dir() { + std::os::windows::fs::symlink_dir(target, dest) + } else { + std::os::windows::fs::symlink_file(target, dest) + } + } + #[cfg(target_os = "redox")] + { + std::os::unix::fs::symlink(target, dest) + } + #[cfg(target_os = "wasi")] + { + rustix::fs::symlinkat(target, rustix::fs::CWD, dest).map_err(Into::into) + } } #[cfg(test)] @@ -1205,6 +1401,40 @@ mod tests { assert!(is_symlink_loop(&symlink1_path)); } + #[cfg(unix)] + #[test] + fn test_is_symlink_loop_follow_limit() { + let temp_dir = tempdir().unwrap(); + let link = |i: usize| temp_dir.path().join(format!("link{i}")); + let target = temp_dir.path().join("target"); + fs::File::create(&target).unwrap(); + unix::fs::symlink(&target, link(1)).unwrap(); + for i in 2..=SYMLINK_FOLLOW_LIMIT + 1 { + unix::fs::symlink(link(i - 1), link(i)).unwrap(); + } + + assert!(!is_symlink_loop(&link(SYMLINK_FOLLOW_LIMIT))); + assert!(is_symlink_loop(&link(SYMLINK_FOLLOW_LIMIT + 1))); + } + + #[cfg(unix)] + #[test] + fn test_canonicalize_growing_symlink_loop() { + let temp_dir = tempdir().unwrap(); + let foo = temp_dir.path().join("foo"); + unix::fs::symlink("foo/bar", &foo).unwrap(); + + for miss_mode in [ + MissingHandling::Normal, + MissingHandling::Existing, + MissingHandling::Missing, + ] { + for res_mode in [ResolveMode::Physical, ResolveMode::Logical] { + assert!(canonicalize(&foo, miss_mode, res_mode).is_err()); + } + } + } + #[cfg(unix)] #[test] fn test_are_hardlinks_to_same_file_same_file() { @@ -1332,4 +1562,59 @@ mod tests { let attributes = file.as_file().metadata().unwrap().file_attributes(); assert_ne!(attributes & FILE_ATTRIBUTE_SPARSE_FILE, 0); } + + #[test] + fn test_are_files_identical() { + use std::io::Write; + use tempfile::NamedTempFile; + + let mut file1 = NamedTempFile::new().unwrap(); + let mut file2 = NamedTempFile::new().unwrap(); + let mut file3 = NamedTempFile::new().unwrap(); + + file1.write_all(b"hello world").unwrap(); + file2.write_all(b"hello world").unwrap(); + file3.write_all(b"hello rust!").unwrap(); + + // Identical contents + assert!(are_files_identical(file1.path(), file2.path()).unwrap()); + + // Same size, different contents + assert!(!are_files_identical(file1.path(), file3.path()).unwrap()); + + // Different size + let mut file4 = NamedTempFile::new().unwrap(); + file4.write_all(b"hello").unwrap(); + assert!(!are_files_identical(file1.path(), file4.path()).unwrap()); + + // Non-existent file + assert!(are_files_identical(file1.path(), "non_existent_file_path").is_err()); + } + + #[cfg(unix)] + #[test] + fn test_path_is_root_dir() { + assert!(path_is_root_dir("/", true)); + assert!(path_is_root_dir("/", false)); + // Reached by a different name, still the same directory. + assert!(path_is_root_dir("/..", true)); + assert!(path_is_root_dir("/tmp/..", true)); + + let dir = tempdir().unwrap(); + assert!(!path_is_root_dir(dir.path(), true)); + assert!(!path_is_root_dir(dir.path().join("nonexistent"), true)); + assert!(!path_is_root_dir("", true)); + } + + /// A symlink to `/` counts only when the caller would follow it. + #[cfg(unix)] + #[test] + fn test_path_is_root_dir_symlink() { + let dir = tempdir().unwrap(); + let link = dir.path().join("root-link"); + unix::fs::symlink("/", &link).unwrap(); + + assert!(path_is_root_dir(&link, true)); + assert!(!path_is_root_dir(&link, false)); + } } diff --git a/src/uucore/src/lib/features/fsext.rs b/src/uucore/src/lib/features/fsext/mod.rs similarity index 76% rename from src/uucore/src/lib/features/fsext.rs rename to src/uucore/src/lib/features/fsext/mod.rs index 0ede0f2875f..54f442032ed 100644 --- a/src/uucore/src/lib/features/fsext.rs +++ b/src/uucore/src/lib/features/fsext/mod.rs @@ -3,9 +3,12 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore DATETIME getmntinfo subsecond (fs) cifs smbfs ohos virtiofs + //! Set of functions to manage file systems -// spell-checker:ignore DATETIME getmntinfo subsecond (fs) cifs smbfs +#[cfg(windows)] +mod windows; #[cfg(any(target_os = "linux", target_os = "android", target_os = "cygwin"))] const LINUX_MTAB: &str = "/etc/mtab"; @@ -13,64 +16,32 @@ const LINUX_MTAB: &str = "/etc/mtab"; const LINUX_MOUNTINFO: &str = "/proc/self/mountinfo"; #[cfg(all(unix, not(any(target_os = "aix", target_os = "redox"))))] static MOUNT_OPT_BIND: &str = "bind"; -#[cfg(windows)] -const MAX_PATH: usize = 266; -#[cfg(windows)] -static EXIT_ERR: i32 = 1; #[cfg(any( - target_os = "freebsd", target_vendor = "apple", + target_os = "freebsd", target_os = "netbsd", target_os = "openbsd" ))] use crate::os_str_from_bytes; -#[cfg(windows)] -use crate::show_warning; -#[cfg(not(target_os = "wasi"))] +#[cfg(unix)] use std::ffi::OsStr; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; -#[cfg(windows)] -use std::os::windows::ffi::OsStrExt; -#[cfg(windows)] -use windows_sys::Win32::{ - Foundation::{ERROR_NO_MORE_FILES, INVALID_HANDLE_VALUE}, - Storage::FileSystem::{ - FindFirstVolumeW, FindNextVolumeW, FindVolumeClose, GetDiskFreeSpaceW, GetDriveTypeW, - GetVolumeInformationW, GetVolumePathNamesForVolumeNameW, QueryDosDeviceW, - }, - System::WindowsProgramming::DRIVE_REMOTE, -}; - -#[cfg(windows)] -#[allow(non_snake_case)] -fn LPWSTR2String(buf: &[u16]) -> String { - let len = buf.iter().position(|&n| n == 0).unwrap(); - String::from_utf16(&buf[..len]).unwrap() -} - -#[cfg(windows)] -fn to_nul_terminated_wide_string(s: impl AsRef) -> Vec { - s.as_ref() - .encode_wide() - .chain(Some(0)) - .collect::>() -} +#[cfg(unix)] +use core::ffi::CStr; #[cfg(unix)] use libc::{ S_IFBLK, S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, S_IFMT, S_IFREG, S_IFSOCK, mode_t, strerror, }; #[cfg(unix)] -use std::ffi::{CStr, CString}; -#[cfg(not(target_os = "wasi"))] +use std::ffi::CString; +#[cfg(unix)] use std::io::Error as IOError; #[cfg(unix)] use std::mem; -#[cfg(windows)] -use std::path::Path; use std::time::SystemTime; #[cfg(unix)] use std::time::UNIX_EPOCH; @@ -83,9 +54,9 @@ use std::os::unix::fs::MetadataExt; use std::time::Duration; #[cfg(any( + target_vendor = "apple", target_os = "linux", target_os = "android", - target_vendor = "apple", target_os = "freebsd", target_os = "openbsd" ))] @@ -102,9 +73,9 @@ pub use libc::statfs as StatFs; pub use libc::statvfs as StatFs; #[cfg(any( + target_vendor = "apple", target_os = "linux", target_os = "android", - target_vendor = "apple", target_os = "freebsd", target_os = "openbsd", ))] @@ -185,7 +156,7 @@ pub fn metadata_get_time(md: &Metadata, md_time: MetadataTimeField) -> Option { - dev_name = String::from_utf8_lossy(raw[0]).to_string(); - fs_type = String::from_utf8_lossy(raw[2]).to_string(); - mount_root = OsString::new(); - mount_dir = OsString::from_vec(replace_special_chars(raw[1])); - mount_option = String::from_utf8_lossy(raw[3]).to_string(); + ( + String::from_utf8_lossy(raw[after_fields + 1]).to_string(), + String::from_utf8_lossy(raw[after_fields]).to_string(), + OsStr::from_bytes(raw[3]).to_owned(), + OsString::from_vec(replace_special_chars(raw[4])), + String::from_utf8_lossy(raw[5]).to_string(), + ) } + LINUX_MTAB => ( + String::from_utf8_lossy(raw[0]).to_string(), + String::from_utf8_lossy(raw[2]).to_string(), + OsString::new(), + OsString::from_vec(replace_special_chars(raw[1])), + String::from_utf8_lossy(raw[3]).to_string(), + ), _ => return None, - } + }; - let dev_id = mount_dev_id(&mount_dir); + // Prefer the device number from mountinfo: stat()ing the mount point + // would trigger automounts, which can hang. + let dev_id = (file_name == LINUX_MOUNTINFO) + .then(|| parse_dev_id(raw[2])) + .flatten() + .unwrap_or_else(|| mount_dev_id(&mount_dir)); let dummy = is_dummy_filesystem(&fs_type, &mount_option); let remote = is_remote_filesystem(&dev_name, &fs_type); @@ -268,84 +240,11 @@ impl MountInfo { dummy, }) } - - #[cfg(windows)] - fn new(mut volume_name: String) -> Option { - let mut dev_name_buf = [0u16; MAX_PATH]; - volume_name.pop(); - unsafe { - QueryDosDeviceW( - OsStr::new(&volume_name) - .encode_wide() - .chain(Some(0)) - .skip(4) - .collect::>() - .as_ptr(), - dev_name_buf.as_mut_ptr(), - dev_name_buf.len() as u32, - ) - }; - volume_name.push('\\'); - let dev_name = LPWSTR2String(&dev_name_buf); - - let mut mount_root_buf = [0u16; MAX_PATH]; - let success = unsafe { - let volume_name = to_nul_terminated_wide_string(&volume_name); - GetVolumePathNamesForVolumeNameW( - volume_name.as_ptr(), - mount_root_buf.as_mut_ptr(), - mount_root_buf.len() as u32, - ptr::null_mut(), - ) - }; - if 0 == success { - // TODO: support the case when `GetLastError()` returns `ERROR_MORE_DATA` - return None; - } - // TODO: This should probably call `OsString::from_wide`, but unclear if - // terminating zeros need to be striped first. - let mount_root = LPWSTR2String(&mount_root_buf); - - let mut fs_type_buf = [0u16; MAX_PATH]; - let success = unsafe { - let mount_root = to_nul_terminated_wide_string(&mount_root); - GetVolumeInformationW( - mount_root.as_ptr(), - ptr::null_mut(), - 0, - ptr::null_mut(), - ptr::null_mut(), - ptr::null_mut(), - fs_type_buf.as_mut_ptr(), - fs_type_buf.len() as u32, - ) - }; - let fs_type = if 0 == success { - None - } else { - Some(LPWSTR2String(&fs_type_buf)) - }; - let remote = DRIVE_REMOTE - == unsafe { - let mount_root = to_nul_terminated_wide_string(&mount_root); - GetDriveTypeW(mount_root.as_ptr()) - }; - Some(Self { - dev_id: volume_name, - dev_name, - fs_type: fs_type.unwrap_or_default(), - mount_root: mount_root.into(), // TODO: We should figure out how to keep an OsString here. - mount_dir: OsString::new(), - mount_option: String::new(), - remote, - dummy: false, - }) - } } #[cfg(any( - target_os = "freebsd", target_vendor = "apple", + target_os = "freebsd", target_os = "netbsd", target_os = "openbsd", ))] @@ -367,7 +266,7 @@ impl From for MountInfo { // spell-checker:disable-next-line CStr::from_ptr(statfs.f_mntonname.as_ptr()).to_bytes() }; - let mount_dir = os_str_from_bytes(mount_dir_bytes).unwrap().into_owned(); + let mount_dir = os_str_from_bytes(mount_dir_bytes).unwrap().to_owned(); let dev_id = mount_dev_id(&mount_dir); let dummy = is_dummy_filesystem(&fs_type, ""); @@ -386,7 +285,7 @@ impl From for MountInfo { } } -#[cfg(all(unix, not(target_os = "redox")))] +#[cfg(all(unix, not(any(target_os = "aix", target_os = "redox"))))] fn is_dummy_filesystem(fs_type: &str, mount_option: &str) -> bool { // spell-checker:disable match fs_type { @@ -416,6 +315,14 @@ fn is_remote_filesystem(dev_name: &str, fs_type: &str) -> bool { || dev_name == "-hosts" } +/// Parse the `major:minor` field of a mountinfo line like [`mount_dev_id`]. +#[cfg(any(target_os = "linux", target_os = "android", target_os = "cygwin"))] +fn parse_dev_id(field: &[u8]) -> Option { + let (major, minor) = std::str::from_utf8(field).ok()?.split_once(':')?; + let dev = libc::makedev(major.parse().ok()?, minor.parse().ok()?); + Some((dev as i32).to_string()) +} + #[cfg(all(unix, not(any(target_os = "aix", target_os = "redox"))))] fn mount_dev_id(mount_dir: &OsStr) -> String { use std::os::unix::fs::MetadataExt; @@ -428,14 +335,12 @@ fn mount_dev_id(mount_dir: &OsStr) -> String { } } -#[cfg(not(target_os = "wasi"))] use crate::error::UResult; #[cfg(any( - target_os = "freebsd", target_vendor = "apple", + target_os = "freebsd", target_os = "netbsd", - target_os = "openbsd", - target_os = "windows" + target_os = "openbsd" ))] use crate::error::USimpleError; #[cfg(any(target_os = "linux", target_os = "android", target_os = "cygwin"))] @@ -445,7 +350,6 @@ use std::io::{BufRead, BufReader}; #[cfg(any( target_vendor = "apple", target_os = "freebsd", - target_os = "windows", target_os = "netbsd", target_os = "openbsd" ))] @@ -459,7 +363,16 @@ use std::ptr; use std::slice; /// Read file system list. -#[cfg(not(target_os = "wasi"))] +#[cfg_attr( + any( + target_os = "aix", + target_os = "redox", + target_os = "illumos", + target_os = "solaris", + target_os = "wasi" + ), + expect(clippy::unnecessary_wraps) +)] pub fn read_fs_list() -> UResult> { #[cfg(any(target_os = "linux", target_os = "android", target_os = "cygwin"))] { @@ -477,8 +390,8 @@ pub fn read_fs_list() -> UResult> { .collect::>()) } #[cfg(any( - target_os = "freebsd", target_vendor = "apple", + target_os = "freebsd", target_os = "netbsd", target_os = "openbsd" ))] @@ -496,50 +409,14 @@ pub fn read_fs_list() -> UResult> { } #[cfg(windows)] { - let mut volume_name_buf = [0u16; MAX_PATH]; - // As recommended in the MS documentation, retrieve the first volume before the others - let find_handle = - unsafe { FindFirstVolumeW(volume_name_buf.as_mut_ptr(), volume_name_buf.len() as u32) }; - if INVALID_HANDLE_VALUE == find_handle { - let os_err = IOError::last_os_error(); - let msg = format!("FindFirstVolumeW failed: {os_err}"); - return Err(USimpleError::new(EXIT_ERR, msg)); - } - let mut mounts = Vec::::new(); - loop { - let volume_name = LPWSTR2String(&volume_name_buf); - if !volume_name.starts_with("\\\\?\\") || !volume_name.ends_with('\\') { - show_warning!("A bad path was skipped: {volume_name}"); - continue; - } - if let Some(m) = MountInfo::new(volume_name) { - mounts.push(m); - } - if 0 == unsafe { - FindNextVolumeW( - find_handle, - volume_name_buf.as_mut_ptr(), - volume_name_buf.len() as u32, - ) - } { - let err = IOError::last_os_error(); - if err.raw_os_error() != Some(ERROR_NO_MORE_FILES as i32) { - let msg = format!("FindNextVolumeW failed: {err}"); - return Err(USimpleError::new(EXIT_ERR, msg)); - } - break; - } - } - unsafe { - FindVolumeClose(find_handle); - } - Ok(mounts) + windows::read_fs_list() } #[cfg(any( target_os = "aix", target_os = "redox", target_os = "illumos", target_os = "solaris", + target_os = "wasi" ))] { // No method to read mounts on these platforms @@ -547,13 +424,6 @@ pub fn read_fs_list() -> UResult> { } } -/// Read file system list. -#[cfg(target_os = "wasi")] -pub fn read_fs_list() -> Vec { - // No method to read mounts on WASI - Vec::new() -} - #[derive(Debug, Clone)] pub struct FsUsage { pub blocksize: u64, @@ -575,7 +445,7 @@ impl FsUsage { target_pointer_width = "64" ))] return Self { - blocksize: statvfs.f_bsize as u64, // or `statvfs.f_frsize` ? + blocksize: statvfs.block_size() as u64, blocks: statvfs.f_blocks, bfree: statvfs.f_bfree, bavail: statvfs.f_bavail, @@ -588,7 +458,7 @@ impl FsUsage { not(target_pointer_width = "64") ))] return Self { - blocksize: statvfs.f_bsize as u64, // or `statvfs.f_frsize` ? + blocksize: statvfs.block_size() as u64, blocks: statvfs.f_blocks.into(), bfree: statvfs.f_bfree.into(), bavail: statvfs.f_bavail.into(), @@ -598,7 +468,9 @@ impl FsUsage { }; #[cfg(target_os = "freebsd")] return Self { - blocksize: statvfs.f_bsize, // or `statvfs.f_frsize` ? + // FreeBSD's `struct statfs` has no fragment size; `f_bsize` + // is the block size the counts are expressed in. + blocksize: statvfs.f_bsize, blocks: statvfs.f_blocks, bfree: statvfs.f_bfree, bavail: statvfs.f_bavail.try_into().unwrap(), @@ -622,61 +494,6 @@ impl FsUsage { }; } } - #[cfg(windows)] - pub fn new(path: &Path) -> UResult { - let mut root_path = [0u16; MAX_PATH]; - let success = unsafe { - let path = to_nul_terminated_wide_string(path); - GetVolumePathNamesForVolumeNameW( - //path_utf8.as_ptr(), - path.as_ptr(), - root_path.as_mut_ptr(), - root_path.len() as u32, - ptr::null_mut(), - ) - }; - if 0 == success { - let msg = format!( - "GetVolumePathNamesForVolumeNameW failed: {}", - IOError::last_os_error() - ); - return Err(USimpleError::new(EXIT_ERR, msg)); - } - - let mut sectors_per_cluster = 0; - let mut bytes_per_sector = 0; - let mut number_of_free_clusters = 0; - let mut total_number_of_clusters = 0; - - unsafe { - let path = to_nul_terminated_wide_string(path); - GetDiskFreeSpaceW( - path.as_ptr(), - &raw mut sectors_per_cluster, - &raw mut bytes_per_sector, - &raw mut number_of_free_clusters, - &raw mut total_number_of_clusters, - ); - } - - let bytes_per_cluster = sectors_per_cluster as u64 * bytes_per_sector as u64; - Ok(Self { - // f_bsize File system block size. - blocksize: bytes_per_cluster, - // f_blocks - Total number of blocks on the file system, in units of f_frsize. - // frsize = Fundamental file system block size (fragment size). - blocks: total_number_of_clusters as u64, - // Total number of free blocks. - bfree: number_of_free_clusters as u64, - // Total number of free blocks available to non-privileged processes. - bavail: 0, - bavail_top_bit_set: ((bytes_per_sector as u64) & (1u64.rotate_right(1))) != 0, - // Total number of file nodes (inodes) on the file system. - files: 0, // Not available on windows - // Total number of free file nodes (inodes). - ffree: 0, // Meaningless on Windows - }) - } } #[cfg(unix)] @@ -695,12 +512,30 @@ pub trait FsMeta { #[cfg(unix)] impl FsMeta for StatFs { + /// The block size the `f_blocks`, `f_bfree` and `f_bavail` counts are + /// expressed in. + /// + /// On Linux that is `f_frsize`, not `f_bsize`: the latter is the preferred + /// transfer size and may be far larger. virtiofs, for one, reports a 1 MiB + /// `f_bsize` next to a 4 KiB `f_frsize`, which scales every size derived + /// from the counts by 256. `f_frsize` is zero on pre-2.6 kernels, so fall + /// back to `f_bsize` there. + #[cfg(any(target_os = "linux", target_os = "android"))] + #[allow(clippy::unnecessary_cast)] + fn block_size(&self) -> i64 { + if self.f_frsize == 0 { + self.f_bsize as i64 + } else { + self.f_frsize as i64 + } + } + + #[cfg(not(any(target_os = "linux", target_os = "android")))] fn block_size(&self) -> i64 { #[cfg(all( not(target_env = "musl"), not(target_vendor = "apple"), not(target_os = "aix"), - not(target_os = "android"), not(target_os = "freebsd"), not(target_os = "netbsd"), not(target_os = "openbsd"), @@ -721,7 +556,6 @@ impl FsMeta for StatFs { any( target_arch = "s390x", target_vendor = "apple", - all(target_os = "android", target_pointer_width = "32"), target_os = "openbsd", not(target_pointer_width = "64") ) @@ -736,7 +570,6 @@ impl FsMeta for StatFs { target_os = "solaris", target_os = "redox", target_os = "cygwin", - all(target_os = "android", target_pointer_width = "64"), ))] return self.f_bsize.try_into().unwrap(); } @@ -783,14 +616,14 @@ impl FsMeta for StatFs { return self.f_ffree.try_into().unwrap(); } #[cfg(any( + target_vendor = "apple", target_os = "linux", target_os = "android", - target_vendor = "apple", target_os = "freebsd" ))] fn fs_type(&self) -> i64 { #[cfg(all( - not(target_env = "musl"), + not(any(target_env = "musl", target_env = "ohos")), not(target_vendor = "apple"), not(target_os = "android"), not(target_os = "freebsd"), @@ -799,7 +632,7 @@ impl FsMeta for StatFs { ))] return self.f_type; #[cfg(all( - not(target_env = "musl"), + not(any(target_env = "musl", target_env = "ohos")), any( target_vendor = "apple", all(target_os = "android", target_pointer_width = "32"), @@ -811,14 +644,15 @@ impl FsMeta for StatFs { return self.f_type.into(); #[cfg(any( target_env = "musl", + target_env = "ohos", all(target_os = "android", target_pointer_width = "64"), ))] return self.f_type.try_into().unwrap(); } #[cfg(not(any( + target_vendor = "apple", target_os = "linux", target_os = "android", - target_vendor = "apple", target_os = "freebsd" )))] fn fs_type(&self) -> i64 { @@ -826,10 +660,11 @@ impl FsMeta for StatFs { unimplemented!() } - #[cfg(any(target_os = "linux", target_os = "android"))] + /// The preferred transfer size, which on Linux is `f_bsize`. + #[cfg(any(target_os = "aix", target_os = "linux", target_os = "android"))] #[allow(clippy::unnecessary_cast)] fn io_size(&self) -> u64 { - self.f_frsize as u64 + self.f_bsize as u64 } #[cfg(any(target_vendor = "apple", target_os = "freebsd", target_os = "netbsd"))] #[allow(clippy::unnecessary_cast)] @@ -842,6 +677,7 @@ impl FsMeta for StatFs { // XXX: dunno if this is right #[cfg(not(any( target_vendor = "apple", + target_os = "aix", target_os = "freebsd", target_os = "linux", target_os = "android", @@ -891,7 +727,12 @@ impl FsMeta for StatFs { fn namelen(&self) -> u64 { 1024 } - #[cfg(any(target_os = "freebsd", target_os = "netbsd", target_os = "openbsd"))] + #[cfg(any( + target_os = "aix", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] #[allow(clippy::unnecessary_cast)] fn namelen(&self) -> u64 { self.f_namemax as u64 // spell-checker:disable-line @@ -899,6 +740,7 @@ impl FsMeta for StatFs { // XXX: should everything just use statvfs? #[cfg(not(any( target_vendor = "apple", + target_os = "aix", target_os = "freebsd", target_os = "linux", target_os = "android", @@ -1012,9 +854,7 @@ pub fn pretty_fstype<'a>(fstype: i64) -> Cow<'a, str> { 0x1130_7854 => "inodefs".into(), 0x0131_11A8 => "ibrix".into(), 0x2BAD_1DEA => "inotifyfs".into(), - 0x9660 => "isofs".into(), - 0x4004 => "isofs".into(), - 0x4000 => "isofs".into(), + 0x9660 | 0x4004 | 0x4000 => "isofs".into(), 0x07C0 => "jffs".into(), 0x72B6 => "jffs2".into(), 0x3153_464A => "jfs".into(), @@ -1064,8 +904,7 @@ pub fn pretty_fstype<'a>(fstype: i64) -> Cow<'a, str> { 0x7472_6163 => "tracefs".into(), 0x2405_1905 => "ubifs".into(), 0x1501_3346 => "udf".into(), - 0x0001_1954 => "ufs".into(), - 0x5419_0100 => "ufs".into(), + 0x0001_1954 | 0x5419_0100 => "ufs".into(), 0x9FA2 => "usbdevfs".into(), 0x0102_1997 => "v9fs".into(), 0xBACB_ACBC => "vmhgfs".into(), @@ -1076,8 +915,7 @@ pub fn pretty_fstype<'a>(fstype: i64) -> Cow<'a, str> { 0x012F_F7B4 => "xenix".into(), 0x5846_5342 => "xfs".into(), 0x012F_D16D => "xia".into(), - 0x2FC1_2FC1 => "zfs".into(), - 0xDE => "zfs".into(), + 0x2FC1_2FC1 | 0xDE => "zfs".into(), other => format!("UNKNOWN ({other:#x})").into(), } // spell-checker:enable @@ -1210,7 +1048,63 @@ mod tests { } #[test] - #[cfg(all(unix, not(target_os = "redox")))] + #[cfg(any(target_os = "linux", target_os = "android"))] + fn test_mountinfo_dev_id_without_stat() { + let info = MountInfo::new( + LINUX_MOUNTINFO, + &b"317 61 254:3 / /nonexistent rw,relatime - ext4 /dev/dm-3 rw" + .split(|c| *c == b' ') + .collect::>(), + ) + .unwrap(); + + assert_eq!(info.dev_id, (libc::makedev(254, 3) as i32).to_string()); + } + + /// A `statfs` as virtiofs fills it in: a 1 MiB preferred transfer size + /// next to a 4 KiB fragment size, with the counts in fragments. + #[cfg(any(target_os = "linux", target_os = "android"))] + fn virtiofs_like_statfs() -> StatFs { + let mut statfs: StatFs = unsafe { mem::zeroed() }; + statfs.f_bsize = 1024 * 1024; + statfs.f_frsize = 4096; + statfs.f_blocks = 120_699_413; + statfs.f_bfree = 30_801_013; + statfs.f_bavail = 30_801_013; + statfs + } + + #[test] + #[cfg(any(target_os = "linux", target_os = "android"))] + fn test_block_size_is_the_fragment_size() { + let statfs = virtiofs_like_statfs(); + assert_eq!(statfs.block_size(), 4096); + assert_eq!(statfs.io_size(), 1024 * 1024); + } + + #[test] + #[cfg(any(target_os = "linux", target_os = "android"))] + fn test_fs_usage_scales_by_the_fragment_size() { + let usage = FsUsage::new(virtiofs_like_statfs()); + assert_eq!(usage.blocksize, 4096); + assert_eq!(usage.blocks * usage.blocksize, 494_384_795_648); + } + + #[test] + #[cfg(any(target_os = "linux", target_os = "android"))] + fn test_block_size_falls_back_when_fragment_size_is_unset() { + // Pre-2.6 kernels leave `f_frsize` at zero. + let mut statfs: StatFs = unsafe { mem::zeroed() }; + statfs.f_bsize = 4096; + statfs.f_frsize = 0; + statfs.f_blocks = 10; + + assert_eq!(statfs.block_size(), 4096); + assert_eq!(FsUsage::new(statfs).blocksize, 4096); + } + + #[test] + #[cfg(all(unix, not(any(target_os = "aix", target_os = "redox"))))] // spell-checker:ignore (word) binfmt fn test_binfmt_misc_is_dummy() { use super::is_dummy_filesystem; diff --git a/src/uucore/src/lib/features/fsext/windows.rs b/src/uucore/src/lib/features/fsext/windows.rs new file mode 100644 index 00000000000..dd3eb15b077 --- /dev/null +++ b/src/uucore/src/lib/features/fsext/windows.rs @@ -0,0 +1,316 @@ +// 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 WNet FAILCRITICALERRORS SUBST + +//! Windows backend of `fsext`: volume enumeration and usage probes. + +use std::ffi::OsString; +use std::io; +use std::path::Path; + +use super::{FsUsage, MountInfo}; +use crate::error::UResult; + +/// Every mount path of every volume, then the drive letters that are not +/// volume mount points (mapped network drives, SUBST drives). +pub(super) fn read_fs_list() -> UResult> { + let _quiet = sys::ErrorMode::fail_critical_errors(); + let mut mounts = Vec::new(); + for volume in sys::volumes()? { + for mount_dir in sys::volume_mount_paths(&volume).unwrap_or_default() { + mounts.push(MountInfo::from_mount_dir(mount_dir)); + } + } + for drive in sys::logical_drives() { + if !mounts.iter().any(|m| m.mount_dir == drive) { + mounts.push(MountInfo::from_mount_dir(drive)); + } + } + Ok(mounts) +} + +impl MountInfo { + /// The filesystem mounted at `mount_dir` (`C:\`, `C:\mount\`, `\\server\share\`). + pub fn from_mount_dir(mount_dir: OsString) -> Self { + let remote = sys::is_remote_drive(&mount_dir); + let dev_name = remote + .then(|| sys::remote_name(&mount_dir)) + .flatten() + .unwrap_or_else(|| mount_dir.to_string_lossy().into_owned()); + let (dev_id, fs_type) = match sys::volume_information(&mount_dir) { + Ok(info) => (info.serial.to_string(), info.fs_type), + Err(_) => (dev_name.clone(), String::new()), + }; + Self { + dev_id, + dev_name, + fs_type, + mount_root: OsString::new(), + mount_dir, + mount_option: String::new(), + remote, + dummy: false, + } + } +} + +impl FsUsage { + /// Usage of the volume mounted at `root`; Windows reports no inode counts. + pub fn new(root: &Path) -> io::Result { + let _quiet = sys::ErrorMode::fail_critical_errors(); + let root = root.as_os_str(); + let space = sys::disk_space(root)?; + let blocksize = sys::cluster_size(root).unwrap_or(1).max(1); + Ok(Self { + blocksize, + blocks: space.total / blocksize, + bfree: space.free / blocksize, + bavail: space.available / blocksize, + bavail_top_bit_set: false, + files: 0, + ffree: 0, + }) + } +} + +/// Safe wrappers around the Win32 calls; every `unsafe` lives here. +mod sys { + use std::ffi::{OsStr, OsString}; + use std::io; + use std::os::windows::ffi::OsStringExt; + use std::ptr; + + use crate::wide::{FromWide, ToWide}; + use windows_sys::Win32::Foundation::{ + ERROR_MORE_DATA, ERROR_NO_MORE_FILES, HANDLE, INVALID_HANDLE_VALUE, MAX_PATH, NO_ERROR, + }; + use windows_sys::Win32::NetworkManagement::WNet::WNetGetConnectionW; + use windows_sys::Win32::Storage::FileSystem::{ + FindFirstVolumeW, FindNextVolumeW, FindVolumeClose, GetDiskFreeSpaceExW, GetDiskFreeSpaceW, + GetDriveTypeW, GetLogicalDrives, GetVolumeInformationW, GetVolumePathNamesForVolumeNameW, + }; + use windows_sys::Win32::System::Diagnostics::Debug::{ + SEM_FAILCRITICALERRORS, SetThreadErrorMode, + }; + use windows_sys::Win32::System::WindowsProgramming::DRIVE_REMOTE; + use windows_sys::core::BOOL; + + const BUF_LEN: usize = MAX_PATH as usize + 1; + + fn cvt(result: BOOL) -> io::Result<()> { + if result == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + /// Keeps the "no disk in drive" dialog away while probing drives without + /// media; the previous mode is restored on drop. + pub struct ErrorMode(u32); + + impl ErrorMode { + pub fn fail_critical_errors() -> Self { + let mut previous = 0; + // SAFETY: `previous` is a valid out-pointer. + unsafe { SetThreadErrorMode(SEM_FAILCRITICALERRORS, &raw mut previous) }; + Self(previous) + } + } + + impl Drop for ErrorMode { + fn drop(&mut self) { + // SAFETY: a null out-pointer is allowed. + unsafe { SetThreadErrorMode(self.0, ptr::null_mut()) }; + } + } + + struct FindVolume(HANDLE); + + impl Drop for FindVolume { + fn drop(&mut self) { + // SAFETY: the handle came from `FindFirstVolumeW` and is closed once. + unsafe { FindVolumeClose(self.0) }; + } + } + + /// The `\\?\Volume{...}\` name of every volume. + pub fn volumes() -> io::Result> { + let mut name = [0u16; BUF_LEN]; + // SAFETY: `name` is a valid buffer of `name.len()` units. + let handle = unsafe { FindFirstVolumeW(name.as_mut_ptr(), name.len() as u32) }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let handle = FindVolume(handle); + let mut volumes = vec![String::from_wide_null(&name)]; + loop { + // SAFETY: `handle` is open; `name` is a valid buffer of `name.len()` units. + if unsafe { FindNextVolumeW(handle.0, name.as_mut_ptr(), name.len() as u32) } == 0 { + let err = io::Error::last_os_error(); + return if err.raw_os_error() == Some(ERROR_NO_MORE_FILES as i32) { + Ok(volumes) + } else { + Err(err) + }; + } + volumes.push(String::from_wide_null(&name)); + } + } + + /// The drive letters and mounted folders `volume` is reachable at, each + /// with a trailing `\`. + pub fn volume_mount_paths(volume: &str) -> io::Result> { + let volume = volume.to_wide_null(); + let mut paths = vec![0u16; BUF_LEN]; + loop { + let mut len = 0; + // SAFETY: `volume` is NUL-terminated; `paths` is a valid buffer of + // `paths.len()` units and `len` a valid out-pointer. + let ok = unsafe { + GetVolumePathNamesForVolumeNameW( + volume.as_ptr(), + paths.as_mut_ptr(), + paths.len() as u32, + &raw mut len, + ) + }; + if ok != 0 { + return Ok(paths[..len as usize] + .split(|&c| c == 0) + .filter(|p| !p.is_empty()) + .map(OsString::from_wide) + .collect()); + } + let err = io::Error::last_os_error(); + if err.raw_os_error() != Some(ERROR_MORE_DATA as i32) { + return Err(err); + } + paths.resize(len as usize, 0); + } + } + + /// The root of every drive letter in use, `A:\` to `Z:\`. + pub fn logical_drives() -> impl Iterator { + // SAFETY: no preconditions. + let mask = unsafe { GetLogicalDrives() }; + (b'A'..=b'Z') + .filter(move |letter| mask & (1 << (letter - b'A')) != 0) + .map(|letter| OsString::from(format!("{}:\\", letter as char))) + } + + pub struct VolumeInformation { + pub fs_type: String, + pub serial: u32, + } + + pub fn volume_information(root: &OsStr) -> io::Result { + let root = root.to_wide_null(); + let mut serial = 0; + let mut fs_type = [0u16; BUF_LEN]; + // SAFETY: `root` is NUL-terminated; `serial` and `fs_type` are valid + // out-buffers and the remaining out-pointers may be null. + cvt(unsafe { + GetVolumeInformationW( + root.as_ptr(), + ptr::null_mut(), + 0, + &raw mut serial, + ptr::null_mut(), + ptr::null_mut(), + fs_type.as_mut_ptr(), + fs_type.len() as u32, + ) + })?; + Ok(VolumeInformation { + fs_type: String::from_wide_null(&fs_type), + serial, + }) + } + + pub fn is_remote_drive(root: &OsStr) -> bool { + let root = root.to_wide_null(); + // SAFETY: `root` is NUL-terminated. + unsafe { GetDriveTypeW(root.as_ptr()) == DRIVE_REMOTE } + } + + /// The UNC name a drive letter is mapped to, `\\server\share`. + pub fn remote_name(root: &OsStr) -> Option { + // `X:` only; the API rejects the trailing separator. + let local: Vec = root.to_wide().into_iter().take(2).chain([0]).collect(); + let mut remote = [0u16; BUF_LEN]; + let mut len = remote.len() as u32; + // SAFETY: `local` is NUL-terminated; `remote` is a valid buffer of `len` units. + let status = + unsafe { WNetGetConnectionW(local.as_ptr(), remote.as_mut_ptr(), &raw mut len) }; + (status == NO_ERROR).then(|| String::from_wide_null(&remote)) + } + + pub struct DiskSpace { + pub total: u64, + pub free: u64, + pub available: u64, + } + + /// Byte counts of the volume at `root`; `available` honours quotas. + pub fn disk_space(root: &OsStr) -> io::Result { + let root = root.to_wide_null(); + let mut space = DiskSpace { + total: 0, + free: 0, + available: 0, + }; + // SAFETY: `root` is NUL-terminated; the three out-pointers are valid. + cvt(unsafe { + GetDiskFreeSpaceExW( + root.as_ptr(), + &raw mut space.available, + &raw mut space.total, + &raw mut space.free, + ) + })?; + Ok(space) + } + + /// Bytes per allocation unit of the volume at `root`. + pub fn cluster_size(root: &OsStr) -> io::Result { + let root = root.to_wide_null(); + let mut sectors_per_cluster = 0; + let mut bytes_per_sector = 0; + // SAFETY: `root` is NUL-terminated; the two out-pointers are valid and + // the remaining ones may be null. + cvt(unsafe { + GetDiskFreeSpaceW( + root.as_ptr(), + &raw mut sectors_per_cluster, + &raw mut bytes_per_sector, + ptr::null_mut(), + ptr::null_mut(), + ) + })?; + Ok(u64::from(sectors_per_cluster) * u64::from(bytes_per_sector)) + } +} + +#[cfg(test)] +mod tests { + use super::read_fs_list; + use std::ffi::OsString; + + #[test] + fn test_read_fs_list_has_system_drive() { + let system_drive = OsString::from(std::env::var("SystemDrive").unwrap() + "\\"); + let mounts = read_fs_list().unwrap(); + assert!( + mounts + .iter() + .all(|m| m.mount_dir.to_string_lossy().ends_with('\\')) + ); + let system = mounts.iter().find(|m| m.mount_dir == system_drive).unwrap(); + assert!(!system.fs_type.is_empty()); + assert_eq!(system.dev_name, system_drive.to_string_lossy()); + } +} diff --git a/src/uucore/src/lib/features/fsxattr.rs b/src/uucore/src/lib/features/fsxattr.rs index b42ea91e345..373fd1b770a 100644 --- a/src/uucore/src/lib/features/fsxattr.rs +++ b/src/uucore/src/lib/features/fsxattr.rs @@ -6,6 +6,7 @@ // spell-checker:ignore getxattr posix_acl_default posix_acl_access ENOTSUP EOPNOTSUPP renamer //! Set of functions to manage xattr on files and dirs + use itertools::Itertools; use rustc_hash::FxHashMap; use std::ffi::{OsStr, OsString}; @@ -28,64 +29,100 @@ fn is_xattr_unsupported(_err: &std::io::Error) -> bool { false } -/// Copies extended attributes (xattrs) from one path to another. -/// All errors propagate, including `ENOTSUP` / `EOPNOTSUPP`; for -/// best-effort callers see [`copy_xattrs_ignore_unsupported`]. -pub fn copy_xattrs>(source: P, dest: P) -> std::io::Result<()> { - for attr_name in xattr::list(&source)? { - if let Some(value) = xattr::get(&source, &attr_name)? { - xattr::set(&dest, &attr_name, &value)?; +/// The result of copying xattrs: each attribute that could not be copied, +/// along with its error. Only failing to list the attributes of the source +/// is an `Err`. +pub type XattrCopyResult = std::io::Result>; + +/// Leaves `ENOTSUP` / `EOPNOTSUPP` out of the result of copying xattrs, for +/// callers where xattr preservation is best-effort. +fn without_unsupported(result: XattrCopyResult) -> XattrCopyResult { + match result { + Ok(mut failed) => { + failed.retain(|(_, e)| !is_xattr_unsupported(e)); + Ok(failed) } + Err(e) if is_xattr_unsupported(&e) => Ok(Vec::new()), + Err(e) => Err(e), } - Ok(()) } -/// Like [`copy_xattrs`], but maps `ENOTSUP` / `EOPNOTSUPP` to `Ok(())` -/// for callers where xattr preservation is best-effort. -pub fn copy_xattrs_ignore_unsupported>(source: P, dest: P) -> std::io::Result<()> { - match copy_xattrs(source, dest) { - Err(e) if is_xattr_unsupported(&e) => Ok(()), - res => res, +/// Copies each attribute in `names` that `keep` accepts, reading its value +/// with `get` and writing it with `set`. An attribute that cannot be copied +/// does not stop the others; it is returned along with its error. +fn copy_each_xattr( + names: impl IntoIterator, + keep: impl Fn(&OsStr) -> bool, + get: impl Fn(&OsStr) -> std::io::Result>>, + set: impl Fn(&OsStr, &[u8]) -> std::io::Result<()>, +) -> Vec<(OsString, std::io::Error)> { + let mut failed = Vec::new(); + for attr_name in names.into_iter().filter(|name| keep(name)) { + let result = get(&attr_name).and_then(|value| match value { + Some(value) => set(&attr_name, &value), + None => Ok(()), + }); + if let Err(e) = result { + failed.push((attr_name, e)); + } } + failed +} + +/// Copies extended attributes (xattrs) from one path to another. +/// +/// An attribute that cannot be copied does not stop the others from being +/// copied. Returns each attribute that failed along with its error, including +/// `ENOTSUP` / `EOPNOTSUPP`; for best-effort callers see +/// [`copy_xattrs_ignore_unsupported`]. Only failing to list the attributes of +/// `source` is an `Err`. +pub fn copy_xattrs>(source: P, dest: P) -> XattrCopyResult { + Ok(copy_each_xattr( + xattr::list(&source)?, + |_| true, + |name| xattr::get(&source, name), + |name, value| xattr::set(&dest, name, value), + )) +} + +/// Like [`copy_xattrs`], but leaves out `ENOTSUP` / `EOPNOTSUPP` for callers +/// where xattr preservation is best-effort. +pub fn copy_xattrs_ignore_unsupported>(source: P, dest: P) -> XattrCopyResult { + without_unsupported(copy_xattrs(source, dest)) } /// Copies xattrs between two open file descriptors. Pins both inodes so /// list/get/set calls cannot be redirected by a concurrent renamer, unlike -/// the path-based [`copy_xattrs`]. +/// the path-based [`copy_xattrs`], and reports failures the same way. #[cfg(unix)] -pub fn copy_xattrs_fd(source: &std::fs::File, dest: &std::fs::File) -> std::io::Result<()> { +pub fn copy_xattrs_fd(source: &std::fs::File, dest: &std::fs::File) -> XattrCopyResult { use xattr::FileExt; - for attr_name in source.list_xattr()? { - if let Some(value) = source.get_xattr(&attr_name)? { - dest.set_xattr(&attr_name, &value)?; - } - } - Ok(()) + Ok(copy_each_xattr( + source.list_xattr()?, + |_| true, + |name| source.get_xattr(name), + |name, value| dest.set_xattr(name, value), + )) } -/// Like [`copy_xattrs_fd`], but maps `ENOTSUP` / `EOPNOTSUPP` to `Ok(())`. +/// Like [`copy_xattrs_fd`], but leaves out `ENOTSUP` / `EOPNOTSUPP`. #[cfg(unix)] pub fn copy_xattrs_fd_ignore_unsupported( source: &std::fs::File, dest: &std::fs::File, -) -> std::io::Result<()> { - match copy_xattrs_fd(source, dest) { - Err(e) if is_xattr_unsupported(&e) => Ok(()), - res => res, - } +) -> XattrCopyResult { + without_unsupported(copy_xattrs_fd(source, dest)) } /// Like `copy_xattrs`, but skips the security.selinux attribute. #[cfg(unix)] -pub fn copy_xattrs_skip_selinux>(source: P, dest: P) -> std::io::Result<()> { - for attr_name in xattr::list(&source)? { - if attr_name.as_bytes() != b"security.selinux" - && let Some(value) = xattr::get(&source, &attr_name)? - { - xattr::set(&dest, &attr_name, &value)?; - } - } - Ok(()) +pub fn copy_xattrs_skip_selinux>(source: P, dest: P) -> XattrCopyResult { + Ok(copy_each_xattr( + xattr::list(&source)?, + |name| name.as_bytes() != b"security.selinux", + |name| xattr::get(&source, name), + |name, value| xattr::set(&dest, name, value), + )) } /// Copies only the POSIX ACL xattrs (`system.posix_acl_access` and @@ -129,6 +166,30 @@ pub fn retrieve_xattrs>(source: P) -> std::io::Result std::io::Result>> { + use xattr::FileExt; + let mut attrs = FxHashMap::default(); + for attr_name in source.list_xattr()? { + if let Some(value) = source.get_xattr(&attr_name)? { + attrs.insert(attr_name, value); + } + } + Ok(attrs) +} + /// Applies extended attributes (xattrs) to a given file or directory. /// /// # Arguments @@ -149,18 +210,62 @@ pub fn apply_xattrs>( Ok(()) } +/// Applies extended attributes (xattrs) to a given file using a file descriptor. +/// +/// This version avoids TOCTOU races by operating on an open file descriptor +/// rather than a path, ensuring all operations target the same inode. +/// +/// # Arguments +/// +/// * `dest` - A reference to the file (open file descriptor). +/// * `xattrs` - A map of attribute names to their corresponding values. +/// +/// # Returns +/// +/// A result indicating success or failure. +#[cfg(unix)] +pub fn apply_xattrs_fd( + dest: &std::fs::File, + xattrs: FxHashMap>, +) -> std::io::Result<()> { + use xattr::FileExt; + for (attr, value) in xattrs { + dest.set_xattr(&attr, &value)?; + } + Ok(()) +} + +/// Like [`apply_xattrs_fd`], but maps `ENOTSUP` / `EOPNOTSUPP` to `Ok(())`. +#[cfg(unix)] +pub fn apply_xattrs_fd_ignore_unsupported( + dest: &std::fs::File, + xattrs: FxHashMap>, +) -> std::io::Result<()> { + match apply_xattrs_fd(dest, xattrs) { + Err(e) if is_xattr_unsupported(&e) => Ok(()), + res => res, + } +} + /// Checks if a file has an Access Control List (ACL) based on its extended attributes. /// /// # Arguments /// /// * `file` - A reference to the path of the file. +/// * `dereference` - Whether to inspect the target of a symlink instead of the link itself. /// /// # Returns /// /// `true` if the file has extended attributes (indicating an ACL), `false` otherwise. -pub fn has_acl>(file: P) -> bool { +pub fn has_acl>(file: P, dereference: bool) -> bool { // don't use exacl here, it is doing more getxattr call then needed - xattr::list_deref(file).is_ok_and(|acl| { + let attrs = if dereference { + xattr::list_deref(file) + } else { + xattr::list(file) + }; + + attrs.is_ok_and(|acl| { // if we have extra attributes, we have an acl acl.count() > 0 }) @@ -171,13 +276,20 @@ pub fn has_acl>(file: P) -> bool { /// # Arguments /// /// * `file` - A reference to the path of the file. +/// * `dereference` - Whether to inspect the target of a symlink instead of the link itself. /// /// # Returns /// /// `true` if the file has an extended attribute named "security.capability", `false` otherwise. -pub fn has_security_cap_acl>(file: P) -> bool { +pub fn has_security_cap_acl>(file: P, dereference: bool) -> bool { // don't use exacl here, it is doing more getxattr call then needed - xattr::list_deref(file).is_ok_and(|mut acl| { + let attrs = if dereference { + xattr::list_deref(file) + } else { + xattr::list(file) + }; + + attrs.is_ok_and(|mut acl| { #[cfg(unix)] return acl.contains(OsStr::from_bytes(b"security.capability")); @@ -228,7 +340,7 @@ pub fn get_acl_perm_bits_from_xattr>(source: P) -> u32 { .copied() .collect::>(); - for entry in acl_entries.chunks_exact(4) { + for entry in acl_entries.as_chunks::<4>().0 { // Third byte and fourth byte will be the perm bits perm = (perm << 3) | u32::from(entry[2]) | u32::from(entry[3]); } @@ -259,7 +371,7 @@ mod tests { let test_value = b"test value"; xattr::set(&source_path, test_attr, test_value).unwrap(); - copy_xattrs(&source_path, &dest_path).unwrap(); + assert!(copy_xattrs(&source_path, &dest_path).unwrap().is_empty()); let copied_value = xattr::get(&dest_path, test_attr).unwrap().unwrap(); assert_eq!(copied_value, test_value); @@ -288,7 +400,7 @@ mod tests { .open(&dest_path) .unwrap(); - copy_xattrs_fd(&src, &dst).unwrap(); + assert!(copy_xattrs_fd(&src, &dst).unwrap().is_empty()); let copied = xattr::get(&dest_path, test_attr).unwrap().unwrap(); assert_eq!(copied, test_value); @@ -371,14 +483,14 @@ mod tests { File::create(&file_path).unwrap(); // FIXME: this fails on a system that uses SELinux - assert!(!has_acl(&file_path)); + assert!(!has_acl(&file_path, true)); let test_attr = "user.test_acl"; let test_value = b"test value"; xattr::set(&file_path, test_attr, test_value).unwrap(); - assert!(has_acl(&file_path)); - assert!(!has_security_cap_acl(&file_path)); + assert!(has_acl(&file_path, true)); + assert!(!has_security_cap_acl(&file_path, true)); // FreeBSD/NetBSD's xattr library does not support the "security" namespace // (https://github.com/Stebalien/xattr/blob/master/src/sys/bsd.rs#L148). @@ -390,7 +502,38 @@ mod tests { 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, true)); } } + + #[test] + fn test_apply_and_retrieve_xattrs_fd() { + use std::fs::OpenOptions; + + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test_file.txt"); + + File::create(&file_path).unwrap(); + + let mut test_xattrs = FxHashMap::default(); + let test_attr = "user.test_attr_fd"; + let test_value = b"test value fd"; + test_xattrs.insert(OsString::from(test_attr), test_value.to_vec()); + + // Apply using file descriptor + let file = OpenOptions::new().write(true).open(&file_path).unwrap(); + apply_xattrs_fd(&file, test_xattrs).unwrap(); + drop(file); + + // Retrieve using file descriptor + let file = File::open(&file_path).unwrap(); + let retrieved_xattrs = retrieve_xattrs_fd(&file).unwrap(); + assert!(retrieved_xattrs.contains_key(OsString::from(test_attr).as_os_str())); + assert_eq!( + retrieved_xattrs + .get(OsString::from(test_attr).as_os_str()) + .unwrap(), + test_value + ); + } } diff --git a/src/uucore/src/lib/features/hardware.rs b/src/uucore/src/lib/features/hardware.rs index 474990343cb..df19f184dc5 100644 --- a/src/uucore/src/lib/features/hardware.rs +++ b/src/uucore/src/lib/features/hardware.rs @@ -54,7 +54,7 @@ pub enum HardwareFeature { Avx2, /// PCLMULQDQ support for CRC acceleration (x86/x86_64 only) PclMul, - /// VMULL support for CRC acceleration (ARM only) + /// PMULL (polynomial multiply) support for CRC acceleration (aarch64 only) Vmull, /// SSE2 support (x86/x86_64 only) Sse2, @@ -312,9 +312,13 @@ fn detect_asimd() -> bool { #[cfg(target_arch = "aarch64")] fn detect_vmull() -> bool { - // VMULL is part of ARM NEON/ASIMD - // For now, we use ASIMD as a proxy - detect_asimd() + // VMULL on 64-bit lanes needs PMULL, an optional extension. + // ASIMD alone is not enough. + if cfg!(target_os = "android") { + false + } else { + std::arch::is_aarch64_feature_detected!("pmull") + } } #[cfg(not(target_arch = "aarch64"))] @@ -324,43 +328,25 @@ fn detect_vmull() -> bool { // GLIBC_TUNABLES parsing -/// Parse GLIBC_TUNABLES environment variable for disabled features -/// -/// Format: `glibc.cpu.hwcaps=-AVX2,-AVX512F` -/// Multiple tunable sections can be separated by colons. +/// Parse the `glibc.cpu.hwcaps` setting of `GLIBC_TUNABLES` into the set of +/// disabled features. Only the last setting counts and names are +/// case-sensitive. fn parse_disabled_features(tunables: &str) -> BTreeSet { - if tunables.is_empty() { + let Some(value) = tunables + .split(':') + .filter_map(|setting| setting.split_once('=')) + .filter(|(name, _)| name.trim_start() == "glibc.cpu.hwcaps") + .map(|(_, value)| value) + .next_back() + else { return BTreeSet::new(); - } - - let mut disabled = BTreeSet::new(); - - // GLIBC_TUNABLES format: "tunable1=value1:tunable2=value2" - for entry in tunables.split(':') { - let entry = entry.trim(); - let Some((name, raw_value)) = entry.split_once('=') else { - continue; - }; + }; - // We only care about glibc.cpu.hwcaps - if name.trim() != "glibc.cpu.hwcaps" { - continue; - } - - // Parse comma-separated features, disabled ones start with '-' - for token in raw_value.split(',') { - let token = token.trim(); - if let Some(feature) = token.strip_prefix('-') { - let feature = - HardwareFeature::try_from(feature.trim().to_ascii_uppercase().as_str()); - if let Ok(feature) = feature { - disabled.insert(feature); - } - } - } - } - - disabled + value + .split(',') + .filter_map(|token| token.trim_start().strip_prefix('-')) + .filter_map(|name| HardwareFeature::try_from(name).ok()) + .collect() } #[cfg(test)] @@ -425,15 +411,49 @@ mod tests { } #[test] - fn test_parse_disabled_features_case_insensitive() { + fn test_parse_disabled_features_is_case_sensitive() { let result = parse_disabled_features("glibc.cpu.hwcaps=-avx2,-Avx512f"); - let mut expected = BTreeSet::new(); + assert_eq!(result, BTreeSet::new()); + } - expected.insert(HardwareFeature::Avx2); - expected.insert(HardwareFeature::Avx512); + #[test] + fn test_parse_disabled_features_last_setting_wins() { + let result = + parse_disabled_features("glibc.cpu.hwcaps=-AVX2:glibc.cpu.hwcaps=-AVX512F:x=y"); + assert_eq!(result, BTreeSet::from([HardwareFeature::Avx512])); + } - // Only features with '-' prefix are disabled - assert_eq!(result, expected); + #[test] + fn test_parse_disabled_features_whole_names_only() { + // A space after the name or after the dash is not a name. + let result = parse_disabled_features("glibc.cpu.hwcaps=-AVX2 ,- AVX512F"); + assert_eq!(result, BTreeSet::new()); + // A space before the dash is tolerated. + let result = parse_disabled_features("glibc.cpu.hwcaps= -AVX2, -AVX512F"); + assert_eq!( + result, + BTreeSet::from([HardwareFeature::Avx2, HardwareFeature::Avx512]) + ); + } + + #[test] + fn test_parse_disabled_features_ignores_invalid_last_setting() { + let result = parse_disabled_features("glibc.cpu.hwcaps=-AVX2:glibc.cpu.hwcaps ="); + assert_eq!(result, BTreeSet::from([HardwareFeature::Avx2])); + let result = parse_disabled_features("glibc.cpu.hwcaps=-AVX2: glibc.cpu.hwcaps=-SSE2"); + assert_eq!(result, BTreeSet::from([HardwareFeature::Sse2])); + } + + #[test] + fn test_parse_disabled_features_empty_setting_enables_everything() { + assert_eq!( + parse_disabled_features("glibc.cpu.hwcaps="), + BTreeSet::new() + ); + assert_eq!( + parse_disabled_features("glibc.cpu.hwcaps=-AVX2:glibc.cpu.hwcaps="), + BTreeSet::new() + ); } #[test] diff --git a/src/uucore/src/lib/features/i18n/charmap.rs b/src/uucore/src/lib/features/i18n/charmap.rs index 2ec99229bc8..e20d2d4c67e 100644 --- a/src/uucore/src/lib/features/i18n/charmap.rs +++ b/src/uucore/src/lib/features/i18n/charmap.rs @@ -3,13 +3,18 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore langinfo charmap eucjp euckr euctw CTYPE HKSCS hkscs localedata +// spell-checker:ignore langinfo charmap eucjp euckr euctw CTYPE HKSCS hkscs localedata iswblank feff //! Locale-aware multi-byte character length detection via `LC_CTYPE`. use std::sync::OnceLock; -enum MbEncoding { +/// Character encoding of the current locale, as far as character *lengths* are +/// concerned. `SingleByte` covers `C`/`POSIX` and every 8-bit encoding. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Encoding { + /// C/POSIX and every 8-bit encoding: each byte is its own character. + SingleByte, Utf8, Gb18030, EucJp, @@ -17,25 +22,33 @@ enum MbEncoding { Big5, } -fn encoding_from_name(enc: &str) -> MbEncoding { +fn encoding_from_name(enc: &str) -> Encoding { match enc { - "gb18030" | "gbk" | "gb2312" => MbEncoding::Gb18030, - "euc-jp" | "eucjp" => MbEncoding::EucJp, - "euc-kr" | "euckr" => MbEncoding::EucKr, - "big5" | "big5-hkscs" | "big5hkscs" | "euc-tw" | "euctw" => MbEncoding::Big5, - _ => MbEncoding::Utf8, + "utf-8" | "utf8" => Encoding::Utf8, + "gb18030" | "gbk" | "gb2312" => Encoding::Gb18030, + "euc-jp" | "eucjp" => Encoding::EucJp, + "euc-kr" | "euckr" => Encoding::EucKr, + "big5" | "big5-hkscs" | "big5hkscs" | "euc-tw" | "euctw" => Encoding::Big5, + _ => Encoding::SingleByte, } } -fn get_encoding() -> &'static MbEncoding { - static ENCODING: OnceLock = OnceLock::new(); - ENCODING.get_or_init(|| { +/// Encoding of the current locale, resolved from the environment on first use. +/// +/// Callers that decode more than one character should hold on to the returned +/// value: it turns the per-character encoding lookup into a register read. +#[inline] +pub fn locale_encoding() -> Encoding { + static ENCODING: OnceLock = OnceLock::new(); + *ENCODING.get_or_init(|| { let val = ["LC_ALL", "LC_CTYPE", "LANG"] .iter() .find_map(|&k| std::env::var(k).ok().filter(|v| !v.is_empty())); let s = match val.as_deref() { Some(s) if s != "C" && s != "POSIX" => s, - _ => return MbEncoding::Utf8, + // Explicit C/POSIX locale, or no locale set at all: the POSIX + // default is `C`, which is byte-oriented. + _ => return Encoding::SingleByte, }; if let Some(enc) = s.split('.').nth(1) { let enc = enc.split('@').next().unwrap_or(enc); @@ -43,46 +56,105 @@ fn get_encoding() -> &'static MbEncoding { } else { // Bare locale defaults from glibc localedata/SUPPORTED match s.split('@').next().unwrap_or(s) { - "zh_CN" | "zh_SG" => MbEncoding::Gb18030, - "zh_TW" | "zh_HK" => MbEncoding::Big5, - _ => MbEncoding::Utf8, + "zh_CN" | "zh_SG" => Encoding::Gb18030, + "zh_TW" | "zh_HK" => Encoding::Big5, + _ => Encoding::Utf8, } } }) } -/// Byte length of the first character in `bytes` under the current locale encoding. -pub fn mb_char_len(bytes: &[u8]) -> usize { - debug_assert!(!bytes.is_empty()); - let b0 = bytes[0]; - if b0 <= 0x7F { - return 1; +impl Encoding { + /// Byte length of the first character in `bytes`. Never returns more than + /// `bytes.len()`, and never `0` for a non-empty slice. + #[inline] + pub fn char_len(self, bytes: &[u8]) -> usize { + debug_assert!(!bytes.is_empty()); + let b0 = bytes[0]; + if b0 <= 0x7F { + return 1; + } + let len = match self { + // `C`/`POSIX` and 8-bit encodings have `MB_CUR_MAX == 1`, so a byte + // is never part of a longer character, even when it would form a + // valid UTF-8 sequence. + Self::SingleByte => 1, + Self::Utf8 => utf8_len(bytes, b0), + Self::Gb18030 => gb18030_len(bytes, b0), + Self::EucJp => eucjp_len(bytes, b0), + Self::EucKr => euckr_len(bytes, b0), + Self::Big5 => big5_len(bytes, b0), + }; + debug_assert!((1..=bytes.len()).contains(&len)); + len } - match get_encoding() { - MbEncoding::Utf8 => utf8_len(bytes, b0), - MbEncoding::Gb18030 => gb18030_len(bytes, b0), - MbEncoding::EucJp => eucjp_len(bytes, b0), - MbEncoding::EucKr => euckr_len(bytes, b0), - MbEncoding::Big5 => big5_len(bytes, b0), + + /// If the first character in `bytes` is horizontal whitespace ("blank"), + /// return its byte length; otherwise `None`. + /// + /// ASCII space and tab always count. Under UTF-8 the Unicode space + /// separators are also recognized, except no-break ones (e.g. U+00A0), + /// matching glibc's `iswblank`. + #[inline] + pub fn blank_len(self, bytes: &[u8]) -> Option { + let len = self.char_len(bytes); + if len == 1 { + return (bytes[0] == b' ' || bytes[0] == b'\t').then_some(1); + } + // `char_len` only looks at the shape of the sequence, so decode it for + // real here: an overlong or surrogate encoding is not a character at + // all, and must not pass for one of the blanks it decodes to. + if self == Self::Utf8 + && std::str::from_utf8(&bytes[..len]).is_ok_and(|s| s.starts_with(is_unicode_blank)) + { + return Some(len); + } + None } } +/// Byte length of the first character in `bytes` under the current locale encoding. +pub fn mb_char_len(bytes: &[u8]) -> usize { + locale_encoding().char_len(bytes) +} + +/// Horizontal whitespace characters (glibc `iswblank`): excludes the +/// no-break variants U+00A0, U+2007 and U+202F. +fn is_unicode_blank(c: char) -> bool { + matches!(c, + '\u{09}' | '\u{20}' | '\u{1680}' | '\u{2000}'..='\u{2006}' | '\u{2008}'..='\u{200A}' | '\u{205F}' | '\u{3000}') +} + // All helpers below assume b0 > 0x7F (ASCII already handled by caller). fn utf8_len(b: &[u8], b0: u8) -> usize { + // Two-byte sequences are by far the most common outside ASCII, so they get + // a single continuation-byte test rather than a loop. + if (0xC2..=0xDF).contains(&b0) { + return if b.get(1).is_some_and(|&b| is_continuation(b)) { + 2 + } else { + 1 + }; + } let n = match b0 { - 0xC2..=0xDF => 2, 0xE0..=0xEF => 3, 0xF0..=0xF4 => 4, _ => return 1, }; - if b.len() >= n && b[1..n].iter().all(|&c| c & 0xC0 == 0x80) { + if b.get(1..n) + .is_some_and(|s| s.iter().copied().all(is_continuation)) + { n } else { 1 } } +fn is_continuation(byte: u8) -> bool { + byte & 0xC0 == 0x80 +} + // 2-byte: [81-FE][40-7E,80-FE] 4-byte: [81-FE][30-39][81-FE][30-39] fn gb18030_len(b: &[u8], b0: u8) -> usize { if !(0x81..=0xFE).contains(&b0) { @@ -138,3 +210,58 @@ fn big5_len(b: &[u8], b0: u8) -> usize { 1 } } + +#[cfg(test)] +mod tests { + use super::{Encoding, is_unicode_blank}; + + #[test] + fn blank_len_ascii_is_encoding_independent() { + for encoding in [Encoding::SingleByte, Encoding::Utf8, Encoding::Gb18030] { + assert_eq!(encoding.blank_len(b" x"), Some(1)); + assert_eq!(encoding.blank_len(b"\tx"), Some(1)); + assert_eq!(encoding.blank_len(b"Qx"), None); + // Vertical whitespace is not a blank. + assert_eq!(encoding.blank_len(b"\nx"), None); + assert_eq!(encoding.blank_len(b"\x0bx"), None); + } + } + + #[test] + fn blank_len_unicode_blanks_only_under_utf8() { + // U+2002 EN SPACE and U+3000 IDEOGRAPHIC SPACE. + for blank in ["\u{2002}", "\u{3000}"] { + assert_eq!(Encoding::Utf8.blank_len(blank.as_bytes()), Some(3)); + // A byte-oriented locale sees only the individual lead byte. + assert_eq!(Encoding::SingleByte.blank_len(blank.as_bytes()), None); + } + } + + #[test] + fn blank_len_rejects_no_break_blanks() { + // U+00A0, U+2007 and U+2009 differ from glibc's iswblank only in that + // the first two are non-breaking; U+2009 is a real blank. + assert_eq!(Encoding::Utf8.blank_len("\u{a0}".as_bytes()), None); + assert_eq!(Encoding::Utf8.blank_len("\u{2007}".as_bytes()), None); + assert_eq!(Encoding::Utf8.blank_len("\u{2009}".as_bytes()), Some(3)); + } + + #[test] + fn blank_len_rejects_ill_formed_sequences() { + // Overlong encoding of U+0020: shaped like a 2-byte sequence, but it is + // not a character and must not pass for a space. + assert_eq!(Encoding::Utf8.blank_len(&[0xc0, 0xa0]), None); + // Truncated lead byte falls back to a single byte, which is not blank. + assert_eq!(Encoding::Utf8.blank_len(&[0xe2, 0x80]), None); + // Surrogate half. + assert_eq!(Encoding::Utf8.blank_len(&[0xed, 0xa0, 0x80]), None); + } + + #[test] + fn unicode_blank_set_matches_iswblank() { + assert!(is_unicode_blank('\u{1680}')); + assert!(is_unicode_blank('\u{205f}')); + assert!(!is_unicode_blank('\u{202f}')); + assert!(!is_unicode_blank('\u{feff}')); + } +} diff --git a/src/uucore/src/lib/features/i18n/collator.rs b/src/uucore/src/lib/features/i18n/collator.rs index fbb9e61f7fa..2a8a9613497 100644 --- a/src/uucore/src/lib/features/i18n/collator.rs +++ b/src/uucore/src/lib/features/i18n/collator.rs @@ -31,8 +31,14 @@ pub fn init_collator(opts: CollatorOptions) { } /// Check if locale collation should be used. +/// +/// The result is cached, since `DEFAULT_LOCALE` is a `const` that materializes a +/// fresh heap-backed `Locale` on every evaluation. Comparing against it on each +/// call (e.g. once per comparison in a sort) is measurably expensive, so we +/// compute the decision once and reuse it. pub fn should_use_locale_collation() -> bool { - get_collating_locale().0 != DEFAULT_LOCALE + static USE_LOCALE_COLLATION: OnceLock = OnceLock::new(); + *USE_LOCALE_COLLATION.get_or_init(|| get_collating_locale().0 != DEFAULT_LOCALE) } /// Initialize the collator for locale-aware string comparison if needed. @@ -61,9 +67,9 @@ pub fn should_use_locale_collation() -> bool { 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 + // Use ICU collation only for UTF-8 locales that are NOT C/POSIX + // (e.g. en_US.UTF-8, but not C.UTF-8 — C still uses byte comparison) + if get_locale_encoding() != UEncoding::Utf8 || !should_use_locale_collation() { return false; } @@ -88,13 +94,13 @@ pub fn compute_sort_key_utf8(input: &[u8], buf: &mut Vec) { /// 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 - if get_collating_locale().0 == DEFAULT_LOCALE { - left.cmp(right) - } else { + // If the detected locale is 'C', just do byte-wise comparison. + if should_use_locale_collation() { // Fall back to byte comparison if collator is not available COLLATOR .get() .map_or_else(|| left.cmp(right), |c| c.compare_utf8(left, right)) + } else { + left.cmp(right) } } diff --git a/src/uucore/src/lib/features/i18n/datetime.rs b/src/uucore/src/lib/features/i18n/datetime.rs index 1e94f299abd..7c4e3c862fc 100644 --- a/src/uucore/src/lib/features/i18n/datetime.rs +++ b/src/uucore/src/lib/features/i18n/datetime.rs @@ -67,6 +67,24 @@ pub enum CalendarType { Ethiopian, } +thread_local! { + // Not a `static OnceLock`: ICU formatters are neither `Send` nor `Sync`. + static MONTH_LONG: Option> = + DateTimeFormatter::try_new(get_time_locale().0.clone().into(), fieldsets::M::long()).ok(); + static MONTH_MEDIUM: Option> = + DateTimeFormatter::try_new(get_time_locale().0.clone().into(), fieldsets::M::medium()).ok(); + static WEEKDAY_LONG: Option> = + DateTimeFormatter::try_new(get_time_locale().0.clone().into(), fieldsets::E::long()).ok(); + static WEEKDAY_SHORT: Option> = + DateTimeFormatter::try_new(get_time_locale().0.clone().into(), fieldsets::E::short()).ok(); +} + +macro_rules! format_with { + ($formatter:ident, $date:expr) => { + $formatter.with(|f| Some(f.as_ref()?.format($date).to_string())) + }; +} + /// 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"; @@ -114,36 +132,33 @@ pub fn localize_format_string(format: &str, date: JiffDate) -> String { } // 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") + && let Some(name) = format_with!(MONTH_LONG, &iso_date) + { + fmt = fmt.replace("%B", &name); } - if fmt.contains("%b") || fmt.contains("%h") { - if let Ok(f) = DateTimeFormatter::try_new(locale_prefs, fieldsets::M::medium()) { - // ICU's medium format may include trailing periods (e.g., "febr." for Hungarian), - // which when combined with locale format strings that also add periods after - // %b (e.g., "%Y. %b. %d") results in double periods ("febr.."). - // The standard C/POSIX locale via nl_langinfo returns abbreviations - // WITHOUT trailing periods, so we strip them here for consistency. - let month_abbrev = f.format(&iso_date).to_string(); - let month_abbrev = month_abbrev.trim_end_matches('.').to_string(); - fmt = fmt - .replace("%b", &month_abbrev) - .replace("%h", &month_abbrev); - } + if (fmt.contains("%b") || fmt.contains("%h")) + && let Some(month_abbrev) = format_with!(MONTH_MEDIUM, &iso_date) + { + // ICU's medium format may include trailing periods (e.g., "febr." for Hungarian), + // which when combined with locale format strings that also add periods after + // %b (e.g., "%Y. %b. %d") results in double periods ("febr.."). + // The standard C/POSIX locale via nl_langinfo returns abbreviations + // WITHOUT trailing periods, so we strip them here for consistency. + let month_abbrev = month_abbrev.trim_end_matches('.').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") + && let Some(name) = format_with!(WEEKDAY_LONG, &iso_date) + { + fmt = fmt.replace("%A", &name); } - 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()); - } + if fmt.contains("%a") + && let Some(name) = format_with!(WEEKDAY_SHORT, &iso_date) + { + fmt = fmt.replace("%a", &name); } fmt.replace(PERCENT_PLACEHOLDER, "%%") @@ -176,8 +191,8 @@ pub fn get_locale_months() -> Option<&'static [Vec; 12]> { not(target_os = "redox") ))] fn get_locale_months_inner() -> Option<[Vec; 12]> { + use core::ffi::CStr; use nix::libc; - use std::ffi::CStr; let abmon_items: [libc::nl_item; 12] = [ libc::ABMON_1, diff --git a/src/uucore/src/lib/features/i18n/mod.rs b/src/uucore/src/lib/features/i18n/mod.rs index e7cdfd32949..c461f4d78e3 100644 --- a/src/uucore/src/lib/features/i18n/mod.rs +++ b/src/uucore/src/lib/features/i18n/mod.rs @@ -38,39 +38,112 @@ const DEFAULT_LOCALE: Locale = locale!("und"); 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()); + .find_map(|&key| std::env::var(key).ok().filter(|l| !l.is_empty())); if let Some(locale_var_str) = locale_var { 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 let bcp47 = simple.replace('_', "-"); let locale = Locale::try_from_str(&bcp47).unwrap_or(DEFAULT_LOCALE); - // If locale parsing failed, parse the encoding part of the - // locale. Treat the special case of the given locale being "C" - // which becomes the default locale. - let encoding = if (locale != DEFAULT_LOCALE || bcp47 == "C") - && split.next().is_some_and(|enc| { + // Determine encoding from the locale suffix (e.g. en_US.UTF-8, C.UTF-8). + let encoding = split + .next() + .filter(|enc| { let lower = enc.to_lowercase(); lower == "utf-8" || lower == "utf8" - }) { - UEncoding::Utf8 - } else { - UEncoding::Ascii - }; + }) + .map_or(UEncoding::Ascii, |_| UEncoding::Utf8); return (locale, encoding); } } - // Default POSIX locale representing LC_ALL=C + + get_locale_from_os() +} + +/// Returns the preferred user locale. +#[cfg(windows)] +pub fn get_locale_from_os() -> (Locale, UEncoding) { + use std::mem::MaybeUninit; + use std::ptr::{null, null_mut}; + use std::str; + use windows_sys::Win32::Globalization::{ + CP_UTF8, GetThreadPreferredUILanguages, MUI_LANGUAGE_NAME, MUI_MERGE_USER_FALLBACK, + WideCharToMultiByte, + }; + + /// TODO(MSRV>=1.93): remove in favor of `slice::assume_init_ref` + /// + /// # Safety + /// + /// Same as the official [`slice::assume_init_ref`](https://doc.rust-lang.org/1.93.0/std/primitive.slice.html#method.assume_init_ref). + #[allow(clippy::ref_as_ptr)] + unsafe fn assume_init_ref(s: &[MaybeUninit]) -> &[T] { + unsafe { &*(s as *const [MaybeUninit] as *const [T]) } + } + + // Each tag is ~5 chars + NUL. How many languages are realistic? 20? + // Then that's 120 chars. + const LEN: usize = 256; + + // NOTE: Worst-case UTF16 -> UTF8 is 3x expansion, + // but MUI_LANGUAGE_NAME tags are ASCII-only. + let mut utf16 = [const { MaybeUninit::::uninit() }; LEN]; + let mut utf8 = [const { MaybeUninit::::uninit() }; LEN]; + let mut len = utf16.len() as u32; + let mut num = 0; + + // MUI_MERGE_USER_FALLBACK combines thread -> process -> user preferences. This is preferable + // over GetUserPreferredUILanguages, since the coreutils may be embedded into a larger app. + // (It also permits a limited form of unit testing.) + let ok = unsafe { + GetThreadPreferredUILanguages( + MUI_LANGUAGE_NAME | MUI_MERGE_USER_FALLBACK, + &raw mut num, + utf16.as_mut_ptr().cast(), + &raw mut len, + ) + }; + if ok == 0 || num == 0 { + return (DEFAULT_LOCALE, UEncoding::Utf8); + } + + let utf16_len = utf16.len().min(len as usize); + let utf8_len = unsafe { + WideCharToMultiByte( + CP_UTF8, + 0, + utf16.as_mut_ptr().cast(), + utf16_len as i32, + utf8.as_mut_ptr().cast(), + utf8.len() as i32, + null(), + null_mut(), + ) + }; + if utf8_len == 0 { + return (DEFAULT_LOCALE, UEncoding::Utf8); + } + + let utf8 = &utf8[..utf8_len as usize]; + let utf8 = unsafe { assume_init_ref(utf8) }; + let utf8 = unsafe { str::from_utf8_unchecked(utf8) }; + let locale = utf8 + .split_terminator('\0') + .filter(|lang| !lang.is_empty()) + .find_map(|lang| Locale::try_from_str(lang).ok()) + .unwrap_or(DEFAULT_LOCALE); + + (locale, UEncoding::Utf8) +} + +/// Returns the default POSIX locale representing LC_ALL=C. +#[cfg(not(windows))] +pub fn get_locale_from_os() -> (Locale, UEncoding) { (DEFAULT_LOCALE, UEncoding::Ascii) } @@ -99,3 +172,35 @@ pub fn get_ctype_encoding() -> UEncoding { *CTYPE_ENCODING.get_or_init(|| get_locale_from_env("LC_CTYPE").1) } + +#[cfg(test)] +mod tests { + #[cfg(windows)] + #[test] + fn test_get_locale_from_os() { + use icu_locale::locale; + use std::ptr::null; + use windows_sys::Win32::Globalization::{MUI_LANGUAGE_NAME, SetThreadPreferredUILanguages}; + use windows_sys::w; + + // Unfortunately it's not possible to test if multiple languages parse properly. + // `Locale::try_from_str` succeeds on the first valid tag and + // `SetThreadPreferredUILanguages` does not allow setting invalid tags. + unsafe { + const LANGS: *const u16 = w!("fr-FR\0"); + let mut num = 0; + SetThreadPreferredUILanguages(MUI_LANGUAGE_NAME, LANGS, &raw mut num); + assert_eq!(num, 1); + } + + let (locale, encoding) = super::get_locale_from_os(); + assert_eq!(encoding, super::UEncoding::Utf8); + assert_eq!(locale, locale!("fr-FR")); + + unsafe { + let mut num = 0; + SetThreadPreferredUILanguages(MUI_LANGUAGE_NAME, null(), &raw mut num); + assert_eq!(num, 0); + } + } +} diff --git a/src/uucore/src/lib/features/lines.rs b/src/uucore/src/lib/features/lines.rs index 3e3c82b3a5c..29a7f89ee68 100644 --- a/src/uucore/src/lib/features/lines.rs +++ b/src/uucore/src/lib/features/lines.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (vars) + //! Iterate over lines, including the line ending character(s). //! //! This module provides the [`lines`] function, similar to the @@ -13,6 +15,7 @@ //! characters. This is useful if the input data does not end with a //! newline character and you want to preserve the exact form of the //! input data. + use std::io::BufRead; /// Returns an iterator over the lines, including line ending characters. diff --git a/src/uucore/src/lib/features/mode.rs b/src/uucore/src/lib/features/mode.rs index b9694fead24..77aef656d12 100644 --- a/src/uucore/src/lib/features/mode.rs +++ b/src/uucore/src/lib/features/mode.rs @@ -3,23 +3,187 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore (vars) fperm srwx + //! Set of functions to parse modes -// spell-checker:ignore (vars) fperm srwx +use std::fmt::{self, Display}; +use std::ops::Range; #[cfg(windows)] use libc::umask; -pub fn parse_numeric(fperm: u32, mut mode: &str, considering_dir: bool) -> Result { +use crate::translate; + +/// A mode string that does not parse, and the part of it that is at fault. +/// +/// `span` is a byte range inside the mode string that was handed to the parser, +/// so that a caller can point a caret at the one clause — often the one +/// character — that broke it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModeError { + pub message: String, + pub span: Range, + pub kind: ModeErrorKind, +} + +/// What went wrong, for callers that want to say more than the message does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModeErrorKind { + /// Something other than `+`, `-` or `=` where an operator was expected. + InvalidOperator, + /// Who the clause applies to, and then nothing. + MissingOperator, + /// A numeric mode that is not octal, or is out of range. + InvalidNumber, +} + +impl ModeError { + fn new(message: String, span: Range, kind: ModeErrorKind) -> Self { + Self { + message, + span, + kind, + } + } + + /// Move the range so that it is relative to a string starting `offset` + /// bytes earlier. + fn shift(self, offset: usize) -> Self { + Self { + span: self.span.start + offset..self.span.end + offset, + ..self + } + } + + /// Render this error against `args`, where the mode is the value of the + /// `-m`/`--mode` option. + /// + /// # Arguments + /// + /// * `args` - The argument list the mode came from, without the program + /// name — as [`crate::diagnostics::operands`] returns it, since the other + /// renderer here takes a positional index into the same list. + /// * `mode` - The whole mode operand. + /// * `clause_start` - Where the clause that failed begins inside `mode`, + /// since a mode is parsed one comma-separated clause at a time. + /// * `message` - The headline, already localized. + /// + /// # Returns + /// + /// `false` when the mode cannot be found among the arguments, in which case + /// the caller should fall back to the plain one-line message. + pub fn render_mode_value( + &self, + args: &[std::ffi::OsString], + mode: &str, + clause_start: usize, + message: &str, + ) -> bool { + let (label, help) = self.describe(); + crate::diagnostics::Snapshot::new(args).render_option_value( + mode, + Some('m'), + Some("mode"), + self.clause_span(clause_start), + message, + label.as_deref(), + help.as_deref(), + ) + } + + /// Render this error against `args`, with the argument carrying the mode + /// already located by the caller. + /// + /// # Arguments + /// + /// * `args` - The argument list the mode came from, without the program + /// name. + /// * `index` - Position of the argument carrying the mode inside `args`. + /// * `mode` - The mode as it appears in that argument. + /// * `clause_start` - Where the clause that failed begins inside `mode`, + /// since a mode is parsed one comma-separated clause at a time. + /// * `message` - The headline, already localized. + /// + /// # Returns + /// + /// `false` when nothing could be rendered, in which case the caller should + /// fall back to the plain one-line message. + pub fn render_at( + &self, + args: &[std::ffi::OsString], + index: usize, + mode: &str, + clause_start: usize, + message: &str, + ) -> bool { + let (label, help) = self.describe(); + crate::diagnostics::Snapshot::new(args).render_inside_at( + index, + mode, + self.clause_span(clause_start), + message, + label.as_deref(), + help.as_deref(), + ) + } + + /// The caret label for this error, translated, and the advice that goes + /// under it. + /// + /// Labelled only where a label would add to the message, per the + /// convention in [`crate::diagnostics`]. + fn describe(&self) -> (Option, Option) { + let label = match self.kind { + // The message already names the expected operators. + ModeErrorKind::InvalidOperator => None, + ModeErrorKind::MissingOperator => Some("mode-diag-label-missing-operator"), + ModeErrorKind::InvalidNumber => Some("mode-diag-label-invalid-number"), + }; + ( + label.map(|label| translate!(label)), + Some(translate!("mode-diag-help-syntax")), + ) + } + + /// Where this error sits inside the whole mode, given where the clause it + /// was raised in begins. + fn clause_span(&self, clause_start: usize) -> Range { + clause_start + self.span.start..clause_start + self.span.end + } +} + +impl Display for ModeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ModeError {} + +pub fn parse_numeric(fperm: u32, mode: &str, considering_dir: bool) -> Result { + let original = mode; let (op, pos) = parse_op(mode).map_or_else(|_| (None, 0), |(op, pos)| (Some(op), pos)); - mode = mode[pos..].trim(); + let digits = mode[pos..].trim(); + let mode = digits; let change = if mode.is_empty() { 0 } else { - u32::from_str_radix(mode, 8).map_err(|e| e.to_string())? + let at = original.len() - original.trim_end().len(); + u32::from_str_radix(mode, 8).map_err(|e| { + ModeError::new( + e.to_string(), + pos..original.len() - at, + ModeErrorKind::InvalidNumber, + ) + })? }; if change > 0o7777 { - Err(format!("mode is too large ({change:o} > 7777)")) + Err(ModeError::new( + format!("mode is too large ({change:o} > 7777)"), + 0..original.len(), + ModeErrorKind::InvalidNumber, + )) } else { Ok(match op { Some('+') => fperm | change, @@ -35,18 +199,28 @@ pub fn parse_numeric(fperm: u32, mut mode: &str, considering_dir: bool) -> Resul pub fn parse_symbolic( mut fperm: u32, - mut mode: &str, + mode: &str, umask: u32, considering_dir: bool, -) -> Result { +) -> Result { + let original = mode; + // Everything below is a suffix of `original`, so how much is left is also + // where we are. + let at = |rest: &str| original.len() - rest.len(); + let (mask, pos) = parse_levels(mode); if pos == mode.len() { - return Err(format!("invalid mode ({mode})")); + // Who the clause applies to, and then nothing: no operator followed. + return Err(ModeError::new( + format!("invalid mode ({mode})"), + 0..mode.len(), + ModeErrorKind::MissingOperator, + )); } let respect_umask = pos == 0; - mode = &mode[pos..]; + let mut mode = &mode[pos..]; while !mode.is_empty() { - let (op, pos) = parse_op(mode)?; + let (op, pos) = parse_op(mode).map_err(|err| err.shift(at(mode)))?; mode = &mode[pos..]; let (mut srwx, pos) = parse_change(mode, fperm, considering_dir); if respect_umask { @@ -88,15 +262,20 @@ fn parse_levels(mode: &str) -> (u32, usize) { (mask, pos) } -fn parse_op(mode: &str) -> Result<(char, usize), String> { - let ch = mode - .chars() - .next() - .ok_or_else(|| "unexpected end of mode".to_owned())?; +fn parse_op(mode: &str) -> Result<(char, usize), ModeError> { + let Some(ch) = mode.chars().next() else { + return Err(ModeError::new( + translate!("mode-error-unexpected-end"), + 0..0, + ModeErrorKind::MissingOperator, + )); + }; match ch { '+' | '-' | '=' => Ok((ch, 1)), - _ => Err(format!( - "invalid operator (expected +, -, or =, but found {ch})" + _ => Err(ModeError::new( + translate!("mode-error-invalid-operator", "operator" => ch), + 0..ch.len_utf8(), + ModeErrorKind::InvalidOperator, )), } } @@ -145,28 +324,34 @@ pub fn parse_chmod( mode_string: &str, considering_dir: bool, umask: u32, -) -> Result { +) -> Result { let mut new_mode: u32 = current_mode; // Split by commas and process each mode part sequentially - for mode_part in mode_string.split(',') { - let mode_part = mode_part.trim(); + let mut offset = 0; + for raw_part in mode_string.split(',') { + let start = offset + (raw_part.len() - raw_part.trim_start().len()); + // Past the part and the comma that followed it. + offset += raw_part.len() + 1; + + let mode_part = raw_part.trim(); if mode_part.is_empty() { continue; } new_mode = if mode_part.chars().any(|c| c.is_ascii_digit()) { - parse_numeric(new_mode, mode_part, considering_dir)? + parse_numeric(new_mode, mode_part, considering_dir) } else { - parse_symbolic(new_mode, mode_part, umask, considering_dir)? - }; + parse_symbolic(new_mode, mode_part, umask, considering_dir) + } + .map_err(|err| err.shift(start))?; } Ok(new_mode) } /// Takes a user-supplied string and tries to parse to u32 mode bitmask. -pub fn parse(mode_string: &str, considering_dir: bool, umask: u32) -> Result { +pub fn parse(mode_string: &str, considering_dir: bool, umask: u32) -> Result { parse_chmod(0, mode_string, considering_dir, umask) } diff --git a/src/uucore/src/lib/features/parser/mod.rs b/src/uucore/src/lib/features/parser/mod.rs index 3f290d93630..c26ac3837b8 100644 --- a/src/uucore/src/lib/features/parser/mod.rs +++ b/src/uucore/src/lib/features/parser/mod.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore extendedbigdecimal #[cfg(any(feature = "parser", feature = "parser-num"))] diff --git a/src/uucore/src/lib/features/parser/num_parser.rs b/src/uucore/src/lib/features/parser/num_parser.rs index 951d23ba257..2b5d34b0d4c 100644 --- a/src/uucore/src/lib/features/parser/num_parser.rs +++ b/src/uucore/src/lib/features/parser/num_parser.rs @@ -3,10 +3,10 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -//! Utilities for parsing numbers in various formats - // spell-checker:ignore powf copysign prec ilog inity infinit infs bigdecimal extendedbigdecimal biguint underflowed muls +//! Utilities for parsing numbers in various formats + use bigdecimal::{ BigDecimal, num_bigint::{BigInt, BigUint, Sign}, @@ -130,9 +130,7 @@ where fn extract(self) -> T { match self { Self::NotNumeric => T::zero(), - Self::PartialMatch(v, _) => v, - Self::Overflow(v) => v, - Self::Underflow(v) => v, + Self::PartialMatch(v, _) | Self::Overflow(v) | Self::Underflow(v) => v, } } @@ -290,10 +288,8 @@ fn parse_digits(base: Base, str: &str, fractional: bool) -> (Option, i6 // If allowed, parse the fractional part of the number if there can be one and the // input contains a '.' decimal separator. - if fractional { - if let Some(rest) = rest.strip_prefix('.') { - return base.parse_digits_count(rest, digits); - } + if fractional && let Some(rest) = rest.strip_prefix('.') { + return base.parse_digits_count(rest, digits); } (digits, 0, rest) @@ -330,13 +326,12 @@ fn parse_exponent(base: Base, str: &str) -> (Option, &str) { /// Parse a multiplier from allowed suffixes (e.g. s/m/h). fn parse_suffix_multiplier<'a>(str: &'a str, allowed_suffixes: &[(char, u32)]) -> (u32, &'a str) { - if let Some(ch) = str.chars().next() { - if let Some(mul) = allowed_suffixes + if let Some(ch) = str.chars().next() + && let Some(mul) = allowed_suffixes .iter() .find_map(|(c, t)| (ch == *c).then_some(*t)) - { - return (mul, &str[1..]); - } + { + return (mul, &str[1..]); } // No suffix, just return 1 and intact string diff --git a/src/uucore/src/lib/features/parser/parse_glob.rs b/src/uucore/src/lib/features/parser/parse_glob.rs index 6f7dae24c98..521cbcb5fdb 100644 --- a/src/uucore/src/lib/features/parser/parse_glob.rs +++ b/src/uucore/src/lib/features/parser/parse_glob.rs @@ -2,11 +2,12 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + //! Parsing a glob Pattern from a string. //! //! Use the [`from_str`] function to parse a [`Pattern`] from a string. -// cSpell:words fnmatch +// spell-checker:words fnmatch use glob::{Pattern, PatternError}; diff --git a/src/uucore/src/lib/features/parser/parse_signed_num.rs b/src/uucore/src/lib/features/parser/parse_signed_num.rs index 82ffcaaca43..fb1a3d46d38 100644 --- a/src/uucore/src/lib/features/parser/parse_signed_num.rs +++ b/src/uucore/src/lib/features/parser/parse_signed_num.rs @@ -3,12 +3,39 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore KMGTPEZYRQ + //! Parser for signed numeric arguments used by head, tail, and similar utilities. //! //! These utilities accept arguments like `-5`, `+10`, `-100K` where the leading //! sign indicates different behavior (e.g., "first N" vs "last N" vs "starting from N"). -use super::parse_size::{ParseSizeError, parse_size_u64, parse_size_u64_max}; +use super::parse_size::{ + ParseSizeError, Parser, allow_list_with_all_suffixes, parse_size_u64, size_offset, +}; +use crate::display::Quotable; + +/// The multiplier suffixes accepted on a count argument. +/// +/// Each of these is also valid followed by `B`, `iB` or `D`. A lowercase +/// letter is only accepted for `k` and `m`; the remaining multipliers must be +/// uppercase. `b` (512-byte blocks) is handled separately because it is the +/// one suffix that has no `B`/`iB`/`D` form. +const MULTIPLIER_SUFFIXES: &str = "kmKMGTPEZYRQ"; + +/// Parse the numeric part of a count argument, rejecting any suffix that is +/// not one of [`MULTIPLIER_SUFFIXES`] or a bare `b`. +/// +/// The generic size parser accepts a lowercase form of every multiplier, which +/// is more than these utilities allow. +fn parse_count(size: &str) -> Result { + let mut allow_list = allow_list_with_all_suffixes(MULTIPLIER_SUFFIXES); + allow_list.push("b".to_string()); + let allow_list: Vec<&str> = allow_list.iter().map(AsRef::as_ref).collect(); + Parser::default() + .with_allow_list(&allow_list) + .parse_u64_max(size) +} /// The sign prefix found on a numeric argument. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -84,11 +111,21 @@ pub fn parse_signed_num_max(src: &str) -> Result { // Remove leading zeros so size is interpreted as decimal, not octal let trimmed = size_string.trim_start_matches('0'); + let had_leading_zeros = trimmed.len() != size_string.len(); let value = if trimmed.is_empty() { // All zeros (e.g., "000" or "0") 0 + } else if had_leading_zeros && !trimmed.chars().any(|c| c.is_ascii_digit()) { + // Only a size suffix remains after stripping leading zeros + // (e.g., "0K"). Parse it as 1 unit to validate the suffix, but + // the value is 0: a zero count times any unit is zero bytes. + // Otherwise "0K" would parse as 1KiB (bare suffix means 1). + // A genuinely bare suffix with no digits at all (e.g. "kiB") + // still parses as 1 of that unit. + parse_count(trimmed).map_err(|e| as_typed(e, size_string))?; + 0 } else { - parse_size_u64_max(trimmed)? + parse_count(trimmed).map_err(|e| as_typed(e, size_string))? }; Ok(SignedNum { value, sign }) @@ -117,6 +154,42 @@ pub fn parse_signed_num(src: &str) -> Result { Ok(SignedNum { value, sign }) } +/// Where the number starts inside `src`, past what this module strips before +/// parsing: the surrounding whitespace and the sign prefix. +/// +/// The prefix here is the sign this module's own `strip_sign_prefix` takes, so +/// the two agree on what was stripped; see [`size_offset`] for the rest of the +/// reasoning. +/// +/// # Arguments +/// +/// * `src` - The argument as typed, the one [`parse_signed_num_max`] was given. +pub fn number_offset(src: &str) -> usize { + size_offset(src, |c| matches!(c, '+' | '-')) +} + +/// Put back the leading zeros the parser stripped, so the error names the +/// argument the way it was typed. +/// +/// Zeros are only removed so the number is read as decimal rather than octal, +/// which is an implementation detail the message should not leak: GNU reports +/// `tail: invalid number of bytes: '007z'`, not `'7z'`. The sign is left off, +/// also matching GNU, which reports `-c-0fb` as `'0fb'`. +fn as_typed(error: ParseSizeError, size_string: &str) -> ParseSizeError { + let quoted = format!("{}", size_string.quote()); + match error { + // These two carry the quoted operand and nothing else, so it can be + // swapped for the one that was actually typed. + ParseSizeError::InvalidSuffix(_) => ParseSizeError::InvalidSuffix(quoted), + ParseSizeError::ParseFailure(_) => ParseSizeError::ParseFailure(quoted), + // `SizeTooBig` carries an explanation after the operand and + // `PhysicalMem` is not about the operand at all, so neither can be + // rebuilt from the string alone. `parse_size_u64_max` clamps instead + // of overflowing, so neither reaches this in practice. + other => other, + } +} + /// Strip the sign prefix from a string and return both the sign and remaining string. fn strip_sign_prefix(src: &str) -> (Option, &str) { let trimmed = src.trim(); @@ -134,6 +207,55 @@ fn strip_sign_prefix(src: &str) -> (Option, &str) { mod tests { use super::*; + /// The offset a caret counts has to be the one the parser skipped, or the + /// underline lands beside what went wrong — under the sign rather than + /// under the unit that was not a unit. + #[test] + fn number_offset_counts_what_the_parser_stripped() { + assert_eq!(number_offset("1fb"), 0); + assert_eq!(number_offset("-1fb"), 1); + assert_eq!(number_offset("+5zz"), 1); + assert_eq!(number_offset(" -1fb"), 3); + // The parser reads leading zeros as part of the number, so they stay. + assert_eq!(number_offset("-001fb"), 1); + } + + /// What the two agree on is the point: `span` is only right about an + /// operand once the sign in front of it has been counted out. + #[test] + fn the_span_lands_on_the_unit_of_a_signed_operand() { + let operand = "-1fb"; + let error = parse_signed_num_max(operand).unwrap_err(); + let at = number_offset(operand); + assert_eq!(error.span(&operand[at..]), 1..3); + assert_eq!(&operand[at..][1..3], "fb"); + } + + /// GNU names the argument as it was typed. The leading zeros are stripped + /// only so the number is read as decimal rather than octal, and that + /// detail must not reach the message: GNU reports `'007z'`, not `'7z'`. + #[test] + fn an_invalid_count_is_reported_with_its_leading_zeros() { + for operand in ["0fb", "00x", "000ff", "0abc"] { + let error = parse_signed_num_max(operand).unwrap_err(); + assert!( + error.to_string().contains(&format!("'{operand}'")), + "{operand} was reported as {error}" + ); + } + } + + /// The sign is not restored along with the zeros: GNU reports the operand + /// of `tail -c-0fb` as `'0fb'`. + #[test] + fn the_sign_is_left_off_the_reported_count() { + let error = parse_signed_num_max("-0fb").unwrap_err(); + assert!( + error.to_string().contains("'0fb'"), + "-0fb was reported as {error}" + ); + } + #[test] fn test_no_sign() { let result = parse_signed_num_max("10").unwrap(); @@ -202,6 +324,38 @@ mod tests { assert_eq!(result.value, 0); } + #[test] + fn test_zero_with_suffix() { + // "0K" must be 0 bytes, not 1KiB (bare suffix parses as 1) + let result = parse_signed_num_max("0K").unwrap(); + assert_eq!(result.value, 0); + assert!(result.is_zero()); + + let result = parse_signed_num_max("+0K").unwrap(); + assert_eq!(result.value, 0); + assert!(result.has_plus()); + + let result = parse_signed_num_max("-0M").unwrap(); + assert_eq!(result.value, 0); + assert!(result.has_minus()); + + let result = parse_signed_num_max("00K").unwrap(); + assert_eq!(result.value, 0); + + let result = parse_signed_num_max("0b").unwrap(); + assert_eq!(result.value, 0); + } + + #[test] + fn test_bare_suffix_still_parses_as_one() { + // a bare suffix with no digits is 1 of that unit, not 0 + let result = parse_signed_num_max("kiB").unwrap(); + assert_eq!(result.value, 1024); + + let result = parse_signed_num_max("K").unwrap(); + assert_eq!(result.value, 1024); + } + #[test] fn test_whitespace() { let result = parse_signed_num_max(" 10 ").unwrap(); diff --git a/src/uucore/src/lib/features/parser/parse_size.rs b/src/uucore/src/lib/features/parser/parse_size.rs index 15a6d58b8c5..0b050d68895 100644 --- a/src/uucore/src/lib/features/parser/parse_size.rs +++ b/src/uucore/src/lib/features/parser/parse_size.rs @@ -2,10 +2,37 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (ToDO) hdsf ghead gtail ACDBK hexdigit //! Parser for sizes in SI or IEC units (multiples of 1000 or 1024 bytes). +/// Where the size itself starts inside `src`, past the leading whitespace and +/// one optional prefix character. +/// +/// A [`ParseSizeError`] reports offsets into the size the parser saw, never +/// into the argument as typed. The parsers taking a SIZE trim it and then take +/// at most one character of their own before the size proper — a sign for +/// `head` and `tail`, a mode character for `truncate` — so a caret over the +/// argument has to count both back in. Only *one* such character is taken, so +/// a second one belongs to the size as far as the parser is concerned and is +/// left in. +/// +/// # Arguments +/// +/// * `src` - The argument as typed. +/// * `is_prefix` - Whether a character is the prefix the caller's parser +/// strips. +pub fn size_offset(src: &str, is_prefix: impl Fn(char) -> bool) -> usize { + let trimmed = src.trim_start(); + (src.len() - trimmed.len()) + + trimmed + .chars() + .next() + .filter(|&c| is_prefix(c)) + .map_or(0, char::len_utf8) +} + /// The first eleven powers of 1000: `1, 10^3, 10^6, ..., 10^30`. /// /// Index `n` is the SI base for the `n`-th suffix (0 → no suffix, 1 → K/kB, @@ -45,6 +72,7 @@ pub const IEC_BASES: [u128; 11] = [ use std::error::Error; use std::fmt; use std::num::{IntErrorKind, ParseIntError}; +use std::ops::Range; use crate::display::Quotable; #[cfg(target_os = "linux")] @@ -145,6 +173,39 @@ enum NumberSystem { Binary, } +/// The leading part of `size` that spells out its number. +/// +/// The rest is the unit, so this is also where a caret goes when the unit is +/// the part at fault. +/// +/// # Arguments +/// +/// * `size` - The SIZE operand as typed. +/// * `number_system` - How its digits are to be read. +fn numeric_prefix(size: &str, number_system: NumberSystem) -> &str { + let len = match number_system { + NumberSystem::Hexadecimal | NumberSystem::Binary => { + let is_digit = |c: char| match number_system { + NumberSystem::Hexadecimal => c.is_ascii_hexdigit(), + _ => c.is_digit(2), + }; + // The `0x` or `0b` that named the system, and the digits after it. + 2 + size + .chars() + .skip(2) + .take_while(|&c| is_digit(c)) + .map(char::len_utf8) + .sum::() + } + _ => size + .chars() + .take_while(char::is_ascii_digit) + .map(char::len_utf8) + .sum(), + }; + &size[..len.min(size.len())] +} + impl<'parser> Parser<'parser> { /// Change allow_list of the parser - whitelist for the suffix pub fn with_allow_list(&mut self, allow_list: &'parser [&str]) -> &mut Self { @@ -255,7 +316,9 @@ impl<'parser> Parser<'parser> { if unit == "%" { let number: u128 = Self::parse_number(&numeric_string, 10, size)?; return match total_physical_memory() { - Ok(total) => Ok((number / 100) * total), + Ok(total) => (number / 100) + .checked_mul(total) + .ok_or_else(|| ParseSizeError::size_too_big(size)), Err(_) => Err(ParseSizeError::PhysicalMem(size.to_string())), }; } @@ -378,10 +441,11 @@ impl<'parser> Parser<'parser> { // Binary prefix: "0b" followed by at least one binary digit (0 or 1) // Note: "0b" alone is treated as decimal 0 with suffix "b" - if let Some(prefix) = size.strip_prefix("0b") { - if !prefix.is_empty() { - return NumberSystem::Binary; - } + if size + .strip_prefix("0b") + .is_some_and(|prefix| !prefix.is_empty()) + { + return NumberSystem::Binary; } let num_digits: usize = size @@ -488,10 +552,10 @@ pub enum ParseSizeError { impl Error for ParseSizeError { fn description(&self) -> &str { match *self { - Self::InvalidSuffix(ref s) => s, - Self::ParseFailure(ref s) => s, - Self::SizeTooBig(ref s) => s, - Self::PhysicalMem(ref s) => s, + Self::InvalidSuffix(ref s) + | Self::ParseFailure(ref s) + | Self::SizeTooBig(ref s) + | Self::PhysicalMem(ref s) => s, } } } @@ -551,6 +615,104 @@ impl ParseSizeError { Self::ParseFailure(format!("{}", s.quote())) } + /// Where in `size` this error belongs. + /// + /// The parser splits a SIZE into the number it starts with and the unit + /// that follows, and every failure is about one or the other. The split + /// is worked out the same way here, so the caret covers exactly the part + /// the parser rejected. + /// + /// # Arguments + /// + /// * `size` - The SIZE operand as typed, the one this error is about. + pub fn span(&self, size: &str) -> Range { + let number = numeric_prefix(size, Parser::determine_number_system(size)).len(); + match self { + // The number is fine; it is the unit that is not. + Self::InvalidSuffix(_) => number..size.len(), + // The number is what does not fit. + Self::SizeTooBig(_) => 0..number, + // Nothing usable was read: the operand as a whole is at fault. + Self::ParseFailure(_) | Self::PhysicalMem(_) => 0..size.len(), + } + } + + /// Render this error against `snapshot`, with a caret under the part of the + /// SIZE that is at fault. + /// + /// Every utility taking a SIZE takes the same syntax, so the label and the + /// advice are written once here rather than in each of them. + /// + /// # Arguments + /// + /// * `args` - The whole argument list, program name included — as + /// [`crate::diagnostics::capture`] returns it. + /// * `option` - The option's value as typed, and the option it was given + /// to. The value may carry something in front of the size — `truncate` + /// takes a mode character, as in `+2K`, `head` and `tail` a sign — which + /// the caret has to count but the parser never saw. + /// * `size_at` - Where the size itself starts inside the value, zero when + /// the whole of it is the size. + /// * `message` - The headline, already localized. It differs between + /// utilities, so it is passed in rather than built here. + /// + /// # Returns + /// + /// `false` when no argument carries the value as that option's value, in + /// which case the caller should fall back to the plain one-line message. + pub fn render_size_value( + &self, + args: &[std::ffi::OsString], + option: &crate::diagnostics::OptionValue, + size_at: usize, + message: &str, + ) -> bool { + let Some(size) = option.value.get(size_at..) else { + return false; + }; + // Labelled only where a label would add to the message, per the + // convention in `crate::diagnostics`. + let label = match self { + Self::InvalidSuffix(_) => Some(crate::translate!("size-diag-label-invalid-suffix")), + Self::SizeTooBig(_) => Some(crate::translate!("size-diag-label-too-big")), + Self::ParseFailure(_) | Self::PhysicalMem(_) => None, + }; + let span = self.span(size); + crate::diagnostics::Snapshot::with_program(args).render_option( + option, + size_at + span.start..size_at + span.end, + message, + label.as_deref(), + Some(&crate::translate!("size-diag-help-syntax")), + ) + } + + /// The error to raise for a SIZE that does not parse. + /// + /// Draws the caret when the arguments as typed were kept, and quiets + /// `error` when it did: the report has already said everything the + /// one-line message would, and the exit code is all that is left to + /// carry. + /// + /// # Arguments + /// + /// * `diag_args` - The arguments as typed, or `None` when they were not + /// kept — as [`crate::diagnostics::capture`] returns them. + /// * `option`, `size_at`, `message` - As for [`Self::render_size_value`]. + /// * `error` - The error to raise if nothing was drawn. + pub fn size_value_error( + &self, + diag_args: Option<&[std::ffi::OsString]>, + option: &crate::diagnostics::OptionValue, + size_at: usize, + message: &str, + error: impl Into>, + ) -> Box { + crate::diagnostics::error_after_report(diag_args, error, |args, _| { + self.render_size_value(args, option, size_at, message) + }) + } + fn size_too_big(s: &str) -> Self { // stderr on linux (GNU coreutils 8.32) (LC_ALL=C) // has to be handled in the respective uutils because strings differ, e.g.: @@ -861,4 +1023,20 @@ mod tests { assert!(parse_size_u64("1.0%").is_err()); assert!(parse_size_u64("0x1%").is_err()); } + + #[test] + #[cfg(target_os = "linux")] + fn parse_percent_overflow() { + // A percentage can fit in a u128 while its product with the total + // physical memory does not. `u128::MAX` overflows for any machine + // reporting more than 100 bytes of memory. + let size = format!("{}%", u128::MAX); + + assert!(variant_eq( + &parse_size_u128(&size).unwrap_err(), + &ParseSizeError::SizeTooBig(String::new()) + )); + assert_eq!(Ok(u128::MAX), parse_size_u128_max(&size)); + assert_eq!(Ok(u64::MAX), parse_size_u64_max(&size)); + } } diff --git a/src/uucore/src/lib/features/parser/parse_time.rs b/src/uucore/src/lib/features/parser/parse_time.rs index 3c4c155d2ef..2af3b29d90d 100644 --- a/src/uucore/src/lib/features/parser/parse_time.rs +++ b/src/uucore/src/lib/features/parser/parse_time.rs @@ -4,6 +4,7 @@ // file that was distributed with this source code. // spell-checker:ignore (vars) NANOS numstr infinityh INFD nans nanh bigdecimal extendedbigdecimal + //! Parsing a duration from a string. //! //! Use the [`from_str`] function to parse a [`Duration`] from a string. diff --git a/src/uucore/src/lib/features/parser/shortcut_value_parser.rs b/src/uucore/src/lib/features/parser/shortcut_value_parser.rs index 60e7847442c..4fdda148b5b 100644 --- a/src/uucore/src/lib/features/parser/shortcut_value_parser.rs +++ b/src/uucore/src/lib/features/parser/shortcut_value_parser.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore abcdefgh abef Strs //! A parser that accepts shortcuts for values. @@ -11,6 +12,7 @@ use clap::{ builder::{PossibleValue, TypedValueParser}, error::{ContextKind, ContextValue, ErrorKind}, }; +use std::fmt::Write as _; /// A parser that accepts shortcuts for values. #[derive(Clone)] @@ -69,7 +71,7 @@ fn add_ambiguous_value_tip( ) { let mut formatted_possible_values = String::new(); for (i, s) in possible_values.iter().enumerate() { - formatted_possible_values.push_str(&format!("'{}'", s.get_name())); + let _ = write!(formatted_possible_values, "'{}'", s.get_name()); if i < possible_values.len() - 2 { formatted_possible_values.push_str(", "); } else if i < possible_values.len() - 1 { diff --git a/src/uucore/src/lib/features/perms.rs b/src/uucore/src/lib/features/perms.rs index 141be21dd79..3eb3dca9fae 100644 --- a/src/uucore/src/lib/features/perms.rs +++ b/src/uucore/src/lib/features/perms.rs @@ -3,17 +3,15 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -//! Common functions to manage permissions - // spell-checker:ignore (jargon) TOCTOU fchownat fchown +//! Common functions to manage permissions + use crate::display::Quotable; use crate::error::{UResult, USimpleError, strip_errno}; pub use crate::features::entries; use crate::{show_error, translate}; -use thiserror::Error; - use clap::{Arg, ArgMatches, Command}; use libc::{gid_t, uid_t}; @@ -27,8 +25,9 @@ use walkdir::WalkDir; #[cfg(target_os = "linux")] use crate::features::fs::FileInformation; +use crate::features::fs::path_is_root_dir; #[cfg(target_os = "linux")] -use crate::features::safe_traversal::{DirFd, SymlinkBehavior}; +use crate::features::safe_traversal::{DirFd, FileInfo, SymlinkBehavior}; use std::ffi::CString; use std::fs::Metadata; @@ -37,9 +36,9 @@ use std::io::Result as IOResult; use std::os::unix::fs::MetadataExt; use std::os::unix::ffi::OsStrExt; -use std::path::{MAIN_SEPARATOR, Path}; +use std::path::Path; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] enum PermsError { #[error("{}: {}", translate!("common-write-error"), strip_errno(.0))] Write(IOError), @@ -108,13 +107,14 @@ pub fn wrap_chown>( VerbosityLevel::Silent => (), level => { out = format!( - "changing {} of {}: {e}", + "changing {} of {}: {}", if verbosity.groups_only { "group" } else { "ownership" }, path.quote(), + strip_errno(&e), ); if level == VerbosityLevel::Verbose { out = if verbosity.groups_only { @@ -224,59 +224,50 @@ pub fn check_root(path: &Path, would_recurse_symlink: bool) -> bool { /// In the context of chown and chgrp, check whether we are in a "preserve-root" scenario. /// -/// In particular, we want to prohibit further traversal only if: +/// Prohibit further traversal only if: /// (--preserve-root and -R present) && -/// (path canonicalizes to "/") && +/// (path *is* "/" by (st_dev, st_ino), so a bind mount of "/" counts too) && /// ( /// (path is a symlink && would traverse/recurse this symlink) || /// (path is not a symlink) /// ) -/// The first clause is checked by the caller, the second and third clause is checked here. +/// The first clause is checked by the caller, the second and third here. /// The caller has to evaluate -P/-H/-L into 'would_recurse_symlink'. -/// Recall that canonicalization resolves both relative paths (e.g. "..") and symlinks. fn is_root(path: &Path, would_traverse_symlink: bool) -> bool { - // The third clause can be evaluated without any syscalls, so we do that first. - // If we would_recurse_symlink, then the clause is true no matter whether the path is a symlink - // or not. Otherwise, we only need to check here if the path can syntactically be a symlink: - if !would_traverse_symlink { - // We cannot check path.is_dir() here, as this would resolve symlinks, - // which we need to avoid here. - // All directory-ish paths match "*/", except ".", "..", "*/.", and "*/..". - let path_bytes = path.as_os_str().as_encoded_bytes(); - let looks_like_dir = path_bytes == *b"." - || path_bytes == *b".." - || path_bytes.ends_with(&[MAIN_SEPARATOR as u8]) - || path_bytes.ends_with(&[MAIN_SEPARATOR as u8, b'.']) - || path_bytes.ends_with(&[MAIN_SEPARATOR as u8, b'.', b'.']); - - if !looks_like_dir { - return false; - } + // Compare by (st_dev, st_ino), not name: a bind mount of "/" is an ordinary + // directory whose path never resolves to "/", so the old syntactic "looks + // like a directory?" pre-filter waved it through. `would_traverse_symlink` + // says whether a symlink to "/" here would be followed (only then is it root). + // + // FIXME: TOCTOU bug! This stat runs at a different time than the recursion + // decision it guards; GNU avoids the window by reusing fts's `struct stat`. + if !path_is_root_dir(path, would_traverse_symlink) { + return false; } - // FIXME: TOCTOU bug! canonicalize() runs at a different time than WalkDir's recursion decision. - // However, we're forced to make the decision whether to warn about --preserve-root - // *before* even attempting to chown the path, let alone doing the stat inside WalkDir. - if let Ok(p) = path.canonicalize() { - let path_buf = path.to_path_buf(); - if p.parent().is_none() { - if path_buf.as_os_str() == "/" { - show_error!("it is dangerous to operate recursively on '/'"); - } else { - show_error!( - "it is dangerous to operate recursively on {} (same as '/')", - path_buf.quote() - ); - } - show_error!("use --no-preserve-root to override this failsafe"); - return true; - } + if path.as_os_str() == "/" { + show_error!("it is dangerous to operate recursively on '/'"); + } else { + show_error!( + "it is dangerous to operate recursively on {} (same as '/')", + path.quote() + ); } + show_error!("use --no-preserve-root to override this failsafe"); + true +} - false +/// Whether `dir_fd` refers to the very object `meta` describes. +/// +/// A pathname can be re-pointed between the stat and the open, so comparing +/// (device, inode) is what detects the swap. The descriptor cannot be re-pointed after. +#[cfg(target_os = "linux")] +fn fd_is(dir_fd: &DirFd, meta: &Metadata) -> IOResult { + let opened = FileInfo::from_stat(&dir_fd.fstat()?); + Ok(opened == FileInfo::new(meta.dev(), meta.ino())) } -pub fn get_metadata(file: &Path, follow: bool) -> Result { +pub fn get_metadata(file: &Path, follow: bool) -> std::io::Result { if follow { file.metadata() } else { @@ -323,21 +314,54 @@ impl ChownExecutor { return 1; } + // Resolve the operand once. `--from`, `--preserve-root` and the + // directory-vs-file classification were all decided on `meta`; re-opening the + // pathname would let a swap apply those decisions to a different object. + #[cfg(target_os = "linux")] + // We cannot check path.is_dir() here, as this would resolve symlinks + let operand_fd = if meta.is_dir() { + match DirFd::open(path, SymlinkBehavior::Follow) { + Ok(dir_fd) => match fd_is(&dir_fd, &meta) { + Ok(true) => Some(dir_fd), + Ok(false) => { + if self.verbosity.level != VerbosityLevel::Silent { + show_error!( + "{}", + translate!("perms-cannot-access-replaced", "file" => path.quote()) + ); + } + return 1; + } + Err(e) => { + if self.verbosity.level != VerbosityLevel::Silent { + show_error!( + "{}", + translate!("perms-cannot-access", "file" => path.quote(), "error" => strip_errno(&e)) + ); + } + return 1; + } + }, + Err(_e) => { + // Don't show error here - let safe_dive_into handle directory traversal + // errors. This prevents duplicate error messages. + None + } + } + } else { + None + }; + let ret = if self.matched(meta.uid(), meta.gid()) { // Use safe syscalls for root directory to prevent TOCTOU attacks on Linux #[cfg(target_os = "linux")] - // We cannot check path.is_dir() here, as this would resolve symlinks let chown_result = if meta.is_dir() { - // For directories on Linux, use safe traversal from the start - match DirFd::open(path, SymlinkBehavior::Follow) { - Ok(dir_fd) => self - .safe_chown_dir(&dir_fd, path, &meta) + match operand_fd.as_ref() { + Some(dir_fd) => self + .safe_chown_dir(dir_fd, path, &meta) .map(|_| String::new()), - Err(_e) => { - // Don't show error here - let safe_dive_into handle directory traversal errors - // This prevents duplicate error messages - Ok(String::new()) - } + // The open failed; safe_dive_into reports it. + None => Ok(String::new()), } } else { // For non-directories (files, symlinks), use the regular wrap_chown method @@ -389,7 +413,7 @@ impl ChownExecutor { if self.recursive { #[cfg(target_os = "linux")] { - ret | self.safe_dive_into(&root) + ret | self.safe_dive_into(&root, &meta, operand_fd) } #[cfg(not(target_os = "linux"))] { @@ -449,25 +473,40 @@ impl ChownExecutor { Ok(()) } + /// `operand_fd` is the descriptor `traverse` opened and verified against `meta`. #[cfg(target_os = "linux")] - fn safe_dive_into>(&self, root: P) -> i32 { + fn safe_dive_into>( + &self, + root: P, + meta: &Metadata, + operand_fd: Option, + ) -> i32 { let root = root.as_ref(); - // Don't traverse into symlinks if configured not to - if self.traverse_symlinks == TraverseSymlinks::None && root.is_symlink() { - return 0; - } - - // Only try to traverse if the root is actually a directory - // This matches WalkDir's behavior with min_depth(1) - if root is not a directory, - // there are no children to traverse, so we return early with success - if !root.is_dir() { + // Classify from `meta`, not a fresh lookup: `meta` already honours the + // dereference policy, and asking again is what would let the operand be swapped. + if !meta.is_dir() { + // No children to visit, matching WalkDir's min_depth(1). return 0; } - // Open directory with safe traversal - let Some(dir_fd) = self.try_open_dir(root) else { - return 1; + let dir_fd = if let Some(dir_fd) = operand_fd { + dir_fd + } else { + // The open in `traverse` failed; report it here, once. + let Some(dir_fd) = self.try_open_dir(root) else { + return 1; + }; + if !fd_is(&dir_fd, meta).unwrap_or(false) { + if self.verbosity.level != VerbosityLevel::Silent { + show_error!( + "{}", + translate!("perms-cannot-access-replaced", "file" => root.quote()) + ); + } + return 1; + } + dir_fd }; let mut ancestors = HashSet::new(); @@ -488,10 +527,11 @@ impl ChownExecutor { // fd. Using the fd is TOCTOU-safe (no path re-resolution through symlinks) and // avoids a redundant path walk. If it's already on the current path, it's a cycle. let dir_info = FileInformation::from_file(dir_fd).ok(); - if let Some(info) = &dir_info { - if !ancestors.insert(info.clone()) { - return; // cycle detected, stop silently - } + if dir_info + .as_ref() + .is_some_and(|info| !ancestors.insert(info.clone())) + { + return; // cycle detected, stop silently } // Read directory entries @@ -521,7 +561,10 @@ impl ChownExecutor { Err(e) => { *ret = 1; if self.verbosity.level != VerbosityLevel::Silent { - show_error!("cannot access {}: {}", entry_path.quote(), strip_errno(&e)); + show_error!( + "{}", + translate!("perms-cannot-access", "file" => entry_path.quote(), "error" => strip_errno(&e)) + ); } continue; } @@ -588,9 +631,8 @@ impl ChownExecutor { *ret = 1; if self.verbosity.level != VerbosityLevel::Silent { show_error!( - "cannot access {}: {}", - entry_path.quote(), - strip_errno(&e) + "{}", + translate!("perms-cannot-access", "file" => entry_path.quote(), "error" => strip_errno(&e)) ); } } @@ -627,13 +669,16 @@ impl ChownExecutor { ret = 1; if let Some(path) = e.path() { show_error!( - "cannot access {}: {}", - path.quote(), - if let Some(error) = e.io_error() { - strip_errno(error) - } else { - "Too many levels of symbolic links".into() - } + "{}", + translate!( + "perms-cannot-access", + "file" => path.quote(), + "error" => if let Some(error) = e.io_error() { + strip_errno(error) + } else { + translate!("error-too-many-symlink-levels") + } + ) ); } else { show_error!("{e}"); @@ -760,7 +805,10 @@ impl ChownExecutor { DirFd::open(path, SymlinkBehavior::Follow) .map_err(|e| { if self.verbosity.level != VerbosityLevel::Silent { - show_error!("cannot access {}: {}", path.quote(), strip_errno(&e)); + show_error!( + "{}", + translate!("perms-cannot-access", "file" => path.quote(), "error" => strip_errno(&e)) + ); } }) .ok() @@ -1045,6 +1093,32 @@ mod tests { #[cfg(unix)] use tempfile::tempdir; + /// `fd_is` must accept the directory that was stat'd and reject anything else. + #[cfg(target_os = "linux")] + #[test] + fn test_fd_is_identifies_the_stated_directory() { + let temp_dir = tempdir().unwrap(); + let dir = temp_dir.path().join("dir"); + let other = temp_dir.path().join("other"); + std::fs::create_dir(&dir).unwrap(); + std::fs::create_dir(&other).unwrap(); + + let meta = std::fs::metadata(&dir).unwrap(); + let dir_fd = DirFd::open(&dir, SymlinkBehavior::Follow).unwrap(); + assert!(fd_is(&dir_fd, &meta).unwrap()); + + // Same pathname, different object underneath: the descriptor no longer matches + // the metadata every decision was made on. + let other_fd = DirFd::open(&other, SymlinkBehavior::Follow).unwrap(); + assert!(!fd_is(&other_fd, &meta).unwrap()); + + // A symlink to the directory resolves to the same object, so it must match: + // following the operand is legitimate when it is what was classified. + unix::fs::symlink(&dir, temp_dir.path().join("link")).unwrap(); + let link_fd = DirFd::open(&temp_dir.path().join("link"), SymlinkBehavior::Follow).unwrap(); + assert!(fd_is(&link_fd, &meta).unwrap()); + } + #[test] fn test_empty_string() { let path = PathBuf::new(); diff --git a/src/uucore/src/lib/features/pipes.rs b/src/uucore/src/lib/features/pipes.rs index 02c13dc8bad..3b7c4654b0c 100644 --- a/src/uucore/src/lib/features/pipes.rs +++ b/src/uucore/src/lib/features/pipes.rs @@ -5,8 +5,6 @@ //! Zero-copy-related functions. -#![cfg(any(target_os = "linux", target_os = "android"))] - use crate::io::{RawReader, RawWriter}; use rustix::pipe::{SpliceFlags, fcntl_setpipe_size}; use std::{ @@ -59,29 +57,24 @@ pub fn splice(source: &impl AsFd, target: &impl AsFd, len: usize) -> rustix::io: #[inline] pub fn drain_pipe(pipe: &PipeReader, dest: &impl AsFd, len: usize) -> PipeRes { debug_assert!(len <= MAX_ROOTLESS_PIPE_SIZE, "unexpected RAM usage"); - let mut remaining = len; + // 1st error is used to detect missing support for splice + let mut remaining = if let Ok(s) = splice(pipe, dest, len) { + len - s + } else { + // read/write fallback + // use read_to_end to make pipe empty for the case write failed + let mut drain = Vec::with_capacity(len); + pipe.take(len as u64).read_to_end(&mut drain)?; + RawWriter(&dest).write_all(&drain)?; + return Ok(Err(())); + }; + // GNU cat catches all strace injections for 2nd+ splice while remaining > 0 { - if let Ok(s) = splice(pipe, dest, remaining) { - remaining -= s; - } else { - // read/write fallback - // use read_to_end to make pipe empty for the case write failed - let mut drain = Vec::with_capacity(remaining); - pipe.take(remaining as u64).read_to_end(&mut drain)?; - RawWriter(&dest).write_all(&drain)?; - return Ok(Err(())); - } + remaining -= splice(pipe, dest, remaining)?; } Ok(Ok(())) } -/// check that source is FUSE -/// we fallback to read() at FUSE -#[inline] -pub fn might_fuse(source: &impl AsFd) -> bool { - rustix::fs::fstatfs(source).map_or(true, |stats| stats.f_type == 0x6573_5546) // FUSE magic number, too many platform specific clippy warning with const -} - /// force-splice source to dest even both of them are not pipe via broker pipe /// /// throughput is better than direct splice for the case one of in/output is pipe by unknown reason @@ -99,17 +92,23 @@ pub fn splice_unbounded_auto(source: &impl AsFd, dest: &mut impl AsFd) -> PipeRe let _ = fcntl_setpipe_size(&mut *dest, MAX_ROOTLESS_PIPE_SIZE); // pre-generate page caches for splice let _ = rustix::fs::fadvise(source, 0, None, rustix::fs::Advice::Sequential); - loop { - match splice(&source, &pipe_wr, MAX_ROOTLESS_PIPE_SIZE) { - Ok(0) => return Ok(Ok(())), - Ok(n) => { - if drain_pipe(pipe_rd, dest, n)?.is_err() { - return Ok(Err(())); - } + // 1st error is used to detect missing support for splice + match splice(&source, &pipe_wr, MAX_ROOTLESS_PIPE_SIZE) { + Ok(0) => return Ok(Ok(())), + Ok(n) => { + if drain_pipe(pipe_rd, dest, n)?.is_err() { + return Ok(Err(())); } - Err(_) => return Ok(Err(())), + } + Err(_) => return Ok(Err(())), + } + // GNU cat catches all strace injections for 2nd+ splice + while let mut n @ 1.. = splice(&source, &pipe_wr, MAX_ROOTLESS_PIPE_SIZE)? { + while n > 0 { + n -= splice(pipe_rd, dest, n)?; } } + Ok(Ok(())) } /// splice `n` bytes with read/write fallback @@ -117,70 +116,71 @@ pub fn splice_unbounded_auto(source: &impl AsFd, dest: &mut impl AsFd) -> PipeRe #[inline] pub fn send_n_bytes(input: impl AsFd, target: impl AsFd, n: u64) -> std::io::Result { static PIPE_CACHE: OnceLock> = OnceLock::new(); - let pipe_size = MAX_ROOTLESS_PIPE_SIZE.min(n as usize); + let pipe_size = n.min(MAX_ROOTLESS_PIPE_SIZE as u64) as usize; // improve throughput if output is pipe // expected that input is already extended if it is coming from splice if pipe_size > KERNEL_DEFAULT_PIPE_SIZE { let _ = fcntl_setpipe_size(&target, pipe_size); } + let Some((broker_r, broker_w)) = PIPE_CACHE.get_or_init(|| { + // use std::io::pipe to avoid unnecessary fcntl + let pair = std::io::pipe().ok()?; + if pipe_size > KERNEL_DEFAULT_PIPE_SIZE { + let _ = fcntl_setpipe_size(&pair.0, pipe_size); + } + Some(pair) + }) else { + return std::io::copy(&mut RawReader(input).take(n), &mut RawWriter(target)); + }; let mut n = n; let mut bytes_written: u64 = 0; - let succeed_or_fuse = loop { - if n == 0 { - // avoid unnecessary syscall - return Ok(bytes_written); - } - match splice(&input, &target, n as usize) { - Ok(0) => break true, + while n > 0 { + match splice(&input, &broker_w, usize::try_from(n).unwrap_or(usize::MAX)) { + Ok(0) => return Ok(bytes_written), Ok(s) => { n -= s as u64; bytes_written += s as u64; - } - _ => break false, // input or output is not pipe - } - }; - let succeed_or_fuse = succeed_or_fuse - || if let Some((broker_r, broker_w)) = PIPE_CACHE - .get_or_init(|| { - // use std::io::pipe to avoid unnecessary fcntl - let pair = std::io::pipe().ok()?; - if pipe_size > KERNEL_DEFAULT_PIPE_SIZE { - let _ = fcntl_setpipe_size(&pair.0, pipe_size); - } - Some(pair) - }) - .as_ref() - { - // todo: create fn splice_bounded_broker - loop { - if n == 0 { - return Ok(bytes_written); - } - match splice(&input, &broker_w, n as usize) { - Ok(0) => break true, - Ok(s) => { - n -= s as u64; - bytes_written += s as u64; - if drain_pipe(broker_r, &target, s)?.is_err() { - break false; - } - } - _ => break false, + if drain_pipe(broker_r, &target, s)?.is_err() { + break; } } - } else { - false - }; - // do not always fallback to write for fuse, or 2 Ctrl+D is required to exit on tty - // todo: move fuse patch to callers - if !succeed_or_fuse || might_fuse(&input) { - // remove buffering from this fallback by RawReader, or order of output would be wrong with multiple input - bytes_written += std::io::copy(&mut RawReader(input).take(n), &mut RawWriter(target))?; + Err(_) => break, + } } - + // remove buffering from this fallback by RawReader, or order of output would be wrong with multiple input + bytes_written += std::io::copy(&mut RawReader(input).take(n), &mut RawWriter(target))?; Ok(bytes_written) } +/// discard `n` bytes by splice +/// return actually discarded bytes +/// Err(b) means we discarded b bytes, but we should try to discarding remaining bytes by read +#[inline] +pub fn discard_n_bytes(fd: impl AsFd, n: usize) -> Result { + let mut discarded = 0; + let dev_null = dev_null().ok_or(0_usize)?; + while discarded < n + && let Ok(s) = splice(&fd, &dev_null, n - discarded) + { + if s == 0 { + return Ok(discarded); + } + discarded += s; + } + // else, input is not a pipe + let (pipe_read, pipe_write) = pipe::().map_err(|_| discarded)?; + while discarded < n + && let Ok(s @ 1..) = splice(&fd, &pipe_write, n - discarded) + { + discarded += s; + // pipe to null is not blocked. So this returns the same length at most cases + // next splice does not hang if we discarded 1+ pages + splice(&pipe_read, &dev_null, s).map_err(|_| discarded)?; + } + + Ok(discarded) +} + /// Return verified /dev/null /// /// `splice` to /dev/null is faster than `read` when we skip or count the non-seekable input diff --git a/src/uucore/src/lib/features/proc_info.rs b/src/uucore/src/lib/features/proc_info.rs index 0d99cb8a8b9..3c114c0590a 100644 --- a/src/uucore/src/lib/features/proc_info.rs +++ b/src/uucore/src/lib/features/proc_info.rs @@ -310,10 +310,10 @@ impl ProcessInformation { }; for dir in result.flatten().filter(|it| it.path().is_symlink()) { - if let Ok(path) = fs::read_link(dir.path()) { - if let Ok(tty) = Teletype::try_from(path) { - return tty; - } + if let Ok(path) = fs::read_link(dir.path()) + && let Ok(tty) = Teletype::try_from(path) + { + return tty; } } @@ -333,7 +333,7 @@ impl ProcessInformation { .follow_links(false) .into_iter() .flatten() - .flat_map(|it| { + .filter_map(|it| { it.path() .file_name() .and_then(|it| it.to_str()) @@ -514,9 +514,15 @@ mod tests { PathBuf::from_str(&format!("/proc/{}", current_pid())).unwrap(), ) .unwrap(); - assert_eq!(pid_entry.uid().unwrap(), crate::process::getuid()); - assert_eq!(pid_entry.euid().unwrap(), crate::process::geteuid()); - assert_eq!(pid_entry.gid().unwrap(), crate::process::getgid()); - assert_eq!(pid_entry.egid().unwrap(), crate::process::getegid()); + assert_eq!(pid_entry.uid().unwrap(), rustix::process::getuid().as_raw()); + assert_eq!( + pid_entry.euid().unwrap(), + rustix::process::geteuid().as_raw() + ); + assert_eq!(pid_entry.gid().unwrap(), rustix::process::getgid().as_raw()); + assert_eq!( + pid_entry.egid().unwrap(), + rustix::process::getegid().as_raw() + ); } } diff --git a/src/uucore/src/lib/features/process.rs b/src/uucore/src/lib/features/process.rs deleted file mode 100644 index 06274eb59f9..00000000000 --- a/src/uucore/src/lib/features/process.rs +++ /dev/null @@ -1,190 +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 (vars) cvar exitstatus cmdline kworker getsid getpid -// spell-checker:ignore (sys/unix) WIFSIGNALED ESRCH -// spell-checker:ignore pgrep pwait snice getpgrp - -use libc::{gid_t, pid_t, uid_t}; -#[cfg(not(target_os = "redox"))] -use nix::errno::Errno; -use nix::sys::signal::{self as nix_signal, SigHandler, Signal}; -use nix::unistd::Pid; -use std::io; -use std::process::Child; -use std::process::ExitStatus; -use std::sync::atomic; -use std::sync::atomic::AtomicBool; -use std::thread; -use std::time::{Duration, Instant}; - -/// `geteuid()` returns the effective user ID of the calling process. -pub fn geteuid() -> uid_t { - nix::unistd::geteuid().as_raw() -} - -/// `getpgrp()` returns the process group ID of the calling process. -/// It is a trivial wrapper over nix::unistd::getpgrp. -pub fn getpgrp() -> pid_t { - nix::unistd::getpgrp().as_raw() -} - -/// `getegid()` returns the effective group ID of the calling process. -pub fn getegid() -> gid_t { - nix::unistd::getegid().as_raw() -} - -/// `getgid()` returns the real group ID of the calling process. -pub fn getgid() -> gid_t { - nix::unistd::getgid().as_raw() -} - -/// `getuid()` returns the real user ID of the calling process. -pub fn getuid() -> uid_t { - rustix::process::getuid().as_raw() -} - -/// `getpid()` returns the pid of the calling process. -pub fn getpid() -> pid_t { - nix::unistd::getpid().as_raw() -} - -/// `getsid()` returns the session ID of the process with process ID pid. -/// -/// If pid is 0, getsid() returns the session ID of the calling process. -/// -/// # Error -/// -/// - [Errno::EPERM] A process with process ID pid exists, but it is not in the same session as the calling process, and the implementation considers this an error. -/// - [Errno::ESRCH] No process with process ID pid was found. -/// -/// -/// # Platform -/// -/// This function only support standard POSIX implementation platform, -/// so some system such as redox doesn't supported. -#[cfg(not(target_os = "redox"))] -pub fn getsid(pid: i32) -> Result { - let pid = if pid == 0 { - None - } else { - Some(Pid::from_raw(pid)) - }; - nix::unistd::getsid(pid).map(Pid::as_raw) -} - -/// Missing methods for Child objects -pub trait ChildExt { - /// Send a signal to a Child process. - /// - /// Caller beware: if the process already exited then you may accidentally - /// send the signal to an unrelated process that recycled the PID. - fn send_signal(&mut self, signal: usize) -> io::Result<()>; - - /// Send a signal to a process group. - fn send_signal_group(&mut self, signal: usize) -> io::Result<()>; - - /// Wait for a process to finish or return after the specified duration. - /// A `timeout` of zero disables the timeout. - fn wait_or_timeout( - &mut self, - timeout: Duration, - signaled: Option<&AtomicBool>, - ) -> io::Result>; -} - -impl ChildExt for Child { - fn send_signal(&mut self, signal: usize) -> io::Result<()> { - let pid = Pid::from_raw(self.id() as pid_t); - let result = if signal == 0 { - nix_signal::kill(pid, None) - } else { - let signal = Signal::try_from(signal as i32) - .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; - nix_signal::kill(pid, Some(signal)) - }; - result.map_err(|e| io::Error::from_raw_os_error(e as i32)) - } - - fn send_signal_group(&mut self, signal: usize) -> io::Result<()> { - // 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 { - return nix_signal::kill(Pid::from_raw(0), None) - .map_err(|e| io::Error::from_raw_os_error(e as i32)); - } - - let signal = Signal::try_from(signal as i32) - .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; - - // Ignore the signal temporarily so we don't receive it ourselves. - let old_handler = unsafe { nix_signal::signal(signal, SigHandler::SigIgn) } - .map_err(|e| io::Error::from_raw_os_error(e as i32))?; - let result = nix_signal::kill(Pid::from_raw(0), Some(signal)); - // Restore the old handler - let _ = unsafe { nix_signal::signal(signal, old_handler) }; - result.map_err(|e| io::Error::from_raw_os_error(e as i32)) - } - - fn wait_or_timeout( - &mut self, - timeout: Duration, - signaled: Option<&AtomicBool>, - ) -> io::Result> { - if timeout == Duration::from_micros(0) { - return self.wait().map(Some); - } - // .try_wait() doesn't drop stdin, so we do it manually - drop(self.stdin.take()); - - let start = Instant::now(); - loop { - if let Some(status) = self.try_wait()? { - return Ok(Some(status)); - } - - if start.elapsed() >= timeout - || signaled.is_some_and(|signaled| signaled.load(atomic::Ordering::Relaxed)) - { - break; - } - - // XXX: this is kinda gross, but it's cleaner than starting a thread just to wait - // (which was the previous solution). We might want to use a different duration - // here as well - thread::sleep(Duration::from_millis(100)); - } - - Ok(None) - } -} - -#[cfg(test)] -mod tests { - #[test] - #[cfg(not(target_os = "redox"))] - fn test_getsid() { - use super::{getpid, getsid}; - - assert_eq!( - getsid(getpid()).expect("getsid(getpid)"), - // zero is a special value for SID. - // https://pubs.opengroup.org/onlinepubs/9699919799/functions/getsid.html - getsid(0).expect("getsid(0)") - ); - - // SID never be 0. - assert!(getsid(getpid()).expect("getsid(getpid)") > 0); - - // This might caused tests failure but the probability is low. - assert!(getsid(999_999).is_err()); - } -} diff --git a/src/uucore/src/lib/features/process/mod.rs b/src/uucore/src/lib/features/process/mod.rs new file mode 100644 index 00000000000..bd5190faf68 --- /dev/null +++ b/src/uucore/src/lib/features/process/mod.rs @@ -0,0 +1,54 @@ +// 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. + +//! Process helpers, most notably [`ChildExt`], which extends +//! [`std::process::Child`] with signal delivery and a wait-with-timeout. +//! +//! Unix delivers real POSIX signals; Windows emulates them (see the `windows` +//! submodule). Both provide identical trait signatures. + +use std::io; +use std::process::ExitStatus; +use std::time::Duration; + +/// Missing methods for Child objects +pub trait ChildExt { + /// Send a signal to a Child process. + /// + /// Caller beware: if the process already exited then you may accidentally + /// send the signal to an unrelated process that recycled the PID. + fn send_signal(&mut self, signal: usize) -> io::Result<()>; + + /// Send a signal to a process group. + fn send_signal_group(&mut self, signal: usize) -> io::Result<()>; + + /// Wait for a process to finish or return after the specified duration. + /// A `timeout` of zero disables the timeout. + /// + /// `ignore_term` keeps waiting through an external request to deliver + /// the termination signal (used for the kill-after grace period). + fn wait_or_timeout(&mut self, timeout: Duration, ignore_term: bool) -> io::Result; +} + +/// The outcome of [`ChildExt::wait_or_timeout`]. +pub enum TimeoutRet { + /// The wait was interrupted by an externally received signal (on Windows, + /// a console control event), identified by its POSIX signal number. + Interrupted(usize), + /// The child exited before the timeout elapsed. + Exited(ExitStatus), + /// The timeout elapsed with the child still running. + TimedOut, +} + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub use unix::*; + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub use windows::*; diff --git a/src/uucore/src/lib/features/process/unix.rs b/src/uucore/src/lib/features/process/unix.rs new file mode 100644 index 00000000000..9bfa86ba747 --- /dev/null +++ b/src/uucore/src/lib/features/process/unix.rs @@ -0,0 +1,320 @@ +// 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 sigwait KTIME timeval itimerval setitimer itimer timerid +// spell-checker:ignore sigevent sigev sigval itimerspec signo clockid sevp + +#[cfg(not(any(target_os = "fuchsia", target_os = "haiku")))] +use libc::pid_t; +#[cfg(not(any(target_os = "fuchsia", target_os = "haiku")))] +use nix::sys::signal::{self as nix_signal, SigHandler}; +use nix::sys::signal::{SigSet, Signal}; +#[cfg(not(any(target_os = "fuchsia", target_os = "haiku")))] +use nix::unistd::Pid; +use rustix::process::Signal as RixSignal; +use std::io; +#[cfg(not(any(target_os = "fuchsia", target_os = "haiku")))] +use std::process::Child; +#[cfg(not(any(target_os = "fuchsia", target_os = "haiku")))] +use std::time::{Duration, Instant}; +#[cfg(not(any(target_os = "fuchsia", target_os = "haiku")))] +use timer::Timer; + +#[cfg(not(any(target_os = "fuchsia", target_os = "haiku")))] +use super::{ChildExt, TimeoutRet}; + +#[cfg(not(any(target_os = "fuchsia", target_os = "haiku")))] +impl ChildExt for Child { + fn send_signal(&mut self, signal: usize) -> io::Result<()> { + let pid = Pid::from_raw(self.id() as pid_t); + let result = if signal == 0 { + nix_signal::kill(pid, None) + } else { + let signal = Signal::try_from(signal as i32) + .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; + nix_signal::kill(pid, Some(signal)) + }; + result.map_err(|e| io::Error::from_raw_os_error(e as i32)) + } + + fn send_signal_group(&mut self, signal: usize) -> io::Result<()> { + // 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 { + return nix_signal::kill(Pid::from_raw(0), None) + .map_err(|e| io::Error::from_raw_os_error(e as i32)); + } + + let signal = Signal::try_from(signal as i32) + .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; + + // Ignore the signal temporarily so we don't receive it ourselves. + let old_handler = unsafe { nix_signal::signal(signal, SigHandler::SigIgn) } + .map_err(|e| io::Error::from_raw_os_error(e as i32))?; + let result = nix_signal::kill(Pid::from_raw(0), Some(signal)); + // Restore the old handler + let _ = unsafe { nix_signal::signal(signal, old_handler) }; + result.map_err(|e| io::Error::from_raw_os_error(e as i32)) + } + + fn wait_or_timeout(&mut self, timeout: Duration, ignore_term: bool) -> io::Result { + if timeout == Duration::from_micros(0) { + return self.wait().map(TimeoutRet::Exited); + } + // .try_wait() doesn't drop stdin, so we do it manually + drop(self.stdin.take()); + + // Waits continuously whenever we receive an external SIGCHLD or + // we SIGTERM when we are ignoring them. + let start = Instant::now(); + let mut remaining = timeout; + let mut timer = Timer::new()?; + loop { + match timer.timed_sigwait(remaining) { + Ok(None) => break Ok(TimeoutRet::TimedOut), + Ok(Some(Signal::SIGCHLD)) => { + if let Some(status) = self.try_wait()? { + break Ok(TimeoutRet::Exited(status)); + } // otherwise waits again + } + Ok(Some(Signal::SIGTERM)) if ignore_term => {} // waits again + Ok(Some(signal)) => break Ok(TimeoutRet::Interrupted(signal as usize)), + Err(e) => break Err(e), + } + remaining = timeout.saturating_sub(start.elapsed()); + } + } +} + +/// These signals must be blocked before calling [`ChildExt::wait_or_timeout`]. +/// Consider unblocking them in the child's pre-exec hook. +pub fn timeout_signal_set() -> SigSet { + let mut set = SigSet::empty(); + set.add(Signal::SIGALRM); + set.add(Signal::SIGINT); + set.add(Signal::SIGQUIT); + set.add(Signal::SIGHUP); + set.add(Signal::SIGTERM); + set.add(Signal::SIGPIPE); + set.add(Signal::SIGUSR1); + set.add(Signal::SIGUSR2); + set.add(Signal::SIGCHLD); + set +} + +/// Unblocks a signal from the current thread. +pub fn unblock_signal(signal: RixSignal) -> io::Result<()> { + let mut set = SigSet::empty(); + // SAFETY: rustix's Signal is also a valid nix Signal. + set.add(unsafe { Signal::try_from(signal.as_raw()).unwrap_unchecked() }); + + set.thread_unblock().map_err(Into::into) +} + +/// Ensures there is no overflow on time_t operations. Some BSDs (notably XNU) +/// will return EINVAL otherwise; POSIX only defines it up to 10e8, so we cap +/// it on all targets we do not trust to support the full integer range. +#[cfg(not(any(target_os = "fuchsia", target_os = "haiku")))] +const MAX_KTIME_T: Duration = if cfg!(target_os = "linux") { + Duration::from_secs(9_223_372_036) +} else { + Duration::from_secs(100_000_000) +}; + +/// Sets up a timer on SIGALRM for platforms that support POSIX.1-2008 realtime +/// clock extensions. Notably, both Android and Redox do not support the latter +/// fallback since it was removed in that same spec. +#[cfg(not(any( + target_vendor = "apple", + target_os = "fuchsia", + target_os = "haiku", + target_os = "openbsd", + windows +)))] +mod timer { + use super::MAX_KTIME_T; + use std::io; + use std::ptr::null_mut; + use std::time::Duration; + #[cfg(any(target_os = "redox", target_os = "android"))] + use timer_sys as libc; // Complements their libc bindings. + + pub(super) struct Timer(libc::timer_t); + + impl Timer { + pub(super) fn new() -> io::Result { + use std::mem::MaybeUninit; + + // We must zero the reserved, private bits and other fields. + // We cannot use nix or rustix because they don't support it in Redox. + let mut sev = MaybeUninit::::zeroed(); + + unsafe { + (*sev.as_mut_ptr()).sigev_notify = libc::SIGEV_SIGNAL; + (*sev.as_mut_ptr()).sigev_signo = libc::SIGALRM; + } + + // On cygwin, it's a u64; otherwise, a ptr with exposed provenance. + let mut timer_id = MaybeUninit::zeroed(); + // SAFETY: All values are properly initialized. + if unsafe { + libc::timer_create( + libc::CLOCK_MONOTONIC, + sev.as_mut_ptr(), + timer_id.as_mut_ptr(), + ) + } == -1 + { + return Err(io::Error::last_os_error()); + } + + // SAFETY: `timer_create` returned success and initialized timer_id. + Ok(Self(unsafe { timer_id.assume_init() })) + } + + pub(super) fn arm(&mut self, timeout: Duration) -> Result<(), io::Error> { + let timeout = timeout.min(MAX_KTIME_T).max(Duration::from_micros(1)); + // `timespec` has private padding members on time64 targets, so its + // fields cannot be listed in a struct literal; start from the + // zeroed default and fill in the ones we care about. + let mut time = libc::itimerspec { + it_interval: libc::timespec::default(), + it_value: libc::timespec::default(), + }; + time.it_value.tv_sec = timeout.as_secs() as _; + time.it_value.tv_nsec = timeout.subsec_nanos() as _; + + // SAFETY: All values are properly initialized. + if unsafe { libc::timer_settime(self.0, 0, &raw const time, null_mut()) } == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + } + + impl Drop for Timer { + fn drop(&mut self) { + unsafe { libc::timer_delete(self.0) }; + } + } + + /// Complements the libc bindings of Redox and Android with missing items. + #[cfg(any(target_os = "redox", target_os = "android"))] + #[allow(non_camel_case_types)] + mod timer_sys { + pub(super) use libc::{CLOCK_MONOTONIC, SIGALRM, timespec}; + #[cfg(not(target_os = "redox"))] + pub(super) use libc::{SIGEV_SIGNAL, sigevent}; + + pub(super) type timer_t = *mut core::ffi::c_void; + + unsafe extern "C" { + pub(super) fn timer_settime( + timerid: timer_t, + flags: core::ffi::c_int, + new_value: *const itimerspec, + old_value: *mut itimerspec, + ) -> core::ffi::c_int; + + pub(super) fn timer_create( + clockid: libc::clockid_t, + sevp: *mut sigevent, + timerid: *mut timer_t, + ) -> core::ffi::c_int; + + pub(super) fn timer_delete(timerid: timer_t) -> core::ffi::c_int; + } + + #[repr(C)] + #[derive(Clone, Copy, Debug)] + pub(super) struct itimerspec { + pub(super) it_interval: timespec, + pub(super) it_value: timespec, + } + + #[cfg(target_os = "redox")] + pub(super) const SIGEV_SIGNAL: core::ffi::c_int = 0; + + #[repr(C)] + #[derive(Clone, Copy, Debug)] + #[cfg(target_os = "redox")] + pub(super) struct sigevent { + pub(super) sigev_value: libc::sigval, + pub(super) sigev_signo: core::ffi::c_int, + pub(super) sigev_notify: core::ffi::c_int, + pub(super) sigev_notify_thread_id: core::ffi::c_int, + #[cfg(target_pointer_width = "64")] + __unused1: std::mem::MaybeUninit<[core::ffi::c_int; 11]>, + #[cfg(target_pointer_width = "32")] + __unused1: std::mem::MaybeUninit<[core::ffi::c_int; 12]>, + } + } +} + +/// Sets up a timer on SIGALRM for platforms that do not support POSIX.1-2008 +/// realtime clock extensions. Notably, Darwin, OpenBSD, and Windows. +#[cfg(any(target_vendor = "apple", target_os = "openbsd", windows))] +mod timer { + use super::MAX_KTIME_T; + use nix::errno::Errno; + use std::io; + use std::ptr::null_mut; + use std::time::Duration; + + pub(super) struct Timer; + + impl Timer { + #[allow(clippy::unnecessary_wraps)] + pub(super) fn new() -> io::Result { + Ok(Self) + } + + pub(super) fn arm(&mut self, timeout: Duration) -> io::Result<()> { + let timeout = timeout.min(MAX_KTIME_T).max(Duration::from_micros(1)); + let time = libc::itimerval { + it_interval: libc::timeval { + tv_sec: 0, + tv_usec: 0, + }, + it_value: libc::timeval { + tv_sec: timeout.as_secs() as _, + tv_usec: timeout.subsec_micros() as _, + }, + }; + + // SAFETY: All values are properly initialized. + Errno::result(unsafe { + libc::setitimer(libc::ITIMER_REAL, &raw const time, null_mut()) + })?; + Ok(()) + } + } +} + +#[cfg(not(any(target_os = "fuchsia", target_os = "haiku")))] +impl Timer { + fn timed_sigwait(&mut self, timeout: Duration) -> io::Result> { + self.arm(timeout)?; + + let set = timeout_signal_set(); + let mut sig = 0; + // SAFETY: All values are properly initialized. + let res = unsafe { libc::sigwait(set.as_ref(), &raw mut sig) }; + + if res != 0 { + Err(io::Error::from_raw_os_error(res)) + } else if sig == libc::SIGALRM { + Ok(None) + } else { + Ok(Some(Signal::try_from(sig)?)) + } + } +} diff --git a/src/uucore/src/lib/features/process/windows.rs b/src/uucore/src/lib/features/process/windows.rs new file mode 100644 index 00000000000..793d715cd72 --- /dev/null +++ b/src/uucore/src/lib/features/process/windows.rs @@ -0,0 +1,1046 @@ +// 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 (win-api) WAITABLE Waitable PHANDLER unsignaled JOBOBJECT +// spell-checker:ignore (signals) CHLD TSTP TTIN TTOU WINCH ESRCH +// spell-checker:ignore catchable targetable wakeup unreaped pids LUID luid + +//! Windows emulation of POSIX signal delivery, for child processes, arbitrary +//! pids ([`send_signal_to_pid`]) and the caller's own process group +//! ([`send_signal_to_own_group`]). +//! +//! Windows has no signals, so this module emulates the POSIX default +//! dispositions with native primitives: signal numbers follow the Linux +//! layout (matching `uucore::signals::ALL_SIGNALS`), "terminate" signals +//! force-exit with exit code `128 + n`, and `INT`/`QUIT` map to a +//! `CTRL_BREAK_EVENT` on a console process group. [`Job`] gives process-tree +//! termination, [`send_signal_to_own_group`] uses the job the caller runs in +//! as the stand-in for a process group, and +//! [`enable_ctrl_forwarding`]/[`take_last_ctrl_signal`] surface console +//! control events (Ctrl-C, Ctrl-Break, close) as POSIX signal numbers for +//! forwarding. All raw Win32 calls live in the safe [`sys`] wrappers. + +use std::io; +use std::os::windows::io::{AsHandle, BorrowedHandle, OwnedHandle}; +use std::process::{Child, Command}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicI32, Ordering}; +use std::time::Duration; + +use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED; +use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT}; +use windows_sys::Win32::System::Threading::{ + CREATE_NEW_PROCESS_GROUP, INFINITE, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, +}; +use windows_sys::core::BOOL; + +use super::{ChildExt, TimeoutRet}; +use crate::translate; + +/// Safe wrappers around the raw Win32 calls used for process control: each +/// validates results into [`io::Error`] and takes +/// [`BorrowedHandle`]/[`OwnedHandle`] so callers never touch raw `HANDLE`s. +pub mod sys { + use std::io; + use std::mem::offset_of; + use std::os::windows::io::{ + AsRawHandle, BorrowedHandle, FromRawHandle, HandleOrNull, OwnedHandle, + }; + + use windows_sys::Win32::Foundation::{ + ERROR_INVALID_PARAMETER, ERROR_MORE_DATA, FALSE, HANDLE, LUID, TRUE, WAIT_FAILED, + WAIT_OBJECT_0, WAIT_TIMEOUT, + }; + use windows_sys::Win32::Security::{ + AdjustTokenPrivileges, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, SE_DEBUG_NAME, + SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, + }; + use windows_sys::Win32::System::Console::{ + GenerateConsoleCtrlEvent, PHANDLER_ROUTINE, SetConsoleCtrlHandler, + }; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, IsProcessInJob, + JOBOBJECT_BASIC_PROCESS_ID_LIST, JobObjectBasicProcessIdList, QueryInformationJobObject, + TerminateJobObject, + }; + use windows_sys::Win32::System::Threading::{ + CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, CreateEventW, CreateWaitableTimerExW, + GetCurrentProcess, OpenProcess, OpenProcessToken, ResetEvent, SetEvent, SetWaitableTimer, + TIMER_ALL_ACCESS, TerminateProcess, WaitForMultipleObjects, WaitForSingleObject, + }; + use windows_sys::core::BOOL; + + /// The outcome of a successful bounded wait. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum WaitOutcome { + /// The object at this index in the wait set became signaled. + Object(u32), + /// The wait interval elapsed first. + TimedOut, + } + + /// The outcome of listing the processes assigned to a job. + #[derive(Debug)] + pub enum JobProcessIds { + Complete(Vec), + Truncated, + } + + /// Convert a Win32 `BOOL` result into an [`io::Result`]: zero (`FALSE`) + /// becomes the error from `GetLastError`. + fn cvt(result: BOOL) -> io::Result<()> { + if result == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + /// Take ownership of a `HANDLE` from a Win32 create-function, treating + /// null as failure. + /// + /// # Safety + /// + /// `raw` must be either null or a valid handle exclusively owned by the + /// caller (i.e. freshly returned by a Win32 function that transfers + /// ownership). + unsafe fn cvt_created_handle(raw: HANDLE) -> io::Result { + // SAFETY: per this function's contract, `raw` is null or owned. + OwnedHandle::try_from(unsafe { HandleOrNull::from_raw_handle(raw) }) + .map_err(|_| io::Error::last_os_error()) + } + + /// Create an anonymous job object. + pub fn create_job_object() -> io::Result { + // SAFETY: null attributes and name are documented as valid; the + // returned handle is owned by us. + unsafe { cvt_created_handle(CreateJobObjectW(std::ptr::null(), std::ptr::null())) } + } + + /// Assign the process behind `process` to the job behind `job`. + pub fn assign_process_to_job(job: BorrowedHandle, process: BorrowedHandle) -> io::Result<()> { + // SAFETY: both handles are valid for the duration of the call by + // construction of `BorrowedHandle`. + cvt(unsafe { + AssignProcessToJobObject(job.as_raw_handle() as HANDLE, process.as_raw_handle()) + }) + } + + /// Terminate every process in the job with the given exit code. + pub fn terminate_job_object(job: BorrowedHandle, exit_code: u32) -> io::Result<()> { + // SAFETY: the job handle is valid for the duration of the call. + cvt(unsafe { TerminateJobObject(job.as_raw_handle() as HANDLE, exit_code) }) + } + + /// One slot on 64-bit, two on 32-bit. + const PID_LIST_HEADER_SLOTS: usize = + offset_of!(JOBOBJECT_BASIC_PROCESS_ID_LIST, ProcessIdList) / size_of::(); + + // `ProcessIdList` is a variable-length trailing array, so its buffers are + // allocated as `usize` slices: aligned for the struct by construction, and + // readable with bounds-checked indexing rather than pointer arithmetic. + // A target where that stops holding is a compile error, not run-time UB. + const _: () = assert!( + align_of::() == align_of::() + && offset_of!(JOBOBJECT_BASIC_PROCESS_ID_LIST, ProcessIdList) % size_of::() == 0 + ); + + /// Whether the calling process runs inside any job object. + /// + /// A null job handle asks "in *a* job", not "in *this* job". Needed + /// because the code a job-less caller gets from + /// [`query_job_process_ids`] is undocumented. + pub fn is_process_in_job() -> io::Result { + let mut in_job: BOOL = FALSE; + // SAFETY: the current-process pseudo-handle is always valid, a null + // job handle is documented as "any job", and `in_job` is an out-param + // valid for the duration of the call. + cvt(unsafe { IsProcessInJob(GetCurrentProcess(), std::ptr::null_mut(), &raw mut in_job) })?; + Ok(in_job != FALSE) + } + + /// List the processes assigned to `job`, with room for `capacity` of them; + /// `None` means the job the calling process itself belongs to. + /// + /// `None` is the only way to reach one's own job — a process cannot open a + /// handle to it — and it addresses the immediate job when jobs are nested, + /// whose list still covers that job's child jobs. It fails when the caller + /// is in no job at all, so check [`is_process_in_job`] first. Truncation + /// reports nothing but the fact: the counts and returned length are all + /// zero on that path on some Windows 10 builds, so only the caller can + /// pick a retry size. + pub fn query_job_process_ids( + job: Option, + capacity: usize, + ) -> io::Result { + // `max(1)` keeps the buffer at least one whole struct wide. + let mut buffer = vec![0usize; PID_LIST_HEADER_SLOTS + capacity.max(1)]; + let length = u32::try_from(buffer.len() * size_of::()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + let job = job.map_or(std::ptr::null_mut(), |job| job.as_raw_handle()); + // SAFETY: `job` is null or a valid handle for the duration of the + // call; `buffer` is a live `usize` allocation of exactly `length` + // bytes, so it is aligned for the struct and as large as we claim; a + // null return-length pointer is documented as valid. + let result = unsafe { + QueryInformationJobObject( + job, + JobObjectBasicProcessIdList, + buffer.as_mut_ptr().cast(), + length, + std::ptr::null_mut(), + ) + }; + match cvt(result) { + Ok(()) => Ok(JobProcessIds::Complete(process_ids(&buffer))), + // How this info class reports STATUS_BUFFER_OVERFLOW. + Err(error) if error.raw_os_error() == Some(ERROR_MORE_DATA as i32) => { + Ok(JobProcessIds::Truncated) + } + Err(error) => Err(error), + } + } + + fn process_ids(buffer: &[usize]) -> Vec { + debug_assert!(buffer.len() > PID_LIST_HEADER_SLOTS); + // SAFETY: `buffer` is a `usize` allocation at least one whole + // `JOBOBJECT_BASIC_PROCESS_ID_LIST` long (its two counts plus one pid + // slot), so the read is aligned and in bounds, and every field is an + // integer, so any bit pattern read back is a valid value. + let header = unsafe { + buffer + .as_ptr() + .cast::() + .read() + }; + buffer[PID_LIST_HEADER_SLOTS..] + .iter() + .take(header.NumberOfProcessIdsInList as usize) + // Pids are `ULONG_PTR`-wide but always fit a `DWORD`, and zero + // names no process. + .filter_map(|&pid| u32::try_from(pid).ok()) + .filter(|&pid| pid != 0) + .collect() + } + + /// Terminate the process behind `process` with the given exit code. + pub fn terminate_process(process: BorrowedHandle, exit_code: u32) -> io::Result<()> { + // SAFETY: the process handle is valid for the duration of the call; + // terminating an already-exited process fails cleanly. + cvt(unsafe { TerminateProcess(process.as_raw_handle() as HANDLE, exit_code) }) + } + + /// Open `pid` with exactly `desired_access`; the handle is non-inheritable. + /// + /// Dead, never-allocated and idle (0) pids report `ERROR_INVALID_PARAMETER`, + /// reported here as the POSIX ESRCH analog. `ERROR_ACCESS_DENIED` stays raw: + /// its kind already renders "Permission denied", like unix EPERM. + pub fn open_process(pid: u32, desired_access: u32) -> io::Result { + // SAFETY: OpenProcess returns null on failure, otherwise a fresh + // handle owned by us — the `cvt_created_handle` contract. + unsafe { cvt_created_handle(OpenProcess(desired_access, FALSE, pid)) }.map_err(|error| { + if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) { + super::no_such_process() + } else { + error + } + }) + } + + /// Request `SeDebugPrivilege` in this process's token. + /// + /// `Ok` does not mean the privilege is now enabled: `AdjustTokenPrivileges` + /// succeeds for a token that does not hold it too, reporting that only + /// through `GetLastError`. + pub fn enable_debug_privilege() -> io::Result<()> { + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: the pseudo-handle is always valid; `token` is an out-param. + cvt(unsafe { + OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES, &raw mut token) + })?; + // SAFETY: OpenProcessToken succeeded, so `token` is a fresh owned handle. + let token = unsafe { OwnedHandle::from_raw_handle(token) }; + + let mut luid = LUID::default(); + // SAFETY: a null system name means the local system; `luid` is an out-param. + cvt(unsafe { LookupPrivilegeValueW(std::ptr::null(), SE_DEBUG_NAME, &raw mut luid) })?; + + let privileges = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: SE_PRIVILEGE_ENABLED, + }], + }; + // SAFETY: `privileges` outlives the call; a null previous-state pointer + // is documented as valid. + cvt(unsafe { + AdjustTokenPrivileges( + token.as_raw_handle(), + FALSE, + &raw const privileges, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }) + } + + /// Create an unnamed manual-reset event, initially unsignaled. + pub fn create_manual_reset_event() -> io::Result { + // SAFETY: null attributes and name are documented as valid; the + // returned handle is owned by us. + unsafe { + cvt_created_handle(CreateEventW( + std::ptr::null(), + TRUE, + FALSE, + std::ptr::null(), + )) + } + } + + /// Signal a (manual-reset or auto-reset) event. + pub fn set_event(event: BorrowedHandle) -> io::Result<()> { + // SAFETY: the event handle is valid for the duration of the call. + cvt(unsafe { SetEvent(event.as_raw_handle() as HANDLE) }) + } + + /// Return a manual-reset event to the unsignaled state. + pub fn reset_event(event: BorrowedHandle) -> io::Result<()> { + // SAFETY: the event handle is valid for the duration of the call. + cvt(unsafe { ResetEvent(event.as_raw_handle() as HANDLE) }) + } + + /// Create a one-shot waitable timer with the best resolution the OS + /// offers: a high-resolution timer (not coalesced to the ~15.6 ms + /// scheduler tick; Windows 10 1803+) when supported, a standard + /// waitable timer otherwise. + pub fn create_waitable_timer() -> io::Result { + create_waitable_timer_with(CREATE_WAITABLE_TIMER_HIGH_RESOLUTION) + // Pre-1803 systems reject the high-resolution flag. + .or_else(|_| create_waitable_timer_with(0)) + } + + fn create_waitable_timer_with(flags: u32) -> io::Result { + // SAFETY: null attributes and name are documented as valid; the + // returned handle is owned by us. + unsafe { + cvt_created_handle(CreateWaitableTimerExW( + std::ptr::null(), + std::ptr::null(), + flags, + TIMER_ALL_ACCESS, + )) + } + } + + /// Arm `timer` to fire once after `ticks_100ns` (in 100 ns units). + pub fn set_relative_timer(timer: BorrowedHandle, ticks_100ns: i64) -> io::Result<()> { + // A negative due time means a relative wait. + let due_time = -ticks_100ns; + // SAFETY: the timer handle is valid, the due-time pointer is valid + // for the duration of the call, and no completion routine is used. + cvt(unsafe { + SetWaitableTimer( + timer.as_raw_handle() as HANDLE, + &raw const due_time, + 0, + None, + std::ptr::null(), + FALSE, + ) + }) + } + + /// Wait until any handle in `handles` is signaled or `timeout_ms` + /// elapses (`INFINITE` for no limit). On simultaneous completion the + /// lowest index wins. + /// + /// Mutexes are not supported (an abandoned-mutex result is reported as + /// an error). + pub fn wait_for_any(handles: &[BorrowedHandle], timeout_ms: u32) -> io::Result { + let count = u32::try_from(handles.len()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + // SAFETY: `BorrowedHandle` is `repr(transparent)` over a raw handle, + // so the slice is layout-compatible with an array of `HANDLE`, and + // every element is a valid open handle for the duration of the call. + let result = + unsafe { WaitForMultipleObjects(count, handles.as_ptr().cast(), FALSE, timeout_ms) }; + wait_outcome(result, count) + } + + /// Wait until `handle` is signaled or `timeout_ms` elapses. A zero + /// timeout makes this a state poll. + pub fn wait_for_one(handle: BorrowedHandle, timeout_ms: u32) -> io::Result { + // SAFETY: the handle is valid for the duration of the call. + let result = unsafe { WaitForSingleObject(handle.as_raw_handle() as HANDLE, timeout_ms) }; + wait_outcome(result, 1) + } + + fn wait_outcome(result: u32, count: u32) -> io::Result { + if result == WAIT_TIMEOUT { + return Ok(WaitOutcome::TimedOut); + } + if result == WAIT_FAILED { + return Err(io::Error::last_os_error()); + } + let index = result.wrapping_sub(WAIT_OBJECT_0); + if index < count { + Ok(WaitOutcome::Object(index)) + } else { + // WAIT_ABANDONED_0..: only possible for mutexes, which this + // module never waits on; surface it instead of guessing. + Err(io::Error::other(format!("unexpected wait result {result}"))) + } + } + + /// Send a console control event (`CTRL_C_EVENT`/`CTRL_BREAK_EVENT`) to + /// the console process group led by `process_group_id`. + pub fn generate_console_ctrl_event(ctrl_event: u32, process_group_id: u32) -> io::Result<()> { + // SAFETY: no pointers involved; fails cleanly without a console. + cvt(unsafe { GenerateConsoleCtrlEvent(ctrl_event, process_group_id) }) + } + + /// Register `handler` to receive console control events. + /// + /// # Safety + /// + /// The OS invokes `handler` on a dedicated thread at any moment, + /// including while other threads run arbitrary code; it must be sound + /// under those conditions (touch only atomics and other thread-safe, + /// non-allocating state). + pub unsafe fn set_console_ctrl_handler(handler: PHANDLER_ROUTINE) -> io::Result<()> { + // SAFETY: the handler contract is upheld by the caller. + cvt(unsafe { SetConsoleCtrlHandler(handler, TRUE) }) + } +} + +// POSIX (Linux-layout) signal numbers, matching the Windows `ALL_SIGNALS` +// table in `uucore::signals`. Kept local so the `process` feature does not +// depend on the `signals` feature. +const SIGNAL_HUP: i32 = 1; +const SIGNAL_INT: i32 = 2; +const SIGNAL_QUIT: i32 = 3; + +/// What delivering a given POSIX signal number means on Windows. +enum Disposition { + Probe, + /// Discarded-by-default and stop signals: accepted, nothing to do. + Ignore, + /// `INT`/`QUIT`: deliverable to a console process group as CTRL_BREAK. + Interrupt, + Terminate, +} + +fn disposition(signal: usize) -> io::Result { + match signal { + 0 => Ok(Disposition::Probe), + 2 | 3 => Ok(Disposition::Interrupt), + // Discarded-by-default (CHLD, CONT, URG, WINCH) and the stop family + // (STOP, TSTP, TTIN, TTOU): no documented emulation, so no-ops. + 17..=23 | 28 => Ok(Disposition::Ignore), + 1..=31 => Ok(Disposition::Terminate), + _ => Err(io::ErrorKind::InvalidInput.into()), + } +} + +/// Terminate so the child's exit status becomes `128 + signal`, emulating +/// "killed by signal" for exit-code observers. +fn terminate_with_signal(handle: BorrowedHandle, signal: usize) -> io::Result<()> { + sys::terminate_process(handle, (128 + signal) as u32) +} + +/// Deliver `signal` (POSIX numbering) to the child process only. +/// +/// A console control event cannot target a single process, so `INT`/`QUIT` +/// fall back to termination here; callers wanting a catchable interrupt must +/// use [`send_signal_to_console_group`]. +pub fn send_signal_to_process(child: &Child, signal: usize) -> io::Result<()> { + match disposition(signal)? { + Disposition::Probe => match sys::wait_for_one(child.as_handle(), 0)? { + sys::WaitOutcome::TimedOut => Ok(()), + // The process has exited: the POSIX analog is ESRCH. + sys::WaitOutcome::Object(_) => Err(io::ErrorKind::NotFound.into()), + }, + Disposition::Ignore => Ok(()), + Disposition::Interrupt | Disposition::Terminate => { + terminate_with_signal(child.as_handle(), signal) + } + } +} + +const PROBE_ACCESS: u32 = PROCESS_SYNCHRONIZE; +// SYNCHRONIZE tells "already exited" apart from a real denial after a failed +// TerminateProcess (both report ERROR_ACCESS_DENIED). +const TERMINATE_ACCESS: u32 = PROCESS_TERMINATE | PROCESS_SYNCHRONIZE; + +fn no_such_process() -> io::Error { + io::Error::new(io::ErrorKind::NotFound, translate!("error-no-such-process")) +} + +/// A process handle becomes signaled when the process exits. +fn has_exited(handle: BorrowedHandle) -> io::Result { + Ok(matches!( + sys::wait_for_one(handle, 0)?, + sys::WaitOutcome::Object(_) + )) +} + +/// Request `SeDebugPrivilege` once per process; with it, opening a process +/// bypasses the target's security descriptor. +/// +/// Silent and best-effort: only an elevated administrator's token holds the +/// privilege, and tokens without it are left untouched. +pub fn enable_debug_privilege() { + static REQUESTED: OnceLock<()> = OnceLock::new(); + REQUESTED.get_or_init(|| { + let _ = sys::enable_debug_privilege(); + }); +} + +/// Deliver `signal` (POSIX numbering) to the arbitrary process `pid`, +/// emulating `kill(2)` for `pid > 0`. +pub fn send_signal_to_pid(pid: u32, signal: usize) -> io::Result<()> { + match disposition(signal)? { + Disposition::Probe | Disposition::Ignore => probe_pid(pid), + Disposition::Interrupt | Disposition::Terminate => terminate_pid(pid, signal), + } +} + +/// Ok while `pid` runs, "No such process" once it has exited, even if open +/// handles still pin its pid. +fn probe_pid(pid: u32) -> io::Result<()> { + let handle = sys::open_process(pid, PROBE_ACCESS)?; + if has_exited(handle.as_handle())? { + return Err(no_such_process()); + } + Ok(()) +} + +fn terminate_pid(pid: u32, signal: usize) -> io::Result<()> { + let handle = sys::open_process(pid, TERMINATE_ACCESS)?; + match terminate_with_signal(handle.as_handle(), signal) { + // An already-exited target also reports ERROR_ACCESS_DENIED; it + // counts as delivered, like unix kill on an unreaped process. + Err(e) + if e.raw_os_error() == Some(ERROR_ACCESS_DENIED as i32) + && has_exited(handle.as_handle()).unwrap_or(false) => + { + Ok(()) + } + result => result, + } +} + +/// Pids in the first attempt at listing a job, doubling up to [`JOB_PID_MAX`]. +/// +/// A job whose list does not fit can report a zero size to retry with +/// (Windows 10, notably under WOW64), so the growth cannot be driven by the +/// kernel's numbers. +const JOB_PID_CAPACITY: usize = 64; +const JOB_PID_MAX: usize = 128 * 1024; + +fn job_process_ids(job: Option) -> io::Result> { + let mut capacity = JOB_PID_CAPACITY; + while capacity <= JOB_PID_MAX { + match sys::query_job_process_ids(job, capacity)? { + sys::JobProcessIds::Complete(pids) => return Ok(pids), + sys::JobProcessIds::Truncated => capacity *= 2, + } + } + Err(io::Error::other(format!( + "job process list did not fit in {JOB_PID_MAX} entries" + ))) +} + +/// `members` with `own_pid` present exactly once, at the end, so a terminating +/// signal reaches every other member before it kills this process. +/// +/// Split out because an empty `members` *is* the job-less case, which no test +/// can reproduce by spawning (a child created with `CREATE_BREAKAWAY_FROM_JOB` +/// leaves only its immediate job; an ancestor job still claims it). +fn with_self_last(mut members: Vec, own_pid: u32) -> Vec { + members.retain(|&pid| pid != own_pid); + members.push(own_pid); + members +} + +/// Deliver `signal` to `pids` in order, succeeding if any one delivery did. +/// +/// Reports the first failure rather than the last, since later ones can be +/// consequences of earlier deliveries. +fn send_signal_to_each(pids: &[u32], signal: usize) -> io::Result<()> { + let mut delivered = false; + let mut first_error = None; + for &pid in pids { + match send_signal_to_pid(pid, signal) { + Ok(()) => delivered = true, + Err(error) => { + first_error.get_or_insert(error); + } + } + } + if delivered { + Ok(()) + } else { + Err(first_error.unwrap_or_else(no_such_process)) + } +} + +/// Deliver `signal` (POSIX numbering) to every process in the caller's own +/// process group, emulating `kill(2)` for `pid == 0`. +/// +/// The group is the caller's job object, which is broader than a POSIX process +/// group: a terminal, IDE or CI agent that runs this process inside a job puts +/// everything else it manages in the same group. A process in no job is a +/// group of one. This process is signalled last, so a terminating signal ends +/// it with exit code `128 + signal` instead of returning. +/// +/// Like `kill(0, sig)`, succeeds as soon as one member was signalled: members +/// this process may not touch, and members that exit between enumeration and +/// delivery, do not fail the call. +pub fn send_signal_to_own_group(signal: usize) -> io::Result<()> { + // Fail fast, before enumerating anything. + disposition(signal)?; + let members = if sys::is_process_in_job()? { + job_process_ids(None)? + } else { + Vec::new() + }; + send_signal_to_each(&with_self_last(members, std::process::id()), signal) +} + +/// Deliver `signal` (POSIX numbering) to the console process group led by +/// `pid` (the process must have been created with `CREATE_NEW_PROCESS_GROUP`, +/// e.g. via [`configure_process_group`]). +/// +/// Only `INT`/`QUIT` are deliverable this way (as `CTRL_BREAK_EVENT`); +/// terminating a whole group requires a [`Job`]. +pub fn send_signal_to_console_group(pid: u32, signal: usize) -> io::Result<()> { + match disposition(signal)? { + Disposition::Probe | Disposition::Ignore => Ok(()), + // CTRL_C_EVENT cannot target a nonzero group (it broadcasts to the + // whole console, including the sender), so INT/QUIT both use + // CTRL_BREAK: targetable, catchable, fatal by default. + Disposition::Interrupt => sys::generate_console_ctrl_event(CTRL_BREAK_EVENT, pid), + Disposition::Terminate => Err(io::ErrorKind::Unsupported.into()), + } +} + +/// Deliver `signal` (POSIX numbering) to the child's whole process tree. +/// +/// Terminating signals (including `INT`/`QUIT`, which cannot reach a tree as +/// console events) terminate the job with exit code `128 + n`, falling back +/// to the direct child when `job` is `None` or the job terminate fails; +/// probe/ignored signals behave as [`send_signal_to_process`]. +pub fn send_signal_to_tree(child: &Child, job: Option<&Job>, signal: usize) -> io::Result<()> { + match disposition(signal)? { + Disposition::Probe | Disposition::Ignore => send_signal_to_process(child, signal), + Disposition::Interrupt | Disposition::Terminate => { + if let Some(job) = job + && job.terminate((128 + signal) as u32).is_ok() + { + return Ok(()); + } + terminate_with_signal(child.as_handle(), signal) + } + } +} + +/// Make `cmd` spawn its child as the leader of a new console process group, +/// so `CTRL_BREAK_EVENT` can target exactly that group and the console's own +/// Ctrl-C no longer reaches the child (the analog of unix `setpgid(0, 0)`). +/// +/// Overwrites any creation flags set earlier via `CommandExt::creation_flags`. +pub fn configure_process_group(cmd: &mut Command) { + use std::os::windows::process::CommandExt; + cmd.creation_flags(CREATE_NEW_PROCESS_GROUP); +} + +/// An anonymous Job Object: the Windows primitive for operating on a whole +/// process tree, used here as the analog of signalling a process group. +/// +/// No limits (in particular no `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) are set: +/// the job is only a handle for [`Job::terminate`], and processes that +/// outlive the job's owner deliberately keep running, just like a process +/// group outlives its creator on unix. +pub struct Job(OwnedHandle); + +impl Job { + /// Create a new anonymous job object. + pub fn new() -> io::Result { + sys::create_job_object().map(Self) + } + + /// Assign `child` (and, transitively, every process it spawns from then + /// on) to this job. + /// + /// This can fail when nested jobs are unsupported (pre-Windows 8) and the + /// current process already runs inside a job; callers should degrade to + /// per-process operations in that case. + pub fn assign(&self, child: &Child) -> io::Result<()> { + sys::assign_process_to_job(self.0.as_handle(), child.as_handle()) + } + + /// Terminate every process in the job with the given exit code + /// (pass `128 + signal` to emulate death by signal). + pub fn terminate(&self, exit_code: u32) -> io::Result<()> { + sys::terminate_job_object(self.0.as_handle(), exit_code) + } +} + +/// Manual-reset event signalled by the console control handler to wake +/// [`ChildExt::wait_or_timeout`]. Unset until [`enable_ctrl_forwarding`] +/// runs; the handle intentionally lives for the rest of the process. +/// +/// Ownership of the *signaled state*: `wait_or_timeout` resets the event when +/// it consumes a wake, and [`enable_ctrl_forwarding`] resets it so each +/// forwarding session starts clean; the handler only ever sets it. +static WAKE_EVENT: OnceLock = OnceLock::new(); +/// POSIX signal number of the last console control event received (0 = none). +/// Consumed (reset to 0) by [`take_last_ctrl_signal`] and cleared by +/// [`enable_ctrl_forwarding`], so one event is observed at most once. +static LAST_CTRL_SIGNAL: AtomicI32 = AtomicI32::new(0); + +/// Console control handler. Runs on an OS-spawned thread, so it only touches +/// atomics and signals the pre-created event — no allocation, no locks. +/// +/// # Safety +/// +/// Registered only via [`sys::set_console_ctrl_handler`] and invoked by the +/// system with a valid `ctrl_type`. +unsafe extern "system" fn console_ctrl_handler(ctrl_type: u32) -> BOOL { + let signal = match ctrl_type { + CTRL_C_EVENT => SIGNAL_INT, + CTRL_BREAK_EVENT => SIGNAL_QUIT, + // Console window closing (analog of losing the controlling terminal); + // the system force-terminates us after a grace period, so react now. + CTRL_CLOSE_EVENT => SIGNAL_HUP, + // Logoff/shutdown reach only services; leave them to the default handler. + _ => return 0, + }; + LAST_CTRL_SIGNAL.store(signal, Ordering::Release); + if let Some(event) = WAKE_EVENT.get() { + let _ = sys::set_event(event.as_handle()); + } + 1 +} + +/// Install a console control handler that records Ctrl-C, Ctrl-Break and +/// console-close events as POSIX signal numbers (INT, QUIT, HUP) instead of +/// letting them terminate this process, and wakes any pending +/// [`ChildExt::wait_or_timeout`] call. +/// +/// Use [`take_last_ctrl_signal`] to consume the last event received. +/// +/// Idempotent; every call also discards any event latched by a previous +/// forwarding session, so repeated in-process runs (benchmarks, fuzzing) +/// start clean. Events arriving after this call still latch until consumed. +pub fn enable_ctrl_forwarding() -> io::Result<()> { + if WAKE_EVENT.get().is_none() { + // Manual-reset so a wakeup latched before a wait starts is never lost. + let event = sys::create_manual_reset_event()?; + // A racing second caller just drops its event; registering the handler + // twice is harmless. + let _ = WAKE_EVENT.set(event); + // SAFETY: the handler only touches atomics, the immutable `WAKE_EVENT` + // cell and a live event handle — sound for arbitrary-thread invocation. + unsafe { sys::set_console_ctrl_handler(Some(console_ctrl_handler))? }; + } + LAST_CTRL_SIGNAL.store(0, Ordering::Release); + if let Some(event) = WAKE_EVENT.get() { + let _ = sys::reset_event(event.as_handle()); + } + Ok(()) +} + +/// Consume the POSIX signal number of the last console control event received +/// since [`enable_ctrl_forwarding`], if any, resetting the latch so a stale +/// event can never influence a later decision. +pub fn take_last_ctrl_signal() -> Option { + match LAST_CTRL_SIGNAL.swap(0, Ordering::AcqRel) { + 0 => None, + signal => Some(signal as usize), + } +} + +/// Create a one-shot waitable timer that fires after `timeout`. +fn start_relative_timer(timeout: Duration) -> io::Result { + let timer = sys::create_waitable_timer()?; + // 100 ns ticks: round up so a sub-tick duration never fires early, and + // clamp huge durations. + let ticks = timeout.as_nanos().div_ceil(100).min(i64::MAX as u128) as i64; + sys::set_relative_timer(timer.as_handle(), ticks)?; + Ok(timer) +} + +impl ChildExt for Child { + fn send_signal(&mut self, signal: usize) -> io::Result<()> { + send_signal_to_process(self, signal) + } + + fn send_signal_group(&mut self, signal: usize) -> io::Result<()> { + // Unlike unix (which signals the caller's own process group), this + // targets the child's console group — the only group Windows lets us + // address at all. + let pid = self.id(); + send_signal_to_console_group(pid, signal) + } + + fn wait_or_timeout(&mut self, timeout: Duration, _ignore_term: bool) -> io::Result { + // The unix implementation drops stdin so the child sees EOF; match it. + drop(self.stdin.take()); + + // Zero disables the timeout: no timer, so the wait blocks until the + // child exits (or a console event fires). `_ignore_term` has no + // effect here: a console cannot deliver TERM, so there is never a + // termination request to ignore. + let timer = if timeout.is_zero() { + None + } else { + Some(start_relative_timer(timeout)?) + }; + let wake_event = WAKE_EVENT.get(); + + // A single blocking wait over up to three handles. On simultaneous + // completion the lowest index wins, so child-exit beats a console + // event, which beats timer expiry — matching the unix races. + let mut handles: Vec = Vec::with_capacity(3); + handles.push(self.as_handle()); + let wake_index = wake_event.map(|event| { + handles.push(event.as_handle()); + handles.len() - 1 + }); + if let Some(timer) = &timer { + handles.push(timer.as_handle()); + } + + loop { + let index = match sys::wait_for_any(&handles, INFINITE)? { + sys::WaitOutcome::Object(index) => index as usize, + // Unreachable with an INFINITE wait; treat as timer expiry. + sys::WaitOutcome::TimedOut => return Ok(TimeoutRet::TimedOut), + }; + if index == 0 { + // Child exited; reap it without blocking. + drop(handles); + return self.wait().map(TimeoutRet::Exited); + } + if Some(index) == wake_index { + // Consume the wake so a handled console event cannot satisfy + // a later wait. + if let Some(event) = wake_event { + let _ = sys::reset_event(event.as_handle()); + } + match take_last_ctrl_signal() { + Some(signal) => return Ok(TimeoutRet::Interrupted(signal)), + // Stale wake with nothing latched: keep waiting (the + // armed timer above still bounds the wait). + None => continue, + } + } + return Ok(TimeoutRet::TimedOut); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::{Command, Stdio}; + use std::time::Instant; + + /// Poll whether the wake event is currently signaled. + fn wake_event_signaled() -> bool { + let event = WAKE_EVENT.get().expect("forwarding enabled"); + matches!( + sys::wait_for_one(event.as_handle(), 0).unwrap(), + sys::WaitOutcome::Object(0) + ) + } + + fn spawn_child() -> Child { + Command::new("ping") + .args(["-n", "10", "127.0.0.1"]) + .stdout(Stdio::null()) + .spawn() + .unwrap() + } + + /// `LAST_CTRL_SIGNAL` and `WAKE_EVENT` are process-global, so every phase + /// runs inside this single test to keep them free of cross-test races + /// (cargo runs tests on parallel threads). + #[test] + fn ctrl_forwarding_latch_and_wake_lifecycle() { + // Phase 1: enabling starts clean. + enable_ctrl_forwarding().unwrap(); + assert_eq!(take_last_ctrl_signal(), None); + assert!(!wake_event_signaled()); + + // Phase 2: a console event latches its signal number exactly once and + // signals the wake event; consuming resets the latch. + // SAFETY: the handler only touches atomics and the event handle. + assert_eq!(unsafe { console_ctrl_handler(CTRL_C_EVENT) }, 1); + assert!(wake_event_signaled()); + assert_eq!(take_last_ctrl_signal(), Some(SIGNAL_INT as usize)); + assert_eq!(take_last_ctrl_signal(), None); + + // Phase 3: unhandled control types latch nothing (5 = logoff). + // SAFETY: as above. + assert_eq!(unsafe { console_ctrl_handler(5) }, 0); + assert_eq!(take_last_ctrl_signal(), None); + + // Phase 4: re-enabling discards state latched by a previous session. + // SAFETY: as above. + assert_eq!(unsafe { console_ctrl_handler(CTRL_BREAK_EVENT) }, 1); + enable_ctrl_forwarding().unwrap(); + assert_eq!(take_last_ctrl_signal(), None); + assert!(!wake_event_signaled()); + + // Phase 5: `wait_or_timeout` consumes a latched console event: it + // returns `Interrupted` promptly with the child still running, and + // leaves latch and event clear so they cannot satisfy a later wait. + let mut child = spawn_child(); + // SAFETY: as above. + assert_eq!(unsafe { console_ctrl_handler(CTRL_C_EVENT) }, 1); + let started = Instant::now(); + let result = child + .wait_or_timeout(Duration::from_secs(60), false) + .unwrap(); + assert!(matches!(result, TimeoutRet::Interrupted(sig) if sig == SIGNAL_INT as usize)); + assert!(started.elapsed() < Duration::from_secs(30)); + assert_eq!(take_last_ctrl_signal(), None); + assert!(!wake_event_signaled()); + child.kill().unwrap(); + child.wait().unwrap(); + } + + // The send_signal_to_pid tests touch no process-global state (unlike the + // ctrl-forwarding lifecycle above), so they can run in parallel. + + #[test] + fn send_signal_to_pid_terminates_with_128_plus_signal() { + let mut child = spawn_child(); + send_signal_to_pid(child.id(), 15).unwrap(); + assert_eq!(child.wait().unwrap().code(), Some(143)); + } + + #[test] + fn send_signal_to_pid_probe_ignore_and_exited_semantics() { + let mut child = spawn_child(); + send_signal_to_pid(child.id(), 0).unwrap(); + send_signal_to_pid(child.id(), 17).unwrap(); + assert!(child.try_wait().unwrap().is_none(), "CHLD must be a no-op"); + + child.kill().unwrap(); + child.wait().unwrap(); + // `child` still holds the process handle, pinning the pid: the checks + // below cannot race a pid reuse. + // The message text is asserted end-to-end in tests/by-util/test_kill.rs; + // unit tests run without localization initialized. + let err = send_signal_to_pid(child.id(), 0).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + send_signal_to_pid(child.id(), 9).unwrap(); + } + + #[test] + fn send_signal_to_pid_rejects_invalid_signal_before_any_open() { + assert_eq!( + send_signal_to_pid(std::process::id(), 64) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); + } + + #[test] + fn enable_debug_privilege_is_silent_whether_or_not_the_token_holds_it() { + enable_debug_privilege(); + enable_debug_privilege(); + } + + #[test] + fn send_signal_to_pid_reports_idle_pid_as_missing() { + // OpenProcess rejects the System Idle Process like any pid that names + // no process. + assert_eq!( + send_signal_to_pid(0, 9).unwrap_err().kind(), + io::ErrorKind::NotFound + ); + } + + // Nothing below assigns the *current* process to a job: joining one is + // irreversible and process-global, so it would rewrite what every other + // test in this binary sees (cargo runs them on parallel threads) — the same + // hazard that forces the ctrl-forwarding phases into a single test above. + // + // For the same reason, never pass a terminating signal to + // `send_signal_to_own_group` here: this process's group is whatever job the + // test runner provides. That path is covered in tests/by-util/test_kill.rs, + // inside a job the test creates itself. + + #[test] + fn with_self_last_puts_this_process_exactly_once_at_the_end() { + // Empty members *is* the job-less case. + assert_eq!(with_self_last(vec![], 7), vec![7]); + assert_eq!(with_self_last(vec![3, 7], 7), vec![3, 7]); + assert_eq!(with_self_last(vec![7, 3, 5], 7), vec![3, 5, 7]); + // Listed twice, signalled once, so the loop cannot kill itself early. + assert_eq!(with_self_last(vec![7, 3, 7], 7), vec![3, 7]); + } + + /// Also the canary for `QueryInformationJobObject(NULL, ...)`: if the + /// null-handle query were ever denied inside a job we do not own, this + /// fails on CI instead of `kill 0` failing in the field. + #[test] + fn send_signal_to_own_group_probe_and_ignore_are_no_ops() { + let mut child = spawn_child(); + send_signal_to_own_group(0).unwrap(); + send_signal_to_own_group(17).unwrap(); + assert!( + child.try_wait().unwrap().is_none(), + "the group probe terminated a process" + ); + child.kill().unwrap(); + child.wait().unwrap(); + } + + #[test] + fn send_signal_to_own_group_rejects_invalid_signal_before_enumerating() { + assert_eq!( + send_signal_to_own_group(64).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + } + + /// Against a job whose membership we control; the caller's own job (`None`) + /// is whatever the machine running the tests happens to provide. + #[test] + fn job_process_ids_lists_members_and_reports_truncation() { + let job = Job::new().unwrap(); + let mut children = [spawn_child(), spawn_child()]; + for child in &children { + job.assign(child).unwrap(); + } + let handle = job.0.as_handle(); + + let pids = job_process_ids(Some(handle)).unwrap(); + for child in &children { + assert!(pids.contains(&child.id())); + } + + // Two processes cannot fit a one-pid buffer. + assert!(matches!( + sys::query_job_process_ids(Some(handle), 1).unwrap(), + sys::JobProcessIds::Truncated + )); + + for child in &mut children { + child.kill().unwrap(); + child.wait().unwrap(); + } + } +} diff --git a/src/uucore/src/lib/features/quoting_style/c_quoter.rs b/src/uucore/src/lib/features/quoting_style/c_quoter.rs index 47a21571985..1083ca59835 100644 --- a/src/uucore/src/lib/features/quoting_style/c_quoter.rs +++ b/src/uucore/src/lib/features/quoting_style/c_quoter.rs @@ -3,11 +3,42 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use super::{EscapedChar, Quoter, Quotes}; +use super::{EscapedChar, Quoter}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CQuotes { + pub(super) opening: char, + pub(super) closing: char, +} + +impl CQuotes { + pub const SINGLE: Self = Self { + opening: '\'', + closing: '\'', + }; + + pub const DOUBLE: Self = Self { + opening: '"', + closing: '"', + }; + + pub const LOCALE_UTF8: Self = Self { + opening: '\u{2018}', + closing: '\u{2019}', + }; + + pub(super) fn opening_as_utf8(self, buf: &mut [u8]) -> &[u8] { + self.opening.encode_utf8(buf).as_bytes() + } + + pub(super) fn closing_as_utf8(self, buf: &mut [u8]) -> &[u8] { + self.closing.encode_utf8(buf).as_bytes() + } +} pub(super) struct CQuoter { - /// The type of quotes to use. - quotes: Quotes, + /// The type of quotes to use, if any. + quotes: Option, dirname: bool, @@ -15,12 +46,12 @@ pub(super) struct CQuoter { } impl CQuoter { - pub fn new(quotes: Quotes, dirname: bool, size_hint: usize) -> Self { + pub(super) fn new(quotes: Option, dirname: bool, size_hint: usize) -> Self { let mut buffer = Vec::with_capacity(size_hint); - match quotes { - Quotes::None => (), - Quotes::Single => buffer.push(b'\''), - Quotes::Double => buffer.push(b'"'), + + if let Some(quotes) = quotes { + let mut quote_buf = [0; 4]; + buffer.extend_from_slice(quotes.opening_as_utf8(&mut quote_buf)); } Self { @@ -46,12 +77,15 @@ impl Quoter for CQuoter { } } - fn finalize(mut self: Box) -> Vec { - match self.quotes { - Quotes::None => (), - Quotes::Single => self.buffer.push(b'\''), - Quotes::Double => self.buffer.push(b'"'), + fn finalize(self: Box) -> Vec { + let Self { + quotes, mut buffer, .. + } = *self; + if let Some(quotes) = quotes { + let mut quote_buf = [0; 4]; + buffer.extend_from_slice(quotes.closing_as_utf8(&mut quote_buf)); } - self.buffer + + buffer } } diff --git a/src/uucore/src/lib/features/quoting_style/escaped_char.rs b/src/uucore/src/lib/features/quoting_style/escaped_char.rs index e9a14ca737a..56040cba5a3 100644 --- a/src/uucore/src/lib/features/quoting_style/escaped_char.rs +++ b/src/uucore/src/lib/features/quoting_style/escaped_char.rs @@ -5,7 +5,8 @@ use std::char::from_digit; -use super::Quotes; +use super::c_quoter::CQuotes; +use super::shell_quoter::ShellQuotes; // PR#6559 : Remove `]{}` from special shell chars. const SPECIAL_SHELL_CHARS: &str = "`$&*()|[;\\'\"<>?! "; @@ -107,21 +108,25 @@ impl EscapeOctal { } impl EscapedChar { - pub fn new_literal(c: char) -> Self { + pub(super) fn new_literal(c: char) -> Self { Self { state: EscapeState::Char(c), } } - pub fn new_octal(b: u8) -> Self { + pub(super) fn new_octal(b: u8) -> Self { Self { state: EscapeState::Octal(EscapeOctal::from_byte(b)), } } - pub fn new_c(c: char, quotes: Quotes, dirname: bool) -> Self { + /// Escape a character for C or locale quoting style. + pub(super) fn new_c(c: char, quotes: Option, dirname: bool) -> Self { use EscapeState::*; let init_state = match c { + // When using locale quoting style, only escape the closing + // character, not the opening one. + c if quotes.is_some_and(|q| q.closing == c) => Backslash(c), '\x07' => Backslash('a'), '\x08' => Backslash('b'), '\t' => Backslash('t'), @@ -130,18 +135,7 @@ impl EscapedChar { '\x0C' => Backslash('f'), '\r' => Backslash('r'), '\\' => Backslash('\\'), - '\'' => match quotes { - Quotes::Single => Backslash('\''), - _ => Char('\''), - }, - '"' => match quotes { - Quotes::Double => Backslash('"'), - _ => Char('"'), - }, - ' ' if !dirname => match quotes { - Quotes::None => Backslash(' '), - _ => Char(' '), - }, + ' ' if !dirname && quotes.is_none() => Backslash(' '), ':' if dirname => Backslash(':'), _ if c.is_control() => Octal(EscapeOctal::from_char(c)), _ => Char(c), @@ -149,7 +143,7 @@ impl EscapedChar { Self { state: init_state } } - pub fn new_shell(c: char, escape: bool, quotes: Quotes) -> Self { + pub(super) fn new_shell(c: char, escape: bool, quotes: ShellQuotes) -> Self { use EscapeState::*; let init_state = match c { _ if !escape && c.is_control() => Char(c), @@ -160,10 +154,7 @@ impl EscapedChar { '\x0B' => Backslash('v'), '\x0C' => Backslash('f'), '\r' => Backslash('r'), - '\'' => match quotes { - Quotes::Single => Backslash('\''), - _ => Char('\''), - }, + '\'' if matches!(quotes, ShellQuotes::Single) => Backslash('\''), _ if c.is_control() => Octal(EscapeOctal::from_char(c)), _ if SPECIAL_SHELL_CHARS.contains(c) => ForceQuote(c), _ => Char(c), diff --git a/src/uucore/src/lib/features/quoting_style/mod.rs b/src/uucore/src/lib/features/quoting_style/mod.rs index 9613e579d9e..b4850511127 100644 --- a/src/uucore/src/lib/features/quoting_style/mod.rs +++ b/src/uucore/src/lib/features/quoting_style/mod.rs @@ -8,10 +8,13 @@ use std::ffi::{OsStr, OsString}; use std::fmt; +use os_display::Quotable; + use crate::i18n::{self, UEncoding}; use crate::quoting_style::c_quoter::CQuoter; use crate::quoting_style::literal_quoter::LiteralQuoter; use crate::quoting_style::shell_quoter::{EscapedShellQuoter, NonEscapedShellQuoter}; +use crate::{show_error, translate}; mod escaped_char; pub use escaped_char::{EscapeState, EscapedChar}; @@ -20,9 +23,18 @@ mod c_quoter; mod literal_quoter; mod shell_quoter; -/// The quoting style to use when escaping a name. +pub use c_quoter::CQuotes; + +/// Enum representing a quoting and escaping strategy to print a string. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum QuotingStyle { + /// Do not escape the string. + /// Used in, e.g., `ls --literal`. + Literal { + /// If false, replaces control and non-Unicode characters with `?`. + show_control: bool, + }, + /// Escape the name as a shell string. /// Used in, e.g., `ls --quoting-style=shell`. Shell { @@ -33,27 +45,31 @@ pub enum QuotingStyle { /// Whether to always quote the name. always_quote: bool, - /// Whether to show control and non-unicode characters, or replace them with `?`. + /// If false, replaces control and non-Unicode characters with `?`. show_control: bool, }, /// Escape the name as a C string. /// Used in, e.g., `ls --quote-name`. C { - /// The type of quotes to use. - quotes: Quotes, + quotes: CQuotes, }, - /// Do not escape the string. - /// Used in, e.g., `ls --literal`. - Literal { - /// Whether to show control and non-unicode characters, or replace them with `?`. - show_control: bool, - }, + /// Simply escape characters without quoting + /// Used in, e.g., `ls --quoting-style=escape`. + Escape, + + /// Escape using locale. + Locale, + CLocale, } /// Provide sane defaults for quoting styles. impl QuotingStyle { + pub const LITERAL: Self = Self::Literal { + show_control: false, + }; + pub const SHELL: Self = Self::Shell { escape: false, always_quote: false, @@ -66,28 +82,30 @@ impl QuotingStyle { show_control: false, }; - pub const SHELL_QUOTE: Self = Self::Shell { + pub const SHELL_ALWAYS: Self = Self::Shell { escape: false, always_quote: true, show_control: false, }; - pub const SHELL_ESCAPE_QUOTE: Self = Self::Shell { + pub const SHELL_ESCAPE_ALWAYS: Self = Self::Shell { escape: true, always_quote: true, show_control: false, }; - pub const C_NO_QUOTES: Self = Self::C { - quotes: Quotes::None, + pub const C_DOUBLE: Self = Self::C { + quotes: CQuotes::DOUBLE, }; - pub const C_DOUBLE: Self = Self::C { - quotes: Quotes::Double, + pub const C_SINGLE: Self = Self::C { + quotes: CQuotes::SINGLE, }; /// Set the `show_control` field of the quoting style. - /// Note: this is a no-op for the `C` variant. + /// + /// > This is a no-op for variants others than [`QuotingStyle::Shell`] + /// > and [`QuotingStyle::Literal`]. pub fn show_control(self, show_control: bool) -> Self { use QuotingStyle::*; match self { @@ -101,11 +119,91 @@ impl QuotingStyle { show_control, }, Literal { .. } => Literal { show_control }, - C { .. } => self, + C { .. } | Escape | Locale | CLocale => self, + } + } + + /// Set the `always_quote` field of the quoting style. + /// + /// > This is a no-op for all variants except [`QuotingStyle::Shell`]. + pub fn always_quote(self, always_quote: bool) -> Self { + match self { + Self::Shell { + escape, + show_control, + .. + } => Self::Shell { + escape, + show_control, + always_quote, + }, + _ => self, + } + } + + /// Parse a [`QuotingStyle`] from a string. + /// + /// Used for e.g., the `QUOTING_STYLE` environment variable and the + /// `--quoting-style` option of `ls`. + pub fn parse(s: &str) -> Option { + Some(match s { + "literal" => Self::LITERAL, + "shell" => Self::SHELL, + "shell-always" => Self::SHELL_ALWAYS, + "shell-escape" => Self::SHELL_ESCAPE, + "shell-escape-always" => Self::SHELL_ESCAPE_ALWAYS, + "c" => Self::C_DOUBLE, + "escape" => Self::Escape, + "locale" => Self::Locale, + "clocale" => Self::CLocale, + _ => return None, + }) + } +} + +impl fmt::Display for QuotingStyle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Self::Shell { + escape, + always_quote, + .. + } => { + write!( + f, + "shell{}{}", + if escape { "-escape" } else { "" }, + if always_quote { "-always" } else { "" } + ) + } + Self::C { .. } => f.write_str("c"), + Self::Escape => f.write_str("escape"), + Self::Literal { .. } => f.write_str("literal"), + Self::Locale => f.write_str("locale"), + Self::CLocale => f.write_str("clocale"), } } } +/// Retrieve the `QUOTING_STYLE` environment variable. If present, parse it +/// through [`QuotingStyle::parse`], and write a standard error message to +/// stderr in case of an invalid value. +pub fn quoting_style_from_env() -> Option { + // If variable is not set, return None quietly. + let style = std::env::var_os("QUOTING_STYLE")?; + + let Some(qs) = style.to_str().and_then(QuotingStyle::parse) else { + // Variable is present but invalid, report an error. + show_error!( + "{}", + translate!("invalid-quoting-style-env-var", "invalid" => style.to_string_lossy().quote()) + ); + return None; + }; + + Some(qs) +} + /// Common interface of quoting mechanisms. trait Quoter { /// Push a valid character. @@ -126,21 +224,7 @@ trait Quoter { fn finalize(self: Box) -> Vec; } -/// The type of quotes to use when escaping a name as a C string. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Quotes { - /// Do not use quotes. - None, - - /// Use single quotes. - Single, - - /// Use double quotes. - Double, - // TODO: Locale -} - -/// Escape a name according to the given quoting style. +/// Escape a name according to the given [`QuotingStyle`] and [`UEncoding`]. /// /// This inner function provides an additional flag `dirname` which /// is meant for ls' directory name display. @@ -157,7 +241,22 @@ fn escape_name_inner( let mut quoter: Box = match style { QuotingStyle::Literal { .. } => Box::new(LiteralQuoter::new(name.len())), - QuotingStyle::C { quotes } => Box::new(CQuoter::new(quotes, dirname, name.len())), + QuotingStyle::C { quotes } => Box::new(CQuoter::new(Some(quotes), dirname, name.len())), + QuotingStyle::Escape => Box::new(CQuoter::new(None, dirname, name.len())), + QuotingStyle::CLocale => { + let quotes = match encoding { + UEncoding::Ascii => CQuotes::DOUBLE, + UEncoding::Utf8 => CQuotes::LOCALE_UTF8, + }; + Box::new(CQuoter::new(Some(quotes), dirname, name.len())) + } + QuotingStyle::Locale => { + let quotes = match encoding { + UEncoding::Ascii => CQuotes::SINGLE, + UEncoding::Utf8 => CQuotes::LOCALE_UTF8, + }; + Box::new(CQuoter::new(Some(quotes), dirname, name.len())) + } QuotingStyle::Shell { escape: true, always_quote, @@ -167,6 +266,7 @@ fn escape_name_inner( always_quote, dirname, name.len(), + encoding, )), QuotingStyle::Shell { escape: false, @@ -202,16 +302,35 @@ fn escape_name_inner( quoter.finalize() } -/// Escape a filename with respect to the given style. +/// Escape a filename with respect to the given [`QuotingStyle`]. pub fn escape_name(name: &OsStr, style: QuotingStyle, encoding: UEncoding) -> OsString { let name = crate::os_str_as_bytes_lossy(name); crate::os_string_from_vec(escape_name_inner(&name, style, false, encoding)) .expect("all byte sequences should be valid for platform, or already replaced in name") } -/// Retrieve the encoding from the locale and pass it to `escape_name`. +/// Retrieve the encoding from the locale and pass it to [`escape_name`]. +#[inline(always)] pub fn locale_aware_escape_name(name: &OsStr, style: QuotingStyle) -> OsString { - escape_name(name, style, i18n::get_locale_encoding()) + escape_name(name, style, i18n::get_ctype_encoding()) +} + +/// Shorthand function for [`locale_aware_escape_name`] +/// Useful for quoting filenames in error messages. +#[inline(always)] +pub fn locale_aware_shell_escape(name: impl AsRef) -> String { + locale_aware_escape_name(name.as_ref(), QuotingStyle::SHELL_ESCAPE) + .into_string() + .unwrap() // SAFETY: string was just escaped +} + +/// Shorthand function for [`locale_aware_escape_name`] +/// Useful for quoting in error messages. +#[inline(always)] +pub fn locale_aware_c_single_escape(name: impl AsRef) -> String { + locale_aware_escape_name(name.as_ref(), QuotingStyle::C_SINGLE) + .into_string() + .unwrap() // SAFETY: string was just escaped } /// Escape a directory name with respect to the given style. @@ -223,91 +342,40 @@ pub fn escape_dir_name(dir_name: &OsStr, style: QuotingStyle, encoding: UEncodin .expect("all byte sequences should be valid for platform, or already replaced in name") } -/// Retrieve the encoding from the locale and pass it to `escape_dir_name`. +/// Retrieve the encoding from the locale and pass it to [`escape_dir_name`]. pub fn locale_aware_escape_dir_name(name: &OsStr, style: QuotingStyle) -> OsString { - escape_dir_name(name, style, i18n::get_locale_encoding()) -} - -impl fmt::Display for QuotingStyle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { - Self::Shell { - escape, - always_quote, - show_control, - } => { - let mut style = "shell".to_string(); - if escape { - style.push_str("-escape"); - } - if always_quote { - style.push_str("-always-quote"); - } - if show_control { - style.push_str("-show-control"); - } - f.write_str(&style) - } - Self::C { .. } => f.write_str("C"), - Self::Literal { .. } => f.write_str("literal"), - } - } -} - -impl fmt::Display for Quotes { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { - Self::None => f.write_str("None"), - Self::Single => f.write_str("Single"), - Self::Double => f.write_str("Double"), - } - } + escape_dir_name(name, style, i18n::get_ctype_encoding()) } #[cfg(test)] mod tests { use crate::{ i18n::UEncoding, - quoting_style::{Quotes, QuotingStyle, escape_name_inner}, + quoting_style::{QuotingStyle, escape_name_inner}, }; // spell-checker:ignore (tests/words) one\'two one'two + // Custom quoting style parsing for testing purposes (a -show might be appended) fn get_style(s: &str) -> QuotingStyle { - match s { - "literal" => QuotingStyle::Literal { - show_control: false, - }, - "literal-show" => QuotingStyle::Literal { show_control: true }, - "escape" => QuotingStyle::C_NO_QUOTES, - "c" => QuotingStyle::C_DOUBLE, - "shell" => QuotingStyle::SHELL, - "shell-show" => QuotingStyle::SHELL.show_control(true), - "shell-always" => QuotingStyle::SHELL_QUOTE, - "shell-always-show" => QuotingStyle::SHELL_QUOTE.show_control(true), - "shell-escape" => QuotingStyle::SHELL_ESCAPE, - "shell-escape-always" => QuotingStyle::SHELL_ESCAPE_QUOTE, - _ => panic!("Invalid name!"), - } - } - - fn check_names_inner(encoding: UEncoding, name: &[u8], map: &[(T, &str)]) -> Vec> { - map.iter() - .map(|(_, style)| escape_name_inner(name, get_style(style), false, encoding)) - .collect() + let show_control = s.ends_with("-show"); + let s = s.trim_end_matches("-show"); + QuotingStyle::parse(s).unwrap().show_control(show_control) } fn check_names_encoding(encoding: UEncoding, name: &str, map: &[(&str, &str)]) { - assert_eq!( - map.iter() - .map(|(correct, _)| *correct) - .collect::>(), - check_names_inner(encoding, name.as_bytes(), map) - .iter() - .map(|bytes| std::str::from_utf8(bytes) - .expect("valid str goes in, valid str comes out")) - .collect::>() - ); + for (expected, style) in map { + assert_eq!( + *expected, + std::str::from_utf8(&escape_name_inner( + name.as_bytes(), + get_style(style), + false, + encoding + )) + .expect("valid str goes in, valid str comes out") + ); + } } fn check_names_both(name: &str, map: &[(&str, &str)]) { @@ -316,12 +384,12 @@ mod tests { } fn check_names_encoding_raw(encoding: UEncoding, name: &[u8], map: &[(&[u8], &str)]) { - assert_eq!( - map.iter() - .map(|(correct, _)| *correct) - .collect::>(), - check_names_inner(encoding, name, map) - ); + for (expected, style) in map { + assert_eq!( + *expected, + escape_name_inner(name, get_style(style), false, encoding) + ); + } } fn check_names_raw_both(name: &[u8], map: &[(&[u8], &str)]) { @@ -329,6 +397,14 @@ mod tests { check_names_encoding_raw(UEncoding::Ascii, name, map); } + #[test] + fn test_invalid_bytes_with_single_quote() { + check_names_raw_both( + b"missing-'\xff", + &[(b"'missing-'\\'''$'\\377'", "shell-escape-always")], + ); + } + #[test] fn test_simple_names() { check_names_both( @@ -1058,30 +1134,108 @@ mod tests { ); } + #[test] + fn test_locale_styles() { + fn test_case(name: &str, utf8: &str, ascii_locale: &str, ascii_clocale: &str) { + check_names_encoding( + UEncoding::Utf8, + name, + &[(utf8, "locale"), (utf8, "clocale")], + ); + check_names_encoding( + UEncoding::Ascii, + name, + &[(ascii_locale, "locale"), (ascii_clocale, "clocale")], + ); + } + fn test_case_raw(name: &[u8], utf8: &[u8], ascii_locale: &[u8], ascii_clocale: &[u8]) { + check_names_encoding_raw( + UEncoding::Utf8, + name, + &[(utf8, "locale"), (utf8, "clocale")], + ); + check_names_encoding_raw( + UEncoding::Ascii, + name, + &[(ascii_locale, "locale"), (ascii_clocale, "clocale")], + ); + } + + // Simple name + + test_case( + "one_two", + "\u{2018}one_two\u{2019}", + "'one_two'", + "\"one_two\"", + ); + + // Space + + test_case(" ", "\u{2018} \u{2019}", "' '", "\" \""); + + // Quotes + + test_case("'", "\u{2018}'\u{2019}", "'\\''", "\"'\""); + test_case("\"", "\u{2018}\"\u{2019}", "'\"'", "\"\\\"\""); + test_case( + "\u{2018}", + "\u{2018}\u{2018}\u{2019}", // opening quote is not escaped + "'\\342\\200\\230'", + "\"\\342\\200\\230\"", + ); + test_case( + "\u{2019}", + "\u{2018}\\\u{2019}\u{2019}", // closing quote is escaped + "'\\342\\200\\231'", + "\"\\342\\200\\231\"", + ); + + // ASCII control + + test_case( + "\x00\n", + "\u{2018}\\000\\n\u{2019}", + "'\\000\\n'", + "\"\\000\\n\"", + ); + + // Non-Unicode + + test_case_raw( + b"\xEF", + "\u{2018}\\357\u{2019}".as_bytes(), + b"'\\357'", + b"\"\\357\"", + ); + } + #[test] fn test_quoting_style_display() { let style = QuotingStyle::SHELL_ESCAPE; assert_eq!(format!("{style}"), "shell-escape"); - let style = QuotingStyle::SHELL_QUOTE; - assert_eq!(format!("{style}"), "shell-always-quote"); + let style = QuotingStyle::SHELL_ALWAYS; + assert_eq!(format!("{style}"), "shell-always"); let style = QuotingStyle::SHELL.show_control(true); - assert_eq!(format!("{style}"), "shell-show-control"); + assert_eq!(format!("{style}"), "shell"); let style = QuotingStyle::C_DOUBLE; - assert_eq!(format!("{style}"), "C"); + assert_eq!(format!("{style}"), "c"); + + let style = QuotingStyle::Escape; + assert_eq!(format!("{style}"), "escape"); let style = QuotingStyle::Literal { show_control: false, }; assert_eq!(format!("{style}"), "literal"); - } - #[test] - fn test_quotes_display() { - assert_eq!(format!("{}", Quotes::None), "None"); - assert_eq!(format!("{}", Quotes::Single), "Single"); - assert_eq!(format!("{}", Quotes::Double), "Double"); + let style = QuotingStyle::Locale; + assert_eq!(format!("{style}"), "locale"); + + let style = QuotingStyle::CLocale; + assert_eq!(format!("{style}"), "clocale"); } } diff --git a/src/uucore/src/lib/features/quoting_style/shell_quoter.rs b/src/uucore/src/lib/features/quoting_style/shell_quoter.rs index bb531b50d16..441f1e54042 100644 --- a/src/uucore/src/lib/features/quoting_style/shell_quoter.rs +++ b/src/uucore/src/lib/features/quoting_style/shell_quoter.rs @@ -3,13 +3,30 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use super::{EscapeState, EscapedChar, Quoter, Quotes}; +use crate::i18n::UEncoding; + +use super::{EscapeState, EscapedChar, Quoter}; // These are characters with special meaning in the shell (e.g. bash). The // first const contains characters that only have a special meaning when they // appear at the beginning of a name. const SPECIAL_SHELL_CHARS_START: &[u8] = b"~#"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ShellQuotes { + Single, + Double, +} + +impl ShellQuotes { + pub fn as_u8(self) -> u8 { + match self { + Self::Single => b'\'', + Self::Double => b'"', + } + } +} + // Escaped and NonEscaped shell quoting strategies are very different. // Therefore, we are using separate Quoter structures for each of them. @@ -19,7 +36,7 @@ pub(super) struct NonEscapedShellQuoter<'a> { reference: &'a [u8], /// The quotes to be used if necessary - quotes: Quotes, + quotes: ShellQuotes, /// Whether to show control and non-unicode characters, or replace them /// with `?`. @@ -94,7 +111,7 @@ pub(super) struct EscapedShellQuoter<'a> { reference: &'a [u8], /// The quotes to be used if necessary - quotes: Quotes, + quotes: ShellQuotes, // INTERNAL STATE /// Whether the name should be quoted. @@ -107,8 +124,24 @@ pub(super) struct EscapedShellQuoter<'a> { } impl<'a> EscapedShellQuoter<'a> { - pub fn new(reference: &'a [u8], always_quote: bool, dirname: bool, size_hint: usize) -> Self { - let (quotes, must_quote) = initial_quoting(reference, dirname, always_quote, true); + pub fn new( + reference: &'a [u8], + always_quote: bool, + dirname: bool, + size_hint: usize, + encoding: UEncoding, + ) -> Self { + let (mut quotes, must_quote) = initial_quoting(reference, dirname, always_quote, true); + // Dollar-quoted byte escapes cannot be nested in double quotes. + if quotes == ShellQuotes::Double { + let invalid = match encoding { + UEncoding::Ascii => !reference.is_ascii(), + UEncoding::Utf8 => std::str::from_utf8(reference).is_err(), + }; + if invalid { + quotes = ShellQuotes::Single; + } + } Self { reference, quotes, @@ -190,19 +223,19 @@ fn initial_quoting( dirname: bool, always_quote: bool, check_control_chars: bool, -) -> (Quotes, bool) { +) -> (ShellQuotes, bool) { let has_special_chars = input.iter().any(|c| { shell_escaped_char_set(dirname).contains(c) || (check_control_chars && c.is_ascii_control()) }); if has_special_chars { - (Quotes::Single, true) + (ShellQuotes::Single, true) } else if input.contains(&b'\'') { - (Quotes::Double, true) + (ShellQuotes::Double, true) } else if always_quote || input.is_empty() { - (Quotes::Single, true) + (ShellQuotes::Single, true) } else { - (Quotes::Single, false) + (ShellQuotes::Single, false) } } @@ -226,17 +259,13 @@ fn finalize_shell_quoter( buffer: Vec, reference: &[u8], must_quote: bool, - quotes: Quotes, + quotes: ShellQuotes, ) -> Vec { let contains_quote_chars = must_quote || bytes_start_with(reference, SPECIAL_SHELL_CHARS_START); - if must_quote | contains_quote_chars && quotes != Quotes::None { + if must_quote | contains_quote_chars { let mut quoted = Vec::::with_capacity(buffer.len() + 2); - let quote = if quotes == Quotes::Single { - b'\'' - } else { - b'"' - }; + let quote = quotes.as_u8(); quoted.push(quote); quoted.extend(buffer); quoted.push(quote); @@ -255,63 +284,63 @@ mod tests { // Control chars (0-31 and 0x7F) force single quotes in escape mode assert_eq!( initial_quoting(b"\x01", false, false, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); // Control + quote uses single quotes (segmented) in escape mode assert_eq!( initial_quoting(b"\x01'\x01", false, false, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); // Simple quote uses double quotes in escape mode assert_eq!( initial_quoting(b"a'b", false, false, true), - (Quotes::Double, true) + (ShellQuotes::Double, true) ); // Shell special chars force single quotes in escape mode assert_eq!( initial_quoting(b"test$var", false, false, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); assert_eq!( initial_quoting(b"test\nline", false, false, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); // Empty string forces quotes in escape mode assert_eq!( initial_quoting(b"", false, false, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); // Always quote flag works in escape mode assert_eq!( initial_quoting(b"normal", false, true, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); // Normal text doesn't need quoting in escape mode assert_eq!( initial_quoting(b"hello", false, false, true), - (Quotes::Single, false) + (ShellQuotes::Single, false) ); // Dirname affects colon handling in escape mode assert_eq!( initial_quoting(b"dir:name", true, false, true), - (Quotes::Single, true) + (ShellQuotes::Single, true) ); assert_eq!( initial_quoting(b"file:name", false, false, true), - (Quotes::Single, false) + (ShellQuotes::Single, false) ); // Control chars ignored in non-escape mode assert_eq!( initial_quoting(b"\x01", false, false, false), - (Quotes::Single, false) + (ShellQuotes::Single, false) ); } } diff --git a/src/uucore/src/lib/features/ranges.rs b/src/uucore/src/lib/features/ranges.rs index 0797a3bd5ec..5688e93d8a2 100644 --- a/src/uucore/src/lib/features/ranges.rs +++ b/src/uucore/src/lib/features/ranges.rs @@ -8,6 +8,7 @@ //! A module for handling ranges of values. use std::cmp::max; +use std::ops::Range as ByteRange; use std::str::FromStr; use crate::display::Quotable; @@ -41,36 +42,172 @@ impl FromStr for Range { /// assert!(Range::from_str("a-b").is_err()); /// ``` fn from_str(s: &str) -> Result { - fn parse(s: &str) -> Result { - match s.parse::() { - Ok(0) => Err("fields and positions are numbered from 1"), + Self::parse(s).map_err(|invalid| invalid.reason) + } +} + +/// What is wrong with a range, so that a caller can label a caret. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RangeErrorKind { + /// A bound that is not a number at all. + NotANumber, + /// A bound of zero, where counting starts at one. + ZeroBound, + /// A bound too large to be used. + TooLarge, + /// A bare `-`, with a bound on neither side. + NoEndpoint, + /// A range that ends before it starts. + Inverted, +} + +/// A list of ranges that does not parse, and the part of it that is at fault. +/// +/// The message is built here so that it reads exactly as it always has; `span` +/// and `kind` are what a caret needs on top of it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RangeError { + pub message: String, + /// Byte range inside the whole list, not inside the one item at fault. + pub span: ByteRange, + pub kind: RangeErrorKind, +} + +impl std::fmt::Display for RangeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for RangeError {} + +impl RangeError { + /// Render this error against `args`, with a caret under the one range in + /// `list` that is at fault. + /// + /// A list of ranges reads the same wherever it is taken, so what a caret + /// says about a bad one is written here rather than in each utility. Only + /// the two texts that genuinely differ between them are passed in: what a + /// range counts, and how the option spells a list. + /// + /// # Arguments + /// + /// * `args` - The whole argument list, program name included — as + /// [`crate::diagnostics::capture`] returns it. + /// * `list` - The list of ranges as typed, the value of the option below. + /// * `short` - The short name of that option, if it has one. + /// * `long` - Its long name, if it has one. + /// * `zero_bound` - Already localized: what a zero bound got wrong, which + /// depends on what the range counts — bytes, fields, characters. + /// * `help` - Already localized: how a list is written for this option. + /// + /// # Returns + /// + /// `false` when no argument carries `list` as that option's value, in which + /// case the caller should fall back to the plain one-line message. + pub fn render_option_value( + &self, + args: &[std::ffi::OsString], + list: &str, + short: Option, + long: Option<&str>, + zero_bound: &str, + help: &str, + ) -> bool { + // Labelled only where a label would add to the message, per the + // convention in `crate::diagnostics`. + let label = match self.kind { + RangeErrorKind::NotANumber | RangeErrorKind::NoEndpoint => None, + RangeErrorKind::ZeroBound => Some(std::borrow::Cow::Borrowed(zero_bound)), + RangeErrorKind::TooLarge => Some(std::borrow::Cow::Owned(crate::translate!( + "range-diag-label-too-large" + ))), + RangeErrorKind::Inverted => Some(std::borrow::Cow::Owned(crate::translate!( + "range-diag-label-inverted" + ))), + }; + + crate::diagnostics::Snapshot::with_program(args).render_option_value( + list, + short, + long, + self.span.clone(), + &self.message, + label.as_deref(), + Some(help), + ) + } +} + +/// One range that does not parse, located inside the item it was read from. +struct Invalid { + reason: &'static str, + span: ByteRange, + kind: RangeErrorKind, +} + +impl Range { + /// Parse one range, reporting where inside `s` the trouble is. + fn parse(s: &str) -> Result { + // Each bound is parsed where it sits, so that a caret can point at the + // one that is wrong rather than at the pair. + let bound = |part: &str, offset: usize| -> Result { + let at = |reason, kind| Invalid { + reason, + span: offset..offset + part.len(), + kind, + }; + match part.parse::() { + Ok(0) => Err(at( + "fields and positions are numbered from 1", + RangeErrorKind::ZeroBound, + )), // GNU fails when we are at the limit. Match their behavior - Ok(n) if n == usize::MAX => Err("byte/character offset is too large"), + Ok(usize::MAX) => Err(at( + "byte/character offset is too large", + RangeErrorKind::TooLarge, + )), Ok(n) => Ok(n), - Err(_) => Err("failed to parse range"), + Err(_) => Err(at("failed to parse range", RangeErrorKind::NotANumber)), } - } + }; + // Everything after the dash starts one byte past it. + let after_dash = |low: &str| low.len() + 1; Ok(match s.split_once('-') { None => { - let n = parse(s)?; + let n = bound(s, 0)?; Self { low: n, high: n } } - Some(("", "")) => return Err("invalid range with no endpoint"), + Some(("", "")) => { + return Err(Invalid { + reason: "invalid range with no endpoint", + span: 0..s.len(), + kind: RangeErrorKind::NoEndpoint, + }); + } Some((low, "")) => Self { - low: parse(low)?, + low: bound(low, 0)?, high: usize::MAX - 1, }, Some(("", high)) => Self { low: 1, - high: parse(high)?, + high: bound(high, after_dash(""))?, }, Some((low, high)) => { - let (low, high) = (parse(low)?, parse(high)?); - if low <= high { - Self { low, high } + let (low_value, high_value) = (bound(low, 0)?, bound(high, after_dash(low))?); + if low_value <= high_value { + Self { + low: low_value, + high: high_value, + } } else { - return Err("high end of range less than low end"); + return Err(Invalid { + reason: "high end of range less than low end", + // Neither bound is wrong on its own; it is the pair. + span: 0..s.len(), + kind: RangeErrorKind::Inverted, + }); } } }) @@ -79,13 +216,26 @@ impl FromStr for Range { impl Range { /// Parse a list of ranges separated by commas and/or spaces - pub fn from_list(list: &str) -> Result, String> { + /// + /// # Returns + /// + /// On failure, the message as it has always read, and where in `list` the + /// item at fault sits, so that a caller may point a caret at it. + pub fn from_list(list: &str) -> Result, RangeError> { let mut ranges = Vec::new(); + // Where the item being read starts inside the whole list. The + // separators are one byte each, so stepping over them is enough to + // keep count. + let mut start = 0; for item in list.split(&[',', ' ']) { - let range_item = FromStr::from_str(item) - .map_err(|e| format!("range {} was invalid: {e}", item.quote()))?; + let range_item = Self::parse(item).map_err(|invalid| RangeError { + message: format!("range {} was invalid: {}", item.quote(), invalid.reason), + span: start + invalid.span.start..start + invalid.span.end, + kind: invalid.kind, + })?; ranges.push(range_item); + start += item.len() + 1; } Ok(Self::merge(ranges)) @@ -94,18 +244,16 @@ impl Range { /// Merge any overlapping ranges. Adjacent ranges are *NOT* merged. /// /// Is guaranteed to return only disjoint ranges in a sorted order. - fn merge(mut ranges: Vec) -> Vec { - ranges.sort(); - - // merge overlapping ranges - for i in 0..ranges.len() { - let j = i + 1; - - while j < ranges.len() && ranges[j].low <= ranges[i].high { - let j_high = ranges.remove(j).high; - ranges[i].high = max(ranges[i].high, j_high); + pub fn merge(mut ranges: Vec) -> Vec { + ranges.sort_unstable_by_key(|r| r.low); + ranges.dedup_by(|a, b| { + if a.low <= b.high { + b.high = max(b.high, a.high); + true + } else { + false } - } + }); ranges } } diff --git a/src/uucore/src/lib/features/ringbuffer.rs b/src/uucore/src/lib/features/ringbuffer.rs index 68e86b6a890..efbec26d6a2 100644 --- a/src/uucore/src/lib/features/ringbuffer.rs +++ b/src/uucore/src/lib/features/ringbuffer.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + //! A fixed-size ring buffer. + use std::collections::VecDeque; /// A fixed-size ring buffer backed by a `VecDeque`. diff --git a/src/uucore/src/lib/features/safe_traversal.rs b/src/uucore/src/lib/features/safe_traversal.rs index 1dff1b4aa4e..b8bd8183fb4 100644 --- a/src/uucore/src/lib/features/safe_traversal.rs +++ b/src/uucore/src/lib/features/safe_traversal.rs @@ -2,32 +2,42 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// + +// spell-checker:ignore CLOEXEC RDONLY TOCTOU closedir dirp fdopendir fstatat openat REMOVEDIR unlinkat smallfile +// spell-checker:ignore RAII dirfd fchownat fchown FchmodatFlags fchmodat fchmod mkdirat CREAT WRONLY ELOOP ENOTDIR EXCL EEXIST +// spell-checker:ignore atimensec mtimensec ctimensec opath chmods fakeroot fakechroot EOVERFLOW chowned chmoded +// spell-checker:ignore LARGEFILE getdents atim mtim ctim statat + // Safe directory traversal using openat() and related syscalls // This module provides TOCTOU-safe filesystem operations for recursive traversal // // 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 mkdirat CREAT WRONLY ELOOP ENOTDIR -// spell-checker:ignore atimensec mtimensec ctimensec opath chmods #[cfg(test)] use std::os::unix::ffi::OsStringExt; -use std::ffi::{CString, OsStr, OsString}; +use std::ffi::{CStr, CString, OsStr, OsString}; use std::fs; use std::io; +#[cfg(any(target_os = "linux", target_os = "android"))] +use std::mem::MaybeUninit; use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::MetadataExt; use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd}; use std::path::{Path, PathBuf}; +use std::time::SystemTime; +#[cfg(not(any(target_os = "linux", target_os = "android")))] use nix::dir::Dir; use nix::fcntl::{OFlag, openat}; use nix::libc; -use nix::sys::stat::{FchmodatFlags, FileStat, Mode, fchmodat, fstatat, mkdirat}; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +use nix::sys::stat::fstatat; +use nix::sys::stat::{FchmodatFlags, Mode, fchmodat, mkdirat}; use nix::unistd::{Gid, Uid, UnlinkatFlags, fchown, fchownat, unlinkat}; use os_display::Quotable; +#[cfg(any(target_os = "linux", target_os = "android"))] +use rustix::fs::{RawDir, fstat, statat}; use crate::translate; @@ -59,7 +69,7 @@ impl From for SymlinkBehavior { } // Custom error types for better error reporting -#[derive(thiserror::Error, Debug)] +#[derive(Debug, thiserror::Error)] pub enum SafeTraversalError { #[error("{}", translate!("safe-traversal-error-path-contains-null"))] PathContainsNull, @@ -100,20 +110,62 @@ impl From for io::Error { io::ErrorKind::InvalidInput, translate!("safe-traversal-error-path-contains-null"), ), - SafeTraversalError::OpenFailed { source, .. } => source, - SafeTraversalError::StatFailed { source, .. } => source, - SafeTraversalError::ReadDirFailed { source, .. } => source, - SafeTraversalError::UnlinkFailed { source, .. } => source, + SafeTraversalError::OpenFailed { source, .. } + | SafeTraversalError::StatFailed { source, .. } + | SafeTraversalError::ReadDirFailed { source, .. } + | SafeTraversalError::UnlinkFailed { source, .. } => source, } } } -// Helper function to read directory entries using nix -fn read_dir_entries(fd: &OwnedFd) -> io::Result> { +/// Use rustix's `Stat` on Linux and Android, whose inode and size fields +/// are 64 bits wide on every architecture, including the 32-bit targets +/// where the plain libc entry points overflow them. Use nix's `FileStat` +/// elsewhere. +#[cfg(any(target_os = "linux", target_os = "android"))] +pub type FileStat = rustix::fs::Stat; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub use nix::sys::stat::FileStat; + +// RawDir refills this buffer as needed; 8 KiB follows rustix's examples. +#[cfg(any(target_os = "linux", target_os = "android"))] +const READ_DIR_BUF_SIZE: usize = 8192; + +// Helper function to read directory entries using rustix. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn read_dir_entries(fd: impl AsFd) -> io::Result> { + let mut buffer = [MaybeUninit::::uninit(); READ_DIR_BUF_SIZE]; + let mut dir = RawDir::new(fd.as_fd(), &mut buffer); + let mut entries = Vec::new(); + + let result = loop { + match dir.next() { + Some(Ok(entry)) => { + let name = OsStr::from_bytes(entry.file_name().to_bytes()); + if name != "." && name != ".." { + entries.push(name.to_os_string()); + } + } + Some(Err(e)) => break Err(e), + None => break Ok(()), + } + }; + + // Rewind directory position. + let rewind = rustix::fs::seek(fd, rustix::fs::SeekFrom::Start(0)); + result?; + rewind?; + Ok(entries) +} + +// Helper function to read directory entries using nix. +#[cfg(not(any(target_os = "linux", target_os = "android")))] +fn read_dir_entries(fd: impl AsFd) -> io::Result> { let mut entries = Vec::new(); // 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 dup_fd = + nix::unistd::dup(fd.as_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))?; @@ -127,11 +179,58 @@ fn read_dir_entries(fd: &OwnedFd) -> io::Result> { Ok(entries) } +fn fstatat_fd( + fd: impl AsFd, + name: &CStr, + symlink_behavior: SymlinkBehavior, +) -> io::Result { + #[cfg(any(target_os = "linux", target_os = "android"))] + { + let flags = if symlink_behavior.should_follow() { + rustix::fs::AtFlags::empty() + } else { + rustix::fs::AtFlags::SYMLINK_NOFOLLOW + }; + statat(fd, name, flags).map_err(Into::into) + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + let flags = if symlink_behavior.should_follow() { + nix::fcntl::AtFlags::empty() + } else { + nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW + }; + fstatat(fd, name, flags).map_err(|e| io::Error::from_raw_os_error(e as i32)) + } +} + +fn fstat_fd(fd: impl AsFd) -> io::Result { + #[cfg(any(target_os = "linux", target_os = "android"))] + { + fstat(fd).map_err(Into::into) + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + nix::sys::stat::fstat(fd).map_err(|e| io::Error::from_raw_os_error(e as i32)) + } +} + /// A directory file descriptor that enables safe traversal pub struct DirFd { fd: OwnedFd, } +/// `O_LARGEFILE`, or nothing where the platform has no such flag. +/// +/// The libc `open`/`openat` bindings do not add it the way glibc's large-file +/// entry points do, so on 32-bit Linux the kernel rejects anything whose +/// metadata overflows the 32-bit ranges with `EOVERFLOW`. The flag is 0 on +/// 64-bit, so adding it unconditionally costs nothing there. +#[cfg(any(target_os = "linux", target_os = "android"))] +const LARGEFILE: OFlag = OFlag::O_LARGEFILE; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +const LARGEFILE: OFlag = OFlag::empty(); + impl DirFd { /// Open a directory and return a file descriptor /// @@ -139,7 +238,7 @@ impl DirFd { /// * `path` - The path to the directory to open /// * `symlink_behavior` - Whether to follow symlinks when opening pub fn open(path: &Path, symlink_behavior: SymlinkBehavior) -> io::Result { - let mut flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC; + let mut flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC | LARGEFILE; if !symlink_behavior.should_follow() { flags |= OFlag::O_NOFOLLOW; } @@ -160,7 +259,7 @@ impl DirFd { pub fn open_subdir(&self, name: &OsStr, symlink_behavior: SymlinkBehavior) -> io::Result { let name_cstr = CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?; - let mut flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC; + let mut flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC | LARGEFILE; if !symlink_behavior.should_follow() { flags |= OFlag::O_NOFOLLOW; } @@ -178,16 +277,10 @@ impl DirFd { let name_cstr = CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?; - let flags = if symlink_behavior.should_follow() { - nix::fcntl::AtFlags::empty() - } else { - nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW - }; - - let stat = fstatat(&self.fd, name_cstr.as_c_str(), flags).map_err(|e| { + let stat = fstatat_fd(&self.fd, name_cstr.as_c_str(), symlink_behavior).map_err(|e| { SafeTraversalError::StatFailed { path: name.into(), - source: io::Error::from_raw_os_error(e as i32), + source: e, } })?; @@ -211,9 +304,9 @@ impl DirFd { /// Get raw stat data for this directory pub fn fstat(&self) -> io::Result { - let stat = nix::sys::stat::fstat(&self.fd).map_err(|e| SafeTraversalError::StatFailed { + let stat = fstat_fd(&self.fd).map_err(|e| SafeTraversalError::StatFailed { path: translate!("safe-traversal-current-directory").into(), - source: io::Error::from_raw_os_error(e as i32), + source: e, })?; Ok(stat) } @@ -287,6 +380,10 @@ impl DirFd { } /// Change mode of a file relative to this directory + /// + /// Goes through the libc `fchmodat()` symbol, which `LD_PRELOAD` tools + /// (fakeroot, fakechroot, pseudo) interpose and a raw syscall would bypass. + /// glibc issues `fchmodat2` from there anyway, so nothing is lost. pub fn chmod_at( &self, name: &OsStr, @@ -296,7 +393,38 @@ impl DirFd { let name_cstr = CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?; - // --- fchmodat2 path (Linux 6.6+, asm-generic arches only) --- + let flags = if symlink_behavior.should_follow() { + FchmodatFlags::FollowSymlink + } else { + FchmodatFlags::NoFollowSymlink + }; + + // nix rather than rustix: rustix defaults to its linux_raw backend, so + // its `chmod` is a raw syscall that no LD_PRELOAD wrapper can see. + // A libc that cannot honor AT_SYMLINK_NOFOLLOW reports it rather than + // following the symlink, so falling back on that error is safe. + // Only the non-Linux tail below consumes this; on Linux the O_PATH + // fallback takes over instead. + #[cfg_attr(target_os = "linux", allow(unused_variables))] + let libc_err = match fchmodat( + &self.fd, + name_cstr.as_c_str(), + Mode::from_bits_truncate(mode as libc::mode_t), + flags, + ) { + Ok(()) => return Ok(()), + Err(e) + if !symlink_behavior.should_follow() + && (e == nix::errno::Errno::ENOSYS + || e == nix::errno::Errno::EOPNOTSUPP + || e == nix::errno::Errno::ENOTSUP) => + { + io::Error::from_raw_os_error(e as i32) + } + Err(e) => return Err(io::Error::from_raw_os_error(e as i32)), + }; + + // --- fchmodat2 fallback (Linux 6.6+, asm-generic arches only) --- // Uses the raw mode value directly; no nix::Mode conversion needed. // Only enabled on asm-generic architectures where syscall number 452 is // correct (x86_64, x86, arm, aarch64, riscv). MIPS/SPARC/PowerPC/Alpha @@ -313,7 +441,7 @@ impl DirFd { target_arch = "riscv32", ), ))] - if matches!(symlink_behavior, SymlinkBehavior::NoFollow) { + { use std::sync::atomic::{AtomicBool, Ordering}; // Cache: if fchmodat2 returned ENOSYS once, the kernel is too old @@ -322,7 +450,7 @@ impl DirFd { if !FCHMODAT2_UNAVAILABLE.load(Ordering::Relaxed) { // Syscall number for fchmodat2 on asm-generic architectures. - const SYS_FCHMODAT2: libc::c_long = 452; + const SYS_FCHMODAT2: core::ffi::c_long = 452; // SAFETY: syscall(2) is an FFI call. We pass valid arguments: // - fd: valid open file descriptor // - name: valid C string pointer (name_cstr lives for the duration) @@ -344,42 +472,20 @@ impl DirFd { match err.raw_os_error() { Some(libc::ENOSYS) => { FCHMODAT2_UNAVAILABLE.store(true, Ordering::Relaxed); - // Fall through to fchmodat + // Fall through to the O_PATH fallback } _ => return Err(err), } } } - // --- fchmodat fallback path --- - // nix::Mode conversion is needed here because fchmodat() requires it. - let nix_mode = Mode::from_bits_truncate(mode as libc::mode_t); - - let flags = if symlink_behavior.should_follow() { - FchmodatFlags::FollowSymlink - } else { - FchmodatFlags::NoFollowSymlink - }; - - match fchmodat(&self.fd, name_cstr.as_c_str(), nix_mode, flags) { - Ok(()) => Ok(()), - Err(e) - if !symlink_behavior.should_follow() - && (e == nix::errno::Errno::EOPNOTSUPP || e == nix::errno::Errno::ENOTSUP) => - { - // musl does not emulate AT_SYMLINK_NOFOLLOW via /proc/self/fd - // like glibc does, so fchmodat returns EOPNOTSUPP on old kernels. - // Fall back to O_PATH + /proc/self/fd/{fd} + fchmod. - #[cfg(target_os = "linux")] - { - self.chmod_at_via_opath(name_cstr.as_c_str(), mode) - } - #[cfg(not(target_os = "linux"))] - { - Err(io::Error::from_raw_os_error(e as i32)) - } - } - Err(e) => Err(io::Error::from_raw_os_error(e as i32)), + #[cfg(target_os = "linux")] + { + self.chmod_at_via_opath(name_cstr.as_c_str(), mode) + } + #[cfg(not(target_os = "linux"))] + { + Err(libc_err) } } @@ -391,23 +497,24 @@ impl DirFd { /// race because the fd pins the inode. /// #[cfg(target_os = "linux")] - fn chmod_at_via_opath(&self, name: &std::ffi::CStr, mode: u32) -> io::Result<()> { - use rustix::fs::{Mode, OFlags, chmod, openat}; + fn chmod_at_via_opath(&self, name: &CStr, mode: u32) -> io::Result<()> { + // Same reason as in chmod_at: rustix's linux_raw backend would make + // these raw syscalls, invisible to LD_PRELOAD wrappers. + use std::os::unix::fs::PermissionsExt; let fd = openat( &self.fd, name, - OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, + OFlag::O_PATH | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC, Mode::empty(), ) - .map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))?; + .map_err(|e| io::Error::from_raw_os_error(e as i32))?; - let proc_path = format!("/proc/self/fd/{}\0", fd.as_raw_fd()); - let proc_cstr = std::ffi::CStr::from_bytes_with_nul(proc_path.as_bytes()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid proc path"))?; - - chmod(proc_cstr, Mode::from_bits_truncate(mode)) - .map_err(|e| io::Error::from_raw_os_error(e.raw_os_error())) + // set_permissions goes through the libc chmod() symbol. + fs::set_permissions( + format!("/proc/self/fd/{}", fd.as_raw_fd()), + fs::Permissions::from_mode(mode), + ) } /// Change mode of this directory @@ -437,13 +544,25 @@ impl DirFd { Ok(()) } - /// Open a file for writing relative to this directory - /// Creates the file if it doesn't exist, truncates if it does + /// Create a file for writing relative to this directory + /// Fails with `EEXIST` if the name already exists pub fn open_file_at(&self, name: &OsStr) -> io::Result { let name_cstr = CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?; - let flags = OFlag::O_CREAT | OFlag::O_WRONLY | OFlag::O_TRUNC | OFlag::O_CLOEXEC; - let mode = Mode::from_bits_truncate(0o666); // Default file permissions + // Callers reach here right after unlinking `name`, which is precisely + // the window an attacker races. O_NOFOLLOW alone only refuses a symlink + // planted in it; a hard link to a victim file would still be opened and + // truncated, then chowned and chmoded by the caller's finalization. + // O_EXCL refuses both. Mode 0o600 rather than the umask-derived 0o666, + // so no other user can read the content while it is being written — + // same reasoning as `safe_copy::DEST_INITIAL_MODE` (issue #10011). + let flags = OFlag::O_CREAT + | OFlag::O_EXCL + | OFlag::O_WRONLY + | OFlag::O_CLOEXEC + | OFlag::O_NOFOLLOW + | LARGEFILE; + let mode = Mode::from_bits_truncate(0o600); let fd: OwnedFd = openat(self.fd.as_fd(), name_cstr.as_c_str(), flags, mode) .map_err(|e| io::Error::from_raw_os_error(e as i32))?; @@ -568,10 +687,16 @@ fn open_or_create_subdir(parent_fd: &DirFd, name: &OsStr, mode: u32) -> io::Resu )), } } - Err(e) if e.kind() == io::ErrorKind::NotFound => { - parent_fd.mkdir_at(name, mode)?; - parent_fd.open_subdir(name, SymlinkBehavior::NoFollow) - } + Err(e) if e.kind() == io::ErrorKind::NotFound => match parent_fd.mkdir_at(name, mode) { + Ok(()) => parent_fd.open_subdir(name, SymlinkBehavior::NoFollow), + // Another process created `name` between the stat and the mkdir + // (issue #12355). Open what it created; O_NOFOLLOW keeps a symlink + // that raced into the name from being followed. + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + parent_fd.open_subdir(name, SymlinkBehavior::NoFollow) + } + Err(e) => Err(e), + }, Err(e) => Err(e), } } @@ -634,7 +759,7 @@ pub struct FileInfo { } impl FileInfo { - pub fn from_stat(stat: &libc::stat) -> Self { + pub fn from_stat(stat: &FileStat) -> Self { // Allow unnecessary cast because st_dev and st_ino have different types on different platforms #[allow(clippy::unnecessary_cast)] Self { @@ -742,10 +867,31 @@ impl Metadata { pub fn is_empty(&self) -> bool { self.len() == 0 } + + fn time_from_secs_nsecs(secs: i64, nsecs: i64) -> Option { + use std::time::{Duration, UNIX_EPOCH}; + if secs >= 0 { + UNIX_EPOCH.checked_add(Duration::new(secs as u64, nsecs as u32)) + } else { + UNIX_EPOCH.checked_sub(Duration::new((-secs) as u64, 0)) + } + } + + pub fn modified(&self) -> Option { + Self::time_from_secs_nsecs(self.mtime(), self.mtime_nsec()) + } + + pub fn accessed(&self) -> Option { + Self::time_from_secs_nsecs(self.atime(), self.atime_nsec()) + } + + pub fn changed(&self) -> Option { + Self::time_from_secs_nsecs(self.ctime(), self.ctime_nsec()) + } } // Add MetadataExt trait implementation for compatibility -impl std::os::unix::fs::MetadataExt for Metadata { +impl MetadataExt for Metadata { // st_dev type varies by platform (i32 on macOS, u64 on Linux) #[allow(clippy::unnecessary_cast)] fn dev(&self) -> u64 { @@ -795,92 +941,92 @@ impl std::os::unix::fs::MetadataExt for Metadata { } fn atime(&self) -> i64 { - #[cfg(target_pointer_width = "32")] + // aix and hurd only have the timespec fields + #[cfg(any(target_os = "aix", target_os = "hurd"))] { - self.stat.st_atime.into() + self.stat.st_atim.tv_sec as _ } - #[cfg(not(target_pointer_width = "32"))] + #[cfg(not(any(target_os = "aix", target_os = "hurd")))] { - self.stat.st_atime + #[allow(clippy::useless_conversion)] + self.stat.st_atime.into() } } fn atime_nsec(&self) -> i64 { #[cfg(target_os = "netbsd")] { - self.stat.st_atimensec + #[allow(clippy::unnecessary_cast)] + (self.stat.st_atimensec as i64) } - #[cfg(not(target_os = "netbsd"))] + #[cfg(any(target_os = "aix", target_os = "hurd"))] { - #[cfg(target_pointer_width = "32")] - { - self.stat.st_atime_nsec.into() - } - #[cfg(not(target_pointer_width = "32"))] - { - self.stat.st_atime_nsec - } + self.stat.st_atim.tv_nsec as _ + } + + #[cfg(not(any(target_os = "netbsd", target_os = "aix", target_os = "hurd")))] + { + #[allow(clippy::unnecessary_cast)] + (self.stat.st_atime_nsec as i64) } } fn mtime(&self) -> i64 { - #[cfg(target_pointer_width = "32")] + #[cfg(any(target_os = "aix", target_os = "hurd"))] { - self.stat.st_mtime.into() + self.stat.st_mtim.tv_sec as _ } - #[cfg(not(target_pointer_width = "32"))] + #[cfg(not(any(target_os = "aix", target_os = "hurd")))] { - self.stat.st_mtime + #[allow(clippy::useless_conversion)] + self.stat.st_mtime.into() } } fn mtime_nsec(&self) -> i64 { #[cfg(target_os = "netbsd")] { - self.stat.st_mtimensec + #[allow(clippy::unnecessary_cast)] + (self.stat.st_mtimensec as i64) } - - #[cfg(not(target_os = "netbsd"))] + #[cfg(any(target_os = "aix", target_os = "hurd"))] { - #[cfg(target_pointer_width = "32")] - { - self.stat.st_mtime_nsec.into() - } - #[cfg(not(target_pointer_width = "32"))] - { - self.stat.st_mtime_nsec - } + self.stat.st_mtim.tv_nsec as _ + } + #[cfg(not(any(target_os = "netbsd", target_os = "aix", target_os = "hurd")))] + { + #[allow(clippy::unnecessary_cast)] + (self.stat.st_mtime_nsec as i64) } } fn ctime(&self) -> i64 { - #[cfg(target_pointer_width = "32")] + #[cfg(any(target_os = "aix", target_os = "hurd"))] { - self.stat.st_ctime.into() + self.stat.st_ctim.tv_sec as _ } - #[cfg(not(target_pointer_width = "32"))] + #[cfg(not(any(target_os = "aix", target_os = "hurd")))] { - self.stat.st_ctime + #[allow(clippy::useless_conversion)] + self.stat.st_ctime.into() } } fn ctime_nsec(&self) -> i64 { #[cfg(target_os = "netbsd")] { - self.stat.st_ctimensec + #[allow(clippy::unnecessary_cast)] + (self.stat.st_ctimensec as i64) } - - #[cfg(not(target_os = "netbsd"))] + #[cfg(any(target_os = "aix", target_os = "hurd"))] { - #[cfg(target_pointer_width = "32")] - { - self.stat.st_ctime_nsec.into() - } - #[cfg(not(target_pointer_width = "32"))] - { - self.stat.st_ctime_nsec - } + self.stat.st_ctim.tv_nsec as _ + } + #[cfg(not(any(target_os = "netbsd", target_os = "aix", target_os = "hurd")))] + { + #[allow(clippy::unnecessary_cast)] + (self.stat.st_ctime_nsec as i64) } } @@ -977,6 +1123,33 @@ mod tests { ); } + /// A directory fd must end up with the same open-file status flags as one + /// `std::fs::File` would produce, `O_LARGEFILE` included. On 32-bit Linux a + /// missing `O_LARGEFILE` makes the kernel reject directories whose metadata + /// overflows the 32-bit ranges with EOVERFLOW. + #[test] + #[cfg(any(target_os = "linux", target_os = "android"))] + fn test_dirfd_open_status_flags_match_std() { + use nix::fcntl::{FcntlArg, fcntl}; + + let temp_dir = TempDir::new().unwrap(); + fs::create_dir(temp_dir.path().join("nested")).unwrap(); + + let reference = fs::File::open(temp_dir.path().join("nested")).unwrap(); + // F_GETFL reports O_DIRECTORY back for a directory fd; std opens the + // same directory as a plain file, so add it to the expected flags. + let expected = fcntl(&reference, FcntlArg::F_GETFL).unwrap() | libc::O_DIRECTORY; + + let parent = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap(); + let direct = DirFd::open(&temp_dir.path().join("nested"), SymlinkBehavior::Follow).unwrap(); + let sub = parent + .open_subdir(OsStr::new("nested"), SymlinkBehavior::Follow) + .unwrap(); + + assert_eq!(fcntl(&direct.fd, FcntlArg::F_GETFL).unwrap(), expected); + assert_eq!(fcntl(&sub.fd, FcntlArg::F_GETFL).unwrap(), expected); + } + #[test] fn test_dirfd_open_nonexistent_subdir() { let temp_dir = TempDir::new().unwrap(); @@ -1025,6 +1198,25 @@ mod tests { assert_eq!(stat_nofollow.st_mode & libc::S_IFMT, libc::S_IFLNK); } + /// A size of 2^31 overflows the plain 32-bit stat flavor, so this + /// fails on 32-bit targets unless the wide one is used. + #[test] + #[allow(clippy::unnecessary_cast)] + fn test_dirfd_stat_and_fstat_large_file() { + let temp_dir = TempDir::new().unwrap(); + let file = fs::File::create(temp_dir.path().join("large")).unwrap(); + let overflow_size = 1u64 << 31; + file.set_len(overflow_size).unwrap(); + let fstat_size = fstat_fd(file).unwrap().st_size as u64; + assert_eq!(fstat_size, overflow_size); + + let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap(); + let stat = dir_fd + .stat_at(OsStr::new("large"), SymlinkBehavior::Follow) + .unwrap(); + assert_eq!(stat.st_size as u64, overflow_size); + } + #[test] fn test_dirfd_fstat() { let temp_dir = TempDir::new().unwrap(); @@ -1049,6 +1241,31 @@ mod tests { assert_eq!(entries.len(), 2); assert!(entries.contains(&OsString::from("file1"))); assert!(entries.contains(&OsString::from("file2"))); + + let entries_again = dir_fd.read_dir().unwrap(); + assert_eq!(entries_again.len(), 2); + assert!(entries_again.contains(&OsString::from("file1"))); + assert!(entries_again.contains(&OsString::from("file2"))); + } + + #[test] + fn test_dirfd_read_dir_refills_buffer() { + let temp_dir = TempDir::new().unwrap(); + // Enough entries of enough length that a single 8 KiB getdents64 + // batch cannot hold them all, so the buffer-refill path is covered. + let expected: std::collections::HashSet = (0..500) + .map(|i| { + let name = format!("entry_with_a_rather_long_name_{i:03}"); + fs::write(temp_dir.path().join(&name), "x").unwrap(); + OsString::from(name) + }) + .collect(); + + let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap(); + let entries: std::collections::HashSet = + dir_fd.read_dir().unwrap().into_iter().collect(); + + assert_eq!(entries, expected); } #[test] @@ -1188,6 +1405,9 @@ mod tests { if let Err(e) = result { // Should be InvalidInput for null byte error assert_eq!(e.kind(), io::ErrorKind::InvalidInput); + // Reported as PathContainsNull, not as a raw EINVAL coming back + // from the openat call itself. + assert_eq!(e.raw_os_error(), None); } } @@ -1246,20 +1466,45 @@ mod tests { } #[test] - fn test_open_file_at_truncates_existing() { - use std::io::Write; - + fn test_open_file_at_refuses_existing() { + // Including a hard link an attacker planted after the caller's unlink: + // without O_EXCL the victim would be truncated and overwritten. let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("existing.txt"); - fs::write(&file_path, "old content that is longer").unwrap(); + let victim = temp_dir.path().join("victim"); + fs::write(&victim, "SECRET").unwrap(); + fs::hard_link(&victim, temp_dir.path().join("planted")).unwrap(); let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap(); - let mut file = dir_fd.open_file_at(OsStr::new("existing.txt")).unwrap(); - file.write_all(b"new").unwrap(); - drop(file); + let err = dir_fd.open_file_at(OsStr::new("planted")).unwrap_err(); - let content = fs::read_to_string(&file_path).unwrap(); - assert_eq!(content, "new"); + assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read_to_string(&victim).unwrap(), "SECRET"); + } + + #[test] + fn test_open_file_at_uses_restrictive_mode() { + let temp_dir = TempDir::new().unwrap(); + let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap(); + + let file = dir_fd.open_file_at(OsStr::new("secret")).unwrap(); + assert_eq!(file.metadata().unwrap().mode() & 0o777, 0o600); + } + + #[test] + fn test_open_file_at_refuses_symlink() { + // A symlink planted at the final component must not be followed: + // otherwise the O_TRUNC would destroy the link's target. + let temp_dir = TempDir::new().unwrap(); + let victim = temp_dir.path().join("victim"); + fs::write(&victim, "SECRET").unwrap(); + symlink(&victim, temp_dir.path().join("link")).unwrap(); + + let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap(); + assert!( + dir_fd.open_file_at(OsStr::new("link")).is_err(), + "open_file_at followed a symlink at the final component" + ); + assert_eq!(fs::read_to_string(&victim).unwrap(), "SECRET"); } #[test] diff --git a/src/uucore/src/lib/features/selinux.rs b/src/uucore/src/lib/features/selinux.rs index b6a69dc9ec4..9f939a45adf 100644 --- a/src/uucore/src/lib/features/selinux.rs +++ b/src/uucore/src/lib/features/selinux.rs @@ -3,32 +3,35 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore defaultcon setfscreatecon + //! Set of functions to manage SELinux security contexts +use std::borrow::Cow; use std::error::Error; +use std::marker::PhantomData; use std::path::Path; use crate::translate; use selinux::SecurityContext; -use thiserror::Error; use crate::error::UError; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum SeLinuxError { #[error("{}", translate!("selinux-error-not-enabled"))] SELinuxNotEnabled, - #[error("{}", translate!("selinux-error-file-open-failure", "error" => .0.clone()))] + #[error("{}", translate!("selinux-error-file-open-failure", "error" => .0))] FileOpenFailure(String), - #[error("{}", translate!("selinux-error-context-retrieval-failure", "error" => .0.clone()))] + #[error("{}", translate!("selinux-error-context-retrieval-failure", "error" => .0))] ContextRetrievalFailure(String), - #[error("{}", translate!("selinux-error-context-set-failure", "context" => .0.clone(), "error" => .1.clone()))] + #[error("{}", translate!("selinux-error-context-set-failure", "context" => .0, "error" => .1))] ContextSetFailure(String, String), - #[error("{}", translate!("selinux-error-context-conversion-failure", "context" => .0.clone(), "error" => .1.clone()))] + #[error("{}", translate!("selinux-error-context-conversion-failure", "context" => .0, "error" => .1))] ContextConversionFailure(String, String), #[error("{}", translate!("selinux-error-operation-not-supported"))] @@ -62,6 +65,127 @@ pub fn is_selinux_enabled() -> bool { selinux::kernel_support() != selinux::KernelSupport::Unsupported } +/// Sets the context the kernel applies to objects created by this thread, and +/// restores the policy default when dropped (`setfscreatecon`). +/// +/// Unlike [`set_selinux_security_context`], which relabels after creating, the +/// object is never visible with the wrong context. Not [`Send`]: the context is +/// per-thread state. +/// +/// # Examples +/// +/// ```no_run +/// use std::path::Path; +/// use uucore::selinux::FsCreateContext; +/// +/// let context = String::from("unconfined_u:object_r:user_home_t:s0"); +/// let _guard = FsCreateContext::new(Path::new("/path/to/fifo"), None, Some(&context))?; +/// // Anything created from here on is labelled at creation time. +/// # Ok::<(), uucore::selinux::SeLinuxError>(()) +/// ``` +#[must_use = "the creation context is restored as soon as the guard is dropped"] +pub struct FsCreateContext { + // False when no default context existed, so there is nothing to restore. + installed: bool, + _not_send: PhantomData<*const ()>, +} + +/// GNU creates the object unlabelled rather than failing when the policy has no +/// default for the path, or the filesystem cannot hold one (`ignorable_ctx_err`). +fn is_ignorable_context_error(error: &(dyn Error + 'static)) -> bool { + let mut current = Some(error); + while let Some(err) = current { + if let Some(io_error) = err.downcast_ref::() { + return matches!( + io_error.raw_os_error(), + Some(libc::ENOENT | libc::ENODATA | libc::ENOTSUP) + ); + } + current = err.source(); + } + false +} + +impl FsCreateContext { + /// Installs the creation context. + /// + /// `context` is an explicit `--context=CTX` value. `None` asks the policy + /// for the default context of `path`, which is what a bare `-Z` does; + /// `mode` refines that lookup with the type and permission bits of the + /// object about to be created, and may be `None` when they are not known. + pub fn new( + path: &Path, + mode: Option, + context: Option<&String>, + ) -> Result { + if !is_selinux_enabled() { + return Err(SeLinuxError::SELinuxNotEnabled); + } + + if let Some(ctx_str) = context { + let c_context = std::ffi::CString::new(ctx_str.as_str()).map_err(|e| { + SeLinuxError::ContextConversionFailure( + ctx_str.to_owned(), + selinux_error_description(&e), + ) + })?; + + SecurityContext::from_c_str(&c_context, false) + .set_for_new_file_system_objects(false) + .map_err(|e| { + SeLinuxError::ContextSetFailure( + ctx_str.to_owned(), + selinux_error_description(&e), + ) + })?; + } else { + // Mirror GNU's `defaultcon`. + let labeler = + selinux::label::Labeler::::new(&[], false) + .map_err(|e| { + SeLinuxError::ContextRetrievalFailure(selinux_error_description(&e)) + })?; + + let default_context = + match labeler.look_up_by_path(path, mode.and_then(selinux::FileAccessMode::new)) { + Ok(context) => context, + Err(e) if is_ignorable_context_error(&e) => { + return Ok(Self { + installed: false, + _not_send: PhantomData, + }); + } + Err(e) => { + return Err(SeLinuxError::ContextRetrievalFailure( + selinux_error_description(&e), + )); + } + }; + + default_context + .set_for_new_file_system_objects(false) + .map_err(|e| { + SeLinuxError::ContextSetFailure(String::new(), selinux_error_description(&e)) + })?; + } + + Ok(Self { + installed: true, + _not_send: PhantomData, + }) + } +} + +impl Drop for FsCreateContext { + fn drop(&mut self) { + if !self.installed { + return; + } + // A failed reset is not actionable here. + let _ = SecurityContext::set_default_context_for_new_file_system_objects(); + } +} + /// Returns a string describing the error and its causes. pub fn selinux_error_description(mut error: &dyn Error) -> String { let mut description = String::new(); @@ -168,7 +292,8 @@ pub fn set_selinux_security_context( }) } else { // If no context provided, set the default SELinux context for the path - SecurityContext::set_default_for_path(path).map_err(|e| match &e { + let path = absolute_for_policy_lookup(path); + SecurityContext::set_default_for_path(&path).map_err(|e| match &e { selinux::errors::Error::IO1Path { source, .. } if source.raw_os_error() == Some(libc::ENOTSUP) => { @@ -179,6 +304,35 @@ pub fn set_selinux_security_context( } } +/// Returns `path` as an absolute name, for the default context lookup. +/// +/// The policy's `file_contexts` only ever holds absolute names, so a relative +/// one matches nothing -- and libselinux reports the miss as success, leaving +/// the object with the context it already carried. +fn absolute_for_policy_lookup(path: &Path) -> Cow<'_, Path> { + if path.is_absolute() { + return Cow::Borrowed(path); + } + // Resolve the directory only, so that `.` and `..` do not reach the policy + // patterns; the last component is labelled itself, symbolic link or not. + let resolved = match (path.parent(), path.file_name()) { + (Some(directory), Some(name)) => { + let directory = if directory.as_os_str().is_empty() { + Path::new(".") + } else { + directory + }; + std::fs::canonicalize(directory).map(|directory| directory.join(name)) + } + // No last component to keep: the name ends in `.` or `..`, which names + // a directory and never a symbolic link, so resolve the whole of it. + // A lexical absolute name would keep those components and match no + // pattern. + _ => std::fs::canonicalize(path), + }; + resolved.map_or(Cow::Borrowed(path), Cow::Owned) +} + /// Gets the SELinux security context for the given filesystem path. /// /// Retrieves the security context of the specified filesystem path if SELinux is enabled @@ -410,6 +564,32 @@ mod tests { use super::*; use tempfile::NamedTempFile; + #[test] + fn test_absolute_for_policy_lookup() { + let directory = tempfile::tempdir().expect("Failed to create tempdir"); + let directory = std::fs::canonicalize(directory.path()).expect("Failed to canonicalize"); + std::fs::create_dir(directory.join("branch")).expect("Failed to create dir"); + let previous = std::env::current_dir().expect("Failed to read the working directory"); + std::env::set_current_dir(&directory).expect("Failed to change the working directory"); + + // An absolute name is handed to the policy untouched. + let untouched = Path::new("/opt/quokka"); + assert_eq!(absolute_for_policy_lookup(untouched), untouched); + + // A relative one becomes absolute, and `.`/`..` never reach the policy. + for (given, expected) in [ + ("quokka", directory.join("quokka")), + ("./quokka", directory.join("quokka")), + ("branch/../quokka", directory.join("quokka")), + ("branch/..", directory.clone()), + (".", directory.clone()), + ] { + assert_eq!(absolute_for_policy_lookup(Path::new(given)), expected); + } + + std::env::set_current_dir(previous).expect("Failed to restore the working directory"); + } + #[test] fn test_selinux_context_setting() { let tmpfile = NamedTempFile::new().expect("Failed to create tempfile"); diff --git a/src/uucore/src/lib/features/signals.rs b/src/uucore/src/lib/features/signals.rs index 0bde7d9aa33..b6482667e30 100644 --- a/src/uucore/src/lib/features/signals.rs +++ b/src/uucore/src/lib/features/signals.rs @@ -3,8 +3,8 @@ // 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 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 +// spell-checker:ignore (vars/api) fcntl setrlimit setitimer rubout pollable sysconf pgrp GETFD pfds revents POLLRDBAND POLLERR unblockable +// 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 SIGSIGTERM SIGSIGKILL //! This module provides a way to handle signals in a platform-independent way. //! It provides a way to convert signal names to their corresponding values and vice versa. @@ -92,6 +92,53 @@ pub static ALL_SIGNALS: [&str; 32] = [ "XCPU", "XFSZ", "VTALRM", "PROF", "WINCH", "INFO", "USR1", "USR2", ]; +/* + + The following signals are defined in GNU/Hurd: + // https://github.com/sailfishos-mirror/glibc/blob/glibc-2.35/bits/signum-generic.h + // https://github.com/sailfishos-mirror/glibc/blob/glibc-2.35/sysdeps/mach/hurd/bits/signum-arch.h + + SIGHUP 1 Hangup. + SIGINT 2 Interactive attention signal. + SIGQUIT 3 Quit. + SIGILL 4 Illegal instruction. + SIGTRAP 5 Trace/breakpoint trap. + SIGABRT 6 Abnormal termination. + SIGEMT 7 Emulator trap (4.2 BSD). + SIGFPE 8 Erroneous arithmetic operation. + SIGKILL 9 Killed. + SIGBUS 10 Bus error. + SIGSEGV 11 Invalid access to storage. + SIGSYS 12 Bad system call. + SIGPIPE 13 Broken pipe. + SIGALRM 14 Alarm clock. + SIGTERM 15 Termination request. + SIGURG 16 Urgent data is available at a socket. + SIGSTOP 17 Stop, unblockable. + SIGTSTP 18 Keyboard stop. + SIGCONT 19 Continue. + SIGCHLD 20 Child terminated or stopped. + SIGTTIN 21 Background read from control terminal. + SIGTTOU 22 Background write to control terminal. + SIGPOLL 23 Pollable event occurred (System V). + SIGXCPU 24 CPU time limit exceeded. + SIGXFSZ 25 File size limit exceeded. + SIGVTALRM 26 Virtual timer expired. + SIGPROF 27 Profiling timer expired. + SIGWINCH 28 Window size change (4.3 BSD, Sun). + SIGINFO 29 Information request (4.4 BSD). + SIGUSR1 30 User-defined signal 1. + SIGUSR2 31 User-defined signal 2. + SIGLOST 32 Resource lost (Sun); server died (GNU). +*/ + +#[cfg(target_os = "hurd")] +pub static ALL_SIGNALS: [&str; 33] = [ + "EXIT", "HUP", "INT", "QUIT", "ILL", "TRAP", "ABRT", "EMT", "FPE", "KILL", "BUS", "SEGV", + "SYS", "PIPE", "ALRM", "TERM", "URG", "STOP", "TSTP", "CONT", "CHLD", "TTIN", "TTOU", "POLL", + "XCPU", "XFSZ", "VTALRM", "PROF", "WINCH", "INFO", "USR1", "USR2", "LOST", +]; + /* The following signals are defined in NetBSD: @@ -392,15 +439,27 @@ pub static ALL_SIGNALS: [&str; 32] = [ "XCPU", "XFSZ", "VTALRM", "PROF", "WINCH", "PWR", "USR1", "USR2", ]; +// Windows has no POSIX signals, but utilities that emulate signal delivery +// (e.g. `timeout`) accept the POSIX names/numbers so cross-platform scripts +// keep working. The Linux layout is used so that synthesized `128 + n` exit +// codes (130, 137, 143, ...) match what such scripts expect. +#[cfg(windows)] +pub static ALL_SIGNALS: [&str; 32] = [ + "EXIT", "HUP", "INT", "QUIT", "ILL", "TRAP", "ABRT", "BUS", "FPE", "KILL", "USR1", "SEGV", + "USR2", "PIPE", "ALRM", "TERM", "STKFLT", "CHLD", "CONT", "STOP", "TSTP", "TTIN", "TTOU", + "URG", "XCPU", "XFSZ", "VTALRM", "PROF", "WINCH", "POLL", "PWR", "SYS", +]; + /// Returns the signal number for a given signal name or value. pub fn signal_by_name_or_value(signal_name_or_value: &str) -> Option { let signal_name_upcase = signal_name_or_value.to_uppercase(); - if let Ok(value) = signal_name_upcase.parse() { - if is_signal(value) { - return Some(value); - } + if let Some(value) = signal_name_upcase.parse().ok().filter(|&v| is_signal(v)) { + return Some(value); } - let signal_name = signal_name_upcase.trim_start_matches("SIG"); + + let signal_name = signal_name_upcase + .strip_prefix("SIG") + .unwrap_or(&signal_name_upcase); if let Some(pos) = ALL_SIGNALS.iter().position(|&s| s == signal_name) { return Some(pos); @@ -411,18 +470,18 @@ pub fn signal_by_name_or_value(signal_name_or_value: &str) -> Option { } realtime_signal_bounds().and_then(|(rtmin, rtmax)| { - if signal_name.starts_with("RTMIN+") { - if let Ok(n) = signal_name.trim_start_matches("RTMIN+").parse::() { - let value = rtmin + n; - return (value >= rtmin && value <= rtmax).then_some(value); - } + if let Some(s) = signal_name.strip_prefix("RTMIN+") + && let Ok(n) = s.parse::() + { + let value = rtmin + n; + return (value >= rtmin && value <= rtmax).then_some(value); } - if signal_name.starts_with("RTMAX-") { - if let Ok(n) = signal_name.trim_start_matches("RTMAX-").parse::() { - let value = rtmax - n; - return (value >= rtmin && value <= rtmax).then_some(value); - } + if let Some(s) = signal_name.strip_prefix("RTMAX-") + && let Ok(n) = s.parse::() + { + let value = rtmax - n; + return (value >= rtmin && value <= rtmax).then_some(value); } match signal_name { @@ -530,7 +589,7 @@ pub fn signal_list_value_by_name_or_number(spec: &str) -> Option { return Some(value); } - let signal_name = spec_upcase.trim_start_matches("SIG"); + let signal_name = spec_upcase.strip_prefix("SIG").unwrap_or(&spec_upcase); realtime_signal_bounds().and_then(|(rtmin, rtmax)| match signal_name { "RTMIN" => Some(rtmin), "RTMAX" => Some(rtmax), @@ -567,7 +626,7 @@ pub fn ignore_interrupts() -> Result<(), Errno> { #[cfg(unix)] pub fn install_signal_handler( sig: i32, - handler: extern "C" fn(std::os::raw::c_int), + handler: extern "C" fn(core::ffi::c_int), ) -> Result<(), Errno> { let signal = Signal::try_from(sig).map_err(|_| Errno::EINVAL)?; let action = SigAction::new( @@ -644,13 +703,13 @@ pub unsafe extern "C" fn capture_startup_state() { #[cfg(unix)] macro_rules! init_startup_state_capture { () => { - #[cfg(not(target_os = "macos"))] + #[cfg(not(target_vendor = "apple"))] #[used] #[unsafe(link_section = ".init_array")] static CAPTURE_STARTUP_STATE: unsafe extern "C" fn() = $crate::signals::capture_startup_state; - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] #[used] #[unsafe(link_section = "__DATA,__mod_init_func")] static CAPTURE_STARTUP_STATE: unsafe extern "C" fn() = @@ -772,6 +831,14 @@ fn signal_by_long_name() { } } +#[test] +fn signal_by_doubled_sig_prefix_is_rejected() { + // A doubled "SIG" (e.g. a typo'd "SIGSIGTERM") must not resolve to a real + // signal: only one leading "SIG" is ever part of the canonical name. + assert_eq!(signal_by_name_or_value("SIGSIGTERM"), None); + assert_eq!(signal_list_value_by_name_or_number("SIGSIGKILL"), None); +} + #[test] fn name() { for (value, signal) in ALL_SIGNALS.iter().enumerate() { diff --git a/src/uucore/src/lib/features/smack.rs b/src/uucore/src/lib/features/smack.rs index 375db8859a6..5c50bd5f4e6 100644 --- a/src/uucore/src/lib/features/smack.rs +++ b/src/uucore/src/lib/features/smack.rs @@ -12,12 +12,10 @@ use std::io::{self, Read, Write}; use std::path::Path; use std::sync::OnceLock; -use thiserror::Error; - use crate::error::{UError, USimpleError, strip_errno}; use crate::translate; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum SmackError { #[error("{}", translate!("smack-error-not-enabled"))] SmackNotEnabled, @@ -25,7 +23,7 @@ pub enum SmackError { #[error("{}", translate!("smack-error-label-retrieval-failure", "error" => strip_errno(.0)))] LabelRetrievalFailure(io::Error), - #[error("{}", translate!("smack-error-label-set-failure", "context" => .0.clone(), "error" => strip_errno(.1)))] + #[error("{}", translate!("smack-error-label-set-failure", "context" => .0, "error" => strip_errno(.1)))] LabelSetFailure(String, io::Error), } diff --git a/src/uucore/src/lib/features/sum.rs b/src/uucore/src/lib/features/sum.rs index 970be48dd64..d356489ab64 100644 --- a/src/uucore/src/lib/features/sum.rs +++ b/src/uucore/src/lib/features/sum.rs @@ -466,40 +466,38 @@ macro_rules! impl_digest_shake { // When the `openssl` feature is enabled, md5/sha1/sha2-family digests are // backed by OpenSSL's libcrypto, which provides hand-tuned assembly // implementations (AVX2, SSSE3, etc.) that are substantially faster than the -// pure-Rust crates on CPUs without SHA-NI. The OpenSSL backend is only -// compiled in on `cfg(unix)`; Windows targets always use the pure-Rust path -// since libcrypto headers aren't generally available there. -#[cfg(not(all(feature = "openssl", unix)))] +// pure-Rust crates on CPUs without SHA-NI. +#[cfg(not(feature = "openssl"))] pub struct Md5(md5::Md5); -#[cfg(not(all(feature = "openssl", unix)))] +#[cfg(not(feature = "openssl"))] pub struct Sha1(sha1::Sha1); -#[cfg(not(all(feature = "openssl", unix)))] +#[cfg(not(feature = "openssl"))] pub struct Sha224(sha2::Sha224); -#[cfg(not(all(feature = "openssl", unix)))] +#[cfg(not(feature = "openssl"))] pub struct Sha256(sha2::Sha256); -#[cfg(not(all(feature = "openssl", unix)))] +#[cfg(not(feature = "openssl"))] pub struct Sha384(sha2::Sha384); -#[cfg(not(all(feature = "openssl", unix)))] +#[cfg(not(feature = "openssl"))] pub struct Sha512(sha2::Sha512); -#[cfg(not(all(feature = "openssl", unix)))] +#[cfg(not(feature = "openssl"))] impl_digest_common!(Md5, 128); -#[cfg(not(all(feature = "openssl", unix)))] +#[cfg(not(feature = "openssl"))] impl_digest_common!(Sha1, 160); -#[cfg(not(all(feature = "openssl", unix)))] +#[cfg(not(feature = "openssl"))] impl_digest_common!(Sha224, 224); -#[cfg(not(all(feature = "openssl", unix)))] +#[cfg(not(feature = "openssl"))] impl_digest_common!(Sha256, 256); -#[cfg(not(all(feature = "openssl", unix)))] +#[cfg(not(feature = "openssl"))] impl_digest_common!(Sha384, 384); -#[cfg(not(all(feature = "openssl", unix)))] +#[cfg(not(feature = "openssl"))] impl_digest_common!(Sha512, 512); // When OpenSSL is built in FIPS mode (or otherwise refuses an algorithm — // e.g. MD5/SHA-1 in strict legacy-off builds), `Hasher::new` returns Err. // To avoid panicking at construction time, each type carries a PureRust // fallback variant built from the same crate the non-OpenSSL path uses. -#[cfg(all(feature = "openssl", unix))] +#[cfg(feature = "openssl")] macro_rules! impl_digest_openssl { ($algo_type:ident, $size:literal, $md:expr, $rust_type:ty) => { pub enum $algo_type { @@ -555,32 +553,32 @@ macro_rules! impl_digest_openssl { }; } -#[cfg(all(feature = "openssl", unix))] +#[cfg(feature = "openssl")] impl_digest_openssl!(Md5, 128, openssl::hash::MessageDigest::md5(), md5::Md5); -#[cfg(all(feature = "openssl", unix))] +#[cfg(feature = "openssl")] impl_digest_openssl!(Sha1, 160, openssl::hash::MessageDigest::sha1(), sha1::Sha1); -#[cfg(all(feature = "openssl", unix))] +#[cfg(feature = "openssl")] impl_digest_openssl!( Sha224, 224, openssl::hash::MessageDigest::sha224(), sha2::Sha224 ); -#[cfg(all(feature = "openssl", unix))] +#[cfg(feature = "openssl")] impl_digest_openssl!( Sha256, 256, openssl::hash::MessageDigest::sha256(), sha2::Sha256 ); -#[cfg(all(feature = "openssl", unix))] +#[cfg(feature = "openssl")] impl_digest_openssl!( Sha384, 384, openssl::hash::MessageDigest::sha384(), sha2::Sha384 ); -#[cfg(all(feature = "openssl", unix))] +#[cfg(feature = "openssl")] impl_digest_openssl!( Sha512, 512, @@ -738,14 +736,14 @@ mod tests { writer_crlf.write_all(b"\r").unwrap(); writer_crlf.write_all(b"\n").unwrap(); writer_crlf.finalize(); - let result_crlf = digest.result(); + let result_crlf = digest.result().unwrap(); // We expect "\r\n" to be replaced with "\n" in text mode on Windows. let mut digest = Box::new(Md5::default()) as Box; let mut writer_lf = DigestWriter::new(&mut digest, false); writer_lf.write_all(b"\n").unwrap(); writer_lf.finalize(); - let result_lf = digest.result(); + let result_lf = digest.result().unwrap(); assert_eq!(result_crlf, result_lf); } diff --git a/src/uucore/src/lib/features/systemd_logind.rs b/src/uucore/src/lib/features/systemd_logind.rs index 4c5370838d1..5d3a23e6d10 100644 --- a/src/uucore/src/lib/features/systemd_logind.rs +++ b/src/uucore/src/lib/features/systemd_logind.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 logind libsystemd btime unref RAII testuser GETPW sysconf //! Systemd-logind support for reading login records @@ -12,7 +12,7 @@ //! When the systemd-logind feature is enabled and systemd is available, //! this will be used instead of traditional utmp files. -use std::ffi::CStr; +use core::ffi::CStr; use std::mem::MaybeUninit; use std::time::{SystemTime, UNIX_EPOCH}; @@ -20,8 +20,7 @@ use crate::error::{UResult, USimpleError}; /// FFI bindings for libsystemd login and D-Bus functions mod ffi { - use std::ffi::c_char; - use std::os::raw::{c_int, c_uint}; + use core::ffi::{c_char, c_int, c_uint}; #[link(name = "systemd")] unsafe extern "C" { @@ -44,13 +43,14 @@ mod ffi { /// Safe wrapper functions for libsystemd FFI calls mod login { use super::ffi; - use std::ffi::{CStr, CString}; + use core::ffi::CStr; + use std::ffi::CString; use std::ptr; use std::time::SystemTime; /// Get all active sessions pub fn get_sessions() -> Result, Box> { - let mut sessions_ptr: *mut *mut libc::c_char = ptr::null_mut(); + let mut sessions_ptr: *mut *mut core::ffi::c_char = ptr::null_mut(); let result = unsafe { ffi::sd_get_sessions(&raw mut sessions_ptr) }; @@ -83,7 +83,7 @@ mod login { /// Get UID for a session pub fn get_session_uid(session_id: &str) -> Result> { let session_cstring = CString::new(session_id)?; - let mut uid: std::os::raw::c_uint = 0; + let mut uid: core::ffi::c_uint = 0; let result = unsafe { ffi::sd_session_get_uid(session_cstring.as_ptr(), &raw mut uid) }; @@ -117,7 +117,7 @@ mod login { /// Get TTY for a session pub fn get_session_tty(session_id: &str) -> Result, Box> { let session_cstring = CString::new(session_id)?; - let mut tty_ptr: *mut libc::c_char = ptr::null_mut(); + let mut tty_ptr: *mut core::ffi::c_char = ptr::null_mut(); let result = unsafe { ffi::sd_session_get_tty(session_cstring.as_ptr(), &raw mut tty_ptr) }; @@ -144,7 +144,7 @@ mod login { session_id: &str, ) -> Result, Box> { let session_cstring = CString::new(session_id)?; - let mut host_ptr: *mut libc::c_char = ptr::null_mut(); + let mut host_ptr: *mut core::ffi::c_char = ptr::null_mut(); let result = unsafe { ffi::sd_session_get_remote_host(session_cstring.as_ptr(), &raw mut host_ptr) }; @@ -173,7 +173,7 @@ mod login { session_id: &str, ) -> Result, Box> { let session_cstring = CString::new(session_id)?; - let mut display_ptr: *mut libc::c_char = ptr::null_mut(); + let mut display_ptr: *mut core::ffi::c_char = ptr::null_mut(); let result = unsafe { ffi::sd_session_get_display(session_cstring.as_ptr(), &raw mut display_ptr) }; @@ -202,7 +202,7 @@ mod login { session_id: &str, ) -> Result, Box> { let session_cstring = CString::new(session_id)?; - let mut type_ptr: *mut libc::c_char = ptr::null_mut(); + let mut type_ptr: *mut core::ffi::c_char = ptr::null_mut(); let result = unsafe { ffi::sd_session_get_type(session_cstring.as_ptr(), &raw mut type_ptr) }; @@ -230,7 +230,7 @@ mod login { session_id: &str, ) -> Result, Box> { let session_cstring = CString::new(session_id)?; - let mut seat_ptr: *mut libc::c_char = ptr::null_mut(); + let mut seat_ptr: *mut core::ffi::c_char = ptr::null_mut(); let result = unsafe { ffi::sd_session_get_seat(session_cstring.as_ptr(), &raw mut seat_ptr) }; diff --git a/src/uucore/src/lib/features/time.rs b/src/uucore/src/lib/features/time.rs index bc5d9ec665d..541cabb5943 100644 --- a/src/uucore/src/lib/features/time.rs +++ b/src/uucore/src/lib/features/time.rs @@ -26,13 +26,15 @@ fn format_zoned(out: &mut W, zoned: Zoned, fmt: &str) -> UResult<()> { } /// Convert a SystemTime` to a number of seconds since UNIX_EPOCH +#[must_use] +#[inline] pub fn system_time_to_sec(time: SystemTime) -> (i64, u32) { - if time > UNIX_EPOCH { - let d = time.duration_since(UNIX_EPOCH).unwrap(); - (d.as_secs() as i64, d.subsec_nanos()) - } else { - let d = UNIX_EPOCH.duration_since(time).unwrap(); - (-(d.as_secs() as i64), d.subsec_nanos()) + match time.duration_since(UNIX_EPOCH) { + Ok(d) => (d.as_secs() as i64, d.subsec_nanos()), + Err(e) => { + let d = e.duration(); + (-(d.as_secs() as i64), d.subsec_nanos()) + } } } diff --git a/src/uucore/src/lib/features/tty.rs b/src/uucore/src/lib/features/tty.rs index c25b5f2e0c1..9984edf954d 100644 --- a/src/uucore/src/lib/features/tty.rs +++ b/src/uucore/src/lib/features/tty.rs @@ -4,6 +4,7 @@ // file that was distributed with this source code. //! Set of functions to parsing TTY + use std::{ fmt::{self, Display, Formatter}, path::PathBuf, diff --git a/src/uucore/src/lib/features/update_control.rs b/src/uucore/src/lib/features/update_control.rs index 5d5a582756e..ded2221b4f9 100644 --- a/src/uucore/src/lib/features/update_control.rs +++ b/src/uucore/src/lib/features/update_control.rs @@ -46,6 +46,7 @@ //! } //! } //! ``` + use clap::ArgMatches; /// Available update mode diff --git a/src/uucore/src/lib/features/uptime.rs b/src/uucore/src/lib/features/uptime.rs deleted file mode 100644 index 3d7215f3dd3..00000000000 --- a/src/uucore/src/lib/features/uptime.rs +++ /dev/null @@ -1,584 +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 gettime BOOTTIME clockid boottime nusers loadavg getloadavg timeval - -//! Provides functions to get system uptime, number of users and load average. - -// The code was originally written in uu_uptime -// (https://github.com/uutils/coreutils/blob/main/src/uu/uptime/src/uptime.rs) -// but was eventually moved here. -// See https://github.com/uutils/coreutils/pull/7289 for discussion. - -use crate::error::{UError, UResult}; -use crate::translate; -use jiff::Timestamp; -use jiff::tz::TimeZone; -use libc::time_t; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum UptimeError { - #[error("{}", translate!("uptime-lib-error-system-uptime"))] - SystemUptime, - #[error("{}", translate!("uptime-lib-error-system-loadavg"))] - SystemLoadavg, - #[error("{}", translate!("uptime-lib-error-windows-loadavg"))] - WindowsLoadavg, - #[error("{}", translate!("uptime-lib-error-boot-time"))] - BootTime, -} - -impl UError for UptimeError { - fn code(&self) -> i32 { - 1 - } -} - -/// Returns the formatted time string, e.g. "12:34:56" -pub fn get_formatted_time() -> String { - Timestamp::now() - .to_zoned(TimeZone::system()) - .strftime("%H:%M:%S") - .to_string() -} - -/// Safely get macOS boot time using sysctl command -/// -/// This function uses the sysctl command-line tool to retrieve the kernel -/// boot time on macOS, avoiding any unsafe code. It parses the output -/// of the sysctl command to extract the boot time. -/// -/// # Returns -/// -/// Returns Some(time_t) if successful, None if the call fails. -#[cfg(target_os = "macos")] -fn get_macos_boot_time_sysctl() -> Option { - use std::process::Command; - - // Execute sysctl command to get boot time - let output = Command::new("sysctl") - .arg("-n") - .arg("kern.boottime") - .output(); - - if let Ok(output) = output { - if output.status.success() { - // Parse output format: { sec = 1729338352, usec = 0 } Wed Oct 19 08:25:52 2025 - // We need to extract the seconds value from the structured output - let stdout = String::from_utf8_lossy(&output.stdout); - - // Extract the seconds from the output - // Look for "sec = " pattern - if let Some(sec_start) = stdout.find("sec = ") { - let sec_part = &stdout[sec_start + 6..]; - if let Some(sec_end) = sec_part.find(',') { - let sec_str = &sec_part[..sec_end]; - if let Ok(boot_time) = sec_str.trim().parse::() { - return Some(boot_time as time_t); - } - } - } - } - } - - None -} - -/// Get the system uptime -/// -/// # Arguments -/// -/// boot_time: Option - Manually specify the boot time, or None to try to get it from the system. -/// -/// # Returns -/// -/// Returns a UResult with the uptime in seconds if successful, otherwise an UptimeError. -#[cfg(any( - target_os = "linux", - target_os = "android", - target_os = "fuchsia", - target_os = "openbsd" -))] -#[allow(clippy::unnecessary_wraps, reason = "needed on some platforms")] -pub fn get_uptime(_boot_time: Option) -> UResult { - use rustix::time::{ClockId, clock_gettime}; - - let tp = clock_gettime(ClockId::Boottime); - - Ok(tp.tv_sec as i64) -} - -/// Get the system uptime -/// -/// # Arguments -/// -/// boot_time: Option - Manually specify the boot time, or None to try to get it from the system. -/// -/// # Returns -/// -/// Returns a UResult with the uptime in seconds if successful, otherwise an UptimeError. -#[cfg(unix)] -#[cfg(not(any( - target_os = "linux", - target_os = "android", - target_os = "fuchsia", - target_os = "openbsd" -)))] -pub fn get_uptime(boot_time: Option) -> UResult { - use crate::utmpx::BOOT_TIME; - use crate::utmpx::Utmpx; - use std::fs::File; - use std::io::Read; - - let mut proc_uptime_s = String::new(); - - let proc_uptime = File::open("/proc/uptime") - .ok() - .and_then(|mut f| f.read_to_string(&mut proc_uptime_s).ok()) - .and_then(|_| proc_uptime_s.split_whitespace().next()) - .and_then(|s| s.split('.').next().unwrap_or("0").parse::().ok()); - - if let Some(uptime) = proc_uptime { - return Ok(uptime); - } - - // Try provided boot_time or derive from utmpx - let derived_boot_time = boot_time.or_else(|| { - 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 - // - // On macOS, the utmpx BOOT_TIME record can be unreliable or absent, causing intermittent - // test failures (see issue #3621: https://github.com/uutils/coreutils/issues/3621). - // The sysctl(CTL_KERN, KERN_BOOTTIME) approach is the canonical way to retrieve boot time - // on macOS and is always available, making uptime more reliable on this platform. - // - // This fallback only runs if utmpx failed to provide a boot time. - #[cfg(target_os = "macos")] - let derived_boot_time = { - let mut t = derived_boot_time; - if t.is_none() { - // Use a safe wrapper function to get boot time via sysctl - if let Some(boot_time) = get_macos_boot_time_sysctl() { - t = Some(boot_time); - } - } - t - }; - - if let Some(t) = derived_boot_time { - let now = Timestamp::now().as_second(); - #[cfg(target_pointer_width = "64")] - let boottime: i64 = t; - #[cfg(not(target_pointer_width = "64"))] - let boottime: i64 = t.into(); - if now < boottime { - Err(UptimeError::BootTime)?; - } - return Ok(now - boottime); - } - - 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 { - days: i64, - hours: i64, - mins: i64, -} - -impl FormattedUptime { - fn new(seconds: i64) -> Self { - let days = seconds / 86400; - let hours = (seconds - (days * 86400)) / 3600; - let mins = (seconds - (days * 86400) - (hours * 3600)) / 60; - - Self { days, hours, mins } - } - - fn get_human_readable_uptime(&self) -> String { - // Hours are not zero-padded (issue #13027); minutes always are. - translate!( - "uptime-format", - "days" => self.days, - "time" => format!("{}:{:02}", self.hours, self.mins)) - } - - fn get_pretty_print_uptime(&self) -> String { - let mut parts = Vec::new(); - if self.days > 0 { - parts.push(translate!("uptime-format-pretty-day", "day" => self.days)); - } - if self.hours > 0 { - parts.push(translate!("uptime-format-pretty-hour", "hour" => self.hours)); - } - if self.mins > 0 || parts.is_empty() { - parts.push(translate!("uptime-format-pretty-min", "min" => self.mins)); - } - parts.join(", ") - } -} - -/// Get the system uptime -/// -/// # Arguments -/// -/// boot_time will be ignored, pass None. -/// -/// # Returns -/// -/// Returns a UResult with the uptime in seconds if successful, otherwise an UptimeError. -#[cfg(windows)] -#[allow(clippy::unnecessary_wraps, reason = "needed on some platforms")] -pub fn get_uptime(_boot_time: Option) -> UResult { - use windows_sys::Win32::System::SystemInformation::GetTickCount; - // SAFETY: always return u32 - let uptime = unsafe { GetTickCount() }; - Ok(uptime as i64 / 1000) -} - -/// Get the system uptime in a human-readable format -/// -/// # 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, - output_format: OutputFormat, -) -> UResult { - let uptime = get_uptime(boot_time)?; - - if uptime < 0 { - Err(UptimeError::SystemUptime)?; - } - - let formatted_uptime = FormattedUptime::new(uptime); - - 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 -/// -/// # Returns -/// -/// Returns the number of users currently logged in if successful, otherwise 0. -#[cfg(unix)] -#[cfg(not(target_os = "openbsd"))] -// see: https://gitlab.com/procps-ng/procps/-/blob/4740a0efa79cade867cfc7b32955fe0f75bf5173/library/uptime.c#L63-L115 -pub fn get_nusers() -> usize { - use crate::utmpx::USER_PROCESS; - use crate::utmpx::Utmpx; - - let mut num_user = 0; - Utmpx::iter_all_records().for_each(|ut| { - if ut.record_type() == USER_PROCESS { - num_user += 1; - } - }); - num_user -} - -/// Get the number of users currently logged in -/// -/// # Returns -/// -/// Returns the number of users currently logged in if successful, otherwise 0 -#[cfg(target_os = "openbsd")] -pub fn get_nusers(file: &str) -> usize { - use utmp_classic::{UtmpEntry, parse_from_path}; - - let Ok(entries) = parse_from_path(file) else { - return 0; - }; - - if entries.is_empty() { - return 0; - } - - // Count entries that have a non-empty user field - entries - .iter() - .filter_map(|entry| match entry { - UtmpEntry::UTMP { user, .. } if !user.is_empty() => Some(()), - _ => None, - }) - .count() -} - -/// Get the number of users currently logged in -/// -/// # Returns -/// -/// Returns the number of users currently logged in if successful, otherwise 0 -#[cfg(target_os = "windows")] -pub fn get_nusers() -> usize { - use std::ptr; - use windows_sys::Win32::System::RemoteDesktop::{ - WTS_CURRENT_SERVER_HANDLE, WTSEnumerateSessionsW, WTSFreeMemory, - WTSQuerySessionInformationW, - }; - - let mut num_user = 0; - - // SAFETY: WTS_CURRENT_SERVER_HANDLE is a valid handle - unsafe { - let mut session_info_ptr = ptr::null_mut(); - let mut session_count = 0; - - let result = WTSEnumerateSessionsW( - WTS_CURRENT_SERVER_HANDLE, - 0, - 1, - &raw mut session_info_ptr, - &raw mut session_count, - ); - if result == 0 { - return 0; - } - - let sessions = std::slice::from_raw_parts(session_info_ptr, session_count as usize); - - for session in sessions { - let mut buffer: *mut u16 = ptr::null_mut(); - let mut bytes_returned = 0; - - let result = WTSQuerySessionInformationW( - WTS_CURRENT_SERVER_HANDLE, - session.SessionId, - 5, - &raw mut buffer, - &raw mut bytes_returned, - ); - if result == 0 || buffer.is_null() { - continue; - } - - let cstr = std::ffi::CStr::from_ptr(buffer.cast()); - if !cstr.is_empty() { - num_user += 1; - } - - WTSFreeMemory(buffer.cast()); - } - - WTSFreeMemory(session_info_ptr.cast()); - } - - num_user -} - -/// Format the number of users to a human-readable string -/// -/// # Returns -/// -/// e.g. "0 users", "1 user", "2 users" -#[inline] -pub fn format_nusers(n: usize) -> String { - translate!( - "uptime-user-count", - "count" => n - ) -} - -/// Get the number of users currently logged in, in a human-readable format -/// -/// # Returns -/// -/// e.g. "0 user", "1 user", "2 users" -#[inline] -pub fn get_formatted_nusers() -> String { - #[cfg(not(target_os = "openbsd"))] - return format_nusers(get_nusers()); - - #[cfg(target_os = "openbsd")] - format_nusers(get_nusers("/var/run/utmp")) -} - -/// Get the system load average -/// -/// # Returns -/// -/// Returns a UResult with the load average if successful, otherwise an UptimeError. -/// The load average is a tuple of three floating point numbers representing the 1-minute, 5-minute, and 15-minute load averages. -#[cfg(unix)] -pub fn get_loadavg() -> UResult<(f64, f64, f64)> { - use crate::libc::c_double; - use libc::getloadavg; - - let mut avg: [c_double; 3] = [0.0; 3]; - // SAFETY: checked whether it returns -1 - let loads: i32 = unsafe { getloadavg(avg.as_mut_ptr(), 3) }; - - if loads == -1 { - Err(UptimeError::SystemLoadavg)? - } else { - Ok((avg[0], avg[1], avg[2])) - } -} - -/// Get the system load average -/// Windows does not have an equivalent to the load average on Unix-like systems. -/// -/// # Returns -/// -/// Returns a UResult with an UptimeError. -#[cfg(windows)] -pub fn get_loadavg() -> UResult<(f64, f64, f64)> { - Err(UptimeError::WindowsLoadavg)? -} - -/// Get the system load average in a human-readable format -/// -/// # Returns -/// -/// Returns a UResult with the load average in a human-readable format if successful, otherwise an UptimeError. -/// e.g. "load average: 0.00, 0.00, 0.00" -#[inline] -pub fn get_formatted_loadavg() -> UResult { - let loadavg = get_loadavg()?; - 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", - args, - )) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::locale; - - #[test] - fn test_format_nusers() { - unsafe { - std::env::set_var("LANG", "en_US.UTF-8"); - } - let _ = locale::setup_localization("uptime"); - assert_eq!("0 users", format_nusers(0)); - assert_eq!("1 user", format_nusers(1)); - assert_eq!("2 users", format_nusers(2)); - } - - #[test] - fn test_human_readable_uptime_hours_not_zero_padded() { - unsafe { - std::env::set_var("LANG", "en_US.UTF-8"); - } - let _ = locale::setup_localization("uptime"); - // Hours below 10 are not zero-padded (issue #13027). - assert_eq!( - "1:27", - FormattedUptime::new(3600 + 27 * 60).get_human_readable_uptime() - ); - assert_eq!( - "9:05", - FormattedUptime::new(9 * 3600 + 5 * 60).get_human_readable_uptime() - ); - // Two-digit hours are unchanged. - assert_eq!( - "10:05", - FormattedUptime::new(10 * 3600 + 5 * 60).get_human_readable_uptime() - ); - } - - /// Test that sysctl kern.boottime is accessible on macOS and returns valid boot time. - /// This ensures the fallback mechanism added for issue #3621 works correctly. - #[test] - #[cfg(target_os = "macos")] - fn test_macos_sysctl_boottime_available() { - // Test the safe wrapper function - let boot_time = get_macos_boot_time_sysctl(); - - // Verify the safe wrapper succeeded - assert!( - boot_time.is_some(), - "get_macos_boot_time_sysctl should succeed on macOS" - ); - - let boot_time = boot_time.unwrap(); - - // Verify boot time is valid (positive, reasonable value) - assert!(boot_time > 0, "Boot time should be positive"); - - // Boot time should be after 2000-01-01 (946684800 seconds since epoch) - assert!( - boot_time > 946_684_800, - "Boot time should be after year 2000" - ); - - // Boot time should be before current time - let boot_time = Timestamp::from_second(boot_time).unwrap(); - let now = Timestamp::now(); - assert!(boot_time < now, "Boot time should be before current time"); - } - - /// Test that get_uptime always succeeds on macOS due to sysctl fallback. - /// This addresses the intermittent failures reported in issue #3621. - #[test] - #[cfg(target_os = "macos")] - fn test_get_uptime_always_succeeds_on_macos() { - // Call get_uptime without providing boot_time, forcing the system - // to use utmpx or fall back to sysctl - let result = get_uptime(None); - - assert!( - result.is_ok(), - "get_uptime should always succeed on macOS with sysctl fallback" - ); - - let uptime = result.unwrap(); - assert!(uptime > 0, "Uptime should be positive"); - - // Reasonable upper bound: system hasn't been up for more than 365 days - // (This is just a sanity check) - assert!( - uptime < 365 * 86400, - "Uptime seems unreasonably high: {uptime} seconds" - ); - } - - /// Test get_uptime consistency by calling it multiple times. - /// Verifies the sysctl fallback produces stable results. - #[test] - #[cfg(target_os = "macos")] - fn test_get_uptime_macos_consistency() { - let uptime1 = get_uptime(None).expect("First call should succeed"); - let uptime2 = get_uptime(None).expect("Second call should succeed"); - - // Uptimes should be very close (within 1 second) - let diff = (uptime1 - uptime2).abs(); - assert!( - diff <= 1, - "Consecutive uptime calls should be consistent, got {uptime1} and {uptime2}" - ); - } -} diff --git a/src/uucore/src/lib/features/uptime/mod.rs b/src/uucore/src/lib/features/uptime/mod.rs new file mode 100644 index 00000000000..5bf75aea41b --- /dev/null +++ b/src/uucore/src/lib/features/uptime/mod.rs @@ -0,0 +1,218 @@ +// 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 nusers loadavg + +//! Provides functions to get system uptime, number of users and load average. +//! +//! The platform-specific sources live in the cfg-gated `unix`/`windows` +//! submodules, which both provide `get_uptime`, `get_nusers`, `get_loadavg` +//! and `default_nusers` with identical signatures (one exception: OpenBSD's +//! `get_nusers` takes the utmp file path as an argument); the shared +//! formatting helpers here only talk to those re-exported functions. + +// The code was originally written in uu_uptime +// (https://github.com/uutils/coreutils/blob/main/src/uu/uptime/src/uptime.rs) +// but was eventually moved here. +// See https://github.com/uutils/coreutils/pull/7289 for discussion. + +use crate::error::{UError, UResult}; +use crate::translate; +use jiff::Timestamp; +use jiff::tz::TimeZone; +use libc::time_t; + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub use unix::*; + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub use windows::*; + +#[derive(Debug, thiserror::Error)] +pub enum UptimeError { + #[error("{}", translate!("uptime-lib-error-system-uptime"))] + SystemUptime, + #[error("{}", translate!("uptime-lib-error-system-loadavg"))] + SystemLoadavg, + #[error("{}", translate!("uptime-lib-error-windows-loadavg"))] + WindowsLoadavg, + #[error("{}", translate!("uptime-lib-error-boot-time"))] + BootTime, +} + +impl UError for UptimeError { + fn code(&self) -> i32 { + 1 + } +} + +/// Returns the formatted time string, e.g. "12:34:56" +pub fn get_formatted_time() -> String { + Timestamp::now() + .to_zoned(TimeZone::system()) + .strftime("%H:%M:%S") + .to_string() +} + +/// 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 { + days: i64, + hours: i64, + mins: i64, +} + +impl FormattedUptime { + fn new(seconds: i64) -> Self { + let days = seconds / 86400; + let hours = (seconds - (days * 86400)) / 3600; + let mins = (seconds - (days * 86400) - (hours * 3600)) / 60; + + Self { days, hours, mins } + } + + fn get_human_readable_uptime(&self) -> String { + // Hours are not zero-padded (issue #13027); minutes always are. + translate!( + "uptime-format", + "days" => self.days, + "time" => format!("{}:{:02}", self.hours, self.mins)) + } + + fn get_pretty_print_uptime(&self) -> String { + let mut parts = Vec::new(); + if self.days > 0 { + parts.push(translate!("uptime-format-pretty-day", "day" => self.days)); + } + if self.hours > 0 { + parts.push(translate!("uptime-format-pretty-hour", "hour" => self.hours)); + } + if self.mins > 0 || parts.is_empty() { + parts.push(translate!("uptime-format-pretty-min", "min" => self.mins)); + } + parts.join(", ") + } +} + +/// Get the system uptime in a human-readable format +/// +/// # 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, + output_format: OutputFormat, +) -> UResult { + let uptime = get_uptime(boot_time)?; + + if uptime < 0 { + Err(UptimeError::SystemUptime)?; + } + + let formatted_uptime = FormattedUptime::new(uptime); + + match output_format { + OutputFormat::HumanReadable => Ok(formatted_uptime.get_human_readable_uptime()), + OutputFormat::PrettyPrint => Ok(formatted_uptime.get_pretty_print_uptime()), + } +} + +/// Format the number of users to a human-readable string +/// +/// # Returns +/// +/// e.g. "0 users", "1 user", "2 users" +#[inline] +pub fn format_nusers(n: usize) -> String { + translate!( + "uptime-user-count", + "count" => n + ) +} + +/// Get the number of users currently logged in, in a human-readable format +/// +/// # Returns +/// +/// e.g. "0 user", "1 user", "2 users" +#[inline] +pub fn get_formatted_nusers() -> String { + format_nusers(default_nusers()) +} + +/// Get the system load average in a human-readable format +/// +/// # Returns +/// +/// Returns a UResult with the load average in a human-readable format if successful, otherwise an UptimeError. +/// e.g. "load average: 0.00, 0.00, 0.00" +#[inline] +pub fn get_formatted_loadavg() -> UResult { + let loadavg = get_loadavg()?; + 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", + args, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::locale; + + #[test] + fn test_format_nusers() { + unsafe { + std::env::set_var("LANG", "en_US.UTF-8"); + } + let _ = locale::setup_localization("uptime"); + assert_eq!("0 users", format_nusers(0)); + assert_eq!("1 user", format_nusers(1)); + assert_eq!("2 users", format_nusers(2)); + } + + #[test] + fn test_human_readable_uptime_hours_not_zero_padded() { + unsafe { + std::env::set_var("LANG", "en_US.UTF-8"); + } + let _ = locale::setup_localization("uptime"); + // Hours below 10 are not zero-padded (issue #13027). + assert_eq!( + "1:27", + FormattedUptime::new(3600 + 27 * 60).get_human_readable_uptime() + ); + assert_eq!( + "9:05", + FormattedUptime::new(9 * 3600 + 5 * 60).get_human_readable_uptime() + ); + // Two-digit hours are unchanged. + assert_eq!( + "10:05", + FormattedUptime::new(10 * 3600 + 5 * 60).get_human_readable_uptime() + ); + } +} diff --git a/src/uucore/src/lib/features/uptime/unix.rs b/src/uucore/src/lib/features/uptime/unix.rs new file mode 100644 index 00000000000..686b26fd0ab --- /dev/null +++ b/src/uucore/src/lib/features/uptime/unix.rs @@ -0,0 +1,319 @@ +// 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 gettime BOOTTIME clockid boottime nusers loadavg getloadavg cfgs + +//! Unix implementation of the platform side of `uucore::uptime`: system +//! uptime, user count and load average. The macOS/NetBSD/Cygwin and OpenBSD +//! variants live here as target cfgs. + +use super::UptimeError; +use crate::error::UResult; +use libc::time_t; + +/// Safely get macOS boot time using sysctl command +/// +/// This function uses the sysctl command-line tool to retrieve the kernel +/// boot time on macOS, avoiding any unsafe code. It parses the output +/// of the sysctl command to extract the boot time. +/// +/// # Returns +/// +/// Returns Some(time_t) if successful, None if the call fails. +#[cfg(target_vendor = "apple")] +fn get_macos_boot_time_sysctl() -> Option { + use std::process::Command; + + // Execute sysctl command to get boot time + let output = Command::new("sysctl") + .arg("-n") + .arg("kern.boottime") + .output(); + + if let Ok(output) = output + && output.status.success() + { + // Parse output format: { sec = 1729338352, usec = 0 } Wed Oct 19 08:25:52 2025 + // We need to extract the seconds value from the structured output + let stdout = String::from_utf8_lossy(&output.stdout); + + // Extract the seconds from the output + // Look for "sec = " pattern + if let Some(sec_start) = stdout.find("sec = ") { + let sec_part = &stdout[sec_start + 6..]; + if let Some(sec_end) = sec_part.find(',') { + let sec_str = &sec_part[..sec_end]; + if let Ok(boot_time) = sec_str.trim().parse::() { + return Some(boot_time as time_t); + } + } + } + } + + None +} + +/// Get the system uptime +/// +/// # Arguments +/// +/// boot_time: Option - Manually specify the boot time, or None to try to get it from the system. +/// +/// # Returns +/// +/// Returns a UResult with the uptime in seconds if successful, otherwise an UptimeError. +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "fuchsia", + target_os = "openbsd", +))] +#[allow(clippy::unnecessary_wraps, reason = "needed on some platforms")] +pub fn get_uptime(_boot_time: Option) -> UResult { + use rustix::time::{ClockId, clock_gettime}; + + let tp = clock_gettime(ClockId::Boottime); + + Ok(tp.tv_sec as i64) +} + +/// Get the system uptime +/// +/// # Arguments +/// +/// boot_time: Option - Manually specify the boot time, or None to try to get it from the system. +/// +/// # Returns +/// +/// Returns a UResult with the uptime in seconds if successful, otherwise an UptimeError. +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "fuchsia", + target_os = "openbsd", +)))] +pub fn get_uptime(boot_time: Option) -> UResult { + use crate::utmpx::BOOT_TIME; + use crate::utmpx::Utmpx; + use jiff::Timestamp; + use std::fs::File; + use std::io::Read; + + let mut proc_uptime_s = String::new(); + + let proc_uptime = File::open("/proc/uptime") + .ok() + .and_then(|mut f| f.read_to_string(&mut proc_uptime_s).ok()) + .and_then(|_| proc_uptime_s.split_whitespace().next()) + .and_then(|s| s.split('.').next().unwrap_or("0").parse::().ok()); + + if let Some(uptime) = proc_uptime { + return Ok(uptime); + } + + // Try provided boot_time or derive from utmpx + let derived_boot_time = boot_time.or_else(|| { + 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 + // + // On macOS, the utmpx BOOT_TIME record can be unreliable or absent, causing intermittent + // test failures (see issue #3621: https://github.com/uutils/coreutils/issues/3621). + // The sysctl(CTL_KERN, KERN_BOOTTIME) approach is the canonical way to retrieve boot time + // on macOS and is always available, making uptime more reliable on this platform. + // + // This fallback only runs if utmpx failed to provide a boot time. + #[cfg(target_vendor = "apple")] + let derived_boot_time = { + let mut t = derived_boot_time; + if t.is_none() { + // Use a safe wrapper function to get boot time via sysctl + if let Some(boot_time) = get_macos_boot_time_sysctl() { + t = Some(boot_time); + } + } + t + }; + + if let Some(t) = derived_boot_time { + let now = Timestamp::now().as_second(); + #[cfg(target_pointer_width = "64")] + let boottime: i64 = t; + #[cfg(not(target_pointer_width = "64"))] + let boottime: i64 = t.into(); + if now < boottime { + Err(UptimeError::BootTime)?; + } + return Ok(now - boottime); + } + + Err(UptimeError::SystemUptime)? +} + +/// Get the number of users currently logged in +/// +/// # Returns +/// +/// Returns the number of users currently logged in if successful, otherwise 0. +#[cfg(not(any(target_os = "openbsd", target_os = "android")))] +// see: https://gitlab.com/procps-ng/procps/-/blob/4740a0efa79cade867cfc7b32955fe0f75bf5173/library/uptime.c#L63-L115 +pub fn get_nusers() -> usize { + use crate::utmpx::USER_PROCESS; + use crate::utmpx::Utmpx; + + let mut num_user = 0; + Utmpx::iter_all_records().for_each(|ut| { + if ut.record_type() == USER_PROCESS { + num_user += 1; + } + }); + num_user +} + +/// Get the number of users currently logged in +/// +/// # Returns +/// +/// Returns the number of users currently logged in if successful, otherwise 0 +#[cfg(target_os = "openbsd")] +pub fn get_nusers(file: &str) -> usize { + use utmp_classic::{UtmpEntry, parse_from_path}; + + let Ok(entries) = parse_from_path(file) else { + return 0; + }; + + if entries.is_empty() { + return 0; + } + + // Count entries that have a non-empty user field + entries + .iter() + .filter_map(|entry| match entry { + UtmpEntry::UTMP { user, .. } if !user.is_empty() => Some(()), + _ => None, + }) + .count() +} + +/// Get the number of users from the default system source, for +/// [`super::get_formatted_nusers`]. On OpenBSD the default source is +/// `/var/run/utmp`. +pub(crate) fn default_nusers() -> usize { + #[cfg(not(any(target_os = "openbsd", target_os = "android")))] + return get_nusers(); + #[cfg(target_os = "openbsd")] + return get_nusers("/var/run/utmp"); + #[cfg(target_os = "android")] + return 0; +} + +/// Get the system load average +/// +/// # Returns +/// +/// Returns a UResult with the load average if successful, otherwise an UptimeError. +/// The load average is a tuple of three floating point numbers representing the 1-minute, 5-minute, and 15-minute load averages. +#[cfg(not(any(target_arch = "wasm32", target_os = "android")))] +pub fn get_loadavg() -> UResult<(f64, f64, f64)> { + use core::ffi::c_double; + use libc::getloadavg; + + let mut avg: [c_double; 3] = [0.0; 3]; + // SAFETY: checked whether it returns -1 + let loads: i32 = unsafe { getloadavg(avg.as_mut_ptr(), 3) }; + + if loads == -1 { + Err(UptimeError::SystemLoadavg)? + } else { + Ok((avg[0], avg[1], avg[2])) + } +} + +#[cfg(any(target_arch = "wasm32", target_os = "android"))] +pub fn get_loadavg() -> UResult<(f64, f64, f64)> { + Err(UptimeError::SystemLoadavg)? +} + +#[cfg(all(test, target_vendor = "apple"))] +mod tests { + use super::*; + use jiff::Timestamp; + + /// Test that sysctl kern.boottime is accessible on macOS and returns valid boot time. + /// This ensures the fallback mechanism added for issue #3621 works correctly. + #[test] + fn test_macos_sysctl_boottime_available() { + // Test the safe wrapper function + let boot_time = get_macos_boot_time_sysctl(); + + // Verify the safe wrapper succeeded + assert!( + boot_time.is_some(), + "get_macos_boot_time_sysctl should succeed on macOS" + ); + + let boot_time = boot_time.unwrap(); + + // Verify boot time is valid (positive, reasonable value) + assert!(boot_time > 0, "Boot time should be positive"); + + // Boot time should be after 2000-01-01 (946684800 seconds since epoch) + assert!( + boot_time > 946_684_800, + "Boot time should be after year 2000" + ); + + // Boot time should be before current time + let boot_time = Timestamp::from_second(boot_time).unwrap(); + let now = Timestamp::now(); + assert!(boot_time < now, "Boot time should be before current time"); + } + + /// Test that get_uptime always succeeds on macOS due to sysctl fallback. + /// This addresses the intermittent failures reported in issue #3621. + #[test] + fn test_get_uptime_always_succeeds_on_macos() { + // Call get_uptime without providing boot_time, forcing the system + // to use utmpx or fall back to sysctl + let result = get_uptime(None); + + assert!( + result.is_ok(), + "get_uptime should always succeed on macOS with sysctl fallback" + ); + + let uptime = result.unwrap(); + assert!(uptime > 0, "Uptime should be positive"); + + // Reasonable upper bound: system hasn't been up for more than 365 days + // (This is just a sanity check) + assert!( + uptime < 365 * 86400, + "Uptime seems unreasonably high: {uptime} seconds" + ); + } + + /// Test get_uptime consistency by calling it multiple times. + /// Verifies the sysctl fallback produces stable results. + #[test] + fn test_get_uptime_macos_consistency() { + let uptime1 = get_uptime(None).expect("First call should succeed"); + let uptime2 = get_uptime(None).expect("Second call should succeed"); + + // Uptimes should be very close (within 1 second) + let diff = (uptime1 - uptime2).abs(); + assert!( + diff <= 1, + "Consecutive uptime calls should be consistent, got {uptime1} and {uptime2}" + ); + } +} diff --git a/src/uucore/src/lib/features/uptime/windows.rs b/src/uucore/src/lib/features/uptime/windows.rs new file mode 100644 index 00000000000..17fb919b3a6 --- /dev/null +++ b/src/uucore/src/lib/features/uptime/windows.rs @@ -0,0 +1,154 @@ +// 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 nusers loadavg INFOW + +//! Windows implementation of the platform side of `uucore::uptime`: +//! `GetTickCount64` for the uptime, WTS session enumeration for the user +//! count, and no load average. + +use super::UptimeError; +use crate::error::UResult; +use libc::time_t; +use std::ffi::OsString; +use std::os::windows::ffi::OsStringExt; +use std::ptr; +use windows_sys::Win32::System::RemoteDesktop::{ + WTS_CURRENT_SERVER_HANDLE, WTS_SESSION_INFOW, WTSEnumerateSessionsW, WTSFreeMemory, + WTSQuerySessionInformationW, WTSUserName, +}; + +/// Get the system uptime +/// +/// # Arguments +/// +/// boot_time will be ignored, pass None. +/// +/// # Returns +/// +/// Returns a UResult with the uptime in seconds if successful, otherwise an UptimeError. +#[allow(clippy::unnecessary_wraps, reason = "needed on some platforms")] +pub fn get_uptime(_boot_time: Option) -> UResult { + // GetTickCount64 (unlike GetTickCount) does not wrap after 49.7 days. + use windows_sys::Win32::System::SystemInformation::GetTickCount64; + // SAFETY: no preconditions; always returns milliseconds since boot + let uptime = unsafe { GetTickCount64() }; + Ok((uptime / 1000) as i64) +} + +/// Owns a WTS-allocated buffer, freeing it on every exit path. +struct WtsBuffer(*mut T); + +impl Drop for WtsBuffer { + fn drop(&mut self) { + // SAFETY: the pointer came from a successful WTS allocation and is + // freed exactly once, here. + unsafe { WTSFreeMemory(self.0.cast()) }; + } +} + +/// The sessions of the local server, enumerated once and freed on drop. +/// +/// Shaped for extraction into a shared module should another consumer appear +/// (e.g. a WTS-backed `users`). +struct Sessions { + buffer: WtsBuffer, + count: usize, +} + +impl Sessions { + /// Enumerate the sessions of the local server, or `None` if the WTS API + /// reports failure. + fn enumerate() -> Option { + let mut ptr = ptr::null_mut(); + let mut count = 0; + // SAFETY: WTS_CURRENT_SERVER_HANDLE is always valid and the two + // out-pointers are valid writable locations. + let result = unsafe { + WTSEnumerateSessionsW( + WTS_CURRENT_SERVER_HANDLE, + 0, + 1, + &raw mut ptr, + &raw mut count, + ) + }; + // A null buffer is not documented as impossible when the call reports + // success with no sessions, and `ids()` may not build a slice from it. + if result == 0 || ptr.is_null() { + return None; + } + Some(Self { + buffer: WtsBuffer(ptr), + count: count as usize, + }) + } + + /// The session identifiers, in enumeration order. + fn ids(&self) -> impl Iterator { + // SAFETY: on success WTSEnumerateSessionsW produced an array of + // `count` entries, owned by `self.buffer` and freed only on drop. + let infos = unsafe { std::slice::from_raw_parts(self.buffer.0, self.count) }; + infos.iter().map(|info| info.SessionId) + } +} + +/// The user name a session is logged on as: `None` when the query fails, +/// `Some("")` when nobody is logged on to the session (services, listeners). +fn session_user_name(session_id: u32) -> Option { + let mut buffer: *mut u16 = ptr::null_mut(); + let mut byte_len = 0; + // SAFETY: WTS_CURRENT_SERVER_HANDLE is always valid and the two + // out-pointers are valid writable locations. + let result = unsafe { + WTSQuerySessionInformationW( + WTS_CURRENT_SERVER_HANDLE, + session_id, + WTSUserName, + &raw mut buffer, + &raw mut byte_len, + ) + }; + if result == 0 || buffer.is_null() { + return None; + } + let buffer = WtsBuffer(buffer); + // SAFETY: on success the buffer holds byte_len / 2 u16 units (the UTF-16 + // name including its terminating NUL), owned by `buffer` until drop. + let units = unsafe { std::slice::from_raw_parts(buffer.0, byte_len as usize / 2) }; + let name = units.split(|&unit| unit == 0).next().unwrap_or(&[]); + Some(OsString::from_wide(name)) +} + +/// Get the number of users currently logged in +/// +/// # Returns +/// +/// Returns the number of users currently logged in if successful, otherwise 0 +pub fn get_nusers() -> usize { + let Some(sessions) = Sessions::enumerate() else { + return 0; + }; + sessions + .ids() + .filter(|&id| session_user_name(id).is_some_and(|name| !name.is_empty())) + .count() +} + +/// Get the number of users from the default system source, for +/// [`super::get_formatted_nusers`]. +pub(crate) fn default_nusers() -> usize { + get_nusers() +} + +/// Get the system load average +/// Windows does not have an equivalent to the load average on Unix-like systems. +/// +/// # Returns +/// +/// Returns a UResult with an UptimeError. +pub fn get_loadavg() -> UResult<(f64, f64, f64)> { + Err(UptimeError::WindowsLoadavg)? +} diff --git a/src/uucore/src/lib/features/utmpx.rs b/src/uucore/src/lib/features/utmpx.rs index 6c538f3a7e8..9471680dd24 100644 --- a/src/uucore/src/lib/features/utmpx.rs +++ b/src/uucore/src/lib/features/utmpx.rs @@ -2,8 +2,8 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// -// spell-checker:ignore IDLEN logind + +// spell-checker:ignore IDLEN logind ohos setutxdb UTXDB //! Aims to provide platform-independent methods to obtain login records //! @@ -49,11 +49,13 @@ pub use self::ut::*; // See the FAQ at https://wiki.musl-libc.org/faq#Q:-Why-is-the-utmp/wtmp-functionality-only-implemented-as-stubs? // Musl implements only stubs for the utmp functions, and the libc crate issues a deprecation warning about this. // However, calling these stubs is the correct approach to maintain consistent behavior with GNU coreutils. -#[cfg_attr(target_env = "musl", allow(deprecated))] +#[cfg_attr(any(target_env = "musl", target_env = "ohos"), allow(deprecated))] pub use libc::endutxent; -#[cfg_attr(target_env = "musl", allow(deprecated))] +#[cfg_attr(any(target_env = "musl", target_env = "ohos"), allow(deprecated))] pub use libc::getutxent; -#[cfg_attr(target_env = "musl", allow(deprecated))] +#[cfg(target_os = "freebsd")] +use libc::setutxdb; +#[cfg_attr(any(target_env = "musl", target_env = "ohos"), allow(deprecated))] pub use libc::setutxent; use libc::utmpx; #[cfg(any( @@ -62,16 +64,9 @@ use libc::utmpx; target_os = "netbsd", target_os = "cygwin" ))] -#[cfg_attr(target_env = "musl", allow(deprecated))] +#[cfg_attr(any(target_env = "musl", target_env = "ohos"), allow(deprecated))] pub use libc::utmpxname; -/// # Safety -/// Just fixed the clippy warning. Please add description here. -#[cfg(target_os = "freebsd")] -pub unsafe extern "C" fn utmpxname(_file: *const libc::c_char) -> libc::c_int { - 0 -} - use crate::libc; // import macros from `../../macros.rs` // In case the c_char array doesn't end with NULL @@ -88,19 +83,19 @@ macro_rules! chars2string { mod ut { pub static DEFAULT_FILE: &str = "/var/run/utmp"; - #[cfg(not(target_env = "musl"))] + #[cfg(target_env = "gnu")] pub use libc::__UT_HOSTSIZE as UT_HOSTSIZE; - #[cfg(target_env = "musl")] + #[cfg(not(target_env = "gnu"))] pub use libc::UT_HOSTSIZE; - #[cfg(not(target_env = "musl"))] + #[cfg(target_env = "gnu")] pub use libc::__UT_LINESIZE as UT_LINESIZE; - #[cfg(target_env = "musl")] + #[cfg(not(target_env = "gnu"))] pub use libc::UT_LINESIZE; - #[cfg(not(target_env = "musl"))] + #[cfg(target_env = "gnu")] pub use libc::__UT_NAMESIZE as UT_NAMESIZE; - #[cfg(target_env = "musl")] + #[cfg(not(target_env = "gnu"))] pub use libc::UT_NAMESIZE; pub const UT_IDSIZE: usize = 4; @@ -346,7 +341,7 @@ impl Utmpx { // This can technically fail, and it would be nice to detect that, // but it doesn't return anything so we'd have to do nasty things // with errno. - #[cfg_attr(target_env = "musl", allow(deprecated))] + #[cfg_attr(any(target_env = "musl", target_env = "ohos"), allow(deprecated))] setutxent(); } iter @@ -376,6 +371,9 @@ impl Utmpx { let iter = UtmpxIter::new(); let path = CString::new(path.as_ref().as_os_str().as_bytes()).unwrap(); unsafe { + #[cfg(target_os = "freebsd")] + setutxdb(libc::UTXDB_ACTIVE, path.as_ptr()); + // In glibc, utmpxname() only fails if there's not enough memory // to copy the string. // Solaris returns 1 on success instead of 0. Supposedly there also @@ -384,9 +382,11 @@ impl Utmpx { // is specified, no warning or anything. // So this function is pretty crazy and we don't try to detect errors. // Not much we can do besides pray. - #[cfg_attr(target_env = "musl", allow(deprecated))] + #[cfg(not(target_os = "freebsd"))] + #[cfg_attr(any(target_env = "musl", target_env = "ohos"), allow(deprecated))] utmpxname(path.as_ptr()); - #[cfg_attr(target_env = "musl", allow(deprecated))] + + #[cfg_attr(any(target_env = "musl", target_env = "ohos"), allow(deprecated))] setutxent(); } iter @@ -563,7 +563,7 @@ impl Iterator for UtmpxIter { // Traditional utmp path unsafe { - #[cfg_attr(target_env = "musl", allow(deprecated))] + #[cfg_attr(any(target_env = "musl", target_env = "ohos"), allow(deprecated))] let res = getutxent(); if res.is_null() { None @@ -583,7 +583,7 @@ impl Iterator for UtmpxIter { impl Drop for UtmpxIter { fn drop(&mut self) { unsafe { - #[cfg_attr(target_env = "musl", allow(deprecated))] + #[cfg_attr(any(target_env = "musl", target_env = "ohos"), allow(deprecated))] endutxent(); } } diff --git a/src/uucore/src/lib/features/version_cmp.rs b/src/uucore/src/lib/features/version_cmp.rs index cf4bf581ba0..d19484d38d3 100644 --- a/src/uucore/src/lib/features/version_cmp.rs +++ b/src/uucore/src/lib/features/version_cmp.rs @@ -18,8 +18,7 @@ fn version_non_digit_cmp(a: &[u8], b: &[u8]) -> Ordering { (Some(c1), Some(c2)) if c1 == c2 => {} (None, None) => return Ordering::Equal, (_, Some(b'~')) => return Ordering::Greater, - (Some(b'~'), _) => return Ordering::Less, - (None, Some(_)) => return Ordering::Less, + (Some(b'~'), _) | (None, Some(_)) => return Ordering::Less, (Some(_), None) => return Ordering::Greater, (Some(c1), Some(c2)) if c1.is_ascii_alphabetic() && !c2.is_ascii_alphabetic() => { return Ordering::Less; diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index 4dfffed2995..903450bb77b 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -2,15 +2,17 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + +// spell-checker:ignore sigaction SIGBUS SIGSEGV extendedbigdecimal myutil logind + //! library ~ (core/bundler file) + // #![deny(missing_docs)] //TODO: enable this -// -// spell-checker:ignore sigaction SIGBUS SIGSEGV extendedbigdecimal myutil logind // * feature-gated external crates (re-shared as public internal modules) #[cfg(feature = "libc")] pub extern crate libc; -#[cfg(all(feature = "windows-sys", target_os = "windows"))] +#[cfg(all(feature = "windows-sys", windows))] pub extern crate windows_sys; //## internal modules @@ -25,7 +27,7 @@ pub use uucore_procs::*; pub use crate::mods::clap_localization; pub use crate::mods::display; pub use crate::mods::error; -#[cfg(feature = "fs")] +#[cfg(all(feature = "fs", any(unix, windows, target_os = "wasi")))] pub use crate::mods::io; pub use crate::mods::line_ending; pub use crate::mods::locale; @@ -40,10 +42,12 @@ pub use crate::features::backup_control; pub use crate::features::benchmark; #[cfg(feature = "buf-copy")] pub use crate::features::buf_copy; +pub use crate::features::char_width; #[cfg(feature = "checksum")] pub use crate::features::checksum; #[cfg(feature = "colors")] pub use crate::features::colors; +pub use crate::features::diagnostics; #[cfg(feature = "encoding")] pub use crate::features::encoding; #[cfg(feature = "extendedbigdecimal")] @@ -52,7 +56,7 @@ pub use crate::features::extendedbigdecimal; pub use crate::features::fast_inc; #[cfg(feature = "format")] pub use crate::features::format; -#[cfg(feature = "fs")] +#[cfg(all(feature = "fs", any(unix, windows, target_os = "wasi")))] pub use crate::features::fs; #[cfg(feature = "hardware")] pub use crate::features::hardware; @@ -95,26 +99,42 @@ pub use crate::features::mode; pub use crate::features::entries; #[cfg(all(unix, feature = "perms"))] pub use crate::features::perms; -#[cfg(all( - any(target_os = "linux", target_os = "android"), - any(feature = "pipes", feature = "buf-copy") -))] +#[cfg(all(feature = "pipes", any(target_os = "linux", target_os = "android")))] pub use crate::features::pipes; -#[cfg(all(unix, feature = "process"))] +#[cfg(all(any(unix, windows), feature = "process"))] pub use crate::features::process; #[cfg(all(unix, feature = "safe-copy"))] pub use crate::features::safe_copy; -#[cfg(all(unix, not(target_os = "redox")))] +#[cfg(all(feature = "safe-traversal", unix, not(target_os = "redox")))] pub use crate::features::safe_traversal; -#[cfg(all(unix, not(target_os = "fuchsia"), feature = "signals"))] +#[cfg(all( + feature = "signals", + any( + windows, + target_vendor = "apple", + target_os = "aix", + target_os = "android", + target_os = "cygwin", + target_os = "freebsd", + target_os = "hurd", + target_os = "illumos", + target_os = "linux", + target_os = "netbsd", + target_os = "openbsd", + target_os = "redox", + target_os = "solaris" + ) +))] pub use crate::features::signals; #[cfg(all( - unix, - not(target_os = "android"), - not(target_os = "fuchsia"), - not(target_os = "openbsd"), - not(target_os = "redox"), - feature = "utmpx" + feature = "utmpx", + any( + target_vendor = "apple", + target_os = "cygwin", + target_os = "freebsd", + target_os = "linux", + target_os = "netbsd" + ) ))] pub use crate::features::utmpx; // ** windows-only @@ -124,7 +144,17 @@ pub use crate::features::wide; #[cfg(feature = "fsext")] pub use crate::features::fsext; -#[cfg(all(unix, feature = "fsxattr"))] +#[cfg(all( + feature = "fsxattr", + any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "netbsd" + ) +))] pub use crate::features::fsxattr; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] @@ -147,7 +177,7 @@ use std::io::{BufRead, BufReader}; use std::iter; #[cfg(unix)] use std::os::unix::ffi::{OsStrExt, OsStringExt}; -#[cfg(target_os = "wasi")] +#[cfg(all(target_os = "wasi", target_env = "p1"))] use std::os::wasi::ffi::{OsStrExt, OsStringExt}; use std::str; use std::str::Utf8Chunk; @@ -178,9 +208,7 @@ pub fn get_canonical_util_name(util_name: &str) -> &str { match util_name { // uu_test aliases - '[' is an alias for test "[" => "test", - "dir" => "ls", // dir is an alias for ls - "vdir" => "ls", // vdir is an alias for ls - + "dir" | "vdir" => "ls", // aliases for ls // Default case - return the util name as is _ => util_name, } @@ -424,10 +452,7 @@ pub fn args_os_filtered() -> impl Iterator { /// Read a line from stdin and check whether the first character is `'y'` or `'Y'` pub fn read_yes() -> bool { let mut s = String::new(); - match std::io::stdin().read_line(&mut s) { - Ok(_) => matches!(s.chars().next(), Some('y' | 'Y')), - _ => false, - } + std::io::stdin().read_line(&mut s).is_ok() && matches!(s.chars().next(), Some('y' | 'Y')) } #[derive(Debug)] @@ -455,7 +480,7 @@ impl error::UError for NonUtf8OsStrError {} #[cfg_attr(any(unix, target_os = "wasi"), expect(clippy::unnecessary_wraps))] pub fn os_str_as_bytes(os_string: &OsStr) -> Result<&[u8], NonUtf8OsStrError> { #[cfg(any(unix, target_os = "wasi"))] - return Ok(os_string.as_bytes()); + return Ok(os_string.as_encoded_bytes()); #[cfg(not(any(unix, target_os = "wasi")))] os_string @@ -472,7 +497,7 @@ pub fn os_str_as_bytes(os_string: &OsStr) -> Result<&[u8], NonUtf8OsStrError> { /// and wraps [`OsStr::to_string_lossy`] on non-unix platforms. pub fn os_str_as_bytes_lossy(os_string: &OsStr) -> Cow<'_, [u8]> { #[cfg(any(unix, target_os = "wasi"))] - return Cow::from(os_string.as_bytes()); + return Cow::from(os_string.as_encoded_bytes()); #[cfg(not(any(unix, target_os = "wasi")))] match os_string.to_string_lossy() { @@ -481,32 +506,37 @@ pub fn os_str_as_bytes_lossy(os_string: &OsStr) -> Cow<'_, [u8]> { } } -/// Converts a `&[u8]` to an `&OsStr`, -/// or parses it as UTF-8 into an [`OsString`] on non-unix platforms. +/// Converts a `&[u8]` to an `&OsStr`. /// /// This always succeeds on unix platforms, /// and fails on other platforms if the bytes can't be parsed as UTF-8. -#[cfg_attr(any(unix, target_os = "wasi"), expect(clippy::unnecessary_wraps))] -pub fn os_str_from_bytes(bytes: &[u8]) -> error::UResult> { - #[cfg(any(unix, target_os = "wasi"))] - return Ok(Cow::Borrowed(OsStr::from_bytes(bytes))); - - #[cfg(not(any(unix, target_os = "wasi")))] - Ok(Cow::Owned(OsString::from(str::from_utf8(bytes).map_err( - |_| error::UUsageError::new(1, "Unable to transform bytes into OsStr"), - )?))) +#[cfg_attr( + any(unix, all(target_os = "wasi", target_env = "p1")), + expect(clippy::unnecessary_wraps) +)] +pub fn os_str_from_bytes(bytes: &[u8]) -> error::UResult<&OsStr> { + #[cfg(any(unix, all(target_os = "wasi", target_env = "p1")))] + return Ok(OsStr::from_bytes(bytes)); + + #[cfg(not(any(unix, all(target_os = "wasi", target_env = "p1"))))] + Ok(OsStr::new(str::from_utf8(bytes).map_err(|_| { + error::UUsageError::new(1, "Unable to transform bytes into OsStr") + })?)) } /// Converts a `Vec` into an `OsString`, parsing as UTF-8 on non-unix platforms. /// /// This always succeeds on unix platforms, /// and fails on other platforms if the bytes can't be parsed as UTF-8. -#[cfg_attr(any(unix, target_os = "wasi"), expect(clippy::unnecessary_wraps))] +#[cfg_attr( + any(unix, all(target_os = "wasi", target_env = "p1")), + expect(clippy::unnecessary_wraps) +)] pub fn os_string_from_vec(vec: Vec) -> error::UResult { - #[cfg(any(unix, target_os = "wasi"))] + #[cfg(any(unix, all(target_os = "wasi", target_env = "p1")))] return Ok(OsString::from_vec(vec)); - #[cfg(not(any(unix, target_os = "wasi")))] + #[cfg(not(any(unix, all(target_os = "wasi", target_env = "p1"))))] Ok(OsString::from(String::from_utf8(vec).map_err(|_| { error::UUsageError::new(1, "invalid UTF-8 was detected in one or more arguments") })?)) @@ -516,11 +546,14 @@ pub fn os_string_from_vec(vec: Vec) -> error::UResult { /// /// This always succeeds on unix platforms, /// and fails on other platforms if the bytes can't be parsed as UTF-8. -#[cfg_attr(any(unix, target_os = "wasi"), expect(clippy::unnecessary_wraps))] +#[cfg_attr( + any(unix, all(target_os = "wasi", target_env = "p1")), + expect(clippy::unnecessary_wraps) +)] pub fn os_string_to_vec(s: OsString) -> error::UResult> { - #[cfg(any(unix, target_os = "wasi"))] + #[cfg(any(unix, all(target_os = "wasi", target_env = "p1")))] let v = s.into_vec(); - #[cfg(not(any(unix, target_os = "wasi")))] + #[cfg(not(any(unix, all(target_os = "wasi", target_env = "p1"))))] let v = s .into_string() .map_err(|_| { @@ -533,15 +566,15 @@ pub fn os_string_to_vec(s: OsString) -> error::UResult> { /// Equivalent to `std::BufRead::lines` which outputs each line as a `Vec`, /// which avoids panicking on non UTF-8 input. -pub fn read_byte_lines( +fn read_byte_lines( mut buf_reader: BufReader, -) -> impl Iterator>> { +) -> impl Iterator>> { iter::from_fn(move || { let mut buf = Vec::with_capacity(256); match buf_reader.read_until(b'\n', &mut buf) { Ok(0) => None, - Err(e) => Some(Err(e)), + Err(e) => Some(Err(e.into())), Ok(_) => { // Trim (\r)\n if buf.ends_with(b"\n") { @@ -557,14 +590,14 @@ pub fn read_byte_lines( }) } -/// Equivalent to `std::BufRead::lines` which outputs each line as an `OsString` -/// This won't panic on non UTF-8 characters on Unix, -/// but it still will on Windows. +/// Equivalent to `std::BufRead::lines` which outputs each line as an `OsString`. +/// +/// On platforms where `OsString` cannot contain arbitrary bytes, +/// non-UTF8 inputs are reported as an error. pub fn read_os_string_lines( buf_reader: BufReader, -) -> impl Iterator> { - read_byte_lines(buf_reader) - .map(|byte_line_res| byte_line_res.map(|bl| os_string_from_vec(bl).expect("UTF-8 error"))) +) -> impl Iterator> { + read_byte_lines(buf_reader).map(|byte_line_res| byte_line_res.and_then(os_string_from_vec)) } /// Prompt the user with a formatted string and returns `true` if they reply `'y'` or `'Y'` @@ -697,7 +730,7 @@ mod tests { ] } - #[cfg(any(unix, target_os = "redox"))] + #[cfg(unix)] fn test_invalid_utf8_args_lossy(os_str: &OsStr) { // assert our string is invalid utf8 assert!(os_str.to_os_string().into_string().is_err()); @@ -716,7 +749,7 @@ mod tests { ); } - #[cfg(any(unix, target_os = "redox"))] + #[cfg(unix)] fn test_invalid_utf8_args_ignore(os_str: &OsStr) { // assert our string is invalid utf8 assert!(os_str.to_os_string().into_string().is_err()); @@ -741,7 +774,7 @@ mod tests { let _ = test_vec.into_iter().collect_lossy(); } - #[cfg(any(unix, target_os = "redox"))] + #[cfg(unix)] #[test] fn invalid_utf8_args_unix() { use std::os::unix::ffi::OsStrExt; @@ -752,6 +785,17 @@ mod tests { test_invalid_utf8_args_ignore(os_str); } + #[cfg(windows)] + #[test] + fn invalid_utf8_line_returns_invalid_data() { + let input = BufReader::new(&b"valid\ninvalid \xff\n"[..]); + let mut lines = read_os_string_lines(input); + + assert_eq!(lines.next().unwrap().unwrap(), OsStr::new("valid")); + assert!(lines.next().unwrap().is_err()); + assert!(lines.next().is_none()); + } + #[test] fn test_format_usage() { assert_eq!(format_usage("expr EXPRESSION"), "expr EXPRESSION"); diff --git a/src/uucore/src/lib/macros.rs b/src/uucore/src/lib/macros.rs index 89030028112..777c7314bee 100644 --- a/src/uucore/src/lib/macros.rs +++ b/src/uucore/src/lib/macros.rs @@ -3,6 +3,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore sourcepath targetpath rustdoc + //! Macros for the uucore utilities. //! //! This module bundles all macros used across the uucore utilities. These @@ -31,8 +33,6 @@ //! - From custom messages: [`crate::show_error!`] //! - Print warnings: [`crate::show_warning!`] -// spell-checker:ignore sourcepath targetpath rustdoc - use std::sync::atomic::AtomicBool; // This file is part of the uutils coreutils package. @@ -89,11 +89,10 @@ macro_rules! show( ($err:expr) => ({ #[allow(unused_imports)] use $crate::error::UError; - use std::io::Write as _; let e = $err; $crate::error::set_exit_code(e.code()); - let _ = writeln!(std::io::stderr().lock(), "{}: {e}", $crate::util_name()); + $crate::error::print_diagnostic("", format_args!("{e}")); }) ); @@ -153,10 +152,7 @@ macro_rules! show_if_err( #[macro_export] macro_rules! show_error( ($($args:tt)+) => ({ - use std::io::Write as _; - let mut error = std::io::stderr().lock(); - let _ = write!(error, "{}: ", $crate::util_name()); - let _ = writeln!(error, $($args)+); + $crate::error::print_diagnostic("", format_args!($($args)+)); }) ); @@ -178,10 +174,7 @@ macro_rules! show_error( #[macro_export] macro_rules! show_warning( ($($args:tt)+) => ({ - use std::io::Write as _; - let mut error = std::io::stderr().lock(); - let _ = write!(error, "{}: warning: ", $crate::util_name()); - let _ = writeln!(error, $($args)+); + $crate::error::print_diagnostic("warning: ", format_args!($($args)+)); }) ); @@ -189,9 +182,6 @@ macro_rules! show_warning( #[macro_export] macro_rules! show_warning_caps( ($($args:tt)+) => ({ - use std::io::Write as _; - let mut error = std::io::stderr().lock(); - let _ = write!(error, "{}: WARNING: ", $crate::util_name()); - let _ = writeln!(error, $($args)+); + $crate::error::print_diagnostic("WARNING: ", format_args!($($args)+)); }) ); diff --git a/src/uucore/src/lib/mods.rs b/src/uucore/src/lib/mods.rs index e33bf031958..2d5c94a4be6 100644 --- a/src/uucore/src/lib/mods.rs +++ b/src/uucore/src/lib/mods.rs @@ -2,12 +2,13 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // mods ~ cross-platforms modules (core/bundler file) pub mod clap_localization; pub mod display; pub mod error; -#[cfg(feature = "fs")] +#[cfg(all(feature = "fs", any(unix, windows, target_os = "wasi")))] pub mod io; pub mod line_ending; pub mod locale; diff --git a/src/uucore/src/lib/mods/clap_localization.rs b/src/uucore/src/lib/mods/clap_localization.rs index 0249089c142..1048d11d27c 100644 --- a/src/uucore/src/lib/mods/clap_localization.rs +++ b/src/uucore/src/lib/mods/clap_localization.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (path) osrelease myutil //! Helper clap functions to localize error handling and options @@ -211,7 +212,7 @@ impl<'a> ErrorFormatter<'a> { ); // Include validation error if present match err.source() { - Some(source) if matches!(err.kind(), ErrorKind::ValueValidation) => { + Some(source) if err.kind() == ErrorKind::ValueValidation => { let _ = writeln!(stderr(), "{error_msg}: {source}"); } _ => eprintln!("{error_msg}"), @@ -219,7 +220,7 @@ impl<'a> ErrorFormatter<'a> { } // Show possible values for InvalidValue errors - if matches!(err.kind(), ErrorKind::InvalidValue) + if err.kind() == ErrorKind::InvalidValue && let Some(valid_values) = err .get(ContextKind::ValidValue) .filter(|v| !v.to_string().is_empty()) @@ -239,7 +240,7 @@ impl<'a> ErrorFormatter<'a> { // But if a utility explicitly requests a high exit code (>= 125), respect it // This allows utilities like runcon (125) to override the default while preserving // the standard behavior for utilities using normal error codes (1, 2, etc.) - if matches!(err.kind(), ErrorKind::InvalidValue) && exit_code < 125 { + if err.kind() == ErrorKind::InvalidValue && exit_code < 125 { 1 // Force exit code 1 for InvalidValue unless using special exit codes } else { exit_code // Respect the requested exit code for special cases @@ -399,6 +400,38 @@ where handle_clap_result_with_exit_code(cmd, itr, 1) } +/// Parses the command line as [`handle_clap_result_with_exit_code`] does, +/// keeping a copy of it for a caret diagnostic first. +/// +/// Parsing consumes the argument list, and a caret echoes it as it was typed, +/// so the copy has to be taken before — which is what this saves every caller +/// from spelling out. A utility that rewrites its arguments before parsing +/// keeps capturing on its own, since only it knows which of the two lists the +/// caret should echo. +/// +/// # Arguments +/// +/// * `cmd` - The clap `Command` to parse arguments against +/// * `args` - The command line, program name included +/// * `exit_code` - The exit code to use when parsing fails +/// +/// # Returns +/// +/// The parsed arguments, and the command line as typed — `None` when +/// diagnostics are off, so that nothing is copied for a report no one will +/// see. +pub fn handle_clap_result_with_diagnostics( + cmd: Command, + args: Vec, + exit_code: i32, +) -> UResult<(ArgMatches, Option>)> { + let diag_args = crate::diagnostics::capture(&args); + Ok(( + handle_clap_result_with_exit_code(cmd, args, exit_code)?, + diag_args, + )) +} + /// Handles clap command parsing with a custom exit code for errors. /// /// Similar to `handle_clap_result` but allows specifying a custom exit code @@ -529,7 +562,7 @@ pub fn configure_localized_command(mut cmd: Command) -> Command { cmd } -/* spell-checker: disable */ +// spell-checker:disable #[cfg(test)] mod tests { use super::*; @@ -703,4 +736,4 @@ mod tests { } } } -/* spell-checker: enable */ +// spell-checker:enable diff --git a/src/uucore/src/lib/mods/display.rs b/src/uucore/src/lib/mods/display.rs index 165070e9a53..a011e355d0d 100644 --- a/src/uucore/src/lib/mods/display.rs +++ b/src/uucore/src/lib/mods/display.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + //! Utilities for printing paths, with special attention paid to special //! characters and invalid unicode. //! @@ -30,11 +31,6 @@ use std::fmt; use std::fs::File; use std::io::{self, BufWriter, Stdout, StdoutLock, Write as _}; -#[cfg(unix)] -use std::os::unix::ffi::OsStrExt; -#[cfg(target_os = "wasi")] -use std::os::wasi::ffi::OsStrExt; - // These used to be defined here, but they live in their own crate now. pub use os_display::{Quotable, Quoted}; @@ -78,7 +74,7 @@ pub trait OsWrite: io::Write { fn write_all_os(&mut self, buf: &OsStr) -> io::Result<()> { #[cfg(any(unix, target_os = "wasi"))] { - self.write_all(buf.as_bytes()) + self.write_all(buf.as_encoded_bytes()) } #[cfg(not(any(unix, target_os = "wasi")))] diff --git a/src/uucore/src/lib/mods/error.rs b/src/uucore/src/lib/mods/error.rs index d2239d12875..ca55ef97f6c 100644 --- a/src/uucore/src/lib/mods/error.rs +++ b/src/uucore/src/lib/mods/error.rs @@ -2,6 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + +// spell-checker:ignore uioerror rustdoc + //! All utils return exit with an exit code. Usually, the following scheme is used: //! * `0`: succeeded //! * `1`: minor problems @@ -52,8 +55,6 @@ //! * Using [`ExitCode`] is not recommended but can be useful for converting utils to use //! [`UResult`]. -// spell-checker:ignore uioerror rustdoc - use std::{ cell::Cell, error::Error, @@ -93,6 +94,15 @@ pub fn set_exit_code(code: i32) { EXIT_CODE.store(code, Ordering::SeqCst); } +/// Print `: ` to stderr, used by [`crate::show!`] and friends. +/// +/// stderr is unbuffered, so the line is formatted first and written at once. +#[doc(hidden)] +pub fn print_diagnostic(prefix: &str, msg: std::fmt::Arguments<'_>) { + let line = format!("{}: {prefix}{msg}\n", crate::util_name()); + let _ = std::io::stderr().write_all(line.as_bytes()); +} + /// Result type that should be returned by all utils. pub type UResult = Result>; @@ -296,6 +306,8 @@ pub struct USimpleError { impl USimpleError { /// Create a new `USimpleError` with a given exit code and message. + #[cold] + #[inline(never)] #[allow(clippy::new_ret_no_self)] pub fn new>(code: i32, message: S) -> Box { Box::new(Self { @@ -319,7 +331,7 @@ impl UError for USimpleError { } } -/// Wrapper type around [`std::io::Error`]. +/// A usage error type with an exit code and a message that implements [`UError`]. #[derive(Debug)] pub struct UUsageError { /// Exit code of the error. @@ -330,8 +342,10 @@ pub struct UUsageError { } impl UUsageError { - #[allow(clippy::new_ret_no_self)] /// Create a new `UUsageError` with a given exit code and message. + #[cold] + #[inline(never)] + #[allow(clippy::new_ret_no_self)] pub fn new>(code: i32, message: S) -> Box { Box::new(Self { code, @@ -391,8 +405,8 @@ pub struct UIoError { } impl UIoError { - #[allow(clippy::new_ret_no_self)] /// Create a new `UIoError` with a given exit code and message. + #[allow(clippy::new_ret_no_self)] pub fn new>(kind: std::io::ErrorKind, context: S) -> Box { Box::new(Self { context: Some(context.into()), @@ -416,6 +430,16 @@ impl Display for UIoError { // and we want to strip the "(os error X)" suffix. match self.inner.kind() { NotFound => "No such file or directory", + // Rust maps both EACCES and EPERM to PermissionDenied. GNU + // prints "Operation not permitted" for EPERM (e.g. touch on a + // writable file owned by someone else) and "Permission denied" + // for EACCES. Prefer the errno when available (#15020). + #[cfg(unix)] + PermissionDenied + if self.inner.raw_os_error() == Some(nix::errno::Errno::EPERM as i32) => + { + "Operation not permitted" + } PermissionDenied => "Permission denied", ConnectionRefused => "Connection refused", ConnectionReset => "Connection reset", @@ -424,7 +448,9 @@ impl Display for UIoError { AddrInUse => "Address in use", AddrNotAvailable => "Address not available", BrokenPipe => "Broken pipe", - AlreadyExists => "Already exists", + // strerror(EEXIST); GNU prints "File exists", not the Rust + // ErrorKind name. + AlreadyExists => "File exists", WouldBlock => "Would block", InvalidInput => "Invalid input", InvalidData => "Invalid data", @@ -432,6 +458,7 @@ impl Display for UIoError { WriteZero => "Write zero", Interrupted => "Interrupted", UnexpectedEof => "Unexpected end of file", + IsADirectory => "Is a directory", _ => { // TODO: When the new error variants // (https://github.com/rust-lang/rust/issues/86442) @@ -458,6 +485,27 @@ impl Display for UIoError { } /// Strip the trailing " (os error XX)" from io error strings. +/// +/// Rust renders OS errors as `"No such file or directory (os error 2)"`, while GNU +/// coreutils only prints the `strerror` part. Use this when formatting a message +/// that has to match GNU's output. +/// +/// # Examples +/// +/// ``` +/// use std::io::{Error, ErrorKind}; +/// use uucore::error::strip_errno; +/// +/// #[cfg(unix)] +/// { +/// let err = Error::from_raw_os_error(2); +/// assert_eq!(strip_errno(&err), "No such file or directory"); +/// } +/// +/// // Errors without an errno are returned unchanged. +/// let err = Error::new(ErrorKind::Other, "custom failure"); +/// assert_eq!(strip_errno(&err), "custom failure"); +/// ``` pub fn strip_errno(err: &std::io::Error) -> String { let mut msg = err.to_string(); if let Some(pos) = msg.find(" (os error ") { @@ -652,13 +700,54 @@ macro_rules! uio_error( pub struct ExitCode(pub i32); impl ExitCode { - #[allow(clippy::new_ret_no_self)] /// Create a new `ExitCode` with a given exit code. + #[cold] + #[inline(never)] + #[allow(clippy::new_ret_no_self)] pub fn new(code: i32) -> Box { Box::new(Self(code)) } } +/// The error to return once its message may already have reached stderr. +/// +/// A utility that renders its own report — a caret diagnostic, a warning it +/// printed itself — has already said everything the error would say, and +/// returning the error too would print the message twice. This keeps the exit +/// code without the second message: the code is taken from the error itself, +/// so both paths always agree on it. +/// +/// # Arguments +/// +/// * `reported` - Whether the message has already been written to stderr. +/// * `error` - The error that would otherwise be printed. +/// +/// # Returns +/// +/// A bare [`ExitCode`] carrying `error`'s code when `reported`, and `error` +/// itself otherwise. An error that asks for a usage hint still gets one: the +/// hint is not part of the message the report replaced. +/// +/// # Examples +/// +/// ``` +/// use uucore::error::{USimpleError, quiet_if_reported}; +/// +/// let reported = false; // e.g. a caret diagnostic could not be rendered +/// let error = quiet_if_reported(reported, USimpleError::new(2, "bad key".to_string())); +/// assert_eq!(error.code(), 2); +/// ``` +pub fn quiet_if_reported>>(reported: bool, error: E) -> Box { + let error = error.into(); + if !reported { + return error; + } + if error.usage() { + return UUsageError::new(error.code(), String::new()); + } + ExitCode::new(error.code()) +} + impl Error for ExitCode {} impl Display for ExitCode { @@ -766,7 +855,7 @@ impl Display for ClapErrorWrapper { 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()); + print_diagnostic("", format_args!("{print_fail}")); // 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); @@ -778,6 +867,34 @@ impl Display for ClapErrorWrapper { #[cfg(test)] mod tests { + use super::{USimpleError, UUsageError, quiet_if_reported}; + + /// A quieted error keeps its code but says nothing: the report already did. + #[test] + fn a_reported_error_carries_only_its_code() { + let error = quiet_if_reported(true, USimpleError::new(3, "bad size".to_string())); + assert_eq!(error.code(), 3); + assert_eq!(error.to_string(), ""); + assert!(!error.usage()); + } + + /// Quieting a usage error must not swallow its "Try --help" hint, or the + /// output would depend on whether a caret happened to be drawn. + #[test] + fn a_reported_usage_error_still_asks_for_the_hint() { + let error = quiet_if_reported(true, UUsageError::new(125, "bad mode".to_string())); + assert_eq!(error.code(), 125); + assert_eq!(error.to_string(), ""); + assert!(error.usage()); + } + + #[test] + fn an_unreported_error_is_left_alone() { + let error = quiet_if_reported(false, UUsageError::new(125, "bad mode".to_string())); + assert_eq!(error.to_string(), "bad mode"); + assert!(error.usage()); + } + #[test] #[cfg(unix)] fn test_nix_error_conversion() { @@ -800,5 +917,12 @@ mod tests { .unwrap_err() .to_string() ); + assert_eq!( + "test: Operation not permitted", + Err::<(), nix::Error>(Errno::EPERM) + .map_err_context(|| String::from("test")) + .unwrap_err() + .to_string() + ); } } diff --git a/src/uucore/src/lib/mods/io.rs b/src/uucore/src/lib/mods/io.rs index eccbd10e37a..f63949a44d4 100644 --- a/src/uucore/src/lib/mods/io.rs +++ b/src/uucore/src/lib/mods/io.rs @@ -99,14 +99,7 @@ impl OwnedFileDescriptorOrHandle { /// instantiates a corresponding `Stdio` #[cfg(not(target_os = "wasi"))] pub fn into_stdio(self) -> Stdio { - #[cfg(not(target_os = "wasi"))] - { - Stdio::from(self.fx) - } - #[cfg(target_os = "wasi")] - { - Stdio::from(File::from(self.fx)) - } + Stdio::from(self.fx) } /// WASI: Stdio::from(OwnedFd) is not available, convert via File instead. @@ -134,3 +127,46 @@ impl From for Stdio { value.into_stdio() } } + +/// Read and discard up to `n` bytes from `reader`, using a `buf_size` buffer. +/// +/// Returns the number of bytes actually read; a value less than `n` means the +/// reader hit EOF first. Reads are retried on [`io::ErrorKind::Interrupted`]. +/// This is used to skip over the start of an input that cannot be `seek`ed. +pub fn read_and_discard(reader: &mut R, n: u64, buf_size: usize) -> io::Result { + use io::Read; + let mut buf = Vec::new(); + buf.try_reserve(buf_size.min(n as usize))?; + let mut total = 0u64; + while total < n { + let to_read = (n - total).min(buf_size as u64); + buf.clear(); + match reader.by_ref().take(to_read).read_to_end(&mut buf) { + Ok(0) => break, // EOF + Ok(read) => total += read as u64, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(total) +} + +#[cfg(test)] +mod tests { + use super::read_and_discard; + use std::io::Cursor; + + #[test] + fn discard_within_input() { + let mut r = Cursor::new(b"abcdefgh".to_vec()); + assert_eq!(read_and_discard(&mut r, 3, 4).unwrap(), 3); + assert_eq!(r.position(), 3); + } + + #[test] + fn discard_stops_at_eof() { + let mut r = Cursor::new(b"abc".to_vec()); + // Asking for more than is available returns only what was read. + assert_eq!(read_and_discard(&mut r, 100, 4).unwrap(), 3); + } +} diff --git a/src/uucore/src/lib/mods/line_ending.rs b/src/uucore/src/lib/mods/line_ending.rs index b7fefe97ebb..9992607be0f 100644 --- a/src/uucore/src/lib/mods/line_ending.rs +++ b/src/uucore/src/lib/mods/line_ending.rs @@ -2,9 +2,11 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + //! Provides consistent newline/zero terminator handling for `-z`/`--zero` flags. //! //! See the [`LineEnding`] struct for more information. + use std::fmt::Display; /// Line ending of either `\n` or `\0` @@ -18,8 +20,8 @@ use std::fmt::Display; #[repr(u8)] #[derive(Clone, Copy, Debug, Default, PartialEq)] pub enum LineEnding { - #[default] /// Newline character (`\n`) + #[default] Newline = b'\n', /// Null character (`\0`) diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 73bdb8b2350..6dcb4a3a42d 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:disable use crate::error::UError; @@ -9,17 +10,17 @@ use crate::error::UError; use fluent::{FluentArgs, FluentBundle, FluentResource}; use fluent_syntax::parser::ParserError; -use std::cell::Cell; +use std::cell::RefCell; +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; -use std::sync::OnceLock; +use std::sync::{Mutex, OnceLock, PoisonError}; use os_display::Quotable; -use thiserror::Error; use unic_langid::LanguageIdentifier; -#[derive(Error, Debug)] +#[derive(Debug, thiserror::Error)] pub enum LocalizationError { #[error("I/O error loading '{path}': {source}")] Io { @@ -93,14 +94,19 @@ impl Localizer { } // Fall back to English bundle if available - #[expect(clippy::collapsible_if)] - if let Some(ref fallback) = self.fallback_bundle { - if let Some(message) = fallback.get_message(id).and_then(|m| m.value()) { - let mut errs = Vec::new(); - return fallback - .format_pattern(message, args, &mut errs) - .to_string(); - } + if let Some(ref fallback) = self.fallback_bundle + && let Some(message) = fallback.get_message(id).and_then(|m| m.value()) + { + let mut errs = Vec::new(); + return fallback + .format_pattern(message, args, &mut errs) + .to_string(); + } + + // Only now, once nothing common has matched, is it worth parsing the + // strings that only an error path asks for. + if let Some(message) = errors_message(self.primary_bundle.locales.as_slice(), id, args) { + return message; } // Return the key ID if not found anywhere @@ -108,12 +114,114 @@ impl Localizer { } } +/// Look `id` up among the strings only an error path ever asks for. +/// +/// Their resource is deliberately not part of the bundle every utility builds +/// at startup: parsing it costs every binary, while almost no run reads one of +/// these. It is parsed here instead, on the first lookup that reaches it, and +/// kept for the rest of the process. +fn errors_message( + locales: &[LanguageIdentifier], + id: &str, + args: Option<&FluentArgs>, +) -> Option { + ERRORS_BUNDLE.with(|cell| { + let bundle = cell.get_or_init(|| build_errors_bundle(locales)).as_ref()?; + let message = bundle.get_message(id)?.value()?; + let mut errs = Vec::new(); + Some(bundle.format_pattern(message, args, &mut errs).to_string()) + }) +} + +/// The error strings for `locale`, from the locales directory when there is +/// one to read and from the embedded copy otherwise. +fn errors_resource(locale: &LanguageIdentifier) -> Option { + let from_disk = get_locales_dir("uucore") + .ok() + .and_then(|dir| fs::read_to_string(dir.join("errors").join(format!("{locale}.ftl"))).ok()); + from_disk + .or_else(|| get_embedded_locale(&format!("uucore-errors/{locale}.ftl")).map(str::to_owned)) +} + +/// Build the bundle behind [`errors_message`]: English underneath, so a locale +/// that has not translated one of these still says something, and the +/// requested locale over the top of it. +fn build_errors_bundle(locales: &[LanguageIdentifier]) -> Option> { + build_errors_bundle_with(locales, errors_resource) +} + +fn build_errors_bundle_with( + locales: &[LanguageIdentifier], + mut resource_for: impl FnMut(&LanguageIdentifier) -> Option, +) -> Option> { + let default_locale = LanguageIdentifier::from_str(DEFAULT_LOCALE) + .expect("Default locale should always be valid"); + let locale = locales.first().unwrap_or(&default_locale).clone(); + + // Own the resources in this thread-local bundle so one thread's requested + // locale cannot populate a process-wide cache used by another locale. + let mut bundle: FluentBundle = FluentBundle::new(vec![locale.clone()]); + bundle.set_use_isolating(false); + + let mut any = false; + if let Some(content) = resource_for(&default_locale) + && let Ok(resource) = parse_fluent_resource_owned(&content) + { + bundle.add_resource_overriding(resource); + any = true; + } + if locale != default_locale + && let Some(content) = resource_for(&locale) + && let Ok(resource) = parse_fluent_resource_owned(&content) + { + bundle.add_resource_overriding(resource); + any = true; + } + + any.then_some(bundle) +} + // Cache localizer. FluentResource cannot be shared between threads while FluentBundle can be shared static UUCORE_FLUENT: OnceLock = OnceLock::new(); static CHECKSUM_FLUENT: OnceLock = OnceLock::new(); -static UTIL_FLUENT: OnceLock = OnceLock::new(); +// Other resources, keyed by their source (a file path or an embedded key). A +// process can host several utilities (e.g. nushell) and switch between them, so +// each source is parsed and leaked once instead of on every switch +static RESOURCES: Mutex> = Mutex::new(BTreeMap::new()); thread_local! { - static LOCALIZER: OnceLock = const { OnceLock::new() }; + #[cfg_attr( + any( + target_os = "android", + target_os = "haiku", + target_os = "illumos", + all(target_os = "linux", target_env = "ohos"), + target_os = "openbsd", + target_os = "solaris", + all(target_os = "windows", target_env = "gnu", not(target_abi = "llvm"))), + allow( + clippy::missing_const_for_thread_local, + reason = "https://github.com/rust-lang/rust-clippy/issues/13422" + ) + )] + static LOCALIZER: RefCell> = const { RefCell::new(None) }; + /// Built on the first lookup that misses every ordinary bundle; `None` + /// when there are no error strings to be found at all. + #[cfg_attr( + any( + target_os = "android", + target_os = "haiku", + target_os = "illumos", + all(target_os = "linux", target_env = "ohos"), + target_os = "openbsd", + target_os = "solaris", + all(target_os = "windows", target_env = "gnu", not(target_abi = "llvm"))), + allow( + clippy::missing_const_for_thread_local, + reason = "https://github.com/rust-lang/rust-clippy/issues/13422" + ) + )] + static ERRORS_BUNDLE: OnceLock>> = + const { OnceLock::new() }; } /// Helper function to find the uucore locales directory from a utility's locales directory @@ -123,16 +231,24 @@ fn find_uucore_locales_dir(utility_locales_dir: &Path) -> Option { .canonicalize() .unwrap_or_else(|_| utility_locales_dir.to_path_buf()); - // Walk up: locales -> printenv -> uu -> src - let uucore_locales = normalized_dir - .parent()? // printenv - .parent()? // uu - .parent()? // src - .join("uucore") - .join("locales"); - - // Only return if the directory actually exists - uucore_locales.exists().then_some(uucore_locales) + // In the source tree, walk up: locales -> printenv -> uu -> src + let in_source_tree = normalized_dir + .parent() // printenv + .and_then(Path::parent) // uu + .and_then(Path::parent) // src + .map(|src| src.join("uucore").join("locales")); + + // Next to an installed binary, the directory sits beside the one of the + // utility: /printenv -> /uucore + let installed = normalized_dir + .parent() + .map(|locales| locales.join("uucore")); + + // Only return a directory that actually exists + [in_source_tree, installed] + .into_iter() + .flatten() + .find(|dir| dir.exists()) } /// Create a bundle that combines common and utility-specific strings @@ -147,15 +263,20 @@ fn create_bundle( bundle.set_use_isolating(false); let mut try_add_resource_from = |dir_opt: Option| -> bool { - if let Some(resource) = dir_opt - .map(|dir| dir.join(format!("{locale}.ftl"))) - .and_then(|locale_path| fs::read_to_string(locale_path).ok()) - // On parse errors, use the partial resource which contains all - // successfully parsed messages - .map(|ftl| FluentResource::try_new(ftl).unwrap_or_else(|(partial, _)| partial)) - { - // use Box::leak to provide 'static lifetime for shared FluentBundle between threads - bundle.add_resource_overriding(Box::leak(Box::new(resource))); + let Some(locale_path) = dir_opt.map(|dir| dir.join(format!("{locale}.ftl"))) else { + return false; + }; + let resource = cached_resource( + locale_path.to_string_lossy().into_owned(), + || -> Result<_, ()> { + let ftl = fs::read_to_string(&locale_path).map_err(|_| ())?; + // On parse errors, use the partial resource which contains all + // successfully parsed messages + Ok(FluentResource::try_new(ftl).unwrap_or_else(|(partial, _)| partial)) + }, + ); + if let Ok(resource) = resource { + bundle.add_resource_overriding(resource); true } else { false @@ -227,25 +348,12 @@ fn init_localization( } }; - LOCALIZER.with(|lock| { - lock.set(loc) - .map_err(|_| LocalizationError::Bundle("Localizer already initialized".into())) - })?; + set_localizer(loc); Ok(()) } -/// Helper function to parse FluentResource from content string -fn parse_fluent_resource( - content: &str, - cache: &'static OnceLock, -) -> Result<&'static FluentResource, LocalizationError> { - // global cache breaks unit tests - #[cfg(not(test))] - if let Some(res) = cache.get() { - return Ok(res); - } - - let resource = FluentResource::try_new(content.to_string()).map_err( +fn parse_fluent_resource_owned(content: &str) -> Result { + FluentResource::try_new(content.to_string()).map_err( |(_partial_resource, errs): (FluentResource, Vec)| { if let Some(first_err) = errs.into_iter().next() { let snippet = first_err @@ -262,7 +370,21 @@ fn parse_fluent_resource( LocalizationError::LocalesDirNotFound("Parse error without details".to_string()) } }, - )?; + ) +} + +/// Helper function to parse FluentResource from content string +fn parse_fluent_resource( + content: &str, + cache: &'static OnceLock, +) -> Result<&'static FluentResource, LocalizationError> { + // global cache breaks unit tests + #[cfg(not(test))] + if let Some(res) = cache.get() { + return Ok(res); + } + + let resource = parse_fluent_resource_owned(content)?; // global cache breaks unit tests if cfg!(not(test)) { Ok(cache.get_or_init(|| resource)) @@ -271,6 +393,21 @@ fn parse_fluent_resource( } } +/// Return the resource cached under `key`, parsing and caching it on first use. +/// Bundles shared between threads need `&'static` resources, so they are leaked. +fn cached_resource( + key: String, + parse: impl FnOnce() -> Result, +) -> Result<&'static FluentResource, E> { + let mut cache = RESOURCES.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(res) = cache.get(&key) { + return Ok(res); + } + let resource: &'static FluentResource = Box::leak(Box::new(parse()?)); + cache.insert(key, resource); + Ok(resource) +} + /// Create a bundle from embedded English locale files with common uucore strings fn create_english_bundle_from_embedded( locale: &LanguageIdentifier, @@ -303,7 +440,9 @@ fn create_english_bundle_from_embedded( // Then, try to load utility-specific strings let locale_key = format!("{util_name}/en-US.ftl"); if let Some(ftl_content) = get_embedded_locale(&locale_key) { - let resource = parse_fluent_resource(ftl_content, &UTIL_FLUENT)?; + let resource = cached_resource(format!("embedded:{locale_key}"), || { + parse_fluent_resource_owned(ftl_content) + })?; bundle.add_resource_overriding(resource); } @@ -320,8 +459,8 @@ fn create_english_bundle_from_embedded( } /// Create a bundle from embedded locale files for any locale on WASI. -/// Bypasses the global OnceLock cache (uses Box::leak) so it can be -/// called for multiple locales in the same process. +/// Resources are cached per key, so it can be called for multiple locales +/// in the same process. #[cfg(target_os = "wasi")] fn create_wasi_bundle_from_embedded( locale: &LanguageIdentifier, @@ -332,10 +471,12 @@ fn create_wasi_bundle_from_embedded( bundle.set_use_isolating(false); let mut try_add = |key: &str| { - if let Some(content) = get_embedded_locale(key) { - if let Ok(resource) = FluentResource::try_new(content.to_string()) { - bundle.add_resource_overriding(Box::leak(Box::new(resource))); - } + if let Some(content) = get_embedded_locale(key) + && let Ok(resource) = cached_resource(format!("embedded:{key}"), || { + FluentResource::try_new(content.to_string()).map_err(|_| ()) + }) + { + bundle.add_resource_overriding(resource); } }; @@ -354,9 +495,13 @@ fn create_wasi_bundle_from_embedded( } } +fn set_localizer(localizer: Localizer) { + LOCALIZER.with_borrow_mut(|slot| *slot = Some(localizer)); +} + fn get_message_internal(id: &str, args: Option) -> String { - LOCALIZER.with(|lock| { - lock.get() + LOCALIZER.with_borrow(|slot| { + slot.as_ref() .map_or_else(|| id.to_string(), |loc| loc.format(id, args.as_ref())) // Return the key ID if localizer not initialized }) } @@ -424,6 +569,25 @@ pub fn get_message_with_args(id: &str, ftl_args: FluentArgs) -> String { get_message_internal(id, Some(ftl_args)) } +/// The value as an `i64` when Fluent can represent it exactly, else `None`. +#[doc(hidden)] +pub fn exact_fluent_integer(s: &str) -> Option { + // Fluent stores numbers as f64, which represents integers exactly only up + // to 2^53. Anything beyond that has to travel as a string. + const MAX_EXACT: i64 = 1 << 53; + s.parse::().ok().filter(|n| n.abs() <= MAX_EXACT) +} + +/// Whether `s` is a plain decimal integer literal, with an optional sign. +/// +/// Used by [`translate!`] to tell an integer that Fluent cannot hold exactly +/// from a genuine float, so the former can bypass Fluent's number type. +#[doc(hidden)] +pub fn is_integer_literal(s: &str) -> bool { + let digits = s.strip_prefix(['-', '+']).unwrap_or(s); + !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) +} + /// Function to detect system locale from environment variables fn detect_system_locale() -> Result { let locale_str = std::env::var("LANG") @@ -477,9 +641,33 @@ fn detect_system_locale() -> Result { pub fn setup_localization(p: &str) -> Result<(), LocalizationError> { // Avoid duplicated and high-cost localizer setup thread_local! { - static LOCALIZER_IS_SET: Cell = const { Cell::new(false) }; + #[cfg_attr( + any( + target_os = "android", + target_os = "haiku", + target_os = "illumos", + all(target_os = "linux", target_env = "ohos"), + target_os = "openbsd", + target_os = "solaris", + all(target_os = "windows", target_env = "gnu", not(target_abi = "llvm"))), + allow( + clippy::missing_const_for_thread_local, + reason = "https://github.com/rust-lang/rust-clippy/issues/13422" + ) + )] + static LOCALIZED_UTIL: RefCell> = const { RefCell::new(None) }; + } + if LOCALIZED_UTIL.with_borrow(|util| util.as_deref() == Some(p)) { + return Ok(()); } - if LOCALIZER_IS_SET.with(Cell::get) { + let locales_dir = get_locales_dir(p); + // Callers such as the help template pass the invoked name (`dir`, `[`, + // a prefixed binary name...), which has no strings of its own: keep the + // utility that is already set up instead of switching to nothing + if LOCALIZED_UTIL.with_borrow(Option::is_some) + && locales_dir.is_err() + && get_embedded_locale(&format!("{p}/{DEFAULT_LOCALE}.ftl")).is_none() + { return Ok(()); } @@ -488,7 +676,7 @@ pub fn setup_localization(p: &str) -> Result<(), LocalizationError> { }); // Load common strings along with utility-specific strings - if let Ok(locales_dir) = get_locales_dir(p) { + if let Ok(locales_dir) = locales_dir { // Load both utility-specific and common strings init_localization(&locale, &locales_dir, p)?; } else { @@ -514,12 +702,9 @@ pub fn setup_localization(p: &str) -> Result<(), LocalizationError> { Localizer::new(english_bundle) }; - LOCALIZER.with(|lock| { - lock.set(localizer) - .map_err(|_| LocalizationError::Bundle("Localizer already initialized".into())) - })?; + set_localizer(localizer); } - LOCALIZER_IS_SET.with(|f| f.set(true)); + LOCALIZED_UTIL.with_borrow_mut(|util| *util = Some(p.to_string())); Ok(()) } @@ -554,6 +739,13 @@ fn get_locales_dir(p: &str) -> Result { { // During development, use the project's locales directory let manifest_dir = env!("CARGO_MANIFEST_DIR"); + if p == "uucore" { + let uucore_path = PathBuf::from(manifest_dir).join("locales"); + if uucore_path.exists() { + return Ok(uucore_path); + } + } + // from uucore path, load the locales directory from the program directory let dev_path = PathBuf::from(manifest_dir) .join("../uu") @@ -600,12 +792,54 @@ fn get_locales_dir(p: &str) -> Result { } } +/// Macro for retrieving localized messages, substituting arguments as text. +/// +/// [`translate!`] hands Fluent a number whenever an argument parses as one, +/// and the locale then formats it: a length of `123456` comes back as +/// `123 456` wherever thousands are grouped. Use this macro instead whenever +/// the argument is text the user typed or a value that must be echoed +/// unchanged — a file name, an escape, a width — and [`translate!`] when the +/// value really is a number the reader should see in their own conventions, +/// or when a plural selector needs one. +/// +/// # Arguments +/// +/// * `$id` - The message identifier string +/// * Key-value pairs in the format `"key" => value`, each substituted with +/// its [`ToString`] rendering +/// +/// # Examples +/// +/// ``` +/// use uucore::translate_text; +/// use fluent::FluentArgs; +/// +/// // '17' stays '17', whatever the locale does to numbers +/// let error = translate_text!("checksum-error-invalid-length", "length" => "17"); +/// ``` +#[macro_export] +macro_rules! translate_text { + ($id:expr, $($key:expr => $value:expr),+ $(,)?) => { + { + let mut args = fluent::FluentArgs::new(); + $( + args.set($key, $value.to_string()); + )+ + $crate::locale::get_message_with_args($id, args) + } + }; +} + /// Macro for retrieving localized messages with optional arguments. /// /// This macro provides a unified interface for both simple message retrieval /// and message retrieval with variable substitution. It accepts a message ID /// and optionally key-value pairs using the `"key" => value` syntax. /// +/// An argument that parses as a number is handed to Fluent as one, so the +/// locale formats it — grouping its digits, for instance. When the value has +/// to come back exactly as it went in, reach for [`translate_text!`]. +/// /// # Arguments /// /// * `$id` - The message identifier string @@ -646,8 +880,13 @@ macro_rules! translate { let mut args = fluent::FluentArgs::new(); $( let value_str = $value.to_string(); - if let Ok(num_val) = value_str.parse::() { + if let Some(num_val) = $crate::locale::exact_fluent_integer(&value_str) { args.set($key, num_val); + } else if $crate::locale::is_integer_literal(&value_str) { + // An integer Fluent cannot hold exactly. Its number type is + // f64-backed, so setting it as a number would round it; keep + // the exact decimal string instead. + args.set($key, value_str); } else if let Ok(float_val) = value_str.parse::() { args.set($key, float_val); } else { @@ -660,17 +899,50 @@ macro_rules! translate { }; } -// Re-export the macro for easier access -pub use translate; +// Re-export the macros for easier access +pub use {translate, translate_text}; #[cfg(test)] mod tests { use super::*; + + #[test] + fn embedded_locales_are_escaped_not_raw() { + // `.ftl` content is untrusted: translations sync into the tree from a + // public translation platform. A raw string ends at `"#`, so content + // holding that pair would close the literal and be compiled as Rust. + let generated = include_str!(concat!(env!("OUT_DIR"), "/embedded_locales.rs")); + assert!( + !generated.contains("Some(r\""), + "locale table uses a raw string; content containing `\"#` becomes code" + ); + } use std::env; use std::fs; use std::path::PathBuf; use tempfile::TempDir; + #[test] + fn integers_beyond_f64_precision_stay_exact() { + // Fluent's number type is f64-backed, so values past 2^53 must travel + // as strings to survive intact. + assert_eq!(exact_fluent_integer("3"), Some(3)); + assert_eq!(exact_fluent_integer("-3"), Some(-3)); + assert_eq!(exact_fluent_integer("9007199254740992"), Some(1 << 53)); + assert_eq!(exact_fluent_integer("9007199254740993"), None); + assert_eq!(exact_fluent_integer("-9007199254740993"), None); + assert_eq!(exact_fluent_integer("18446744073709551615"), None); + assert_eq!(exact_fluent_integer("1.5"), None); + + assert!(is_integer_literal("18446744073709551615")); + assert!(is_integer_literal("-7")); + assert!(is_integer_literal("+7")); + assert!(!is_integer_literal("1.5")); + assert!(!is_integer_literal("")); + assert!(!is_integer_literal("-")); + assert!(!is_integer_literal("12a")); + } + /// Test-specific helper function to create a bundle from test directory only #[cfg(test)] fn create_test_bundle( @@ -721,10 +993,7 @@ mod tests { } }; - LOCALIZER.with(|lock| { - lock.set(loc) - .map_err(|_| LocalizationError::Bundle("Localizer already initialized".into())) - })?; + set_localizer(loc); Ok(()) } @@ -906,6 +1175,28 @@ invalid-syntax = This is { $missing } } + /// The common strings also have to be found next to an installed binary, + /// where there is no source tree to walk up and the uucore directory sits + /// beside the one of the utility. + #[test] + fn test_find_uucore_locales_dir_installed_layout() { + // /share/locales/fake_util/ <- locales directory of the utility + // /share/locales/uucore/ <- common strings + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let locales = temp_dir.path().join("share").join("locales"); + let util_dir = locales.join("fake_util"); + let uucore_dir = locales.join("uucore"); + + fs::create_dir_all(&util_dir).expect("Failed to create fake util locales dir"); + assert_eq!(find_uucore_locales_dir(&util_dir), None); + + fs::create_dir_all(&uucore_dir).expect("Failed to create fake uucore locales dir"); + assert_eq!( + find_uucore_locales_dir(&util_dir), + Some(uucore_dir.canonicalize().unwrap()) + ); + } + #[test] fn test_localizer_format_primary_bundle() { let temp_dir = create_test_locales_dir(); @@ -1034,25 +1325,17 @@ invalid-syntax = This is { $missing } #[test] - fn test_init_localization_already_initialized() { + fn test_init_localization_twice_replaces_localizer() { std::thread::spawn(|| { let temp_dir = create_test_locales_dir(); - let locale = LanguageIdentifier::from_str("en-US").unwrap(); - - // Initialize once - let result1 = init_test_localization(&locale, temp_dir.path()); - assert!(result1.is_ok()); + let en_us = LanguageIdentifier::from_str("en-US").unwrap(); + let fr_fr = LanguageIdentifier::from_str("fr-FR").unwrap(); - // Try to initialize again - should fail - let result2 = init_test_localization(&locale, temp_dir.path()); - assert!(result2.is_err()); + init_test_localization(&en_us, temp_dir.path()).unwrap(); + assert_eq!(get_message("greeting"), "Hello, world!"); - match result2 { - Err(LocalizationError::Bundle(msg)) => { - assert!(msg.contains("already initialized")); - } - _ => panic!("Expected Bundle error"), - } + init_test_localization(&fr_fr, temp_dir.path()).unwrap(); + assert_eq!(get_message("greeting"), "Bonjour, le monde!"); }) .join() .unwrap(); @@ -1350,6 +1633,56 @@ invalid-syntax = This is { $missing } } + #[test] + fn test_lazy_error_resources_follow_runtime_locale() { + fn resource_for(locale: &LanguageIdentifier) -> Option { + match locale.to_string().as_str() { + "en-US" => Some(include_str!("../../../locales/errors/en-US.ftl").to_string()), + "fr-FR" => Some(include_str!("../../../locales/errors/fr-FR.ftl").to_string()), + _ => None, + } + } + + fn message_for(locale: &str, id: &str) -> String { + let locale = LanguageIdentifier::from_str(locale).unwrap(); + let bundle = build_errors_bundle_with(&[locale], resource_for).unwrap(); + let message = bundle + .get_message(id) + .and_then(|message| message.value()) + .unwrap(); + let mut errors = Vec::new(); + bundle + .format_pattern(message, None, &mut errors) + .to_string() + } + + assert_eq!( + message_for("en-US", "size-diag-label-invalid-suffix"), + "not a known unit" + ); + assert_eq!( + message_for("fr-FR", "size-diag-label-invalid-suffix"), + "unité inconnue" + ); + assert_eq!( + message_for("en-US", "checksum-error-need-algorithm-to-hash"), + "Needs an algorithm to hash with.\nUse --help for more information." + ); + assert_eq!( + message_for("fr-FR", "checksum-error-need-algorithm-to-hash"), + "Un algorithme de hachage est nécessaire.\nUtilisez --help pour plus d'informations." + ); + } + + #[cfg(debug_assertions)] + #[test] + fn test_uucore_locale_directory_in_development() { + assert_eq!( + get_locales_dir("uucore").unwrap(), + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("locales") + ); + } + #[test] fn test_localization_error_from_io_error() { let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found"); @@ -1511,6 +1844,69 @@ invalid-syntax = This is { $missing .unwrap(); } + #[test] + fn test_setup_localization_second_util_on_same_thread() { + // A process hosting several utilities (e.g. nushell) sets them up one + // after another on the same thread + std::thread::spawn(|| { + unsafe { + env::set_var("LANG", "en-US"); + } + + setup_localization("test").unwrap(); + assert_eq!( + get_message("test-about"), + "Check file types and compare values." + ); + + setup_localization("whoami").unwrap(); + assert_eq!(get_message("whoami-about"), "Print the current username."); + }) + .join() + .unwrap(); + } + + #[test] + fn test_setup_localization_keeps_util_for_name_without_strings() { + // e.g. `dir` or a prefixed binary name passed by the help template + std::thread::spawn(|| { + unsafe { + env::set_var("LANG", "en-US"); + } + + setup_localization("test").unwrap(); + setup_localization("no-such-util").unwrap(); + assert_eq!( + get_message("test-about"), + "Check file types and compare values." + ); + }) + .join() + .unwrap(); + } + + #[test] + fn test_bundles_reuse_cached_resources() { + // Switching utilities rebuilds bundles; the resources must come from + // the cache instead of being parsed and leaked again + fn assert_cached(key: String) { + cached_resource(key, || -> Result { + panic!("resource is not cached") + }) + .unwrap(); + } + let en_us = LanguageIdentifier::from_str("en-US").unwrap(); + + let bundle = create_english_bundle_from_embedded(&en_us, "whoami").unwrap(); + assert!(bundle.has_message("whoami-about")); + assert_cached("embedded:whoami/en-US.ftl".to_string()); + + let locales_dir = get_locales_dir("test").unwrap(); + let bundle = create_bundle(&en_us, &locales_dir, "test").unwrap(); + assert!(bundle.has_message("test-about")); + assert_cached(locales_dir.join("en-US.ftl").to_string_lossy().into_owned()); + } + #[test] fn test_error_display() { let io_error = LocalizationError::Io { diff --git a/src/uucore/src/lib/mods/os.rs b/src/uucore/src/lib/mods/os.rs index 88a6a23f113..96ed34d4caa 100644 --- a/src/uucore/src/lib/mods/os.rs +++ b/src/uucore/src/lib/mods/os.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (path) osrelease //! Test if the program is running under WSL diff --git a/src/uucore/src/lib/mods/panic.rs b/src/uucore/src/lib/mods/panic.rs index 3df3c4f197a..22224bd23b1 100644 --- a/src/uucore/src/lib/mods/panic.rs +++ b/src/uucore/src/lib/mods/panic.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + //! Custom panic hooks that allow silencing certain types of errors. //! //! Use the [`mute_sigpipe_panic`] function to silence panics caused by @@ -13,6 +14,7 @@ //! $ seq inf | head -n 1 //! ``` //! + use std::panic::{self, PanicHookInfo}; /// Decide whether a panic was caused by a broken pipe (SIGPIPE) error. diff --git a/src/uucore/src/lib/mods/posix.rs b/src/uucore/src/lib/mods/posix.rs index 47bdd7692c2..2ead419f5bf 100644 --- a/src/uucore/src/lib/mods/posix.rs +++ b/src/uucore/src/lib/mods/posix.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (vars) + //! Iterate over lines, including the line ending character(s). //! //! This module provides the [`posix_version`] function, that returns @@ -18,6 +20,7 @@ //! `touch` (TBD) //! `uniq` //! + use std::env; /// '199209' for POSIX 1003.2-1992, which would define Obsolete mode diff --git a/src/uucore_procs/Cargo.toml b/src/uucore_procs/Cargo.toml index 99e57e4af38..e07f366729c 100644 --- a/src/uucore_procs/Cargo.toml +++ b/src/uucore_procs/Cargo.toml @@ -16,5 +16,8 @@ test = false doctest = false [dependencies] -proc-macro2 = "1.0.81" -quote = "1.0.36" +proc-macro2.workspace = true +quote.workspace = true + +[lints] +workspace = true diff --git a/src/uucore_procs/src/lib.rs b/src/uucore_procs/src/lib.rs index e2aeb18101e..afb2dd54622 100644 --- a/src/uucore_procs/src/lib.rs +++ b/src/uucore_procs/src/lib.rs @@ -2,10 +2,11 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// + // spell-checker:ignore SIGSEGV //! A collection of procedural macros for uutils. + #![deny(missing_docs)] use proc_macro::TokenStream; @@ -32,7 +33,7 @@ pub fn main(args: TokenStream, stream: TokenStream) -> TokenStream { // 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(all(#signals, unix))] + #[cfg(all(#signals, unix, not(any(target_os = "fuchsia", target_os = "haiku"))))] uucore::init_startup_state_capture!(); pub fn uumain(args: impl uucore::Args) -> i32 { @@ -42,7 +43,7 @@ pub fn main(args: TokenStream, stream: TokenStream) -> TokenStream { // The Rust runtime ignores SIGPIPE, but we need to respect the parent's // signal disposition for proper pipeline behavior (GNU compatibility). // needed even for true --version - #[cfg(unix)] + #[cfg(all(unix, not(any(target_os = "fuchsia", target_os = "haiku"))))] if !uucore::signals::sigpipe_was_ignored() { let _ = uucore::signals::enable_pipe_errors(); } diff --git a/tests/by-util/test_arch.rs b/tests/by-util/test_arch.rs index 2936d42cdf6..6761dc1f3e0 100644 --- a/tests/by-util/test_arch.rs +++ b/tests/by-util/test_arch.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use uutests::new_ucmd; #[test] diff --git a/tests/by-util/test_b2sum.rs b/tests/by-util/test_b2sum.rs index 08b13fabf90..eea837fd630 100644 --- a/tests/by-util/test_b2sum.rs +++ b/tests/by-util/test_b2sum.rs @@ -3,12 +3,14 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore checkfile, testf, ntestf + 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] @@ -175,6 +177,19 @@ fn test_check_b2sum_length_option_8() { .stdout_only("testf: OK\n"); } +#[test] +fn test_check_status_reports_unusable_checksum_input() { + // --status silences per-file results, but a checksum list with no usable + // line is an input error and must still be reported. + new_ucmd!() + .arg("-c") + .arg("--status") + .pipe_in("not-a-checksum-line\n") + .fails() + .no_stdout() + .stderr_contains("'standard input': no properly formatted checksum lines found"); +} + #[test] fn test_invalid_b2sum_length_option_not_multiple_of_8() { let scene = TestScenario::new(util_name!()); diff --git a/tests/by-util/test_base32.rs b/tests/by-util/test_base32.rs index fbff336b86b..1816cb2927b 100644 --- a/tests/by-util/test_base32.rs +++ b/tests/by-util/test_base32.rs @@ -2,8 +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(unix)] use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -171,7 +171,7 @@ fn test_encode_large_input_is_buffered() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] fn test_base32_file_with_trailing_slash() { let (at, mut ucmd) = at_and_ucmd!(); at.write("a", "b"); diff --git a/tests/by-util/test_base64.rs b/tests/by-util/test_base64.rs index 8c9dac7ef27..e9d7da79362 100644 --- a/tests/by-util/test_base64.rs +++ b/tests/by-util/test_base64.rs @@ -5,7 +5,7 @@ // spell-checker:ignore unpadded, QUJD -#[cfg(target_os = "linux")] +#[cfg(unix)] use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -219,6 +219,18 @@ fn test_wrap_bad_arg() { } } +#[test] +fn test_wrap_negative_arg() { + // GNU treats the token after -w as the wrap size even if it starts with '-'. + for arg in ["-5", "-d"] { + new_ucmd!() + .arg("-w") + .arg(arg) + .fails() + .stderr_only(format!("base64: invalid wrap size: '{arg}'\n")); + } +} + #[test] fn test_base64_extra_operand() { // Expect a failure when multiple files are specified. @@ -244,7 +256,7 @@ fn test_no_repeated_trailing_newline() { .pipe_in("The quick brown fox jumps over the lazy dog.") .succeeds() .stdout_only( - // cSpell:disable + // spell-checker:disable "\ VGhlIHF1aW NrIGJyb3du @@ -253,7 +265,7 @@ IGZveCBqdW IHRoZSBsYX p5IGRvZy4= ", - // cSpell:enable + // spell-checker:enable ); } @@ -266,13 +278,13 @@ fn test_wrap_default() { .pipe_in(PIPE_IN) .succeeds() .stdout_only( - // cSpell:disable + // spell-checker:disable "\ VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4gVGhlIHF1aWNrIGJy b3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4gVGhlIHF1aWNrIGJyb3duIGZveCBqdW1w cyBvdmVyIHRoZSBsYXp5IGRvZy4= ", - // cSpell:enable + // spell-checker:enable ); } @@ -287,7 +299,7 @@ fn test_read_error() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] fn test_base64_file_with_trailing_slash() { let (at, mut ucmd) = at_and_ucmd!(); at.write("a", "b"); diff --git a/tests/by-util/test_basename.rs b/tests/by-util/test_basename.rs index 62b1481ca21..2b85116e064 100644 --- a/tests/by-util/test_basename.rs +++ b/tests/by-util/test_basename.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (words) reallylongexecutable nbaz use uutests::new_ucmd; @@ -47,6 +48,31 @@ fn test_file() { .stdout_only("passwd\n"); } +#[test] +fn test_trailing_separators() { + for (path, expected) in [ + ("foo/bar", "bar\n"), + ("foo/bar/", "bar\n"), + ("foo/bar///", "bar\n"), + ("foo/./", ".\n"), + ("foo/.//", ".\n"), + ] { + new_ucmd!().arg(path).succeeds().stdout_only(expected); + } + + let separator = std::path::MAIN_SEPARATOR; + let native_dot_path = format!("foo{separator}.{separator}{separator}"); + new_ucmd!() + .arg(native_dot_path) + .succeeds() + .stdout_only(".\n"); + + let root = if cfg!(windows) { "\\\n" } else { "/\n" }; + for path in ["/", "//", "///"] { + new_ucmd!().arg(path).succeeds().stdout_only(root); + } +} + #[test] fn test_remove_suffix() { new_ucmd!() @@ -136,7 +162,7 @@ fn test_too_many_args_output() { .usage_error("extra operand 'c'"); } -#[cfg(any(unix, target_os = "redox"))] +#[cfg(unix)] #[test] #[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_invalid_utf8_args() { @@ -144,7 +170,7 @@ fn test_invalid_utf8_args() { .expect("Only unix platforms can test non-unicode names"); new_ucmd!() - .arg(¶m) + .arg(param) .succeeds() .stdout_is_bytes(b"some-\xc0-file.k\xf3\n"); @@ -152,18 +178,12 @@ fn test_invalid_utf8_args() { .expect("Only unix platforms can test non-unicode names"); new_ucmd!() - .arg(¶m) - .arg(&suffix) + .arg(param) + .arg(suffix) .succeeds() .stdout_is_bytes(b"some-\xc0-file\n"); } -#[test] -fn test_root() { - let expected = if cfg!(windows) { "\\\n" } else { "/\n" }; - new_ucmd!().arg("/").succeeds().stdout_is(expected); -} - #[test] fn test_double_slash() { // TODO The GNU tests seem to suggest that some systems treat "//" @@ -181,12 +201,6 @@ fn test_double_slash() { .stdout_is(expected); } -#[test] -fn test_triple_slash() { - let expected = if cfg!(windows) { "\\\n" } else { "/\n" }; - new_ucmd!().arg("///").succeeds().stdout_is(expected); -} - #[test] fn test_trailing_dot() { new_ucmd!().arg("/.").succeeds().stdout_is(".\n"); diff --git a/tests/by-util/test_basenc.rs b/tests/by-util/test_basenc.rs index ff3b7f43335..e4f2c3d3f84 100644 --- a/tests/by-util/test_basenc.rs +++ b/tests/by-util/test_basenc.rs @@ -3,8 +3,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker: ignore (encodings) lsbf msbf -// spell-checker: ignore autopad MFRGG MFRGGZDF abcdeabc baddecode CPNMUO +// spell-checker:ignore (encodings) lsbf msbf +// spell-checker:ignore autopad MFRGG MFRGGZDF abcdeabc baddecode CPNMUO use uutests::{at_and_ucmd, new_ucmd}; @@ -188,6 +188,19 @@ fn test_base16() { .stdout_only("48656C6C6F2C20576F726C6421\n"); } +// This test fails if the Base16 output buffer is not explicitly flushed. +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] +fn test_base16_write_error_is_reported() { + new_ucmd!() + .arg("--base16") + .pipe_in("Hello, World!") + .set_stdout(std::fs::File::create("/dev/full").unwrap()) + .fails() + .stderr_is("basenc: No space left on device\n"); +} + #[test] fn test_base16_decode() { new_ucmd!() diff --git a/tests/by-util/test_cat.rs b/tests/by-util/test_cat.rs index df2765b79b6..402bb1a39c9 100644 --- a/tests/by-util/test_cat.rs +++ b/tests/by-util/test_cat.rs @@ -2,10 +2,11 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore NOFILE nonewline cmdline + +// spell-checker:ignore Nofile nonewline cmdline setrlimit ELOOP bcde acde #[cfg(any(target_os = "linux", target_os = "android"))] -use rlimit::Resource; +use rustix::process::Resource; #[cfg(unix)] use std::fs::File; use std::fs::OpenOptions; @@ -87,6 +88,7 @@ fn test_no_options_big_input() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: no FIFO/mkfifo support")] fn test_fifo_symlink() { use std::io::Write; use std::thread; @@ -121,6 +123,7 @@ fn test_fifo_symlink() { // TODO(#7542): Re-enable on Android once we figure out why setting limit is broken. // #[cfg(any(target_os = "linux", target_os = "android"))] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: rlimit/setrlimit not supported")] fn test_closes_file_descriptors() { // Each file creates a pipe, which has two file descriptors. // If they are not closed then five is certainly too many. @@ -132,12 +135,13 @@ fn test_closes_file_descriptors() { "alpha.txt", "alpha.txt", ]) - .limit(Resource::NOFILE, 9, 9) + .limit(Resource::Nofile, 9, 9) .succeeds(); } #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: no pipe/signal support")] fn test_broken_pipe() { let mut cmd = new_ucmd!(); let mut child = cmd @@ -514,6 +518,7 @@ fn test_squeeze_blank_before_numbering() { /// This tests reading from Unix character devices #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_dev_random() { #[cfg(any(target_os = "linux", target_os = "android"))] const DEV_RANDOM: &str = "/dev/urandom"; @@ -544,6 +549,7 @@ fn test_dev_random() { /// Wikipedia says there is support on Linux, FreeBSD, and `NetBSD`. #[test] #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_dev_full() { let mut proc = new_ucmd!() .set_stdout(Stdio::piped()) @@ -559,6 +565,7 @@ fn test_dev_full() { #[test] #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_dev_full_show_all() { let buf_len = 2048; let mut proc = new_ucmd!() @@ -581,6 +588,7 @@ fn test_dev_full_show_all() { // without additional flush output gets reversed. #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_write_fast_fallthrough_uses_flush() { const PROC_INIT_CMDLINE: &str = "/proc/1/cmdline"; let cmdline = read_to_string(PROC_INIT_CMDLINE).unwrap(); @@ -593,6 +601,7 @@ fn test_write_fast_fallthrough_uses_flush() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: no Unix domain socket support")] fn test_domain_socket() { use std::os::unix::net::UnixListener; @@ -618,10 +627,11 @@ fn test_write_to_self_empty() { .open(&file_path) .unwrap(); - s.ucmd().set_stdout(file).arg(&file_path).succeeds(); + s.ucmd().set_stdout(file).arg("file.txt").succeeds(); } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: cannot detect unsafe overwrite")] fn test_write_to_self() { let s = TestScenario::new(util_name!()); let file_path = s.fixtures.plus("first_file"); @@ -640,7 +650,7 @@ fn test_write_to_self() { .arg("first_file") .arg("first_file") .arg("second_file") - .fails_with_code(2) + .fails_with_code(1) .stderr_only("cat: first_file: input file is output file\ncat: first_file: input file is output file\n"); assert_eq!( @@ -649,60 +659,67 @@ fn test_write_to_self() { ); } -/// Test derived from the following GNU test in `tests/cat/cat-self.sh`: +/// Test that cat handles self-referential input gracefully. /// /// `cat fxy2 fy 1<>fxy2` // TODO: make this work on windows #[test] #[cfg(unix)] -fn test_successful_write_to_read_write_self() { +fn test_cat_rw_self_succeeds() { let (at, mut ucmd) = at_and_ucmd!(); - at.write("fy", "y"); - at.write("fxy2", "x"); + at.write("extra", "world"); + at.write("combined", "hello"); // Open `rw_file` as both stdin and stdout (read/write) - let fxy2_file_path = at.plus("fxy2"); - let fxy2_file = OpenOptions::new() + let combined_file_path = at.plus("combined"); + let combined_file = OpenOptions::new() .read(true) .write(true) - .open(&fxy2_file_path) + .open(&combined_file_path) .unwrap(); - ucmd.args(&["fxy2", "fy"]).set_stdout(fxy2_file).succeeds(); + ucmd.args(&["combined", "extra"]) + .set_stdout(combined_file) + .succeeds(); - // The contents of `fxy2` and `fy` files should be merged - let fxy2_contents = read_to_string(fxy2_file_path).unwrap(); - assert_eq!(fxy2_contents, "xy"); + // The contents of `combined` and `extra` files should be merged + let combined_contents = read_to_string(combined_file_path).unwrap(); + assert_eq!(combined_contents, "helloworld"); } -/// Test derived from the following GNU test in `tests/cat/cat-self.sh`: +/// Test that cat handles self-referential input gracefully. /// /// `cat fx fx3 1<>fx3` #[test] -fn test_failed_write_to_read_write_self() { +#[cfg_attr(wasi_runner, ignore = "WASI: cannot detect unsafe overwrite")] +fn test_cat_rw_self_conflict_fails() { let (at, mut ucmd) = at_and_ucmd!(); - at.write("fx", "g"); - at.write("fx3", "bold"); + at.write("source", "a"); + at.write("dest", "bcde"); // Open `rw_file` as both stdin and stdout (read/write) - let fx3_file_path = at.plus("fx3"); - let fx3_file = OpenOptions::new() + let dest_file_path = at.plus("dest"); + let dest_file = OpenOptions::new() .read(true) .write(true) - .open(&fx3_file_path) + .open(&dest_file_path) .unwrap(); - ucmd.args(&["fx", "fx3"]) - .set_stdout(fx3_file) + ucmd.args(&["source", "dest"]) + .set_stdout(dest_file) .fails_with_code(1) - .stderr_only("cat: fx3: input file is output file\n"); + .stderr_only("cat: dest: input file is output file\n"); - // The contents of `fx` should have overwritten the beginning of `fx3` - let fx3_contents = read_to_string(fx3_file_path).unwrap(); - assert_eq!(fx3_contents, "gold"); + // The contents of `source` should have overwritten the beginning of `dest` + let dest_contents = read_to_string(dest_file_path).unwrap(); + assert_eq!(dest_contents, "acde"); } #[test] #[cfg(unix)] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: symlink loop traversal does not surface ELOOP ('Too many levels of symbolic links')" +)] fn test_error_loop() { let (at, mut ucmd) = at_and_ucmd!(); at.symlink_file("2", "1"); @@ -713,6 +730,15 @@ fn test_error_loop() { .stderr_is("cat: 1: Too many levels of symbolic links\n"); } +#[test] +fn test_exit_code_is_one_regardless_of_error_count() { + // cat's exit code must always be 1 when errors occur, matching GNU cat. + // Counting is problematic, since process exit codes are truncated mod 256 by the OS + let missing_files: Vec = (0..256).map(|i| format!("missing-{i}")).collect(); + + new_ucmd!().args(&missing_files).fails_with_code(1); +} + #[test] fn test_u_ignored() { for same_param in ["-u", "-uu"] { @@ -726,6 +752,7 @@ fn test_u_ignored() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: errno/error-message mismatches")] fn test_write_fast_read_error() { use std::os::unix::fs::PermissionsExt; @@ -746,6 +773,7 @@ fn test_write_fast_read_error() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_cat_non_utf8_paths() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; @@ -769,7 +797,8 @@ fn test_cat_non_utf8_paths() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: cannot detect unsafe overwrite")] fn test_appending_same_input_output() { let (at, mut ucmd) = at_and_ucmd!(); diff --git a/tests/by-util/test_chcon.rs b/tests/by-util/test_chcon.rs index f90bbdb0c78..c409f010bfb 100644 --- a/tests/by-util/test_chcon.rs +++ b/tests/by-util/test_chcon.rs @@ -2,12 +2,11 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (jargon) xattributes + #![allow(clippy::missing_errors_doc, clippy::similar_names)] -#![cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#![cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use std::ffi::CString; use std::path::Path; diff --git a/tests/by-util/test_chgrp.rs b/tests/by-util/test_chgrp.rs index 45024745845..3f29e130a69 100644 --- a/tests/by-util/test_chgrp.rs +++ b/tests/by-util/test_chgrp.rs @@ -2,13 +2,14 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (words) nosuchgroup groupname +use rustix::process::getegid; #[cfg(not(target_vendor = "apple"))] use rustix::process::getgroups; #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStringExt; -use uucore::process::getegid; use uutests::{at_and_ucmd, new_ucmd}; #[cfg(not(target_vendor = "apple"))] use uutests::{util::TestScenario, util_name}; @@ -63,7 +64,7 @@ fn test_invalid_group() { #[test] fn test_error_1() { - if getegid() != 0 { + if !getegid().is_root() { new_ucmd!().arg("bin").arg(DIR).fails().stderr_contains( // linux fails with "Operation not permitted (os error 1)" // because of insufficient permissions, @@ -76,7 +77,7 @@ fn test_error_1() { #[test] fn test_fail_silently() { - if getegid() != 0 { + if !getegid().is_root() { for opt in ["-f", "--silent", "--quiet", "--sil", "--qui"] { new_ucmd!() .arg(opt) @@ -201,13 +202,14 @@ fn test_reference() { // skip for root or MS-WSL // * MS-WSL is bugged (as of 2019-12-25), allowing non-root accounts su-level privileges for `chgrp` // * for MS-WSL, succeeds and stdout == 'group of /etc retained as root' - if !(getegid() == 0 || uucore::os::is_wsl_1()) { + if !(getegid().is_root() || uucore::os::is_wsl_1()) { new_ucmd!() .arg("-v") .arg("--reference=/etc/passwd") .arg("/etc") .fails() - .stderr_is("chgrp: changing group of '/etc': Operation not permitted\nfailed to change group of '/etc' from root to root\n"); + // group name can differ, so just check the first part of the message + .stderr_contains("chgrp: changing group of '/etc': Operation not permitted\nfailed to change group of '/etc' from "); } } @@ -220,11 +222,11 @@ fn test_reference() { .arg("/etc") .fails() // group name can differ, so just check the first part of the message - .stderr_contains("chgrp: changing group of '/etc': Operation not permitted (os error 1)\nfailed to change group of '/etc' from "); + .stderr_contains("chgrp: changing group of '/etc': Operation not permitted\nfailed to change group of '/etc' from "); } #[test] -#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] fn test_reference_multi_no_equal() { new_ucmd!() .arg("-v") @@ -239,7 +241,7 @@ fn test_reference_multi_no_equal() { } #[test] -#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] fn test_reference_last() { new_ucmd!() .arg("-v") @@ -269,14 +271,14 @@ fn test_missing_files() { #[test] #[cfg(target_os = "linux")] fn test_big_p() { - if getegid() != 0 { + if !getegid().is_root() { new_ucmd!() .arg("-RP") .arg("bin") .arg("/proc/self/cwd") .fails() .stderr_contains( - "chgrp: changing group of '/proc/self/cwd': Operation not permitted (os error 1)\n", + "chgrp: changing group of '/proc/self/cwd': Operation not permitted\n", ); } } @@ -284,7 +286,7 @@ fn test_big_p() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] fn test_big_h() { - if getegid() != 0 { + if !getegid().is_root() { assert!( new_ucmd!() .arg("-RH") @@ -476,18 +478,28 @@ fn test_from_option() { } #[test] -#[cfg(not(any(target_os = "android", target_os = "macos")))] +#[cfg(not(any(target_vendor = "apple", target_os = "android")))] fn test_from_with_invalid_group() { - let (at, mut ucmd) = at_and_ucmd!(); + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; at.touch("test_file"); - #[cfg(not(target_os = "android"))] + let err_msg = "chgrp: invalid user: 'nonexistent_group'\n"; - #[cfg(target_os = "android")] - let err_msg = "chgrp: invalid user: 'staff'\n"; - ucmd.arg("--from") + scene + .ucmd() + .arg("--from") + .arg("nonexistent_group") + .arg("nobody") // assumption: group exists + .arg("test_file") + .fails() + .stderr_is(err_msg); + + scene + .ucmd() + .arg("--from") .arg("nonexistent_group") - .arg("staff") + .arg("another_nonexistent_group") .arg("test_file") .fails() .stderr_is(err_msg); @@ -614,7 +626,7 @@ fn test_chgrp_non_utf8_paths() { std::fs::write(at.plus(&filename), b"test content").unwrap(); // Get current user's primary group - let current_gid = getegid(); + let current_gid = getegid().as_raw(); ucmd.arg(current_gid.to_string()).arg(&filename).succeeds(); } @@ -630,7 +642,7 @@ fn test_chgrp_recursive_on_file() { at.touch("regular_file"); - let current_gid = getegid(); + let current_gid = getegid().as_raw(); ucmd.arg("-R") .arg(current_gid.to_string()) @@ -648,7 +660,7 @@ fn test_chgrp_recursive_on_file() { fn test_chgrp_exit_code_not_being_overwritten_by_last_file() { use std::os::unix::prelude::PermissionsExt; - let current_gid = getegid(); + let current_gid = getegid().as_raw(); let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("dir"); at.mkdir("dir/a"); diff --git a/tests/by-util/test_chmod.rs b/tests/by-util/test_chmod.rs index a1ac2b2b1dc..f4e07d9bdbf 100644 --- a/tests/by-util/test_chmod.rs +++ b/tests/by-util/test_chmod.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (words) dirfd subdirs openat FDCWD rwxr use std::fs::{OpenOptions, Permissions, metadata, set_permissions}; @@ -405,6 +406,30 @@ fn test_permission_denied() { .stderr_is("chmod: cannot access 'd/no-x/y': Permission denied\n"); } +#[test] +fn test_chmod_inaccessible_file_reports_permission_denied() { + // Non-recursive chmod of a file whose metadata cannot be read because its + // parent directory lacks search permission must report "Permission denied", + // not "No such file or directory" (issue #9789). `Path::exists()` collapses + // the permission error into `false`, so the fix relies on `try_exists()`. + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("locked"); + make_file(&at.plus_as_string("locked/file"), 0o100_644); + set_permissions(at.plus_as_string("locked"), Permissions::from_mode(0o000)).unwrap(); + + scene + .ucmd() + .arg("644") + .arg("locked/file") + .fails() + .stderr_is("chmod: cannot access 'locked/file': Permission denied\n"); + + // Restore search permission so the fixture directory can be cleaned up. + set_permissions(at.plus_as_string("locked"), Permissions::from_mode(0o755)).unwrap(); +} + #[test] #[allow(clippy::unreadable_literal)] fn test_chmod_recursive_correct_exit_code() { @@ -541,13 +566,92 @@ fn test_chmod_preserve_root() { #[test] fn test_chmod_preserve_root_with_paths_that_resolve_to_root() { + // Only a bare "/" is reported as such; any other spelling of the root + // directory is named as the user wrote it, followed by "(same as '/')". + // "//" also checks we compare the raw operand, since Path("//") == Path("/"). new_ucmd!() .arg("-R") .arg("--preserve-root") .arg("755") - .arg("/../") + .arg("//") .fails_with_code(1) - .stderr_contains("chmod: it is dangerous to operate recursively on '/'"); + .stderr_contains("chmod: it is dangerous to operate recursively on '//' (same as '/')"); +} + +#[test] +fn test_chmod_preserve_root_symlink_during_recursion() { + // The failsafe must be re-checked during the descent, not only for the + // operands: with -L, a symlink met inside the tree that resolves to '/' + // would otherwise be recursed into. + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("tree"); + at.symlink_dir("/", "tree/link"); + + ucmd.arg("-R") + .arg("-L") + .arg("--preserve-root") + .arg("755") + .arg("tree") + .fails_with_code(1) + .stderr_contains( + "chmod: it is dangerous to operate recursively on 'tree/link' (same as '/')", + ); +} + +#[test] +fn test_chmod_recursive_reference_does_not_follow_inner_symlink() { + // A symlink met inside the tree during `chmod -R` must not have its referent + // touched: chmod(2) follows the link, and the target can live outside the + // tree. GNU never follows such a link, and the --reference path has to behave + // like the symbolic/numeric path, which already skips it. + let (at, mut ucmd) = at_and_ucmd!(); + + // A file that lives outside the tree being modified. + at.touch("outside_target"); + at.set_mode("outside_target", 0o600); + + // The reference file carries a distinctive mode. + at.touch("ref"); + at.set_mode("ref", 0o745); + + // The tree holds a symlink pointing at the outside file. + at.mkdir("tree"); + at.symlink_file("outside_target", "tree/link"); + + ucmd.arg("-R").arg("--reference=ref").arg("tree").succeeds(); + + // The referent outside the tree keeps its mode, while the walked directory + // still picks up the reference mode. + assert_eq!( + at.metadata("outside_target").permissions().mode() & 0o7777, + 0o600 + ); + assert_eq!(at.metadata("tree").permissions().mode() & 0o7777, 0o745); +} + +#[test] +fn test_chmod_recursive_symlink_option_like_mode() { + // A symlink met inside the tree during `chmod -R` must be left alone. + // When using an option-like mode (e.g. `-w`), umask sensitivity checks + // must not trigger for symlinks inside the tree. + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir_all("d/sub"); + at.set_mode("d", 0o755); + at.set_mode("d/sub", 0o755); + at.touch("d/sub/target"); + at.set_mode("d/sub/target", 0o644); + // Verbatim reproducer from Launchpad #2167122: ln -s target d/sub/link + at.relative_symlink_file("target", "d/sub/link"); + + ucmd.umask(0o022).arg("-R").arg("-w").arg("d").succeeds(); + + assert_eq!(at.metadata("d").permissions().mode() & 0o7777, 0o555); + assert_eq!(at.metadata("d/sub").permissions().mode() & 0o7777, 0o555); + assert_eq!( + at.metadata("d/sub/target").permissions().mode() & 0o7777, + 0o444 + ); } #[test] @@ -867,6 +971,141 @@ fn test_gnu_special_options() { scene.ucmd().arg("--").arg("--").fails(); } +/// Every row of the operand matrix from +/// . +/// +/// The left column is the argument list, the right column is the exact list of operands, in order, +/// that must end up being changed. Three files named `f`, `--` and `-w` exist in every case, so a +/// row that expects `[]` is asserting that the arguments are rejected outright rather than being +/// resolved to some file that happens to exist. None of the rows are redundant: they only differ +/// pairwise in where a `--` sits, which is precisely what decides whether a leading-hyphen argument +/// is a mode or a file name. +#[test] +#[cfg(not(target_os = "android"))] +fn test_gnu_usage_matrix() { + let matrix: &[(&[&str], &[&str])] = &[ + (&["--"], &[]), + (&["--", "--"], &[]), + (&["--", "--", "--", "f"], &["--", "f"]), + (&["--", "--", "-w", "f"], &["-w", "f"]), + (&["--", "--", "f"], &["f"]), + (&["--", "-w"], &[]), + (&["--", "-w", "--", "f"], &["--", "f"]), + (&["--", "-w", "-w", "f"], &["-w", "f"]), + (&["--", "-w", "f"], &["f"]), + (&["--", "f"], &[]), + (&["-w"], &[]), + (&["-w", "--"], &[]), + (&["-w", "--", "--", "f"], &["--", "f"]), + (&["-w", "--", "-w", "f"], &["-w", "f"]), + (&["-w", "--", "f"], &["f"]), + (&["-w", "-w"], &[]), + (&["-w", "-w", "--", "f"], &["f"]), + (&["-w", "-w", "-w", "f"], &["f"]), + (&["-w", "-w", "f"], &["f"]), + (&["-w", "f"], &["f"]), + (&["f"], &[]), + (&["f", "--"], &[]), + (&["f", "-w"], &["f"]), + (&["f", "f"], &[]), + (&["u+gr", "f"], &[]), + (&["ug,+x", "f"], &[]), + ]; + + for (args, expected) in matrix { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + // 0o644 has no group or other write bit, so `-w` lands on 0o444 under any umask. This test + // is about which operands get picked, not about umask arithmetic. + for name in ["f", "--", "-w"] { + make_file(&at.plus_as_string(name), 0o644); + } + + let result = scene.ucmd().arg("-v").args(args).run(); + let context = format!("chmod -v {}", args.join(" ")); + + // `-v` names every operand it visits, whether or not the mode ends up changing anything. + let visited: Vec<&str> = result + .stdout_str() + .lines() + .filter_map(|line| line.strip_prefix("mode of '")) + .filter_map(|rest| rest.split('\'').next()) + .collect(); + assert_eq!(visited, *expected, "{context}: acted on the wrong operands"); + + // A row that names no operand is an error (a missing or invalid mode), never a silent + // no-op. + assert_eq!( + result.succeeded(), + !expected.is_empty(), + "{context}: unexpected exit status" + ); + + // Independently of `-v`, nothing outside the expected list may be touched. This is what + // catches an implementation that mistakes the file named `--` for a separator. + for name in ["f", "--", "-w"] { + if !expected.contains(&name) { + assert_eq!( + at.metadata(name).permissions().mode() & 0o7777, + 0o644, + "{context}: {name} should have been left alone" + ); + } + } + } +} + +/// `chmod` warns that the umask kept bits the mode asked to remove only when the mode was written +/// in the option-like form, i.e. as a leading-hyphen argument before any `--`. Once `--` has been +/// seen the operand is unambiguously a mode, and the change is applied silently. +#[test] +#[cfg(not(target_os = "android"))] +fn test_umask_conflict_reported_only_for_option_like_mode() { + // (arguments, resulting permission bits, whether the umask conflict is reported) + let cases: &[(&[&str], u32, bool)] = &[ + (&["-w", "file"], 0o466, true), + // A `--` after the mode does not retroactively make it an ordinary operand. + (&["-w", "--", "file"], 0o466, true), + (&["file", "-w"], 0o466, true), + (&["-w", "-w", "--", "file"], 0o466, true), + (&["--", "-w", "file"], 0o466, false), + (&["--", "-rw", "file"], 0o022, false), + // What matters is whether the argument itself began with a hyphen, not whether the mode + // contains an umask-relative clause: these two modes do the same thing, and only the one + // that looks like an option is reported. + (&["u+x,-w", "file"], 0o566, false), + (&["-w,u+x", "file"], 0o566, true), + (&["--", "-w,u+x", "file"], 0o566, false), + ]; + + for (args, expected_mode, reported) in cases { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + // 0o666 is required: on 0o644 the umask has nothing left to keep, so the conflict never + // arises and every one of these cases would pass vacuously. + make_file(&at.plus_as_string("file"), 0o666); + + let result = scene.ucmd().umask(0o022).args(args).run(); + let context = format!("chmod {}", args.join(" ")); + + assert_eq!( + at.metadata("file").permissions().mode() & 0o7777, + *expected_mode, + "{context}: wrong resulting permissions" + ); + if *reported { + result.code_is(1); + assert!( + result.stderr_str().contains("new permissions are"), + "{context}: expected the umask conflict to be reported, got {:?}", + result.stderr_str() + ); + } else { + result.success().no_stderr(); + } + } +} + #[test] fn test_chmod_dereference_symlink() { let scene = TestScenario::new(util_name!()); @@ -1366,13 +1605,13 @@ fn test_chmod_non_utf8_paths() { #[test] fn test_chmod_operator_only_still_calls_syscall() { - use uucore::process::geteuid; + use rustix::process::geteuid; // An operator with no permission letters ('+', '-', '=') leaves the mode // bits unchanged, yet chmod must still issue the chmod(2) call so that a // lack of permission is reported instead of silently succeeding. As a // non-root user, '/' (owned by root) is a file we cannot chmod. - if geteuid() == 0 { + if geteuid().is_root() { return; } if metadata("/").map_or(0, |m| m.uid()) != 0 { @@ -1435,13 +1674,13 @@ fn test_chmod_symlink_cycles() { at.set_mode("a/b", 0o755); at.set_mode("a/b/c", 0o755); + // spell-checker:disable scene .ucmd() .arg("-vRL") .arg("+r") .arg("a") .run() - // cSpell:disable .stdout_contains_line("mode of 'a' retained as 0755 (rwxr-xr-x)") .stdout_contains_line("mode of 'a/b' retained as 0755 (rwxr-xr-x)") .stdout_contains_line("mode of 'a/b/c' retained as 0755 (rwxr-xr-x)") @@ -1449,7 +1688,7 @@ fn test_chmod_symlink_cycles() { .stdout_does_not_contain("mode of 'a/b/c/d/b' retained as 0755 (rwxr-xr-x)") .stdout_does_not_contain("mode of 'a/b/c/d/b/c' retained as 0755 (rwxr-xr-x)") .stdout_does_not_contain("mode of 'a/b/c/d/b/c/d' retained as 0755 (rwxr-xr-x)"); - // cSpell:enable + // spell-checker:enable } #[test] @@ -1461,7 +1700,7 @@ fn test_chmod_symlink_two_links_same_dir() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; - // cSpell:disable + // spell-checker:disable at.mkdir_all("base/realdir"); at.touch("base/realdir/file"); at.symlink_dir("base/realdir", "base/link1"); @@ -1478,5 +1717,151 @@ fn test_chmod_symlink_two_links_same_dir() { .stdout_contains("mode of 'base/realdir/file'") .stdout_contains("mode of 'base/link1/file'") .stdout_contains("mode of 'base/link2/file'"); - // cSpell:enable + // spell-checker:enable +} + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_bad_operator() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("probe"); + + let result = ucmd + .terminal_sim_stderr() + .args(&["g+rw?x", "probe"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.contains("invalid operator"), "{stderr}"); + // The caret lands on `?`, the fifth character of the mode. + assert_eq!(result.caret_column(), Some(5), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_into_the_second_clause() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("probe"); + + let result = ucmd + .terminal_sim_stderr() + .args(&["o=r,ug!w", "probe"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + // Clauses are parsed one at a time, but the caret is placed in the + // whole mode: `!` is its seventh character. + assert_eq!(result.caret_column(), Some(7), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_marks_a_clause_with_no_operator() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("probe"); + + let result = ucmd + .terminal_sim_stderr() + .args(&["go", "probe"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.contains("invalid mode"), "{stderr}"); + assert_eq!(result.caret_column(), Some(1), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_marks_a_non_octal_mode() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("probe"); + + ucmd.terminal_sim_stderr() + .args(&["779", "probe"]) + .fails_with_code(1) + .stderr_contains("779"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_into_a_negative_mode() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("probe"); + + // A leading-dash mode is pulled out of the argument list before clap + // sees it, but the caret still lands on `%`, its fourth character. + let result = ucmd + .terminal_sim_stderr() + .args(&["-rw%x", "probe"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.contains("invalid operator"), "{stderr}"); + assert_eq!(result.caret_column(), Some(4), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_into_the_right_negative_mode() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("probe"); + + // Several negative modes are joined into one mode string before being + // parsed, but the caret still lands on `%` inside the one argument + // that carries the bad clause. + let result = ucmd + .terminal_sim_stderr() + .args(&["-w", "-r%x", "probe"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.contains("invalid operator"), "{stderr}"); + // Column 6 of `-w -r%x probe` is the `%`. + assert_eq!(result.caret_column(), Some(6), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_mode_not_a_file_with_the_same_name() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("g+rw?x"); + + // The file is named exactly like the mode; the caret belongs to the + // mode operand, the first of the two. + let result = ucmd + .terminal_sim_stderr() + .args(&["g+rw?x", "g+rw?x"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.contains("invalid operator"), "{stderr}"); + assert_eq!(result.caret_column(), Some(5), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("probe"); + + // The test harness pipes stderr, so the report must not appear. + ucmd.args(&["g+rw?x", "probe"]) + .fails_with_code(1) + .stderr_only("chmod: invalid operator (expected +, -, or =, but found ?)\n"); + } + + #[cfg(unix)] + #[test] + fn test_quiet_stays_quiet() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("probe"); + + // -f suppresses the message entirely, report included. + ucmd.terminal_sim_stderr() + .args(&["-f", "g+rw?x", "probe"]) + .fails_with_code(1) + .no_output(); + } } diff --git a/tests/by-util/test_chown.rs b/tests/by-util/test_chown.rs index 9e87bb7d17e..34b37e69e60 100644 --- a/tests/by-util/test_chown.rs +++ b/tests/by-util/test_chown.rs @@ -2,12 +2,14 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (words) agroupthatdoesntexist auserthatdoesntexist cuuser groupname notexisting passgrp + +// spell-checker:ignore (words) agroupthatdoesntexist auserthatdoesntexist groupname notexisting passgrp + #[cfg(all(unix, not(target_os = "openbsd")))] use std::os::unix::fs::MetadataExt; use uutests::util::{CmdResult, TestScenario, is_ci, run_ucmd_as_root}; -use uutests::util_name; -use uutests::{at_and_ucmd, new_ucmd}; +use uutests::{at_and_ucmd, new_ucmd, util_name}; + // Apparently some CI environments have configuration issues, e.g. with 'whoami' and 'id'. // If we are running inside the CI and "needle" is in "stderr" skipping this test is // considered okay. If we are not inside the CI this calls assert!(result.success). @@ -35,9 +37,9 @@ fn skipping_test_is_okay(result: &CmdResult, needle: &str) -> bool { false } -#[cfg(any(target_os = "linux", target_os = "android", target_os = "windows"))] +#[cfg(any(target_os = "linux", target_os = "android", windows))] const ROOT_GROUP: &str = "root"; -#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))] +#[cfg(not(any(target_os = "linux", target_os = "android", windows)))] const ROOT_GROUP: &str = "wheel"; #[cfg(test)] @@ -560,7 +562,7 @@ fn test_chown_only_group_id() { // Apparently on CI "macos-latest, x86_64-apple-darwin, feat_os_unix" // the process has the rights to change from runner:staff to runner:wheel - #[cfg(any(windows, all(unix, not(target_os = "macos"))))] + #[cfg(any(windows, all(unix, not(target_vendor = "apple"))))] // FreeBSD user on CI is part of wheel group if group_id != "0" { scene @@ -903,6 +905,26 @@ fn test_chown_reference_file() { .no_stderr(); } +#[test] +#[cfg(target_os = "linux")] +fn test_chown_reference_file_with_non_utf8_path() { + use std::os::unix::ffi::OsStrExt; + + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("file"); + + let reference = std::ffi::OsStr::from_bytes(b"reference_\xFF\xFE"); + std::fs::File::create(at.plus(reference)).unwrap(); + + ucmd.arg("--verbose") + .arg("--reference") + .arg(reference) + .arg("file") + .succeeds() + .stdout_contains("ownership of 'file' retained as") + .no_stderr(); +} + #[test] #[cfg(all(unix, not(target_os = "openbsd")))] fn test_chown_no_dereference_symlink_to_dir() { @@ -965,7 +987,7 @@ fn test_chown_symlink_cycles() { let result = scene.ucmd().arg("-vRL").arg(&user_name).arg("a").run(); - if cfg!(target_os = "macos") || cfg!(target_os = "openbsd") || cfg!(target_os = "android") { + if cfg!(target_vendor = "apple") || cfg!(target_os = "openbsd") || cfg!(target_os = "android") { result .stdout_contains(format!("ownership of 'a' retained as {user_name}")) .stdout_contains(format!("ownership of 'a/b' retained as {user_name}")) @@ -1008,7 +1030,7 @@ fn test_chown_symlink_two_links_same_dir() { let user_name = String::from(result.stdout_str().trim()); assert!(!user_name.is_empty()); - // cSpell:disable + // spell-checker:disable at.mkdir_all("base/realdir"); at.touch("base/realdir/file"); at.symlink_dir("base/realdir", "base/link1"); @@ -1031,14 +1053,14 @@ fn test_chown_symlink_two_links_same_dir() { "ownership of 'base/link2/file' retained as {user_name}" )); } - // cSpell:enable + // spell-checker:enable } #[cfg(target_os = "linux")] #[test] fn verbose_missing_file_write_error_is_reported_not_panic() { + use rustix::process::geteuid; use std::fs::OpenOptions; - use uucore::process::geteuid; let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); new_ucmd!() diff --git a/tests/by-util/test_chroot.rs b/tests/by-util/test_chroot.rs index 66e10927740..a6d5a8bbce5 100644 --- a/tests/by-util/test_chroot.rs +++ b/tests/by-util/test_chroot.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (words) araba newroot userspec chdir pwd's isroot use uutests::at_and_ucmd; @@ -37,23 +38,42 @@ fn test_enter_chroot_fails() { at.mkdir("jail"); - let result = ucmd.arg("jail").fails_with_code(125); - assert!( - result - .stderr_str() - .starts_with("chroot: cannot chroot to 'jail': Operation not permitted (os error 1)") - ); + ucmd.arg("jail") + .fails_with_code(125) + .stderr_only("chroot: cannot chroot to 'jail': Operation not permitted\n"); } #[test] -fn test_no_such_directory() { - let (at, mut ucmd) = at_and_ucmd!(); +#[cfg(target_env = "gnu")] // todo: override by proper error message on other platforms +fn test_too_long_name() { + let (_at, mut ucmd) = at_and_ucmd!(); + let long_name = "a".repeat(256); - at.touch(at.plus_as_string("a")); + ucmd.arg(&long_name) + .fails_with_code(125) + .stderr_only(format!( + "chroot: cannot chroot to '{long_name}': File name too long\n", + )); +} + +#[test] +#[cfg(target_env = "gnu")] // todo: override by proper error message on other platforms +fn test_no_such() { + let (_at, mut ucmd) = at_and_ucmd!(); + ucmd.arg("missing") + .fails_with_code(125) + .stderr_only("chroot: cannot chroot to 'missing': No such file or directory\n"); +} + +#[test] +#[cfg(target_env = "gnu")] // todo: override by proper error message on other platforms +fn test_not_a_directory() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch(at.plus_as_string("a")); ucmd.arg("a") .fails_with_code(125) - .stderr_is("chroot: cannot change root directory to 'a': no such directory\n"); + .stderr_only("chroot: cannot chroot to 'a': Not a directory\n"); } #[test] diff --git a/tests/by-util/test_cksum.rs b/tests/by-util/test_cksum.rs index 23b0341c080..c20d640731c 100644 --- a/tests/by-util/test_cksum.rs +++ b/tests/by-util/test_cksum.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (words) asdf algo algos asha mgmt xffname hexa GFYEQ HYQK Yqxb dont checkfile use rstest::rstest; @@ -42,6 +43,22 @@ fn sha_fixture_name(algo: &str, len: u32, prefix: &str, suffix: &str) -> String } } +#[test] +#[cfg(feature = "openssl")] +fn test_openssl_link() { + let Ok(out) = std::process::Command::new("ldd") + .arg(uutests::util::get_tests_binary()) + .output() + else { + return; // missing ldd + }; + let dynamic = String::from_utf8_lossy(&out.stdout).contains("crypto"); // covering MSYS/MinGW too + assert_eq!( + std::env::var("OPENSSL_NO_VENDOR") == Ok("1".to_string()), + dynamic + ); +} + #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); @@ -655,6 +672,28 @@ fn test_check_tagged_missing_algo() { .stderr_contains("no properly formatted checksum lines found"); } +#[test] +fn test_check_tagged_blanks_around_equal_sign() { + // Like GNU, accept any blanks, or none, around the '=' of a tagged line, + // with or without a space before the '('. + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("f"); + let digest = "d41d8cd98f00b204e9800998ecf8427e"; + let lines = [ + format!("MD5(f) = {digest}"), + format!("MD5 (f)= {digest}"), + format!("MD5 (f) ={digest}"), + format!("MD5 (f)={digest}"), + format!("MD5 (f)\t=\t{digest}"), + format!("MD5 (f) = {digest}"), + ]; + + ucmd.arg("-c") + .pipe_in(lines.join("\n")) + .succeeds() + .stdout_only("f: OK\n".repeat(lines.len())); +} + #[rstest] #[case::md5("md5", "d41d8cd98f00b204e9800998ecf8427e")] #[case::sha1("sha1", "da39a3ee5e6b4b0d3255bfef95601890afd80709")] @@ -1676,6 +1715,20 @@ fn test_md5_bits() { .stderr_contains("f: no properly formatted checksum lines found"); } +#[test] +fn test_blake2b_check_digest_too_long() { + let (at, mut ucmd) = at_and_ucmd!(); + // The referenced file must exist: the digest length is only used once the + // line is accepted and the file is about to be hashed. + at.write("f1", "content\n"); + // 65 bytes, above the 64 bytes BLAKE2b maximum. + at.write("sums", &format!("{} f1\n", "a".repeat(130))); + + ucmd.args(&["-a", "blake2b", "-c", "sums"]) + .fails_with_code(1) + .stderr_contains("sums: no properly formatted checksum lines found"); +} + #[test] fn test_blake2b_bits() { let (at, mut ucmd) = at_and_ucmd!(); @@ -2239,8 +2292,8 @@ fn test_check_incorrectly_formatted_checksum_keeps_processing_hex() { .stderr_contains("cksum: WARNING: 1 line is improperly formatted"); } -/// This module reimplements the cksum-base64.pl GNU test. -mod gnu_cksum_base64 { +/// Tests for cksum with base64 output encoding. +mod cksum_base64_encoding { use super::*; use uutests::util::log_info; @@ -2285,7 +2338,7 @@ mod gnu_cksum_base64 { } #[test] - fn test_generating() { + fn test_cksum_base64_generating() { // Ensure that each algorithm works with `--base64`. let scene = make_scene(); @@ -2303,7 +2356,7 @@ mod gnu_cksum_base64 { } #[test] - fn test_chk() { + fn test_cksum_base64_verify() { // For each algorithm that accepts `--check`, // ensure that it works with base64 digests. let scene = make_scene(); @@ -2335,7 +2388,7 @@ mod gnu_cksum_base64 { } #[test] - fn test_chk_eq1() { + fn test_cksum_base64_verify_truncated_eq1() { // For digests ending with '=', ensure `--check` fails if '=' is removed. let scene = make_scene(); @@ -2361,7 +2414,7 @@ mod gnu_cksum_base64 { } #[test] - fn test_chk_eq2() { + fn test_cksum_base64_verify_truncated_eq2() { // For digests ending with '==', // ensure `--check` fails if '==' is removed. let scene = make_scene(); @@ -2386,8 +2439,8 @@ mod gnu_cksum_base64 { } } -/// This module reimplements the cksum-base64-untagged.sh GNU test. -mod gnu_cksum_base64_untagged { +/// Tests for cksum with base64 output encoding (untagged mode). +mod cksum_base64_untagged_encoding { use super::*; macro_rules! decl_sha_test { @@ -2499,8 +2552,8 @@ mod gnu_cksum_base64_untagged { decl_blake_test!(blake2b_504, 504); decl_blake_test!(blake2b_512, 512); } -/// This module reimplements the cksum-c.sh GNU test. -mod gnu_cksum_c { +/// Tests for cksum check mode (-c/--check). +mod cksum_check_mode { use super::*; const INVALID_SUM: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaafdb57c725157cb40b5aee8d937b8351477e"; @@ -2770,6 +2823,28 @@ mod gnu_cksum_c { .no_output(); } + #[test] + fn test_status_with_directory() { + let scene = make_scene(); + scene.fixtures.mkdir("dir"); + scene + .fixtures + .write("CHECKSUMS2", &format!("SM3 (dir) = {INVALID_SUM}\n")); + + #[cfg(not(windows))] + let err_msg = "cksum: dir: Is a directory\n"; + #[cfg(windows)] + let err_msg = "cksum: dir: Permission denied\n"; + + scene + .ucmd() + .arg("--check") + .arg("--status") + .arg("CHECKSUMS2") + .fails_with_code(1) + .stderr_only(err_msg); + } + #[test] fn test_check_with_non_existing_file() { let scene = make_scene(); @@ -2777,6 +2852,23 @@ mod gnu_cksum_c { .fixtures .write("CHECKSUMS2", &format!("SM3 (input2) = {INVALID_SUM}\n")); + scene + .ucmd() + .arg("--check") + .arg("--status") + .arg("CHECKSUMS2") + .fails_with_code(1) + .stderr_only("cksum: input2: No such file or directory\n"); + + scene + .ucmd() + .arg("--check") + .arg("--quiet") + .arg("CHECKSUMS2") + .fails_with_code(1) + .stdout_contains("input2: FAILED open or read") + .stderr_contains("input2: No such file or directory"); + scene .ucmd() .arg("--check") @@ -2821,6 +2913,28 @@ mod gnu_cksum_c { .stderr_contains("CHECKSUMS: 9: improperly formatted BLAKE2b checksum line"); } + #[test] + fn test_warn_malformed_line_before_first_tagged_checksum() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.touch("empty"); + at.write( + "CHECKSUMS", + "invalid line\nMD5 (empty) = d41d8cd98f00b204e9800998ecf8427e\ninvalid line\n", + ); + + ts.ucmd() + .arg("--warn") + .arg("--check") + .arg("CHECKSUMS") + .succeeds() + .stdout_contains("empty: OK") + .stderr_contains("CHECKSUMS: 1: improperly formatted CRC checksum line") + .stderr_contains("CHECKSUMS: 3: improperly formatted MD5 checksum line") + .stderr_contains("WARNING: 2 lines are improperly formatted"); + } + fn make_scene_with_checksum_missing() -> TestScenario { let scene = make_scene_with_another_improperly_formatted(); @@ -3034,6 +3148,7 @@ mod debug_flag { use super::*; #[test] + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] fn test_debug_flag() { // Test with default CRC algorithm - should output CPU feature detection new_ucmd!() @@ -3090,6 +3205,7 @@ mod debug_flag { } #[test] + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] fn test_debug_with_algorithms() { // Test with SHA256 - CPU detection should be same regardless of algorithm new_ucmd!() @@ -3137,6 +3253,57 @@ mod debug_flag { .stderr_contains("avx2") .stderr_contains("pclmul"); } + + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + #[test] + fn test_debug_with_glibc_tunables() { + if is_x86_feature_detected!("avx2") { + for (tunables, expected) in [ + ("glibc.cpu.hwcaps=-AVX2", "avx2 support not detected"), // correctly disabling AVX2 + ("glibc.cpu.hwcaps=-avx2", "using avx2 hardware support"), // lowercase invalidates AVX2 disabling + ( + "glibc.cpu.hwcaps=-AVX2:glibc.cpu.hwcaps=-AVX512F", + "using avx2 hardware support", + ), // last wins + ("glibc.cpu.hwcaps=-AVX2 ", "using avx2 hardware support"), // trailing spaces invalidate AVX2 disabling + ] { + new_ucmd!() + .arg("--debug") + .arg("lorem_ipsum.txt") + .env("GLIBC_TUNABLES", tunables) + .succeeds() + .stderr_contains(expected); + } + } + } + + #[test] + #[cfg(target_arch = "aarch64")] + fn test_debug_flag_aarch64() { + // The vmull path needs PMULL. ASIMD alone is not enough. + let expected = if std::arch::is_aarch64_feature_detected!("pmull") { + "using vmull hardware support\n" + } else { + "vmull support not detected\n" + }; + new_ucmd!() + .arg("--debug") + .arg("lorem_ipsum.txt") + .succeeds() + .stdout_is_fixture("crc_single_file.expected") + .stderr_is(expected); + } + + #[test] + #[cfg(not(any(target_arch = "x86", target_arch = "x86_64", target_arch = "aarch64")))] + fn test_debug_flag_no_hardware_features() { + new_ucmd!() + .arg("--debug") + .arg("lorem_ipsum.txt") + .succeeds() + .stdout_is_fixture("crc_single_file.expected") + .no_stderr(); + } } #[test] @@ -3483,3 +3650,41 @@ fn test_check_blake3_untagged( .succeeds() .stdout_only("FILE: OK\n"); } + +#[test] +fn test_locale_aware_error_filename_escaping() { + // 'Ã' is valid UTF-8, invalid ASCII. + let filename = "file_Ã"; + + // The character is valid and not escaped in UTF-8 + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .arg(filename) + .fails_with_code(1) + .stderr_is("cksum: file_Ã: No such file or directory\n"); + + // In C locale, non-ASCII bytes are escaped + new_ucmd!() + .env("LC_ALL", "C") + .arg(filename) + .fails_with_code(1) + .stderr_is("cksum: 'file_'$'\\303\\203': No such file or directory\n"); + + // Same with a directory error + let dirname = "dir_Ã"; + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir(dirname); + ucmd.env("LC_ALL", "C") + .arg(dirname) + .fails_with_code(1) + .stderr_is("cksum: 'dir_'$'\\303\\203': Is a directory\n"); + + // A parenthesis is escaped in any case. + for locale in ["C", "en_US.UTF-8"] { + new_ucmd!() + .env("LC_ALL", locale) + .arg("file_x(y") + .fails_with_code(1) + .stderr_is("cksum: 'file_x(y': No such file or directory\n"); + } +} diff --git a/tests/by-util/test_comm.rs b/tests/by-util/test_comm.rs index d082284485f..f65e198d6be 100644 --- a/tests/by-util/test_comm.rs +++ b/tests/by-util/test_comm.rs @@ -2,10 +2,13 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (words) defaultcheck nocheck helpb helpz nwordb nwordwordz wordtotal use uutests::new_ucmd; use uutests::util::TestScenario; +#[cfg(unix)] +use uutests::util::is_locale_available; use uutests::util_name; #[test] @@ -309,7 +312,6 @@ fn zero_terminated_with_total() { } } -#[ignore = "not implemented"] #[test] fn check_order() { let scene = TestScenario::new(util_name!()); @@ -320,36 +322,42 @@ fn check_order() { .ucmd() .args(&["--check-order", "bad_order_1", "bad_order_2"]) .fails() - .stdout_is("\t\te") - .stderr_is("error to be defined"); + .stdout_is("\t\te\n") + .stderr_is("comm: file 2 is not in sorted order\n"); } -#[ignore = "not implemented"] #[test] fn nocheck_order() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; at.write("bad_order_1", "e\nd\nb\na\n"); at.write("bad_order_2", "e\nc\nb\na\n"); - new_ucmd!() + scene + .ucmd() .args(&["--nocheck-order", "bad_order_1", "bad_order_2"]) .succeeds() - .stdout_is("\t\te\n\tc\n\tb\n\ta\nd\nb\na\n"); + .stdout_is("\t\te\n\tc\n\tb\n\ta\nd\nb\na\n") + .no_stderr(); } // when neither --check-order nor --no-check-order is provided, // stderr and the error code behaves like check order, but stdout // behaves like nocheck_order. However with some quirks detailed below. -#[ignore = "not implemented"] #[test] fn defaultcheck_order() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; + at.write("a", "a\n"); at.write("bad_order_1", "e\nd\nb\na\n"); - new_ucmd!() + scene + .ucmd() .args(&["a", "bad_order_1"]) .fails() - .stderr_only("error to be defined"); + .stdout_is("a\n\te\n\td\n\tb\n\ta\n") + .stderr_is( + "comm: file 2 is not in sorted order\n\ + comm: input is not in sorted order\n", + ); } // * the first: if both files are not in order, the default behavior is the only @@ -455,17 +463,12 @@ fn test_sorted() { let at = &scene.fixtures; at.write("comm1", "1\n3"); at.write("comm2", "3\n2"); - let cmd = scene.ucmd().args(&["comm1", "comm2"]).run(); - // WASI's strcoll (C locale only) may not detect unsorted input, - // but the comparison output is still correct. - if std::env::var("UUTESTS_WASM_RUNNER").is_ok() { - cmd.success().stdout_is("1\n\t\t3\n\t2\n"); - } else { - cmd.failure() - .code_is(1) - .stdout_is("1\n\t\t3\n\t2\n") - .stderr_is("comm: file 2 is not in sorted order\ncomm: input is not in sorted order\n"); - } + scene + .ucmd() + .args(&["comm1", "comm2"]) + .fails_with_code(1) + .stdout_is("1\n\t\t3\n\t2\n") + .stderr_is("comm: file 2 is not in sorted order\ncomm: input is not in sorted order\n"); } #[test] @@ -492,19 +495,16 @@ fn test_both_inputs_out_of_order() { at.write("file_a", "3\n1\n0\n"); at.write("file_b", "3\n2\n0\n"); - let cmd = scene.ucmd().args(&["file_a", "file_b"]).run(); - if std::env::var("UUTESTS_WASM_RUNNER").is_ok() { - cmd.success().stdout_is("\t\t3\n1\n0\n\t2\n\t0\n"); - } else { - cmd.failure() - .code_is(1) - .stdout_is("\t\t3\n1\n0\n\t2\n\t0\n") - .stderr_is( - "comm: file 1 is not in sorted order\n\ - comm: file 2 is not in sorted order\n\ - comm: input is not in sorted order\n", - ); - } + scene + .ucmd() + .args(&["file_a", "file_b"]) + .fails_with_code(1) + .stdout_is("\t\t3\n1\n0\n\t2\n\t0\n") + .stderr_is( + "comm: file 1 is not in sorted order\n\ + comm: file 2 is not in sorted order\n\ + comm: input is not in sorted order\n", + ); } #[test] @@ -514,19 +514,16 @@ fn test_both_inputs_out_of_order_last_pair() { at.write("file_a", "3\n1\n"); at.write("file_b", "3\n2\n"); - let cmd = scene.ucmd().args(&["file_a", "file_b"]).run(); - if std::env::var("UUTESTS_WASM_RUNNER").is_ok() { - cmd.success().stdout_is("\t\t3\n1\n\t2\n"); - } else { - cmd.failure() - .code_is(1) - .stdout_is("\t\t3\n1\n\t2\n") - .stderr_is( - "comm: file 1 is not in sorted order\n\ - comm: file 2 is not in sorted order\n\ - comm: input is not in sorted order\n", - ); - } + scene + .ucmd() + .args(&["file_a", "file_b"]) + .fails_with_code(1) + .stdout_is("\t\t3\n1\n\t2\n") + .stderr_is( + "comm: file 1 is not in sorted order\n\ + comm: file 2 is not in sorted order\n\ + comm: input is not in sorted order\n", + ); } #[test] @@ -585,7 +582,7 @@ fn test_both_inputs_out_of_order_but_identical() { fn test_comm_arg_error() { let scene = TestScenario::new(util_name!()); - // Test extra argument error case from GNU test + // Test extra argument error case scene .ucmd() .args(&["a", "b", "no-such"]) @@ -594,7 +591,7 @@ fn test_comm_arg_error() { .stderr_contains("error: unexpected argument 'no-such' found") .stderr_contains("Usage: comm [OPTION]... FILE1 FILE2") .stderr_contains("For more information, try '--help'."); - // Test extra argument error case from GNU test + // Test extra argument error case scene .ucmd() .args(&["a"]) @@ -686,6 +683,7 @@ fn test_output_lossy_utf8() { #[cfg(any(target_os = "linux", target_os = "android"))] #[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_comm_anonymous_pipes() { + use std::fmt::Write as _; use std::{io::Write, os::fd::AsRawFd, process}; let scene = TestScenario::new(util_name!()); @@ -701,7 +699,7 @@ fn test_comm_anonymous_pipes() { // 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")); + let _ = writeln!(content, "{i:05}"); } assert!(comm1_writer.write_all(content.as_bytes()).is_ok()); drop(comm1_writer); @@ -738,6 +736,51 @@ fn test_read_error() { .stderr_contains("comm: /proc/self/mem: Input/output error"); } +#[test] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: the guest does not inherit LC_ALL")] +fn test_locale_collation() { + // In a UTF-8 locale the collation puts `a1` before `a-b` and the byte order + // puts them the other way round. Reading a file `sort` produced with a byte + // comparison rejects it as unsorted and drops the line the two files share. + let locale = "en_US.UTF-8"; + if !is_locale_available(locale) { + return; + } + let scene = TestScenario::new(util_name!()); + scene.fixtures.write("f1", "a1\na-b\n"); + scene.fixtures.write("f2", "a-b\n"); + + scene + .ucmd() + .env("LC_ALL", locale) + .args(&["-12", "f1", "f2"]) + .succeeds() + .stdout_only("a-b\n"); + + scene + .ucmd() + .env("LC_ALL", locale) + .args(&["-23", "f1", "f2"]) + .succeeds() + .stdout_only("a1\n"); +} + +#[test] +#[cfg(unix)] +fn test_c_locale_still_orders_by_bytes() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.write("f1", "a-b\na1\n"); + scene.fixtures.write("f2", "a-b\n"); + + scene + .ucmd() + .env("LC_ALL", "C") + .args(&["-12", "f1", "f2"]) + .succeeds() + .stdout_only("a-b\n"); +} + #[test] #[cfg(target_os = "linux")] fn test_comm_write_error_dev_full() { @@ -750,5 +793,33 @@ fn test_comm_write_error_dev_full() { .args(&["a", "a"]) .set_stdout(dev_full) .fails() - .stderr_contains("No space left on device"); + .stderr_is("comm: write error: No space left on device\n"); +} + +#[test] +fn test_identical_unsorted_prefix_no_error() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.write("f1", "b\na\nc\n"); + at.write("f2", "b\na\nd\n"); + scene + .ucmd() + .args(&["f1", "f2"]) + .succeeds() + .stdout_is("\t\tb\n\t\ta\nc\n\td\n") + .no_stderr(); +} + +#[test] +fn test_identical_unsorted_prefix_check_order_fails() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.write("f1", "b\na\nc\n"); + at.write("f2", "b\na\nd\n"); + scene + .ucmd() + .args(&["--check-order", "f1", "f2"]) + .fails() + .stdout_is("\t\tb\n") + .stderr_is("comm: file 1 is not in sorted order\n"); } diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index f0531c5a218..d7d07b570ee 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -3,15 +3,13 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (flags) reflink (fs) tmpfs (linux) filefrag rlimit Rlim NOFILE clob btrfs neve ROOTDIR USERDIR outfile subvolume uufs xattrs ELOOP -// spell-checker:ignore bdfl hlsl IRWXO IRWXG nconfined matchpathcon libselinux-devel prwx doesnotexist reftests subdirs mksocket srwx +// spell-checker:ignore (flags) reflink (fs) tmpfs (linux) filefrag rlimit Rlim Nofile clob btrfs neve ROOTDIR USERDIR outfile subvolume uufs xattrs ELOOP +// spell-checker:ignore bdfl hlsl IRWXO IRWXG nconfined matchpathcon libselinux-devel prwx doesnotexist reftests subdirs mksocket srwx dstlink mcstransd + #[cfg(unix)] use rstest::rstest; use uucore::display::Quotable; -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use uucore::selinux::get_getfattr_output; use uutests::util::TestScenario; use uutests::{at_and_ucmd, new_ucmd, path_concat, util_name}; @@ -28,13 +26,10 @@ use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::{FileTypeExt, MetadataExt}; #[cfg(windows)] use std::os::windows::fs::symlink_file; -#[cfg(not(windows))] use std::path::Path; #[cfg(target_os = "linux")] use std::path::PathBuf; -#[cfg(any(target_os = "linux", target_os = "android"))] -use filetime::FileTime; #[cfg(target_os = "linux")] use std::ffi::OsString; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -66,9 +61,11 @@ static TEST_MOUNT_MOUNTPOINT: &str = "mount"; #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] static TEST_MOUNT_OTHER_FILESYSTEM_FILE: &str = "mount/DO_NOT_copy_me.txt"; static TEST_NONEXISTENT_FILE: &str = "nonexistent_file.txt"; -#[cfg(all( - unix, - not(any(target_os = "android", target_os = "macos", target_os = "openbsd")) +#[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "netbsd" ))] use uutests::util::compare_xattrs; @@ -95,6 +92,10 @@ macro_rules! assert_metadata_eq { #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] fn test_cp_stream_to_full() { let (_, mut ucmd) = at_and_ucmd!(); ucmd.arg("/dev/zero") @@ -313,6 +314,10 @@ fn test_cp_multiple_files_with_nonexistent_file() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: an empty path argument resolves to a directory instead of ENOENT" +)] fn test_cp_multiple_files_with_empty_file_name() { #[cfg(windows)] let error_msg = "The system cannot find the path specified"; @@ -332,7 +337,7 @@ fn test_cp_multiple_files_with_empty_file_name() { #[test] // FixME: for MacOS, this has intermittent failures; track repair progress at GH:uutils/coreutils/issues/1590 -#[cfg(not(target_os = "macos"))] +#[cfg(not(target_vendor = "apple"))] fn test_cp_recurse() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.arg("-r") @@ -345,7 +350,7 @@ fn test_cp_recurse() { } #[test] -#[cfg(not(target_os = "macos"))] +#[cfg(not(target_vendor = "apple"))] fn test_cp_recurse_several() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.arg("-r") @@ -388,7 +393,7 @@ fn test_cp_with_dirs_t() { #[test] // FixME: for MacOS, this has intermittent failures; track repair progress at GH:uutils/coreutils/issues/1590 -#[cfg(not(target_os = "macos"))] +#[cfg(not(target_vendor = "apple"))] fn test_cp_with_dirs() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -503,6 +508,55 @@ fn test_cp_arg_update_none() { assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "How are you?\n"); } +#[cfg(unix)] +#[rstest] +#[case::no_clobber("-n", true)] +#[case::update_none("--update=none", true)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] +#[case::archive_no_clobber("-an", true)] +#[case::declined_prompt("-i", false)] +fn test_cp_recursive_continues_after_skipped_file(#[case] arg: &str, #[case] succeeds: bool) { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("source"); + at.mkdir_all("destination/source"); + at.write("source/first", "first contents"); + at.write("source/second", "second contents"); + // Skip whichever file the traversal reaches first, so the other one comes after it. + let order = walkdir::WalkDir::new(at.plus("source")) + .min_depth(1) + .into_iter() + .map(|entry| entry.unwrap().file_name().to_str().unwrap().to_owned()) + .collect::>(); + let (skipped, copied) = (&order[0], &order[1]); + at.write(&format!("destination/source/{skipped}"), "old contents"); + + ucmd.args(&["-R", arg, "source", "destination"]); + // Only the prompt reads stdin; writing to a cp that never reads it can fail. + if !succeeds { + ucmd.pipe_in("n\n"); + } + let result = ucmd.run(); + if succeeds { + result.success().no_output(); + } else { + result + .code_is(1) + .stderr_is(format!("cp: overwrite 'destination/source/{skipped}'? ")); + } + + assert_eq!( + at.read(&format!("destination/source/{skipped}")), + "old contents" + ); + assert_eq!( + at.read(&format!("destination/source/{copied}")), + at.read(&format!("source/{copied}")) + ); +} + #[test] fn test_cp_arg_update_none_fail() { let (at, mut ucmd) = at_and_ucmd!(); @@ -803,6 +857,10 @@ fn test_cp_arg_interactive_verbose_clobber() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_f_i_verbose_non_writeable_destination_y() { let (at, mut ucmd) = at_and_ucmd!(); @@ -821,6 +879,10 @@ fn test_cp_f_i_verbose_non_writeable_destination_y() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_f_i_verbose_non_writeable_destination_empty() { let (at, mut ucmd) = at_and_ucmd!(); @@ -852,6 +914,10 @@ fn test_cp_arg_link() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: same-file hard link detection not supported" +)] fn test_cp_arg_link_with_dest_hardlink_to_source() { use std::os::linux::fs::MetadataExt; @@ -871,6 +937,10 @@ fn test_cp_arg_link_with_dest_hardlink_to_source() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: same-file hard link detection not supported" +)] fn test_cp_arg_link_with_same_file() { use std::os::linux::fs::MetadataExt; @@ -885,6 +955,33 @@ fn test_cp_arg_link_with_same_file() { assert!(at.file_exists(file)); } +// A hard link shares the source's inode, so `cp --link` must not change the +// mode of the file it links to. +#[test] +// Android's app-private filesystem refuses hard links. +#[cfg(all(unix, not(target_os = "android")))] +fn test_cp_arg_link_keeps_source_mode() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("src", "a"); + at.set_mode("src", 0o755); + scene + .ucmd() + .umask(0o077) + .args(&["-l", "src", "lnk"]) + .succeeds(); + assert_eq!(at.metadata("src").permissions().mode() & 0o777, 0o755); + + at.write("orig", "a"); + at.set_mode("orig", 0o600); + at.write("dest", "b"); + at.set_mode("dest", 0o777); + scene.ucmd().args(&["-lf", "orig", "dest"]).succeeds(); + assert_eq!(at.metadata("orig").permissions().mode() & 0o777, 0o600); + assert_eq!(at.metadata("dest").permissions().mode() & 0o777, 0o600); +} + #[test] #[cfg(target_os = "linux")] fn test_cp_verbose_preserved_link_to_dir() { @@ -924,6 +1021,246 @@ fn test_cp_arg_symlink() { .succeeds(); assert!(at.is_symlink(TEST_HELLO_WORLD_DEST)); + assert_eq!( + std::fs::read_link(at.plus(TEST_HELLO_WORLD_DEST)).unwrap(), + Path::new(TEST_HELLO_WORLD_SOURCE) + ); + assert_eq!(at.read(TEST_HELLO_WORLD_DEST), "Hello, World!\n"); +} + +// Recursively copying a tree that contains a symlink must not run chmod through +// the destination symlink cp just created. chmod() follows symlinks, so doing so +// would change the mode of the link target, which can live outside the copied +// tree. GNU cp leaves the target untouched. +#[test] +#[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] +fn test_cp_recursive_symlink_preserves_target_mode() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("target_dir"); + at.touch("target_dir/file.txt"); + at.set_mode("target_dir/file.txt", 0o600); + + at.mkdir("src"); + at.symlink_file("target_dir/file.txt", "src/link"); + + ucmd.arg("-r").arg("src").arg("dst").succeeds(); + + assert!(at.is_symlink("dst/link")); + assert_eq!( + at.metadata("target_dir/file.txt").permissions().mode() & 0o777, + 0o600 + ); +} + +// With --remove-destination onto a symlink, cp removes the link and creates a +// fresh regular file, so the final chmod must not be skipped based on the +// destination's pre-copy symlink state. GNU cp gives the new file the source +// mode masked by the umask (664 & ~022 = 644 here); skipping the chmod leaves +// it at the restrictive 0o600 creation mode (or at the raw cloned source mode +// on filesystems that copy via clonefile). +#[test] +#[cfg(unix)] +fn test_cp_remove_destination_symlink_applies_mode() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.touch("src"); + at.set_mode("src", 0o664); + at.touch("target"); + at.symlink_file("target", "dst"); + + ucmd.umask(0o022) + .arg("--remove-destination") + .arg("src") + .arg("dst") + .succeeds(); + + assert!(!at.is_symlink("dst")); + assert_eq!(at.metadata("dst").permissions().mode() & 0o777, 0o644); +} + +// A umask that strips the owner write bit (0o333 here) makes the freshly +// created destination read-only. The copy must keep using the fd from the +// creating open instead of re-opening the path for writing, which fails +// with EACCES and leaves an empty file behind (LP: #2164777). +#[test] +#[cfg(unix)] +fn test_cp_umask_stripping_owner_write_bit() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("input.txt", "copied through the first fd\n"); + at.set_mode("input.txt", 0o664); + + ucmd.umask(0o333) + .arg("input.txt") + .arg("output.txt") + .succeeds() + .no_output(); + + assert_eq!(at.read("output.txt"), "copied through the first fd\n"); + assert_eq!( + at.metadata("output.txt").permissions().mode() & 0o777, + 0o444 + ); +} + +// Same umask scenario without a clone attempt: on reflink-capable +// filesystems the test above succeeds via FICLONE and never reaches the +// fallback, so pin the plain and sparse copy paths explicitly. +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] +fn test_cp_umask_stripping_owner_write_bit_reflink_never() { + for sparse in ["--sparse=auto", "--sparse=always"] { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("input.txt", "written while still writable\n"); + at.set_mode("input.txt", 0o664); + + ucmd.umask(0o333) + .args(&["--reflink=never", sparse, "input.txt", "output.txt"]) + .succeeds() + .no_output(); + + assert_eq!(at.read("output.txt"), "written while still writable\n"); + assert_eq!( + at.metadata("output.txt").permissions().mode() & 0o777, + 0o444 + ); + } +} + +// Regression for #14549: `cp -r` (without preserve) must apply the umask to +// directories it creates, matching GNU, instead of copying the source's mode. +#[test] +#[cfg(unix)] +fn test_cp_recursive_dir_applies_umask() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("src"); + at.mkdir("src/dir"); + at.set_mode("src/dir", 0o777); + + ucmd.umask(0o077).args(&["-r", "src", "d"]).succeeds(); + + // 0o777 & ~0o077 = 0o700, not the source's raw 0o777. + assert_eq!(at.metadata("d/dir").permissions().mode() & 0o777, 0o700); +} + +// The umask alone never covers setuid/setgid, so a non-preserving `cp -r` +// must clear them on the directories it creates. The sticky bit survives. +#[test] +#[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: directory modes/umask are not faithfully reproduced" +)] +fn test_cp_recursive_dir_drops_setuid_setgid() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("tree"); + for (name, mode) in [ + ("tree/setgid", 0o2731u32), + ("tree/setuid", 0o4713), + ("tree/sticky", 0o1735), + ] { + at.mkdir(name); + at.set_mode(name, mode); + } + + scene + .ucmd() + .umask(0o026) + .args(&["-r", "tree", "plain"]) + .succeeds(); + + assert_eq!( + at.metadata("plain/setgid").permissions().mode() & 0o7777, + 0o711 + ); + assert_eq!( + at.metadata("plain/setuid").permissions().mode() & 0o7777, + 0o711 + ); + assert_eq!( + at.metadata("plain/sticky").permissions().mode() & 0o7777, + 0o1711 + ); + + // An explicit preserve keeps the mode as-is, umask and special bits alike. + scene + .ucmd() + .umask(0o026) + .args(&["-r", "--preserve=mode", "tree", "kept"]) + .succeeds(); + + assert_eq!( + at.metadata("kept/setgid").permissions().mode() & 0o7777, + 0o2731 + ); + assert_eq!( + at.metadata("kept/setuid").permissions().mode() & 0o7777, + 0o4713 + ); +} + +// When --reflink=always fails, GNU cp removes a destination it created +// itself but keeps a pre-existing (truncated) one. Only observable on +// filesystems without clone support; when the clone succeeds there is +// nothing to clean up. +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: --reflink is only supported on linux and macOS" +)] +fn test_cp_reflink_always_failure_dest_cleanup() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("payload.txt", "reflink me\n"); + let fresh = scene + .ucmd() + .args(&["--reflink=always", "payload.txt", "fresh.txt"]) + .run(); + if fresh.succeeded() { + return; // clone worked, the failure path is unreachable here + } + assert!(!at.file_exists("fresh.txt")); + + at.write("kept.txt", "previous contents\n"); + scene + .ucmd() + .args(&["--reflink=always", "payload.txt", "kept.txt"]) + .fails(); + assert!(at.file_exists("kept.txt")); + assert_eq!(at.read("kept.txt"), ""); +} + +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] +fn test_cp_reflink_always_failure() { + let scene = TestScenario::new(util_name!()); + scene + .ucmd() + .args(&["--reflink=always", "/dev/null", "/dev/full"]) + .fails() + .no_stdout() + .stderr_contains("Invalid argument"); + scene + .ucmd() + .args(&["--reflink=always", "/dev/null", "target"]) + .fails() + .no_stdout() + .stderr_contains("ross-device link"); // cover both of glibc and musl } #[test] @@ -1049,6 +1386,10 @@ fn test_cp_arg_backup() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_arg_backup_with_dest_a_symlink() { let (at, mut ucmd) = at_and_ucmd!(); let source = "source"; @@ -1070,6 +1411,10 @@ fn test_cp_arg_backup_with_dest_a_symlink() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_arg_backup_with_dest_a_symlink_to_source() { let (at, mut ucmd) = at_and_ucmd!(); let source = "source"; @@ -1155,6 +1500,26 @@ fn test_cp_arg_suffix_without_backup_option() { ); } +#[test] +fn test_cp_empty_backup_suffix_uses_default() { + for backup_arg in ["--backup=nil", "--backup"] { + let (at, mut ucmd) = at_and_ucmd!(); + + ucmd.arg(backup_arg) + .arg("--suffix=") + .arg(TEST_HELLO_WORLD_SOURCE) + .arg(TEST_HOW_ARE_YOU_SOURCE) + .succeeds() + .no_stderr(); + + assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "Hello, World!\n"); + assert_eq!( + at.read(&format!("{TEST_HOW_ARE_YOU_SOURCE}~")), + "How are you?\n" + ); + } +} + #[test] fn test_cp_arg_suffix_hyphen_value() { let (at, mut ucmd) = at_and_ucmd!(); @@ -1243,6 +1608,27 @@ fn test_cp_backup_existing() { ); } +#[test] +#[cfg(unix)] +fn test_cp_backup_existing_target_is_fifo() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkfifo(&format!("{TEST_HOW_ARE_YOU_SOURCE}~")); + + ucmd.arg("--backup=simple") + .arg(TEST_HELLO_WORLD_SOURCE) + .arg(TEST_HOW_ARE_YOU_SOURCE) + .timeout(Duration::from_secs(10)) + .succeeds() + .no_stderr(); + + assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "Hello, World!\n"); + assert!(!at.is_fifo(&format!("{TEST_HOW_ARE_YOU_SOURCE}~"))); + assert_eq!( + at.read(&format!("{TEST_HOW_ARE_YOU_SOURCE}~")), + "How are you?\n" + ); +} + #[test] fn test_cp_backup_nil() { let (at, mut ucmd) = at_and_ucmd!(); @@ -1334,6 +1720,59 @@ fn test_cp_backup_simple_protect_source() { assert_eq!(at.read(&source), ""); } +#[test] +// Android denies hard links on the filesystem backing the test directory, so +// the setup cannot be built there; see the mv analogue. +#[cfg(not(target_os = "android"))] +fn test_cp_backup_simple_allows_hardlink_under_another_name() { + // `other` shares an inode with the backup path but is not named after it, + // so the backup rename cannot clobber it. GNU copies this happily. + let (at, mut ucmd) = at_and_ucmd!(); + let backup = format!("{TEST_HELLO_WORLD_SOURCE}~"); + at.write(&backup, "backup content"); + at.hard_link(&backup, "other"); + + ucmd.arg("--backup=simple") + .arg("other") + .arg(TEST_HELLO_WORLD_SOURCE) + .succeeds() + .no_stderr(); + + assert_eq!(at.read(TEST_HELLO_WORLD_SOURCE), "backup content"); +} + +#[test] +fn test_cp_backup_simple_protect_source_regardless_of_spelling() { + // The guard compares files, not the strings naming them. + let (at, mut ucmd) = at_and_ucmd!(); + let source = format!("{TEST_HELLO_WORLD_SOURCE}~"); + at.write(&source, "source content"); + + ucmd.arg("--backup=simple") + .arg(format!("./{source}")) + .arg(TEST_HELLO_WORLD_SOURCE) + .fails() + .stderr_contains("might destroy source"); + + assert_eq!(at.read(&source), "source content"); +} + +#[test] +fn test_cp_backup_numbered_allows_source_named_like_backup() { + // A numbered backup never reuses an existing name, so nothing is at risk. + let (at, mut ucmd) = at_and_ucmd!(); + let source = format!("{TEST_HELLO_WORLD_SOURCE}~"); + at.write(&source, "source content"); + + ucmd.arg("--backup=numbered") + .arg(&source) + .arg(TEST_HELLO_WORLD_SOURCE) + .succeeds(); + + assert_eq!(at.read(&source), "source content"); + assert_eq!(at.read(TEST_HELLO_WORLD_SOURCE), "source content"); +} + #[test] fn test_cp_backup_never() { let (at, mut ucmd) = at_and_ucmd!(); @@ -1591,6 +2030,10 @@ fn test_cp_parents_dest_not_directory() { #[test] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_parents_with_permissions_copy_file() { let (at, mut ucmd) = at_and_ucmd!(); @@ -1632,6 +2075,10 @@ fn test_cp_parents_with_permissions_copy_file() { #[test] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_parents_with_permissions_copy_dir() { let (at, mut ucmd) = at_and_ucmd!(); @@ -1675,12 +2122,20 @@ fn test_cp_parents_with_permissions_copy_dir() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] fn test_cp_writable_special_file_permissions() { new_ucmd!().arg("/dev/null").arg("/dev/zero").succeeds(); } #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] fn test_cp_issue_1665() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.arg("/dev/null").arg("foo").succeeds(); @@ -1690,6 +2145,10 @@ fn test_cp_issue_1665() { #[test] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_preserve_no_args() { let (at, mut ucmd) = at_and_ucmd!(); let src_file = "a"; @@ -1718,6 +2177,10 @@ fn test_cp_preserve_no_args() { #[test] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_preserve_no_args_before_opts() { let (at, mut ucmd) = at_and_ucmd!(); let src_file = "a"; @@ -1745,6 +2208,10 @@ fn test_cp_preserve_no_args_before_opts() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_preserve_all() { for argument in ["--preserve=all", "--preserve=al"] { let (at, mut ucmd) = at_and_ucmd!(); @@ -1776,7 +2243,7 @@ fn test_cp_preserve_all() { #[test] #[cfg(all( unix, - not(any(target_os = "android", target_os = "openbsd", target_os = "macos")) + not(any(target_vendor = "apple", target_os = "android", target_os = "openbsd")) ))] fn test_cp_p_does_not_preserve_xattr_by_default() { use std::process::Command; @@ -1835,7 +2302,7 @@ fn test_cp_preserve_xattr() { .succeeds(); // FIXME: macos copy keeps the original mtime - #[cfg(not(any(target_os = "freebsd", target_os = "macos")))] + #[cfg(not(any(target_vendor = "apple", target_os = "freebsd")))] { // Assert that the mode, ownership, and timestamps are *NOT* preserved // NOTICE: the ownership is not modified on the src file, because that requires root permissions @@ -1848,7 +2315,7 @@ fn test_cp_preserve_xattr() { #[test] #[cfg(all( - not(feature = "feat_selinux"), + not(feature = "selinux"), any(target_os = "linux", target_os = "android") ))] fn test_cp_preserve_all_context_fails_on_non_selinux() { @@ -1929,6 +2396,10 @@ fn test_cp_preserve_links_case_1() { #[test] // android platform will causing stderr = cp: Permission denied (os error 13) #[cfg(not(target_os = "android"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_preserve_links_case_2() { let (at, mut ucmd) = at_and_ucmd!(); @@ -1960,6 +2431,10 @@ fn test_cp_preserve_links_case_2() { #[test] // android platform will causing stderr = cp: Permission denied (os error 13) #[cfg(not(target_os = "android"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_preserve_links_case_3() { let (at, mut ucmd) = at_and_ucmd!(); @@ -2125,6 +2600,10 @@ fn test_cp_no_preserve_mode() { // For now, disable the test on Windows. Symlinks aren't well support on Windows. // It works on Unix for now and it works locally when run from a powershell #[cfg(not(windows))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_deref_folder_to_folder() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -2223,6 +2702,10 @@ fn test_cp_deref_folder_to_folder() { // For now, disable the test on Windows. Symlinks aren't well support on Windows. // It works on Unix for now and it works locally when run from a powershell #[cfg(not(windows))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_no_deref_folder_to_folder() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -2319,15 +2802,22 @@ fn test_cp_no_deref_folder_to_folder() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: ACL/xattr syscalls are not implemented, so --archive's attribute preservation fails" +)] fn test_cp_archive() { let (at, mut ucmd) = at_and_ucmd!(); - let ts = time::OffsetDateTime::now_utc(); - let previous = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + let previous = std::time::SystemTime::now() - Duration::from_secs(3600); // set the file creation/modification an hour ago - filetime::set_file_times( - at.plus_as_string(TEST_HELLO_WORLD_SOURCE), - previous, - previous, + let file = std::fs::OpenOptions::new() + .write(true) + .open(at.plus(TEST_HELLO_WORLD_SOURCE)) + .unwrap(); + file.set_times( + std_fs::FileTimes::new() + .set_accessed(previous) + .set_modified(previous), ) .unwrap(); ucmd.arg(TEST_HELLO_WORLD_SOURCE) @@ -2352,6 +2842,10 @@ fn test_cp_archive() { #[test] #[cfg(all(unix, not(target_os = "android")))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_archive_recursive() { let (at, mut ucmd) = at_and_ucmd!(); @@ -2418,13 +2912,16 @@ fn test_cp_archive_recursive() { #[cfg(any(target_os = "linux", target_os = "android"))] fn test_cp_preserve_timestamps() { let (at, mut ucmd) = at_and_ucmd!(); - let ts = time::OffsetDateTime::now_utc(); - let previous = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + let previous = std::time::SystemTime::now() - Duration::from_secs(3600); // set the file creation/modification an hour ago - filetime::set_file_times( - at.plus_as_string(TEST_HELLO_WORLD_SOURCE), - previous, - previous, + let file = std::fs::OpenOptions::new() + .write(true) + .open(at.plus(TEST_HELLO_WORLD_SOURCE)) + .unwrap(); + file.set_times( + std_fs::FileTimes::new() + .set_accessed(previous) + .set_modified(previous), ) .unwrap(); ucmd.arg(TEST_HELLO_WORLD_SOURCE) @@ -2451,13 +2948,16 @@ fn test_cp_preserve_timestamps() { #[cfg(any(target_os = "linux", target_os = "android"))] fn test_cp_no_preserve_timestamps() { let (at, mut ucmd) = at_and_ucmd!(); - let ts = time::OffsetDateTime::now_utc(); - let previous = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + let previous = std::time::SystemTime::now() - Duration::from_secs(3600); // set the file creation/modification an hour ago - filetime::set_file_times( - at.plus_as_string(TEST_HELLO_WORLD_SOURCE), - previous, - previous, + let file = std::fs::OpenOptions::new() + .write(true) + .open(at.plus(TEST_HELLO_WORLD_SOURCE)) + .unwrap(); + file.set_times( + std_fs::FileTimes::new() + .set_accessed(previous) + .set_modified(previous), ) .unwrap(); sleep(Duration::from_millis(100)); @@ -2492,8 +2992,70 @@ fn test_cp_no_preserve_timestamps() { assert!(res.as_secs() < 3605); } +// `filetime::FileTime::from_last_{access,modification}_time` panics on WASI +// (the crate has no WASI-specific backend). `cp --preserve=timestamps` must +// instead read timestamps via `Metadata::accessed`/`modified` there. See +// `source_times` in src/uu/cp/src/cp.rs. +#[test] +#[cfg(wasi_runner)] +fn test_cp_preserve_timestamps_wasi() { + let (at, mut ucmd) = at_and_ucmd!(); + let previous = std::time::SystemTime::now() - Duration::from_secs(3600); + // set the file creation/modification an hour ago + let file = std::fs::OpenOptions::new() + .write(true) + .open(at.plus(TEST_HELLO_WORLD_SOURCE)) + .unwrap(); + file.set_times( + std::fs::FileTimes::new() + .set_accessed(previous) + .set_modified(previous), + ) + .unwrap(); + + ucmd.arg(TEST_HELLO_WORLD_SOURCE) + .arg("--preserve=timestamps") + .arg(TEST_HOW_ARE_YOU_SOURCE) + .succeeds(); + + assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "Hello, World!\n"); + + let src_mtime = std::fs::metadata(at.subdir.join(TEST_HELLO_WORLD_SOURCE)) + .unwrap() + .modified() + .unwrap(); + let dst_mtime = std::fs::metadata(at.subdir.join(TEST_HOW_ARE_YOU_SOURCE)) + .unwrap() + .modified() + .unwrap(); + assert_eq!(src_mtime, dst_mtime); +} + +// WASI has no chmod syscall at all, so `fs::set_permissions` always fails +// with ENOSYS there. A plain recursive copy tries to match a freshly +// created destination directory's mode to the source's as a best-effort +// (not explicitly requested) preservation; `cp` must treat ENOSYS the same +// as EOPNOTSUPP and skip it silently instead of erroring out. See +// `is_enotsup_error` in src/uu/cp/src/cp.rs. +#[test] +#[cfg(wasi_runner)] +fn test_cp_preserve_mode_optional_wasi() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("src"); + at.touch("src/file"); + + ucmd.args(&["-r", "src", "dst"]).succeeds().no_stderr(); + + assert!(at.dir_exists("dst")); + assert!(at.file_exists("dst/file")); +} + #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] fn test_cp_target_file_dev_null() { let (at, mut ucmd) = at_and_ucmd!(); let file1 = "/dev/null"; @@ -2564,7 +3126,7 @@ fn test_cp_one_file_system() { } #[test] -#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] +#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] fn test_cp_reflink_always() { let (at, mut ucmd) = at_and_ucmd!(); let result = ucmd @@ -2582,7 +3144,7 @@ fn test_cp_reflink_always() { } #[test] -#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] +#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] fn test_cp_reflink_auto() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.arg("--reflink=auto") @@ -2595,7 +3157,7 @@ fn test_cp_reflink_auto() { } #[test] -#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] +#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] fn test_cp_reflink_none() { let (at, mut ucmd) = at_and_ucmd!(); let result = ucmd @@ -2613,7 +3175,7 @@ fn test_cp_reflink_none() { } #[test] -#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] +#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] fn test_cp_reflink_never() { for argument in ["--reflink=never", "--reflink=neve", "--reflink=n"] { let (at, mut ucmd) = at_and_ucmd!(); @@ -2637,8 +3199,33 @@ fn test_cp_reflink_never() { } } +// Regression test for #14052: on macOS, `cp` used to call clonefile(2) unconditionally, so +// `--reflink=never` still cloned — and clonefile copies the source's metadata, including mtime. +// A real (non-clone) copy stamps the destination with its own mtime, so after `--reflink=never` +// the destination must NOT inherit the source's (old) mtime. +#[test] +#[cfg(target_vendor = "apple")] +fn test_cp_reflink_never_does_not_clonefile() { + use filetime::FileTime; // todo: replace with std + let (at, mut ucmd) = at_and_ucmd!(); + at.write("src", "reflink never contents"); + // Stamp the source with a mtime well in the past; a clonefile would copy it verbatim. + let past = FileTime::from_unix_time(1_000_000_000, 0); // 2001-09-09 + filetime::set_file_times(at.plus("src"), past, past).unwrap(); + + ucmd.arg("--reflink=never").arg("src").arg("dst").succeeds(); + + assert_eq!(at.read("dst"), "reflink never contents"); + let src_mtime = FileTime::from_last_modification_time(&at.metadata("src")); + let dst_mtime = FileTime::from_last_modification_time(&at.metadata("dst")); + assert_ne!( + dst_mtime, src_mtime, + "--reflink=never must copy (not clonefile), so dst must not inherit the source's mtime" + ); +} + #[test] -#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] +#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] fn test_cp_reflink_bad() { let (_, mut ucmd) = at_and_ucmd!(); let _result = ucmd @@ -2680,7 +3267,7 @@ fn test_cp_reflink_insufficient_permission() { #[cfg(target_os = "linux")] #[test] fn test_closes_file_descriptors() { - use rlimit::Resource; + use rustix::process::Resource; let pid = std::process::id(); let fd_path = format!("/proc/{pid}/fd"); @@ -2700,12 +3287,13 @@ fn test_closes_file_descriptors() { .arg("--reflink=auto") .arg("dir_with_10_files/") .arg("dir_with_10_files_new/") - .limit(Resource::NOFILE, limit_fd, limit_fd) + .limit(Resource::Nofile, limit_fd, limit_fd) .succeeds(); } #[cfg(any(target_os = "linux", target_os = "android"))] #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_sparse_never_empty() { const BUFFER_SIZE: usize = 4096 * 4; let (at, mut ucmd) = at_and_ucmd!(); @@ -2726,6 +3314,7 @@ fn test_cp_sparse_never_empty() { #[cfg(any(target_os = "linux", target_os = "android"))] #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_sparse_always_empty() { const BUFFER_SIZE: usize = 4096 * 4; for argument in ["--sparse=always", "--sparse=alway", "--sparse=al"] { @@ -2746,6 +3335,7 @@ fn test_cp_sparse_always_empty() { #[cfg(any(target_os = "linux", target_os = "android"))] #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_sparse_always_non_empty() { const BUFFER_SIZE: usize = 4096 * 16 + 3; let (at, mut ucmd) = at_and_ucmd!(); @@ -2816,7 +3406,7 @@ fn test_cp_sparse_never_reflink_always() { // Regression test for https://github.com/uutils/coreutils/issues/12186 // `cp --sparse=always` should be supported on Windows (matching GNU), not // rejected with "--sparse is only supported on linux". -#[cfg(target_os = "windows")] +#[cfg(windows)] #[test] fn test_cp_sparse_always_windows() { use std::os::windows::fs::MetadataExt; @@ -2853,7 +3443,7 @@ fn test_cp_sparse_always_windows() { // Companion to the regression above: `cp --sparse=never` must also be accepted // on Windows and produce a faithful, non-sparse copy (it was rejected by the // same "--sparse is only supported on linux" error before #12186). -#[cfg(target_os = "windows")] +#[cfg(windows)] #[test] fn test_cp_sparse_never_windows() { use std::os::windows::fs::MetadataExt; @@ -2880,7 +3470,7 @@ fn test_cp_sparse_never_windows() { // `--sparse=auto` (the default) must preserve an already-sparse source on Windows, // matching GNU. Before this fix the default mode did a plain copy that dropped the // source's holes. A non-sparse source must still copy plainly (no new holes). -#[cfg(target_os = "windows")] +#[cfg(windows)] #[test] fn test_cp_sparse_auto_preserves_sparse_source_windows() { use std::os::windows::fs::MetadataExt; @@ -2935,7 +3525,7 @@ fn test_cp_sparse_auto_preserves_sparse_source_windows() { // `--reflink=auto` means "clone if possible, else plain copy" (GNU), so on // Windows — which has no reflink support — it must fall back to a plain copy // rather than fail. -#[cfg(target_os = "windows")] +#[cfg(windows)] #[test] fn test_cp_reflink_auto_windows() { let (at, mut ucmd) = at_and_ucmd!(); @@ -2948,7 +3538,7 @@ fn test_cp_reflink_auto_windows() { // `--reflink=always` (and bare `--reflink`, which defaults to `always`) must // still fail on Windows: cloning was explicitly required but is unsupported. -#[cfg(target_os = "windows")] +#[cfg(windows)] #[test] fn test_cp_reflink_always_windows() { for argument in ["--reflink=always", "--reflink"] { @@ -2959,9 +3549,36 @@ fn test_cp_reflink_always_windows() { } } +// `--reflink=auto` means "clone if possible, else plain copy" (GNU); WASI has +// no reflink support, so `copy_on_write` (src/uu/cp/src/platform/other.rs) +// must fall back to a plain copy rather than fail. +#[cfg(wasi_runner)] +#[test] +fn test_cp_reflink_auto_wasi() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("src", "hello"); + + ucmd.args(&["--reflink=auto", "src", "dst"]).succeeds(); + + assert_eq!(at.read("dst"), "hello"); +} + +// `--reflink=always` (and bare `--reflink`, which defaults to `always`) must +// still fail on WASI: cloning was explicitly required but is unsupported. +#[cfg(wasi_runner)] +#[test] +fn test_cp_reflink_always_wasi() { + for argument in ["--reflink=always", "--reflink"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("src", "hello"); + + ucmd.args(&[argument, "src", "dst"]).fails(); + } +} + // `--debug` must report the sparse detection that actually happened: `zeros` // when the sparse copy ran (the test temp dir is NTFS, which supports it). -#[cfg(target_os = "windows")] +#[cfg(windows)] #[test] fn test_cp_debug_sparse_always_windows() { let (at, mut ucmd) = at_and_ucmd!(); @@ -2977,6 +3594,10 @@ fn test_cp_debug_sparse_always_windows() { #[cfg(any(target_os = "linux", target_os = "android"))] #[cfg(feature = "truncate")] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: --reflink is only supported on linux and macOS" +)] fn test_cp_reflink_always_override() { const DISK: &str = "disk.img"; const ROOTDIR: &str = "disk_root/"; @@ -3056,6 +3677,10 @@ fn test_cp_reflink_always_override() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_copy_dir_symlink() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("dir"); @@ -3067,6 +3692,10 @@ fn test_copy_dir_symlink() { #[test] #[cfg(not(target_os = "freebsd"))] // FIXME: fix this test for FreeBSD #[cfg(feature = "ln")] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_copy_dir_with_symlinks() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("dir"); @@ -3085,6 +3714,10 @@ fn test_copy_dir_with_symlinks() { #[test] #[cfg(not(windows))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_copy_symlink_force() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("file"); @@ -3099,6 +3732,10 @@ fn test_copy_symlink_force() { #[test] #[cfg(unix)] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_no_preserve_mode() { use std::os::unix::prelude::MetadataExt; @@ -3129,6 +3766,10 @@ fn test_no_preserve_mode() { #[test] #[cfg(unix)] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_preserve_mode() { use std::os::unix::prelude::MetadataExt; @@ -3179,7 +3820,7 @@ fn test_copy_through_just_created_symlink() { .arg("b/1") .arg("c") .fails() - .stderr_only(if cfg!(not(target_os = "windows")) { + .stderr_only(if cfg!(not(windows)) { "cp: will not copy 'b/1' through just-created symlink 'c/1'\n" } else { "cp: will not copy 'b/1' through just-created symlink 'c\\1'\n" @@ -3202,6 +3843,10 @@ fn test_copy_through_dangling_symlink() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_copy_through_dangling_symlink_posixly_correct() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("file"); @@ -3217,6 +3862,10 @@ fn test_copy_through_dangling_symlink_posixly_correct() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_copy_through_dangling_symlink_no_dereference() { let (at, mut ucmd) = at_and_ucmd!(); at.symlink_file("no-such-file", "dangle"); @@ -3228,6 +3877,10 @@ fn test_copy_through_dangling_symlink_no_dereference() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_symlink_overwrite_detection() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -3245,14 +3898,14 @@ fn test_cp_symlink_overwrite_detection() { .arg("good/README") .arg("tmp") .fails() - .stderr_only(if cfg!(target_os = "windows") { + .stderr_only(if cfg!(windows) { "cp: will not copy 'good/README' through just-created symlink 'tmp\\README'\n" } else { "cp: will not copy 'good/README' through just-created symlink 'tmp/README'\n" }); let contents = at.read("tmp/foo"); // None of the files seem to be copied in macos - if cfg!(not(target_os = "macos")) { + if cfg!(not(target_vendor = "apple")) { assert_eq!(contents, "file1"); } } @@ -3274,7 +3927,7 @@ fn test_cp_dangling_symlink_inside_directory() { .arg("good/README") .arg("tmp") .fails() - .stderr_only( if cfg!(target_os="windows") { + .stderr_only( if cfg!(windows) { "cp: not writing through dangling symlink 'tmp\\README'\ncp: not writing through dangling symlink 'tmp\\README'\n" } else { "cp: not writing through dangling symlink 'tmp/README'\ncp: not writing through dangling symlink 'tmp/README'\n" @@ -3284,6 +3937,10 @@ fn test_cp_dangling_symlink_inside_directory() { /// Test for copying a dangling symbolic link and its permissions. #[cfg(not(any(target_os = "freebsd", target_os = "openbsd")))] // FIXME: fix this test for FreeBSD/OpenBSD #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_copy_through_dangling_symlink_no_dereference_permissions() { let (at, mut ucmd) = at_and_ucmd!(); // target name link name @@ -3356,12 +4013,39 @@ fn test_cp_link_backup() { .arg("file2") .succeeds(); - assert!(at.file_exists("file2~")); - assert_eq!(at.read("file2"), "Hello, World!\n"); + assert!(at.file_exists("file2~")); + assert_eq!(at.read("file2"), "Hello, World!\n"); +} + +/// `--attributes-only` must still give the destination the source's type. +/// It used to create a regular file for any source, which both got the type +/// wrong and made the later xattr copy `open()` the source FIFO -- blocking +/// forever, since nothing ever writes to it. GNU creates the FIFO and returns. +#[test] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: no FIFO/mkfifo support")] +fn test_cp_attributes_only_fifo_keeps_type_and_returns() { + for recursive in ["-a", "-R"] { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkfifo("fifo"); + + scene + .ucmd() + .args(&[recursive, "--attributes-only", "fifo", "copy"]) + .succeeds() + .no_stderr(); + + assert!( + at.is_fifo("copy"), + "--attributes-only {recursive} did not create a FIFO" + ); + } } #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: no FIFO/mkfifo support")] fn test_cp_fifo() { let (at, mut ucmd) = at_and_ucmd!(); at.mkfifo("fifo"); @@ -3380,6 +4064,25 @@ fn test_cp_fifo() { assert_eq!(permission, "prwx-wx--x".to_string()); } +#[test] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: no FIFO/mkfifo support")] +fn test_cp_fifo_preserve_timestamps() { + // Preserving timestamps must not open the FIFO: opening a FIFO with no + // writer blocks forever. If this regresses, the test hangs instead of + // completing. See the `-a`/`--preserve=timestamps` path in copy_attributes. + let (at, mut ucmd) = at_and_ucmd!(); + at.mkfifo("pipe"); + at.set_mode("pipe", 0o624); + ucmd.arg("--preserve=timestamps") + .arg("-r") + .arg("pipe") + .arg("pipe_dup") + .succeeds() + .no_output(); + assert!(at.is_fifo("pipe_dup")); +} + #[rstest] #[case::recursive("-R")] #[case::archive("-a")] @@ -3423,6 +4126,10 @@ fn test_cp_recursive_char_device(#[case] flag: &str) { #[case::recursive("-R")] #[case::archive("-a")] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] fn test_cp_recursive_char_device_no_permission(#[case] flag: &str) { new_ucmd!() .args(&[flag, "/dev/null", "null2"]) @@ -3432,6 +4139,10 @@ fn test_cp_recursive_char_device_no_permission(#[case] flag: &str) { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] fn test_cp_recursive_char_device_copy_contents() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.args(&["-R", "--copy-contents", "/dev/null", "null2"]) @@ -3443,6 +4154,10 @@ fn test_cp_recursive_char_device_copy_contents() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] fn test_cp_char_device() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.args(&["/dev/null", "null2"]).succeeds().no_stderr(); @@ -3509,6 +4224,7 @@ fn test_cp_block_device_no_permission() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: no Unix domain socket support")] fn test_cp_socket() { let (at, mut ucmd) = at_and_ucmd!(); at.mksocket("socket"); @@ -3543,6 +4259,10 @@ fn find_other_group(_current: u32) -> Option { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_r_symlink() { let (at, mut ucmd) = at_and_ucmd!(); // Specifically test copying a link in a subdirectory, as the internal path @@ -3721,6 +4441,10 @@ fn test_copy_same_symlink_no_dereference_dangling() { // TODO: enable for Android, when #3477 solved #[cfg(not(any(windows, target_os = "android", target_os = "openbsd")))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_parents_2_dirs() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir_all("a/b/c"); @@ -3877,6 +4601,10 @@ fn test_cp_mode_hardlink_no_dereference() { #[cfg(not(any(windows, target_os = "android")))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_remove_destination_with_destination_being_a_hardlink_to_source() { let (at, mut ucmd) = at_and_ucmd!(); let file = "file"; @@ -3938,6 +4666,10 @@ fn test_remove_destination_symbolic_link_loop() { #[test] #[cfg(not(windows))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_symbolic_link_loop() { let (at, mut ucmd) = at_and_ucmd!(); at.symlink_file("loop", "loop"); @@ -3978,6 +4710,10 @@ fn test_copy_nested_directory_to_itself_disallowed() { /// Test for preserving permissions when copying a directory. #[cfg(all(not(windows), not(target_os = "freebsd"), not(target_os = "openbsd")))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_copy_dir_preserve_permissions() { // Create a directory that has some non-default permissions. let (at, mut ucmd) = at_and_ucmd!(); @@ -4004,6 +4740,10 @@ fn test_copy_dir_preserve_permissions() { /// cp should preserve attributes of subdirectories when copying recursively. #[cfg(all(not(windows), not(target_os = "freebsd"), not(target_os = "openbsd")))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_copy_dir_preserve_subdir_permissions() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("a1"); @@ -4026,6 +4766,10 @@ fn test_copy_dir_preserve_subdir_permissions() { /// read-only mode, causing EPERM when copying files into it. #[cfg(all(not(windows), not(target_os = "freebsd"), not(target_os = "openbsd")))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_copy_dir_preserve_readonly_source_with_files() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("src"); @@ -4045,6 +4789,10 @@ fn test_copy_dir_preserve_readonly_source_with_files() { /// the face of an inaccessible file in that directory. #[cfg(all(not(windows), not(target_os = "freebsd"), not(target_os = "openbsd")))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_copy_dir_preserve_permissions_inaccessible_file() { // Create a directory that has some non-default permissions and // contains an inaccessible file. @@ -4101,6 +4849,10 @@ fn test_same_file_force() { /// Test that copying file to itself with forced backup succeeds. #[cfg(not(windows))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_same_file_force_backup() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("f"); @@ -4113,6 +4865,7 @@ fn test_same_file_force_backup() { /// Test for copying the contents of a FIFO as opposed to the FIFO object itself. #[cfg(unix)] #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: no FIFO/mkfifo support")] fn test_copy_contents_fifo() { let scenario = TestScenario::new(util_name!()); let at = &scenario.fixtures; @@ -4138,6 +4891,7 @@ fn test_copy_contents_fifo() { #[cfg(target_os = "linux")] #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_reflink_never_sparse_always() { let (at, mut ucmd) = at_and_ucmd!(); @@ -4165,6 +4919,10 @@ fn test_reflink_never_sparse_always() { /// Test for preserving attributes of a hard link in a directory. #[test] #[cfg(not(any(target_os = "android", target_os = "openbsd")))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_preserve_hardlink_attributes_in_directory() { let (at, mut ucmd) = at_and_ucmd!(); @@ -4221,6 +4979,10 @@ fn test_symbolic_link_file() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_src_base_dot() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -4244,6 +5006,10 @@ fn non_utf8_name(suffix: &str) -> OsString { #[cfg(target_os = "linux")] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: requires valid UTF-8 paths, so the non-UTF-8 error is expected" +)] fn test_non_utf8_src() { let (at, mut ucmd) = at_and_ucmd!(); let src = non_utf8_name("src"); @@ -4254,6 +5020,10 @@ fn test_non_utf8_src() { #[cfg(target_os = "linux")] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: requires valid UTF-8 paths, so the non-UTF-8 error is expected" +)] fn test_non_utf8_dest() { let (at, mut ucmd) = at_and_ucmd!(); let dest = non_utf8_name("dest"); @@ -4265,6 +5035,10 @@ fn test_non_utf8_dest() { #[cfg(target_os = "linux")] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: requires valid UTF-8 paths, so the non-UTF-8 error is expected" +)] fn test_non_utf8_target() { let (at, mut ucmd) = at_and_ucmd!(); let dest = non_utf8_name("dest"); @@ -4279,6 +5053,10 @@ fn test_non_utf8_target() { #[test] #[cfg(not(windows))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_archive_on_directory_ending_dot() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("dir1"); @@ -4289,9 +5067,13 @@ fn test_cp_archive_on_directory_ending_dot() { } #[test] -#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] +#[cfg(any(windows, target_vendor = "apple", target_os = "linux"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: --debug reports 'unsupported' instead of 'unknown' for this platform" +)] fn test_cp_debug_default() { - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] let expected = "copy offload: unknown, reflink: unsupported, sparse detection: unsupported"; #[cfg(target_os = "linux")] let expected = "copy offload: unknown, reflink: unsupported, sparse detection: no"; @@ -4311,7 +5093,11 @@ fn test_cp_debug_default() { } #[test] -#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] +#[cfg(any(windows, target_vendor = "apple", target_os = "linux"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: --debug reports 'unsupported' instead of 'unknown' for this platform" +)] fn test_cp_debug_multiple_default() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -4328,7 +5114,7 @@ fn test_cp_debug_multiple_default() { .arg(dir) .succeeds(); - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] let expected = "copy offload: unknown, reflink: unsupported, sparse detection: unsupported"; #[cfg(target_os = "linux")] let expected = "copy offload: unknown, reflink: unsupported, sparse detection: no"; @@ -4341,6 +5127,7 @@ fn test_cp_debug_multiple_default() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_sparse_reflink() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -4373,6 +5160,7 @@ fn test_cp_debug_no_update() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_sparse_always() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -4389,6 +5177,7 @@ fn test_cp_debug_sparse_always() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_sparse_never() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -4404,12 +5193,16 @@ fn test_cp_debug_sparse_never() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: --debug reports 'unsupported' instead of 'unknown' for this platform" +)] fn test_cp_debug_sparse_auto() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; at.touch("a"); - #[cfg(not(any(target_os = "linux", target_os = "macos")))] + #[cfg(not(any(target_vendor = "apple", target_os = "linux")))] ts.ucmd() .arg("--debug") .arg("--sparse=auto") @@ -4417,9 +5210,9 @@ fn test_cp_debug_sparse_auto() { .arg("b") .succeeds(); - #[cfg(any(target_os = "linux", target_os = "macos"))] + #[cfg(any(target_vendor = "apple", target_os = "linux"))] { - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] let expected = "copy offload: unknown, reflink: unsupported, sparse detection: unsupported"; #[cfg(target_os = "linux")] let expected = "copy offload: unknown, reflink: unsupported, sparse detection: no"; @@ -4435,9 +5228,13 @@ fn test_cp_debug_sparse_auto() { } #[test] -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_vendor = "apple", target_os = "linux"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: --debug reports 'unsupported' instead of 'unknown' for this platform" +)] fn test_cp_debug_reflink_auto() { - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] let expected = "copy offload: unknown, reflink: unsupported, sparse detection: unsupported"; #[cfg(target_os = "linux")] let expected = "copy offload: unknown, reflink: unsupported, sparse detection: no"; @@ -4457,6 +5254,7 @@ fn test_cp_debug_reflink_auto() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_sparse_always_reflink_auto() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -4499,8 +5297,12 @@ fn test_cp_dest_no_permissions() { } /// Test readonly destination behavior with reflink options -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_vendor = "apple", target_os = "linux"))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: --reflink is only supported on linux and macOS" +)] fn test_cp_readonly_dest_with_reflink() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -4644,6 +5446,10 @@ fn test_cp_attributes_only_dest_open_error() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] fn test_cp_cannot_create_regular_file() { let (at, mut ucmd) = at_and_ucmd!(); at.write("source.txt", "hello"); @@ -4655,6 +5461,10 @@ fn test_cp_cannot_create_regular_file() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] fn test_cp_cannot_create_regular_file_attributes_only() { let (at, mut ucmd) = at_and_ucmd!(); at.write("source.txt", "hello"); @@ -4663,6 +5473,34 @@ fn test_cp_cannot_create_regular_file_attributes_only() { .stderr_only("cp: cannot create regular file '/dev/null/n.txt': Not a directory\n"); } +#[test] +fn test_cp_attributes_only_same_file() { + let (at, mut ucmd) = at_and_ucmd!(); + let file = "a"; + + at.touch(file); + + ucmd.arg("--attributes-only") + .arg(file) + .arg(file) + .fails_with_code(1) + .stderr_contains(format!("'{file}' and '{file}' are the same file")); +} + +#[test] +fn test_cp_attributes_only_same_file_dot_path() { + let (at, mut ucmd) = at_and_ucmd!(); + let file = "a"; + + at.touch(file); + + ucmd.arg("--attributes-only") + .arg(file) + .arg("./a") + .fails_with_code(1) + .stderr_contains(format!("'{file}' and './{file}' are the same file")); +} + #[test] fn test_cp_seen_file() { let ts = TestScenario::new(util_name!()); @@ -4720,11 +5558,17 @@ fn test_cp_no_such() { .stderr_is("cp: 'no-such/' is not a directory\n"); } -#[cfg(all( - unix, - not(any(target_os = "android", target_os = "macos", target_os = "openbsd")) +#[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "netbsd" ))] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: absolute host tmpdir path is outside the mapped guest root, so cp cannot stat it" +)] fn test_acl_preserve() { use std::process::Command; @@ -4765,15 +5609,17 @@ fn test_acl_preserve() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_reflink_never_with_hole() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; + let page_size = rustix::param::page_size(); at.write("a", "hello"); let f = std::fs::OpenOptions::new() .write(true) .open(at.plus("a")) .unwrap(); - f.set_len(10000).unwrap(); + f.set_len((page_size as u64) * 4).unwrap(); ts.ucmd() .arg("--debug") @@ -4790,6 +5636,7 @@ fn test_cp_debug_reflink_never_with_hole() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_reflink_never_empty_file_with_hole() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -4815,15 +5662,17 @@ fn test_cp_debug_reflink_never_empty_file_with_hole() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_default_with_hole() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; at.touch("a"); + let page_size = rustix::param::page_size(); let f = std::fs::OpenOptions::new() .write(true) .open(at.plus("a")) .unwrap(); - f.set_len(10000).unwrap(); + f.set_len((page_size as u64) * 4).unwrap(); at.append_bytes("a", "hello".as_bytes()); @@ -4841,6 +5690,7 @@ fn test_cp_debug_default_with_hole() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_default_less_than_512_bytes() { let ts = TestScenario::new(util_name!()); @@ -4864,6 +5714,7 @@ fn test_cp_debug_default_less_than_512_bytes() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_default_without_hole() { let ts = TestScenario::new(util_name!()); @@ -4884,6 +5735,7 @@ fn test_cp_debug_default_without_hole() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_default_empty_file_with_hole() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -4910,15 +5762,17 @@ fn test_cp_debug_default_empty_file_with_hole() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_reflink_never_sparse_always_with_hole() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; + let page_size = rustix::param::page_size(); at.write("a", "hello"); let f = std::fs::OpenOptions::new() .write(true) .open(at.plus("a")) .unwrap(); - f.set_len(10000).unwrap(); + f.set_len((page_size as u64) * 4).unwrap(); ts.ucmd() .arg("--debug") @@ -4936,6 +5790,7 @@ fn test_cp_debug_reflink_never_sparse_always_with_hole() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_reflink_never_sparse_always_without_hole() { let ts = TestScenario::new(util_name!()); let empty_bytes = [0_u8; 10000]; @@ -4961,6 +5816,7 @@ fn test_cp_debug_reflink_never_sparse_always_without_hole() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_reflink_never_sparse_always_empty_file_with_hole() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -4983,6 +5839,10 @@ fn test_cp_debug_reflink_never_sparse_always_empty_file_with_hole() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: /sys and /proc virtual files are not exposed to the sandbox" +)] fn test_cp_default_virtual_file() { // This file has existed at least since 2008, so we assume that it is present on "all" Linux kernels. // https://www.kernel.org/doc/Documentation/ABI/testing/sysfs-profiling @@ -5006,6 +5866,7 @@ fn test_cp_default_virtual_file() { } #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_reflink_auto_sparse_always_non_sparse_file_with_long_zero_sequence() { let ts = TestScenario::new(util_name!()); @@ -5032,6 +5893,7 @@ fn test_cp_debug_reflink_auto_sparse_always_non_sparse_file_with_long_zero_seque #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_sparse_never_empty_sparse_file() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -5048,6 +5910,7 @@ fn test_cp_debug_sparse_never_empty_sparse_file() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_reflink_never_sparse_always_non_sparse_file_with_long_zero_sequence() { let ts = TestScenario::new(util_name!()); @@ -5075,6 +5938,10 @@ fn test_cp_debug_reflink_never_sparse_always_non_sparse_file_with_long_zero_sequ #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: /sys and /proc virtual files are not exposed to the sandbox" +)] fn test_cp_debug_sparse_always_sparse_virtual_file() { // This file has existed at least since 2008, so we assume that it is present on "all" Linux kernels. // https://www.kernel.org/doc/Documentation/ABI/testing/sysfs-profiling @@ -5099,6 +5966,7 @@ fn test_cp_debug_sparse_always_sparse_virtual_file() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_reflink_never_less_than_512_bytes() { let ts = TestScenario::new(util_name!()); @@ -5121,6 +5989,7 @@ fn test_cp_debug_reflink_never_less_than_512_bytes() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_reflink_never_sparse_never_empty_file_with_hole() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -5143,16 +6012,18 @@ fn test_cp_debug_reflink_never_sparse_never_empty_file_with_hole() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_reflink_never_file_with_hole() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; at.touch("a"); + let page_size = rustix::param::page_size(); let f = std::fs::OpenOptions::new() .write(true) .open(at.plus("a")) .unwrap(); - f.set_len(10000).unwrap(); - at.append_bytes("a", "hello".as_bytes()); + f.set_len((page_size as u64) * 4).unwrap(); + at.append_bytes("a", b"hello"); ts.ucmd() .arg("--debug") @@ -5166,6 +6037,7 @@ fn test_cp_debug_reflink_never_file_with_hole() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_sparse_never_less_than_512_bytes() { let ts = TestScenario::new(util_name!()); @@ -5189,6 +6061,7 @@ fn test_cp_debug_sparse_never_less_than_512_bytes() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_sparse_never_without_hole() { let ts = TestScenario::new(util_name!()); @@ -5211,6 +6084,7 @@ fn test_cp_debug_sparse_never_without_hole() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_sparse_never_empty_file_with_hole() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -5233,15 +6107,17 @@ fn test_cp_debug_sparse_never_empty_file_with_hole() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_sparse_never_file_with_hole() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; + let page_size = rustix::param::page_size(); at.touch("a"); let f = std::fs::OpenOptions::new() .write(true) .open(at.plus("a")) .unwrap(); - f.set_len(10000).unwrap(); + f.set_len((page_size as u64) * 4).unwrap(); at.append_bytes("a", "hello".as_bytes()); ts.ucmd() @@ -5256,6 +6132,10 @@ fn test_cp_debug_sparse_never_file_with_hole() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: /sys and /proc virtual files are not exposed to the sandbox" +)] fn test_cp_debug_default_sparse_virtual_file() { // This file has existed at least since 2008, so we assume that it is present on "all" Linux kernels. // https://www.kernel.org/doc/Documentation/ABI/testing/sysfs-profiling @@ -5279,6 +6159,10 @@ fn test_cp_debug_default_sparse_virtual_file() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: /sys and /proc virtual files are not exposed to the sandbox" +)] fn test_cp_debug_sparse_never_zero_sized_virtual_file() { let ts = TestScenario::new(util_name!()); ts.ucmd() @@ -5290,8 +6174,44 @@ fn test_cp_debug_sparse_never_zero_sized_virtual_file() { .stdout_contains("copy offload: avoided, reflink: no, sparse detection: no"); } +// A file that stats as zero-sized but still returns data (/proc entries) +// is probed for content by reading from it. The copy reuses that same +// descriptor, so it has to start over at offset 0 -- otherwise everything +// the probe consumed is dropped and the destination ends up empty. The +// --debug tests above only look at the reported strategy, not the bytes. +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: /sys and /proc virtual files are not exposed to the sandbox" +)] +fn test_cp_zero_sized_virtual_file_contents() { + let expected = std::fs::read_to_string("/proc/version").unwrap(); + assert!(!expected.is_empty()); + + for extra in [ + &[][..], + &["--sparse=never"], + &["--sparse=always"], + &["--sparse=auto"], + &["--reflink=never"], + &["--reflink=never", "--sparse=always"], + ] { + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(extra) + .args(&["/proc/version", "copied"]) + .succeeds() + .no_output(); + assert_eq!(at.read("copied"), expected, "with {extra:?}"); + } +} + #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: /sys and /proc virtual files are not exposed to the sandbox" +)] fn test_cp_debug_default_zero_sized_virtual_file() { let ts = TestScenario::new(util_name!()); ts.ucmd() @@ -5304,6 +6224,7 @@ fn test_cp_debug_default_zero_sized_virtual_file() { #[test] #[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] fn test_cp_debug_reflink_never_without_hole() { let ts = TestScenario::new(util_name!()); let filler_bytes = [0_u8; 1000]; @@ -5321,6 +6242,10 @@ fn test_cp_debug_reflink_never_without_hole() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_force_remove_destination_attributes_only_with_symlink() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5374,9 +6299,9 @@ fn test_cp_no_dereference_attributes_only_with_symlink() { "file2 content does not match expected" ); } +/// contains the test for cp when the source and destination points to the same file #[cfg(all(unix, not(target_os = "android")))] #[cfg(test)] -/// contains the test for cp when the source and destination points to the same file mod same_file { use std::os::unix::fs::MetadataExt; @@ -5390,6 +6315,10 @@ mod same_file { // the following tests tries to copy a file to the symlink of the same file with // various options #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_symlink() { for option in ["-d", "-f", "-df"] { let scene = TestScenario::new(util_name!()); @@ -5425,6 +6354,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_symlink_with_backup_option() { for option in ["-b", "-bd", "-bf", "-bdf"] { let scene = TestScenario::new(util_name!()); @@ -5446,6 +6379,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_symlink_with_link_option() { for option in ["-l", "-dl"] { let scene = TestScenario::new(util_name!()); @@ -5465,6 +6402,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_symlink_with_options_link_and_force() { for option in ["-fl", "-dfl"] { let scene = TestScenario::new(util_name!()); @@ -5482,6 +6423,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_symlink_with_options_backup_and_link() { for option in ["-bl", "-bdl", "-bfl", "-bdfl"] { let scene = TestScenario::new(util_name!()); @@ -5502,6 +6447,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_symlink_with_options_symlink() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5519,6 +6468,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_symlink_with_options_symlink_and_force() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5536,6 +6489,10 @@ mod same_file { // the following tests tries to copy a symlink to the file that symlink points to with // various options #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_file() { for option in ["-d", "-f", "-df", "--rem"] { let scene = TestScenario::new(util_name!()); @@ -5555,6 +6512,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_file_with_option_backup() { for option in ["-b", "-bf"] { let scene = TestScenario::new(util_name!()); @@ -5573,6 +6534,10 @@ mod same_file { } } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_file_with_option_backup_without_deref() { for option in ["-bd", "-bdf"] { let scene = TestScenario::new(util_name!()); @@ -5594,6 +6559,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_file_with_options_link() { for option in ["-l", "-dl", "-fl", "-bl", "-bfl"] { let scene = TestScenario::new(util_name!()); @@ -5612,6 +6581,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_file_with_option_symlink() { for option in ["-s", "-sf"] { let scene = TestScenario::new(util_name!()); @@ -5663,6 +6636,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_file_with_options_backup_and_no_deref() { for option in ["-bf", "-bdf"] { let scene = TestScenario::new(util_name!()); @@ -5681,6 +6658,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_file_with_options_link() { for option in ["-l", "-dl", "-fl", "-dfl", "-bl", "-bdl"] { let scene = TestScenario::new(util_name!()); @@ -5698,6 +6679,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_file_with_options_link_and_backup_and_force() { for option in ["-bfl", "-bdfl"] { let scene = TestScenario::new(util_name!()); @@ -5734,6 +6719,10 @@ mod same_file { // the following tests tries to copy a symlink that points to a file to a symlink // that points to the same file with various options #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_symlink_with_option_no_deref() { for option in ["-d", "-df"] { let scene = TestScenario::new(util_name!()); @@ -5752,6 +6741,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_symlink_with_option_force() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5772,6 +6765,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_symlink_with_option_rem() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5789,6 +6786,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_symlink_with_option_backup() { for option in ["-b", "-bf"] { let scene = TestScenario::new(util_name!()); @@ -5810,6 +6811,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_symlink_with_option_backup_and_no_deref() { for option in ["-bd", "-bdf"] { let scene = TestScenario::new(util_name!()); @@ -5829,6 +6834,10 @@ mod same_file { } } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_symlink_with_option_link() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5849,6 +6858,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_symlink_with_option_force_link() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5866,6 +6879,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_symlink_with_option_backup_and_link() { for option in ["-bl", "-bfl"] { let scene = TestScenario::new(util_name!()); @@ -5887,6 +6904,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_symlink_with_option_symlink() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5907,6 +6928,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_symlink_to_symlink_with_option_symlink_and_force() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5970,6 +6995,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_hardlink_with_option_rem() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -5986,6 +7015,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_hardlink_with_option_backup() { for option in ["-b", "-bd", "-bf", "-bdf"] { let scene = TestScenario::new(util_name!()); @@ -6003,6 +7036,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_file_to_hardlink_with_option_link() { for option in ["-l", "-dl", "-fl", "-dfl", "-bl", "-bdl", "-bfl", "-bdfl"] { let scene = TestScenario::new(util_name!()); @@ -6039,6 +7076,10 @@ mod same_file { // the following tests tries to copy symlink to a hardlink of the same symlink with // various options #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_hard_link_of_symlink_to_symlink() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6060,6 +7101,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_hard_link_of_symlink_to_symlink_with_option_force() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6081,6 +7126,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_hard_link_of_symlink_to_symlink_with_option_no_deref() { for option in ["-d", "-df"] { let scene = TestScenario::new(util_name!()); @@ -6103,6 +7152,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_hard_link_of_symlink_to_symlink_with_option_rem() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6124,6 +7177,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_hard_link_of_symlink_to_symlink_with_option_backup() { for option in ["-b", "-bf"] { let scene = TestScenario::new(util_name!()); @@ -6150,6 +7207,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_hard_link_of_symlink_to_symlink_with_option_backup_and_no_deref() { for option in ["-bd", "-bdf"] { let scene = TestScenario::new(util_name!()); @@ -6175,6 +7236,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_hard_link_of_symlink_to_symlink_with_option_link() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6196,6 +7261,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_hard_link_of_symlink_to_symlink_with_option_link_and_no_deref() { for option in ["-dl", "-dfl"] { let scene = TestScenario::new(util_name!()); @@ -6218,6 +7287,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_hard_link_of_symlink_to_symlink_with_option_link_and_force() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6238,6 +7311,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_hard_link_of_symlink_to_symlink_with_option_link_and_backup() { for option in ["-bl", "-bfl"] { let scene = TestScenario::new(util_name!()); @@ -6285,6 +7362,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_hard_link_of_symlink_to_symlink_with_option_symlink() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6306,6 +7387,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_same_file_from_hard_link_of_symlink_to_symlink_with_option_symlink_and_force() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6326,6 +7411,10 @@ mod same_file { } #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_hardlink_of_symlink_to_hardlink_of_same_symlink_with_option_no_deref() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6400,6 +7489,10 @@ mod link_deref { // Dereferencing should fail for dangling symlink. #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_cp_dang_link_as_source_with_link() { for option in ["", "-L", "-H"] { for r in [false, true] { @@ -6424,6 +7517,10 @@ mod link_deref { // Dereferencing should fail for the 'dir_link' without -R. #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_cp_dir_link_as_source_with_link() { for option in ["", "-L", "-H"] { let scene = TestScenario::new(util_name!()); @@ -6443,6 +7540,10 @@ mod link_deref { // cp --link -R 'dir_link' should create a new directory. #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_cp_dir_link_as_source_with_link_and_r() { for option in ["", "-L", "-H"] { let scene = TestScenario::new(util_name!()); @@ -6459,6 +7560,10 @@ mod link_deref { //cp --link 'file_link' should create a hard link to the target. #[test] + #[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" + )] fn test_cp_file_link_as_source_with_link() { for option in ["", "-L", "-H"] { for r in [false, true] { @@ -6491,6 +7596,10 @@ mod link_deref { #[test] #[cfg(unix)] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_dir_perm_race_with_preserve_mode_and_ownership() { const SRC_DIR: &str = "src"; const DEST_DIR: &str = "dest"; @@ -6546,6 +7655,10 @@ fn test_dir_perm_race_with_preserve_mode_and_ownership() { #[test] // when -d and -a are overridden with --preserve or --no-preserve make sure that it only // overrides attributes not other flags like -r or --no_deref implied in -a and -d. +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_preserve_attrs_overriding_1() { const FILE: &str = "file"; const SYMLINK: &str = "symlink"; @@ -6565,6 +7678,10 @@ fn test_preserve_attrs_overriding_1() { #[test] #[cfg(all(unix, not(target_os = "android")))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_preserve_attrs_overriding_2() { const FILE1: &str = "file1"; const FILE2: &str = "file2"; @@ -6611,9 +7728,66 @@ fn test_preserve_attrs_overriding_2() { } } +#[test] +#[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] +fn test_no_preserve_mode_with_later_preserve() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("src"); + at.set_mode("src", 0o755); + + // 1. cp --no-preserve=mode src dst1 -> mode should not have execute bit (default umask strips permissions) + scene + .ucmd() + .args(&["--no-preserve=mode", "src", "dst1"]) + .succeeds(); + let dst1_mode = at.metadata("dst1").mode() & 0o777; + assert_ne!(dst1_mode & 0o111, 0o111); + + // 2. cp --no-preserve=mode --preserve=timestamps src dst2 -> mode should match dst1 (--no-preserve=mode not forgotten) + scene + .ucmd() + .args(&["--no-preserve=mode", "--preserve=timestamps", "src", "dst2"]) + .succeeds(); + let dst2_mode = at.metadata("dst2").mode() & 0o777; + assert_eq!(dst1_mode, dst2_mode); + + // 3. cp --no-preserve=all --preserve=timestamps src dst3 -> mode should match dst1 + scene + .ucmd() + .args(&["--no-preserve=all", "--preserve=timestamps", "src", "dst3"]) + .succeeds(); + let dst3_mode = at.metadata("dst3").mode() & 0o777; + assert_eq!(dst1_mode, dst3_mode); + + // 4. cp --preserve=timestamps --no-preserve=mode src dst4 -> mode should match dst1 + scene + .ucmd() + .args(&["--preserve=timestamps", "--no-preserve=mode", "src", "dst4"]) + .succeeds(); + let dst4_mode = at.metadata("dst4").mode() & 0o777; + assert_eq!(dst1_mode, dst4_mode); + + // 5. cp --no-preserve=mode --preserve=mode src dst5 -> mode should be preserved (0o755) because later flag wins + scene + .ucmd() + .args(&["--no-preserve=mode", "--preserve=mode", "src", "dst5"]) + .succeeds(); + let dst5_mode = at.metadata("dst5").mode() & 0o777; + assert_eq!(dst5_mode, 0o755); +} + /// Test the behavior of preserving permissions when copying through a symlink #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_symlink_permissions() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6636,6 +7810,10 @@ fn test_cp_symlink_permissions() { /// Test the behavior of preserving permissions of parents when copying through a symlink #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_parents_symlink_permissions_file() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6656,10 +7834,62 @@ fn test_cp_parents_symlink_permissions_file() { ); } +/// A destination subdirectory that is really a symlink must not be descended +/// into: doing so writes the source subtree through the link and out of the +/// destination tree. GNU refuses with "cannot overwrite non-directory ... with +/// directory". +#[test] +#[cfg(unix)] +fn test_cp_recursive_dest_subdir_symlink_not_followed() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkdir_all("src/hooks"); + at.write("src/hooks/payload", "PAYLOAD"); + at.mkdir("dst"); + at.mkdir("outside"); + at.symlink_dir("../outside", "dst/hooks"); + + scene + .ucmd() + .args(&["-a", "src/.", "dst"]) + .fails() + .stderr_contains("cannot overwrite non-directory"); + + assert!( + !at.file_exists("outside/payload"), + "cp wrote through the destination symlink and escaped the target tree" + ); +} + +/// A symlinked directory named as the *target* is still a legitimate +/// destination -- only entries discovered inside the tree are refused. +#[test] +#[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] +fn test_cp_recursive_target_dir_symlink_still_allowed() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkdir_all("srcdir"); + at.write("srcdir/f", "X"); + at.mkdir("real"); + at.symlink_dir("real", "dstlink"); + + scene.ucmd().args(&["-r", "srcdir", "dstlink/"]).succeeds(); + + assert!(at.file_exists("real/srcdir/f")); +} + /// Test the behavior of preserving permissions of parents when copying through /// a symlink when source is a dir. #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_parents_symlink_permissions_dir() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6682,6 +7912,10 @@ fn test_cp_parents_symlink_permissions_dir() { /// Test the behavior of copying a file to a destination with parents using absolute paths. #[cfg(unix)] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] fn test_cp_parents_absolute_path() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -6713,7 +7947,7 @@ fn test_copy_symlink_overwrite() { .arg("b/1") .arg("c") .fails() - .stderr_only(if cfg!(not(target_os = "windows")) { + .stderr_only(if cfg!(not(windows)) { "cp: will not overwrite just-created 'c/1' with 'b/1'\n" } else { "cp: will not overwrite just-created 'c\\1' with 'b/1'\n" @@ -6729,7 +7963,7 @@ fn test_symlink_mode_overwrite() { at.write("a/t", "hello"); at.write("b/t", "hello"); - if cfg!(not(target_os = "windows")) { + if cfg!(not(windows)) { ucmd.arg("-s") .arg("a/t") .arg("b/t") @@ -6786,9 +8020,11 @@ fn test_cp_no_file() { } #[test] -#[cfg(all( - unix, - not(any(target_os = "android", target_os = "macos", target_os = "openbsd")) +#[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "netbsd" ))] fn test_cp_preserve_xattr_readonly_source() { use std::process::Command; @@ -6870,6 +8106,10 @@ fn test_cp_preserve_xattr_readonly_source() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_archive_preserves_directory_permissions() { // Test for issue #8407 let (at, mut ucmd) = at_and_ucmd!(); @@ -6910,7 +8150,11 @@ fn test_cp_archive_preserves_directory_permissions() { #[test] #[cfg(unix)] -#[cfg_attr(target_os = "macos", ignore = "Flaky on MacOS, see #8453")] +#[cfg_attr(target_vendor = "apple", ignore = "Flaky on MacOS, see #8453")] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] fn test_cp_from_stdin() { let (at, mut ucmd) = at_and_ucmd!(); let target = "target"; @@ -6978,7 +8222,11 @@ fn test_cp_update_none_interactive_prompt_no() { /// only unix has `/dev/fd/0` #[cfg(unix)] -#[cfg_attr(target_os = "macos", ignore = "Flaky on MacOS, see #8453")] +#[cfg_attr(target_vendor = "apple", ignore = "Flaky on MacOS, see #8453")] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] #[test] fn test_cp_from_stream() { let target = "target"; @@ -7005,7 +8253,11 @@ fn test_cp_from_stream() { /// only unix has `/dev/fd/0` #[cfg(unix)] -#[cfg_attr(target_os = "macos", ignore = "Flaky on MacOS, see #8453")] +#[cfg_attr(target_vendor = "apple", ignore = "Flaky on MacOS, see #8453")] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev, /private) not visible" +)] #[test] fn test_cp_from_stream_permission() { let target = "target"; @@ -7028,10 +8280,7 @@ fn test_cp_from_stream_permission() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_cp_selinux() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -7056,10 +8305,48 @@ fn test_cp_selinux() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] +fn test_cp_selinux_default_context_relative_dest() { + // -Z labels the destination with the context the policy has for its path, + // and the policy only lists absolute paths: a relative destination used to + // match nothing and silently keep the context it already carried. + use std::path::Path; + use uucore::selinux::set_selinux_security_context; + + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.touch(TEST_HELLO_WORLD_SOURCE); + + // A type the policy will not hand out for a path under the test directory, + // so that the comparison below still sees a difference when -Z leaves the + // relative destination alone. Usable even when mcstransd is not running. + let ctx = "root:object_r:etc_t:s0".to_string(); + for dest in ["relative", "absolute"] { + at.touch(dest); + if set_selinux_security_context(Path::new(&at.plus_as_string(dest)), Some(&ctx)).is_err() { + return; + } + } + + let absolute = at.plus_as_string("absolute"); + ts.ucmd() + .args(&["-Z", TEST_HELLO_WORLD_SOURCE, &absolute]) + .succeeds(); + ts.ucmd() + .args(&["-Z", TEST_HELLO_WORLD_SOURCE, "relative"]) + .succeeds(); + + // Compare the type only, the one field -Z is about. + let selinux_type = |context: &str| context.split(':').nth(2).unwrap_or("").to_string(); + assert_eq!( + selinux_type(&get_getfattr_output(&at.plus_as_string("relative"))), + selinux_type(&get_getfattr_output(&absolute)), + "-Z gave a relative and an absolute destination different contexts" + ); +} + +#[test] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_cp_selinux_invalid() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -7083,10 +8370,7 @@ fn test_cp_selinux_invalid() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_cp_preserve_selinux() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -7124,10 +8408,7 @@ fn test_cp_preserve_selinux() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_cp_preserve_selinux_admin_context() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -7186,10 +8467,7 @@ fn test_cp_preserve_selinux_admin_context() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_cp_selinux_context_priority() { // This test verifies that -Z takes priority over --context @@ -7269,10 +8547,7 @@ fn test_cp_selinux_context_priority() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_cp_selinux_empty_context() { // This test verifies that --context without a value works like -Z @@ -7318,10 +8593,7 @@ fn test_cp_selinux_empty_context() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_cp_selinux_recursive() { // Test SELinux context preservation in recursive directory copies @@ -7375,10 +8647,7 @@ fn test_cp_selinux_recursive() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_cp_preserve_context_root() { use uutests::util::run_ucmd_as_root; let scene = TestScenario::new(util_name!()); @@ -7424,11 +8693,65 @@ fn test_cp_preserve_context_root() { } } +// Regression test for https://github.com/uutils/coreutils/issues/9105. +// Absolute operands must not require resolving the current working directory. +#[cfg(unix)] +#[rstest] +#[case::existing_target(true)] +#[case::new_target(false)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: wasmtime cannot exec the binary via a raw shell from a deleted cwd" +)] +fn test_cp_absolute_paths_from_deleted_cwd(#[case] target_exists: bool) { + use std::process::Command; + + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.mkdir_all("src/sub"); + at.write("src/sub/file", "contents"); + at.mkdir("deleted-cwd"); + if target_exists { + at.mkdir("dst"); + } + + let source = at.plus("src"); + let target = at.plus("dst"); + let deleted_cwd = at.plus("deleted-cwd"); + let output = Command::new("sh") + .arg("-c") + .arg("cd \"$1\" && rmdir \"$1\" && exec \"$2\" \"$3\" -Ra --no-preserve=ownership \"$4\" \"$5\"") + .arg("sh") + .arg(&deleted_cwd) + .arg(&ts.bin_path) + .arg(&ts.util_name) + .arg(&source) + .arg(&target) + .output() + .unwrap(); + + assert!( + output.status.success(), + "cp failed from a deleted cwd: {}", + String::from_utf8_lossy(&output.stderr) + ); + let copied_file = if target_exists { + target.join("src/sub/file") + } else { + target.join("sub/file") + }; + assert_eq!(std::fs::read_to_string(copied_file).unwrap(), "contents"); +} + // Test copying current directory (.) to an existing directory. // This tests the special case where we copy the current directory // to an existing directory, ensuring the directory name is properly // stripped from the descendant path. #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: relative '..' path canonicalization differs, causing a false self-copy detection" +)] fn test_cp_current_directory_to_existing_directory() { let (at, mut ucmd) = at_and_ucmd!(); @@ -7461,6 +8784,10 @@ fn test_cp_current_directory_to_existing_directory() { // Test copying current directory (.) to a new directory. // This should create the new directory and copy contents. #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: relative '..' path canonicalization differs, causing a false self-copy detection" +)] fn test_cp_current_directory_to_new_directory() { let (at, mut ucmd) = at_and_ucmd!(); @@ -7486,9 +8813,48 @@ fn test_cp_current_directory_to_new_directory() { assert!(at.file_exists("new_dest_dir/subdir/file3.txt")); } +// Regression test for Launchpad #2167118: +// When copying current directory (.), files or directories inside it that share +// the same name as the current directory itself must not be stripped to empty paths. +#[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: relative '..' path canonicalization differs, causing a false self-copy detection" +)] +fn test_cp_current_directory_with_entry_matching_parent_basename() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("demo"); + at.touch("demo/file"); + at.touch("demo/demo"); // file has the same name as parent directory + + // Copy current directory (.) to a new directory (reproducer from LP #2167118) + ucmd.current_dir(at.plus("demo")) + .args(&["-R", ".", "../out"]) + .succeeds(); + + assert!(at.file_exists("out/file")); + assert!(at.file_exists("out/demo")); + + // Also test copying to an already existing destination directory + at.mkdir("existing_out"); + let mut ucmd2 = uutests::new_ucmd!(); + ucmd2 + .current_dir(at.plus("demo")) + .args(&["-R", ".", "../existing_out"]) + .succeeds(); + + assert!(at.file_exists("existing_out/file")); + assert!(at.file_exists("existing_out/demo")); +} + // Test copying current directory (.) with verbose output. // This ensures the verbose output shows the correct paths. #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: relative '..' path canonicalization differs, causing a false self-copy detection" +)] fn test_cp_current_directory_verbose() { let (at, mut ucmd) = at_and_ucmd!(); @@ -7524,8 +8890,11 @@ fn test_cp_current_directory_verbose() { // This ensures attributes are preserved when copying the current directory. #[test] #[cfg(all(not(windows), not(target_os = "freebsd"), not(target_os = "openbsd")))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: relative '..' path canonicalization differs, causing a false self-copy detection" +)] fn test_cp_current_directory_preserve_attributes() { - use filetime::FileTime; use std::os::unix::prelude::MetadataExt; let (at, mut ucmd) = at_and_ucmd!(); @@ -7540,10 +8909,29 @@ fn test_cp_current_directory_preserve_attributes() { at.set_mode("source_dir/file2.txt", 0o755); // Set specific timestamps on the source files (1 hour ago) - let ts = time::OffsetDateTime::now_utc(); - let previous = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); - filetime::set_file_times(at.plus("source_dir/file1.txt"), previous, previous).unwrap(); - filetime::set_file_times(at.plus("source_dir/file2.txt"), previous, previous).unwrap(); + let previous = std::time::SystemTime::now() - Duration::from_secs(3600); + let file1 = std::fs::OpenOptions::new() + .write(true) + .open(at.plus("source_dir/file1.txt")) + .unwrap(); + file1 + .set_times( + std::fs::FileTimes::new() + .set_accessed(previous) + .set_modified(previous), + ) + .unwrap(); + let file2 = std::fs::OpenOptions::new() + .write(true) + .open(at.plus("source_dir/file2.txt")) + .unwrap(); + file2 + .set_times( + std::fs::FileTimes::new() + .set_accessed(previous) + .set_modified(previous), + ) + .unwrap(); // Create existing destination directory at.mkdir("dest_dir"); @@ -7610,6 +8998,10 @@ fn test_cp_current_directory_to_itself_disallowed() { // Test copying current directory (.) with symlinks. // This ensures symlinks are handled correctly when copying the current directory. #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: relative '..' path canonicalization differs, causing a false self-copy detection" +)] fn test_cp_current_directory_with_symlinks() { let (at, mut ucmd) = at_and_ucmd!(); @@ -7639,6 +9031,10 @@ fn test_cp_current_directory_with_symlinks() { #[test] #[cfg(not(windows))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_no_dereference_symlink_with_parents() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -7659,6 +9055,10 @@ fn test_cp_no_dereference_symlink_with_parents() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_recursive_no_dereference_symlink_to_directory() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -7761,6 +9161,10 @@ fn test_cp_recurse_verbose_output() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_recurse_verbose_output_with_symlink() { let source_dir = "source_dir"; let target_dir = "target_dir"; @@ -7790,6 +9194,10 @@ fn test_cp_recurse_verbose_output_with_symlink() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_recurse_verbose_output_with_symlink_already_exists() { let source_dir = "source_dir"; let target_dir = "target_dir"; @@ -7823,6 +9231,10 @@ fn test_cp_recurse_verbose_output_with_symlink_already_exists() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] 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!(); @@ -7844,6 +9256,10 @@ fn test_cp_hlp_flag_ordering() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] 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 [ @@ -7869,6 +9285,10 @@ fn test_cp_archive_deref_flag_ordering() { /// https://github.com/uutils/coreutils/issues/13207 #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_archive_deref_preserves_recursive() { for flags in ["-afL", "-aLf", "-aHL", "-adL"] { let (at, mut ucmd) = at_and_ucmd!(); @@ -7886,6 +9306,10 @@ fn test_cp_archive_deref_preserves_recursive() { /// -aL should preserve file permissions (--preserve=all from -a). #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_archive_deref_preserves_mode() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("srcdir"); @@ -7924,6 +9348,10 @@ fn test_cp_no_deref_preserve_with_deref_keeps_hardlinks() { /// while -a preserves them (last-flag-wins for dereference). #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_archive_deref_symlinks_inside_dir() { use std::os::unix::fs::symlink; let scene = TestScenario::new(util_name!()); @@ -7957,6 +9385,10 @@ fn test_cp_archive_deref_symlinks_inside_dir() { /// -aH: inner symlinks preserved (a wins for recursive), CLI symlinks followed (H wins for CLI). #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_archive_cli_deref_inner_preserved() { use std::os::unix::fs::symlink; let scene = TestScenario::new(util_name!()); @@ -7983,6 +9415,10 @@ fn test_cp_archive_cli_deref_inner_preserved() { /// Precedence: repeating the same flag should take the last position. #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_archive_deref_repeated_flag_last_wins() { use std::os::unix::fs::symlink; let scene = TestScenario::new(util_name!()); @@ -8126,8 +9562,152 @@ fn test_cp_xattr_enotsup_handling() { } } +/// A failed --preserve=xattr must not empty the already-copied destination; +/// only a failure to preserve the SELinux context empties it. +#[test] +#[cfg(target_os = "linux")] +fn test_cp_xattr_failure_keeps_dest_contents() { + use std::process::Command; + let scene = TestScenario::new(util_name!()); + + // tmpfs accepts large user-xattr values while ext4 and friends cap them + // near the block size, so copying such a source out of tmpfs makes + // --preserve=xattr fail only after the file data has been written. + // The fixtures dir may itself be on tmpfs, so put the destination in + // target/tmp, which lives on the build filesystem. + let big_value = "y".repeat(9_100); + let pid = std::process::id(); + let source = format!("/dev/shm/cp_keep_dest_{pid}"); + let dest_dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("cp_keep_dest_{pid}")); + if std_fs::write(&source, "kept content").is_err() || std_fs::create_dir(&dest_dir).is_err() { + return; // skip: no usable /dev/shm or target/tmp + } + let source_accepts = Command::new("setfattr") + .args(["-n", "user.huge", "-v", &big_value, &source]) + .status() + .is_ok_and(|s| s.success()); + let probe = dest_dir.join("probe"); + std_fs::write(&probe, "x").unwrap(); + let dest_rejects = !Command::new("setfattr") + .args(["-n", "user.huge", "-v", &big_value]) + .arg(&probe) + .status() + .is_ok_and(|s| s.success()); + if !source_accepts || !dest_rejects { + std_fs::remove_file(&source).ok(); + std_fs::remove_dir_all(&dest_dir).ok(); + return; // skip: this filesystem combination cannot produce the failure + } + + let out = dest_dir.join("out"); + scene + .ucmd() + .arg("--preserve=xattr") + .arg(&source) + .arg(&out) + .fails() + .stderr_contains("setting attributes"); + assert_eq!(std_fs::read_to_string(&out).unwrap(), "kept content"); + + // A read-only source propagates its mode to the destination; the failure + // handling must neither empty the file nor leave the mode altered. + set_permissions(&source, std_fs::Permissions::from_mode(0o444)).unwrap(); + let out_ro = dest_dir.join("out_ro"); + scene + .ucmd() + .arg("--preserve=xattr") + .arg(&source) + .arg(&out_ro) + .fails() + .stderr_contains("setting attributes"); + assert_eq!(std_fs::read_to_string(&out_ro).unwrap(), "kept content"); + assert_eq!( + std_fs::metadata(&out_ro).unwrap().mode() & 0o777, + 0o444, + "destination mode should stay read-only" + ); + + std_fs::remove_file(&source).ok(); + set_permissions(&out_ro, std_fs::Permissions::from_mode(0o644)).ok(); + std_fs::remove_dir_all(&dest_dir).ok(); +} + +/// An xattr the destination refuses must not stop the other xattrs from being +/// copied. cp still reports the failure and exits 1. tmpfs takes large values +/// while ext4 caps a value at one block, so the large attributes fail there +/// and the small ones must survive. +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: host paths (/dev/shm) not visible" +)] +fn test_cp_preserve_xattr_failure_keeps_the_rest() { + use rustc_hash::FxHashMap; + use std::ffi::OsStr; + use tempfile::TempDir; + use uucore::fsxattr::{apply_xattrs, retrieve_xattrs}; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + let too_big = vec![b'x'; 8000]; + at.touch("probe"); + let probe = FxHashMap::from_iter([(OsString::from("user.probe"), too_big.clone())]); + if apply_xattrs(at.plus("probe"), probe).is_ok() { + println!("test skipped: the destination filesystem accepts large xattr values"); + return; + } + + // tmpfs lists attributes sorted by name, so interleaving the names puts a + // refused attribute before a kept one whichever way the list is sorted. + let attrs: FxHashMap> = [ + ("user.a_kept", b"first".to_vec()), + ("user.b_too_big", too_big.clone()), + ("user.c_kept", b"middle".to_vec()), + ("user.d_too_big", too_big), + ("user.e_kept", b"last".to_vec()), + ] + .into_iter() + .map(|(name, value)| (OsString::from(name), value)) + .collect(); + + let src_dir = + TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm"); + let file = src_dir.path().join("file"); + let dir = src_dir.path().join("dir"); + std_fs::write(&file, "content").unwrap(); + std_fs::create_dir(&dir).unwrap(); + if apply_xattrs(&file, attrs.clone()).is_err() { + println!("test skipped: /dev/shm does not accept user xattrs"); + return; + } + apply_xattrs(&dir, attrs.clone()).unwrap(); + + // A regular file is copied through file descriptors, a directory by path. + for (src, dest) in [(&file, "file"), (&dir, "dir")] { + scene + .ucmd() + .args(&["-r", "--preserve=xattr"]) + .arg(src) + .arg(dest) + .fails_with_code(1) + .stderr_contains(format!("cp: setting attributes for '{dest}")); + + let copied = retrieve_xattrs(at.plus(dest)).unwrap(); + for name in ["user.a_kept", "user.c_kept", "user.e_kept"] { + let name = OsStr::new(name); + assert_eq!(copied.get(name), attrs.get(name), "{name:?} on {dest}"); + } + } +} + #[test] -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_preserve_directory_permissions_by_default() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -8171,7 +9751,7 @@ fn test_cp_preserve_directory_permissions_by_default() { } #[test] -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] fn test_cp_existing_perm_dir() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -8201,7 +9781,7 @@ fn test_cp_existing_perm_dir() { } #[test] -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] fn test_cp_gnu_preserve_mode() { use std::io; @@ -8228,10 +9808,7 @@ fn test_cp_gnu_preserve_mode() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_cp_a_z_overrides_context() { // Verifies -aZ succeeds (-Z overrides implicit --preserve=context from -a) use std::path::Path; @@ -8249,10 +9826,7 @@ fn test_cp_a_z_overrides_context() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_cp_a_preserves_context() { use std::path::Path; use uucore::selinux::{get_selinux_security_context, set_selinux_security_context}; @@ -8275,10 +9849,7 @@ fn test_cp_a_preserves_context() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_cp_preserve_context_with_z_fails() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("src"); @@ -8294,6 +9865,10 @@ fn test_cp_preserve_context_with_z_fails() { // is exercised by GNU's test suite; documenting here as future coverage. #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no chmod syscall, so required mode/ownership preservation always fails" +)] fn test_cp_preserve_setuid_when_chown_succeeds() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("src"); @@ -8310,7 +9885,11 @@ fn test_cp_preserve_setuid_when_chown_succeeds() { } #[test] -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(all(unix, not(target_vendor = "apple")))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: requires valid UTF-8 paths, so the non-UTF-8 error is expected" +)] fn test_cp_recursive_non_utf8_source() { use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; let (at, mut ucmd) = at_and_ucmd!(); @@ -8358,6 +9937,10 @@ fn test_cp_final_mode_unchanged_after_restrictive_create() { // change drops `O_NOFOLLOW` from the source open under `-P`. #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_no_dereference_copies_symlink_as_symlink() { let (at, mut ucmd) = at_and_ucmd!(); at.write("target", "secret target contents"); @@ -8375,6 +9958,10 @@ fn test_cp_no_dereference_copies_symlink_as_symlink() { // O_NOFOLLOW to the dest open broke this and surfaced as ELOOP. #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: symlink/hardlink capability restrictions cause dangling-symlink/same-file detection to differ" +)] fn test_cp_d_overwrites_existing_symlink_dest() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("f"); @@ -8396,6 +9983,10 @@ fn test_cp_d_overwrites_existing_symlink_dest() { // non-Linux CI do not flag spurious failures. #[test] #[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: ACL/xattr syscalls are not implemented, so -p's attribute preservation fails" +)] fn test_cp_p_preserves_posix_acls() { use std::process::Command; diff --git a/tests/by-util/test_csplit.rs b/tests/by-util/test_csplit.rs index 2349fc34648..9f1ae574556 100644 --- a/tests/by-util/test_csplit.rs +++ b/tests/by-util/test_csplit.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use glob::glob; use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -50,6 +51,39 @@ fn test_line_numbers_suppress_matched_final_empty_elided_with_z() { assert_eq!(at.read("xx02"), "5\n"); } +#[test] +fn test_up_to_match_suppress_matched_final_empty() { + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["--suppress-matched", "-", "2", "/a/"]) + .pipe_in("1\n2\n3\n4\na\n") + .succeeds() + .stdout_only("2\n4\n0\n"); + + let count = glob(&at.plus_as_string("xx*")) + .expect("there should be splits created") + .count(); + assert_eq!(count, 3); + assert_eq!(at.read("xx00"), "1\n"); + assert_eq!(at.read("xx01"), "3\n4\n"); + assert_eq!(at.read("xx02"), ""); +} + +#[test] +fn test_up_to_match_offset_final_empty() { + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["-", "/a/+1"]) + .pipe_in("1\na\n") + .succeeds() + .stdout_only("4\n0\n"); + + let count = glob(&at.plus_as_string("xx*")) + .expect("there should be splits created") + .count(); + assert_eq!(count, 2); + assert_eq!(at.read("xx00"), "1\na\n"); + assert_eq!(at.read("xx01"), ""); +} + #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); @@ -236,6 +270,137 @@ fn test_up_to_match_negative_offset() { assert_eq!(at.read("xx01"), generate(6, 51)); } +#[test] +fn test_up_to_match_negative_offset_before_split_start() { + // The target line (match minus the offset) may not precede the start of the + // current split. + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["numbers50.txt", "/3$/-3"]) + .fails() + .stdout_is("0\n") + .stderr_is("csplit: '/3$/-3': line number out of range\n"); + + assert_eq!( + glob(&at.plus_as_string("xx*")) + .expect("there should be no splits created") + .count(), + 0 + ); +} + +#[test] +fn test_up_to_match_negative_offset_at_split_start() { + // The target line is exactly the first line of the current split: an empty + // split, not an error. + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["numbers50.txt", "/3$/-2"]) + .succeeds() + .stdout_only("0\n141\n"); + + assert_eq!(at.read("xx00"), ""); + assert_eq!(at.read("xx01"), generate(1, 51)); +} + +#[test] +fn test_up_to_match_negative_offset_before_second_split_start() { + // The bound is the start of the current split, not the start of the input. + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["numbers50.txt", "/10$/", "/12$/-3"]) + .fails() + .stdout_is("18\n0\n") + .stderr_is("csplit: '/12$/-3': line number out of range\n"); + + assert_eq!( + glob(&at.plus_as_string("xx*")) + .expect("there should be no splits created") + .count(), + 0 + ); +} + +#[test] +fn test_up_to_match_negative_offset_at_second_split_start() { + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["numbers50.txt", "/10$/", "/12$/-2"]) + .succeeds() + .stdout_only("18\n0\n123\n"); + + assert_eq!(at.read("xx00"), generate(1, 10)); + assert_eq!(at.read("xx01"), ""); + assert_eq!(at.read("xx02"), generate(10, 51)); +} + +#[test] +fn test_up_to_match_negative_offset_before_split_start_suppress_matched() { + // The matched line is dropped, so the second split starts one line later. + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["--suppress-matched", "numbers50.txt", "/10$/", "/12$/-2"]) + .fails() + .stdout_is("18\n0\n") + .stderr_is("csplit: '/12$/-2': line number out of range\n"); + + assert_eq!( + glob(&at.plus_as_string("xx*")) + .expect("there should be no splits created") + .count(), + 0 + ); +} + +#[test] +fn test_up_to_match_negative_offset_at_split_start_suppress_matched() { + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["--suppress-matched", "numbers50.txt", "/10$/", "/12$/-1"]) + .succeeds() + .stdout_only("18\n0\n117\n"); + + assert_eq!(at.read("xx00"), generate(1, 10)); + assert_eq!(at.read("xx01"), ""); + assert_eq!(at.read("xx02"), generate(12, 51)); +} + +#[test] +fn test_up_to_match_negative_offset_before_split_start_keep_files() { + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["-k", "numbers50.txt", "/3$/-3"]) + .fails() + .stdout_is("0\n") + .stderr_is("csplit: '/3$/-3': line number out of range\n"); + + assert_eq!(at.read("xx00"), ""); +} + +#[test] +fn test_skip_to_match_negative_offset_before_split_start() { + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["numbers50.txt", "%3$%-3"]) + .fails() + .stderr_is("csplit: '%3$%-3': line number out of range\n"); + + assert_eq!( + glob(&at.plus_as_string("xx*")) + .expect("there should be no splits created") + .count(), + 0 + ); +} + +#[test] +fn test_up_to_match_negative_offset_min_i32() { + new_ucmd!() + .args(&["numbers50.txt", "/45/-2147483648"]) + .fails() + .stderr_is("csplit: '/45/-2147483648': line number out of range\n"); +} + +#[test] +fn test_skip_to_match_negative_offset_min_i32() { + new_ucmd!() + .args(&["numbers50.txt", "%45%-2147483648"]) + .fails() + .stderr_is("csplit: '%45%-2147483648': line number out of range\n"); +} + #[test] fn test_up_to_match_negative_offset_repeat_twice() { let (at, mut ucmd) = at_and_ucmd!(); @@ -654,8 +819,7 @@ fn test_skip_to_match_context_underflow() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.args(&["numbers50.txt", "%5%-10"]) .fails() - .stdout_is("") - .stderr_is("csplit: '%5%-10': line number out of range\n"); + .stderr_only("csplit: '%5%-10': line number out of range\n"); let count = glob(&at.plus_as_string("xx*")) .expect("counting splits") @@ -665,8 +829,7 @@ fn test_skip_to_match_context_underflow() { let (at, mut ucmd) = at_and_ucmd!(); ucmd.args(&["numbers50.txt", "%5%-10", "-k"]) .fails() - .stdout_is("") - .stderr_is("csplit: '%5%-10': line number out of range\n"); + .stderr_only("csplit: '%5%-10': line number out of range\n"); let count = glob(&at.plus_as_string("xx*")) .expect("counting splits") @@ -1431,6 +1594,20 @@ fn precision_format() { } } +#[test] +fn suffix_format_hyphen_leading_as_separate_arg() { + // A hyphen-leading suffix format passed as its own argument (not + // attached with `-b-%02d`/`=`) must not be mistaken for a new, + // unrecognized flag. + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["numbers50.txt", "10", "--suffix-format", "-%02d"]) + .succeeds() + .stdout_only("18\n123\n"); + + assert_eq!(at.read("xx-00"), generate(1, 10)); + assert_eq!(at.read("xx-01"), generate(10, 51)); +} + #[test] fn zero_precision_format() { let (at, mut ucmd) = at_and_ucmd!(); @@ -1521,7 +1698,7 @@ fn test_named_pipe_input_file() { #[cfg(unix)] fn create_named_pipe_with_writer(path: &str, data: &str) -> std::process::Child { - // cSpell:ignore IRWXU + // spell-checker:ignore IRWXU nix::unistd::mkfifo(path, nix::sys::stat::Mode::S_IRWXU).unwrap(); std::process::Command::new("sh") .arg("-c") @@ -1535,10 +1712,12 @@ fn test_directory_input_file() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("test_directory"); + // The split that was in progress is still reported, as GNU does. #[cfg(unix)] ucmd.args(&["test_directory", "1"]) .fails_with_code(1) - .stderr_only("csplit: read error: Is a directory\n"); + .stdout_is("0\n") + .stderr_is("csplit: read error: Is a directory\n"); #[cfg(windows)] ucmd.args(&["test_directory", "1"]) .fails_with_code(1) @@ -1616,3 +1795,40 @@ fn test_create_error_reports_filename() { .fails() .stderr_is("csplit: xx00: Permission denied\n"); } + +#[test] +#[cfg(all(target_os = "linux", not(wasi_runner)))] +fn test_csplit_dev_full_stdout() { + use std::fs::OpenOptions; + + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + + new_ucmd!() + .args(&["/etc/hosts", "1"]) + .set_stdout(dev_full) + .fails_with_code(1) + .stderr_is("csplit: No space left on device\n"); +} + +#[test] +fn test_empty_regex_matches_every_line() { + // An empty regex between the delimiters is valid and matches at once, so + // the split happens before the very first line. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("letters", "delta\necho\nfoxtrot\n"); + + ucmd.args(&["letters", "//"]) + .succeeds() + .stdout_only("0\n19\n"); + assert_eq!(at.read("xx00"), ""); + assert_eq!(at.read("xx01"), "delta\necho\nfoxtrot\n"); +} + +#[test] +fn test_empty_skip_to_regex_is_accepted() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("letters", "delta\necho\nfoxtrot\n"); + + ucmd.args(&["letters", "%%"]).succeeds().stdout_only("19\n"); + assert_eq!(at.read("xx00"), "delta\necho\nfoxtrot\n"); +} diff --git a/tests/by-util/test_cut.rs b/tests/by-util/test_cut.rs index 563c752b115..7d27692380f 100644 --- a/tests/by-util/test_cut.rs +++ b/tests/by-util/test_cut.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 defg +// spell-checker:ignore defg naïve nave närd nøys ntøys nfjärd undelimited xbfw use uutests::{at_and_ucmd, new_ucmd}; @@ -45,9 +45,9 @@ const COMPLEX_SEQUENCE: &str = "9-,6-7,-2,4"; #[test] fn test_no_args() { - new_ucmd!().fails().stderr_is( - "cut: invalid usage: expects one of --fields (-f), --chars (-c) or --bytes (-b)\n", - ); + new_ucmd!() + .fails() + .stderr_contains("cut: you must specify a list of bytes, characters, or fields"); } #[test] @@ -55,6 +55,77 @@ fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); } +#[test] +fn test_range_error_messages() { + // Mode-aware diagnostics for invalid ranges. + let cases: &[(&[&str], &str)] = &[ + ( + &["-c0"], + "cut: byte/character positions are numbered from 1", + ), + ( + &["-b0-7"], + "cut: byte/character positions are numbered from 1", + ), + (&["-f0-9"], "cut: fields are numbered from 1"), + (&["-f", ""], "cut: fields are numbered from 1"), + ( + &["-c", ""], + "cut: byte/character positions are numbered from 1", + ), + (&["-f", "q"], "cut: invalid field value 'q'"), + (&["-c", "zz"], "cut: invalid byte/character position 'zz'"), + (&["-f", "9-4"], "cut: invalid decreasing range"), + (&["-c", "-"], "cut: invalid range with no endpoint: -"), + (&["-f", "8,-"], "cut: invalid range with no endpoint: -"), + // The offending text is what could not be consumed, not the whole item. + (&["-f", "7k"], "cut: invalid field value 'k'"), + (&["-f", "4-w2"], "cut: invalid field value 'w2'"), + ( + &["-c", "3x-5"], + "cut: invalid byte/character position 'x-5'", + ), + // A leading sign is not part of a number here. + (&["-f", "+6"], "cut: invalid field value '+6'"), + // A second dash makes it a malformed range instead. + (&["-f", "2-5-8"], "cut: invalid field range"), + (&["-c", "3--6"], "cut: invalid byte or character range"), + // `usize::MAX` itself is rejected, and so is anything above it. + ( + &["-f", "18446744073709551615"], + "cut: field number '18446744073709551615' is too large", + ), + ( + &["-c", "4-77777777777777777777777"], + "cut: byte/character offset '77777777777777777777777' is too large", + ), + ]; + for (args, expected) in cases { + new_ucmd!() + .args(args) + .fails_with_code(1) + .stderr_contains(*expected); + } +} + +#[test] +fn test_field_only_options_without_fields() { + new_ucmd!() + .args(&["-s", "-c7"]) + .fails_with_code(1) + .stderr_contains( + "cut: suppressing non-delimited lines makes sense\n\tonly when operating on fields", + ); +} + +#[test] +fn test_delimiter_and_whitespace_are_exclusive() { + new_ucmd!() + .args(&["-w", "-d,", "-f3"]) + .fails_with_code(1) + .stderr_contains("cut: -d and -w are mutually exclusive"); +} + #[test] fn test_byte_sequence() { for param in ["-b", "--bytes", "--byt"] { @@ -109,16 +180,19 @@ fn test_whitespace_with_explicit_delimiter() { #[test] fn test_whitespace_with_byte() { + // `-w` counts as an input delimiter for the purpose of this diagnostic. new_ucmd!() .args(&["-w", "-b", COMPLEX_SEQUENCE]) - .fails_with_code(1); + .fails_with_code(1) + .stderr_contains("cut: an input delimiter makes sense\n\tonly when operating on fields"); } #[test] fn test_whitespace_with_char() { new_ucmd!() .args(&["-c", COMPLEX_SEQUENCE, "-w"]) - .fails_with_code(1); + .fails_with_code(1) + .stderr_contains("cut: an input delimiter makes sense\n\tonly when operating on fields"); } #[test] @@ -127,8 +201,9 @@ fn test_delimiter_with_byte_and_char() { new_ucmd!() .args(&[conflicting_arg, COMPLEX_SEQUENCE, "-d="]) .fails_with_code(1) - .stderr_is("cut: invalid input: The '--delimiter' ('-d') option can only be used when printing a sequence of fields\n") -; + .stderr_contains( + "cut: an input delimiter makes sense\n\tonly when operating on fields", + ); } } @@ -562,9 +637,9 @@ fn test_multiple_mode_args() { vec!["-b1", "-c2", "-f3"], ] { new_ucmd!() - .args(&args) - .fails() - .stderr_is("cut: invalid usage: expects no more than one of --fields (-f), --chars (-c) or --bytes (-b)\n"); + .args(&args) + .fails() + .stderr_contains("cut: only one list may be specified"); } } @@ -610,13 +685,255 @@ fn test_output_delimiter_with_adjacent_ranges() { } #[test] +fn test_fields_merged() { + // -F: merge adjacent delimiters, default delimiter whitespace, output a space. + new_ucmd!() + .args(&["-F", "1,3"]) + .pipe_in("one\ttwo three\n") + .succeeds() + .stdout_only("one three\n"); + new_ucmd!() + .args(&["-F", "1,3", "-O", "+"]) + .pipe_in("one\ttwo three\n") + .succeeds() + .stdout_only("one+three\n"); + // -F with an explicit delimiter still uses a space as the output delimiter. + new_ucmd!() + .args(&["-F", "2,4", "-d", ";"]) + .pipe_in("p;q;r;s\n") + .succeeds() + .stdout_only("q s\n"); +} + +#[test] +fn test_fields_merged_conflicts_with_fields() { + new_ucmd!() + .args(&["-f", "3", "-F", "5"]) + .fails_with_code(1) + .stderr_contains("cut: only one list may be specified"); +} + +#[test] +fn test_whitespace_delimited_long_and_trimmed() { + // Long form behaves like -w (leading blanks make an empty first field). + new_ucmd!() + .args(&["--whitespace-delimited", "-f1,2"]) + .pipe_in(" alpha beta\n") + .succeeds() + .stdout_only("\talpha\n"); + // =trimmed (or it's shortcuts) strips leading/trailing blanks before splitting. + for trimmed in ["trimmed", "tri", ""] { + new_ucmd!() + .arg(format!("--whitespace-delimited={trimmed}")) + .arg("-f1,2") + .pipe_in(" hello world \n") + .succeeds() + .stdout_only("hello\tworld\n"); + } + // With -s a single (undelimited) field is suppressed. + new_ucmd!() + .args(&["-s", "--whitespace-delimited=trimmed", "-f1"]) + .pipe_in(" solo \n") + .succeeds() + .stdout_only(""); + // Without -s an undelimited line is printed whole, whatever field is asked + // for, while a blank-only line collapses to an empty one. + new_ucmd!() + .args(&["--whitespace-delimited=trimmed", "-f4"]) + .pipe_in(" loner\n\t\n one two\n") + .succeeds() + .stdout_only("loner\n\n\n"); + // Only `trimmed` is a valid value. + new_ucmd!() + .args(&["--whitespace-delimited=middle", "-f1"]) + .fails_with_code(1); +} + +#[test] +fn test_whitespace_delimited_trimmed_zero_terminated() { + // The record terminator must not count as a non-blank when trimming, or the + // trailing blanks of a record would survive and add a phantom field. NUL is + // the interesting case: unlike `\n` it is not whitespace to begin with. + new_ucmd!() + .args(&["-z", "--whitespace-delimited=trimmed", "-f2"]) + .pipe_in(&b" red blue \0 green pink \0"[..]) + .succeeds() + .stdout_only_bytes(&b"blue\0pink\0"[..]); + // Asking past the last field yields an empty record, not the stray blanks. + new_ucmd!() + .args(&["-z", "--whitespace-delimited=trimmed", "-f3"]) + .pipe_in(&b" red blue \0"[..]) + .succeeds() + .stdout_only_bytes(&b"\0"[..]); + // A blank-only record has no delimiter left after trimming, so -s drops it. + new_ucmd!() + .args(&["-z", "-s", "--whitespace-delimited=trimmed", "-f1"]) + .pipe_in(&b"\t \0 amber violet \0"[..]) + .succeeds() + .stdout_only_bytes(&b"amber\0"[..]); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI: the guest does not inherit LC_ALL")] +#[cfg(target_os = "linux")] +fn test_byte_no_split_partially_selected_char() { + // -b -n: the selected bytes of a character must reach its end without a + // hole. "🗿" (f0 9f 97 bf) spans bytes 1-4, "w" is byte 5. + let stone = &b"\xf0\x9f\x97\xbfw\n"[..]; + // Byte 3 is left out, so the character is split and dropped. + new_ucmd!() + .env("LC_ALL", "C.UTF-8") + .args(&["-b1-2,4-5", "-n"]) + .pipe_in(stone) + .succeeds() + .stdout_only_bytes(b"w\n"); + // The same holds when the hole comes from a single-byte range. + new_ucmd!() + .env("LC_ALL", "C.UTF-8") + .args(&["-b2,4", "-n"]) + .pipe_in(stone) + .succeeds() + .stdout_only_bytes(b"\n"); + // Selecting only the tail of the character still prints it whole. + new_ucmd!() + .env("LC_ALL", "C.UTF-8") + .args(&["-b3-", "-n"]) + .pipe_in(stone) + .succeeds() + .stdout_only_bytes(stone); + // Adjacent ranges cover it without a hole, and the boundary inside the + // character emits no output delimiter. + new_ucmd!() + .env("LC_ALL", "C.UTF-8") + .args(&["-b1-3,4-5", "-n", "--output-d=|"]) + .pipe_in(stone) + .succeeds() + .stdout_only_bytes(stone); + // "q€é r": q is byte 1, € (e2 82 ac) bytes 2-4, é (c3 a9) bytes 5-6. + let mixed = &b"q\xe2\x82\xac\xc3\xa9r\n"[..]; + // The delimiter a range owes is carried to whatever prints next, and only + // a boundary between two printed characters produces one. + new_ucmd!() + .env("LC_ALL", "C.UTF-8") + .args(&["-b1,2,5-7", "-n", "--output-d=|"]) + .pipe_in(mixed) + .succeeds() + .stdout_only_bytes(&b"q|\xc3\xa9r\n"[..]); + new_ucmd!() + .env("LC_ALL", "C.UTF-8") + .args(&["-b1,2-4,5-6", "-n", "--output-d=|"]) + .pipe_in(mixed) + .succeeds() + .stdout_only_bytes(&b"q|\xe2\x82\xac|\xc3\xa9\n"[..]); +} + +#[test] +fn test_unset_locale_is_byte_oriented() { + // With no locale set the POSIX default is C, so characters are bytes. + // "ж" is d0 b6, and -c3 must take just the b6. + new_ucmd!() + .env("LC_ALL", "") + .args(&["-c3"]) + .pipe_in(&b"p\xd0\xb6t\n"[..]) + .succeeds() + .stdout_only_bytes(b"\xb6\n"); +} + +#[test] +fn test_newline_delim_suppress_missing_field() { + // -s with the newline as delimiter must not emit a spurious blank line. + new_ucmd!() + .args(&["-s", "-d", "\n", "-f3"]) + .pipe_in("solo\n") + .succeeds() + .stdout_only(""); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI: the guest does not inherit LC_ALL")] +#[cfg(target_os = "linux")] +fn test_byte_no_split_with_output_delimiter() { + // -b -n with an output delimiter: a range covering only part of a + // multibyte character contributes nothing and emits no delimiter. + // "ü" (c3 bc) spans bytes 1-2; byte 1 alone selects no whole character. + new_ucmd!() + .env("LC_ALL", "C.UTF-8") + .args(&["-b1,3", "-n", "--output-d=|"]) + .pipe_in(&b"\xc3\xbcZ\n"[..]) + .succeeds() + .stdout_only_bytes(b"Z\n"); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI: the guest does not inherit LC_ALL")] +#[cfg(target_os = "linux")] +fn test_field_delimiter_not_split_inside_multibyte_char() { + use std::os::unix::ffi::OsStrExt; + // In a UTF-8 locale, a delimiter byte that is part of a multibyte character + // must not split it. Here U+20AC (€ = e2 82 ac) contains 0xac, and 0xac is + // also used as a standalone delimiter byte. + new_ucmd!() + .env("LC_ALL", "C.UTF-8") + .arg("-d") + .arg(std::ffi::OsStr::from_bytes(b"\xac")) + .arg("-f2") + .pipe_in(&b"1\xe2\x82\xac2\xac3\n"[..]) + .succeeds() + .stdout_only_bytes(b"3\n"); + new_ucmd!() + .env("LC_ALL", "C.UTF-8") + .arg("-d") + .arg(std::ffi::OsStr::from_bytes(b"\xac")) + .arg("-f1") + .pipe_in(&b"1\xe2\x82\xac2\xac3\n"[..]) + .succeeds() + .stdout_only_bytes(b"1\xe2\x82\xac2\n"); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI: the guest does not inherit LC_ALL")] +#[cfg(target_os = "linux")] +fn test_whitespace_delimiter_unicode_blank() { + // U+2002 (EN SPACE) is a Unicode blank and splits fields under -w. + new_ucmd!() + .env("LC_ALL", "C.UTF-8") + .args(&["-w", "-f2"]) + .pipe_in(&b"x\xe2\x80\x82y\n"[..]) + .succeeds() + .stdout_only_bytes(b"y\n"); + // U+2007 (FIGURE SPACE) is not a blank: the line stays a single field and + // is suppressed by -s. + new_ucmd!() + .env("LC_ALL", "C.UTF-8") + .args(&["-s", "-w", "-f2"]) + .pipe_in(&b"x\xe2\x80\x87y\n"[..]) + .succeeds() + .stdout_only_bytes(b""); +} + +#[test] +fn test_delimiter_multibyte_rejected_in_c_locale() { + // In the C locale a valid UTF-8 multibyte sequence is several characters. + // The delimiter is passed as ordinary text so this also runs on Windows. + new_ucmd!() + .env("LC_ALL", "C") + .args(&["-d", "\u{20ac}", "-f1"]) + .fails_with_code(1) + .stderr_contains("cut: the delimiter must be a single character"); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI: the guest does not inherit LC_ALL")] fn test_emoji_delim() { + // A multibyte delimiter is only a single character in a UTF-8 locale. new_ucmd!() + .env("LC_ALL", "C.UTF-8") .args(&["-d🗿", "-f1"]) .pipe_in("💐🗿🌹\n") .succeeds() .stdout_only("💐\n"); new_ucmd!() + .env("LC_ALL", "C.UTF-8") .args(&["-d🗿", "-f2"]) .pipe_in("💐🗿🌹\n") .succeeds() @@ -660,3 +977,354 @@ fn test_cut_non_utf8_paths() { .succeeds() .stdout_only("a\tc\n1\t3\n"); } + +// We exercise the GB18030 path with two real two-byte characters that are not +// valid UTF-8: 啊 (0xB0 0xA1) and 中 (0xD6 0xD0). The active encoding comes +// straight from `LC_ALL`, so the host does not need the locale installed. +#[cfg(target_os = "linux")] +const GB_LOCALE: &str = "zh_CN.gb18030"; +#[cfg(target_os = "linux")] +const A: &[u8] = b"\xB0\xA1"; // 啊 + +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] +fn test_cut_fields_gb18030_delimiter() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + // Three words separated by the two-byte 啊: "red啊green啊blue". + let line = b"red\xB0\xA1green\xB0\xA1blue\n"; + let delim = OsString::from_vec(A.to_vec()); + + // Pick the last field; the chosen output delimiter replaces the input one. + new_ucmd!() + .env("LC_ALL", GB_LOCALE) + .arg("-d") + .arg(&delim) + .args(&["-f3", "--output-delimiter=/"]) + .pipe_in(line.to_vec()) + .succeeds() + .stdout_only("blue\n"); + + // Two non-adjacent fields; with no override the multibyte delimiter itself + // is re-emitted between them. + new_ucmd!() + .env("LC_ALL", GB_LOCALE) + .arg("-d") + .arg(&delim) + .arg("-f1,3") + .pipe_in(line.to_vec()) + .succeeds() + .stdout_only_bytes(b"red\xB0\xA1blue\n"); +} + +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] +fn test_cut_fields_gb18030_complement_and_gaps() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let delim = OsString::from_vec(A.to_vec()); + + // --complement of the middle field leaves the two outer ones, rejoined. + new_ucmd!() + .env("LC_ALL", GB_LOCALE) + .arg("--complement") + .arg("-d") + .arg(&delim) + .arg("-f2") + .pipe_in(b"red\xB0\xA1green\xB0\xA1blue\n".to_vec()) + .succeeds() + .stdout_only_bytes(b"red\xB0\xA1blue\n"); + + // A line that is only delimiters yields empty fields around a trailing one. + new_ucmd!() + .env("LC_ALL", GB_LOCALE) + .arg("-d") + .arg(&delim) + .args(&["-f1-3", "--output-delimiter=|"]) + .pipe_in(b"\xB0\xA1\xB0\xA1z\n".to_vec()) + .succeeds() + .stdout_only("||z\n"); +} + +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: non-UTF-8 arguments can't be passed through wasmtime" +)] +fn test_cut_fields_single_byte_delimiter_in_mb_locale() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + // 0x80 never starts a GB18030 sequence, yet a lone byte is a fine delimiter. + let delim = OsString::from_vec(vec![0x80]); + new_ucmd!() + .env("LC_ALL", GB_LOCALE) + .arg("-d") + .arg(&delim) + .args(&["-f1,3", "--output-delimiter=-"]) + .pipe_in(b"a\x80b\x80c\n".to_vec()) + .succeeds() + .stdout_only("a-c\n"); +} + +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] +fn test_cut_chars_gb18030() { + // "啊w中": -c counts characters, so the second one is the ASCII 'w'. + let line = b"\xB0\xA1w\xD6\xD0\n"; + + new_ucmd!() + .env("LC_ALL", GB_LOCALE) + .arg("-c2") + .pipe_in(line.to_vec()) + .succeeds() + .stdout_only("w\n"); + + // Selecting the trailing multibyte character returns it whole. + new_ucmd!() + .env("LC_ALL", GB_LOCALE) + .arg("-c3") + .pipe_in(line.to_vec()) + .succeeds() + .stdout_only_bytes(b"\xD6\xD0\n"); + + // A range that spans the leading and ASCII characters. + new_ucmd!() + .env("LC_ALL", GB_LOCALE) + .arg("-c1-2") + .pipe_in(line.to_vec()) + .succeeds() + .stdout_only_bytes(b"\xB0\xA1w\n"); +} + +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] +fn test_cut_chars_gb18030_ranges_and_complement() { + // "啊w中": list of two single-char ranges joined by a custom delimiter. + new_ucmd!() + .env("LC_ALL", GB_LOCALE) + .args(&["-c1,3", "--output-delimiter=+"]) + .pipe_in(b"\xB0\xA1w\xD6\xD0\n".to_vec()) + .succeeds() + .stdout_only_bytes(b"\xB0\xA1+\xD6\xD0\n"); + + // Complement of the ASCII middle character keeps both multibyte ones. + new_ucmd!() + .env("LC_ALL", GB_LOCALE) + .args(&["--complement", "-c2"]) + .pipe_in(b"\xB0\xA1w\xD6\xD0\n".to_vec()) + .succeeds() + .stdout_only_bytes(b"\xB0\xA1\xD6\xD0\n"); +} + +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] +fn test_cut_bytes_no_split_gb18030() { + // -n forbids splitting a multibyte character: a byte index landing inside + // 啊 only produces output once its final byte is included. + let line = b"\xB0\xA1w\n"; + + // Byte 1 is the first half of 啊 -> nothing is emitted. + new_ucmd!() + .env("LC_ALL", GB_LOCALE) + .args(&["-b1", "-n"]) + .pipe_in(line.to_vec()) + .succeeds() + .stdout_only("\n"); + + // Byte 2 completes 啊 -> the whole character comes out. + new_ucmd!() + .env("LC_ALL", GB_LOCALE) + .args(&["-b2", "-n"]) + .pipe_in(line.to_vec()) + .succeeds() + .stdout_only_bytes(b"\xB0\xA1\n"); +} + +// `-c` also operates on whole characters in a UTF-8 locale. The harness runs +// under `LC_ALL=C`, so the locale is forced here. "naïve" is n a ï(2 bytes) v e. +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] +fn test_cut_chars_utf8() { + // The third character is the accented 'ï', returned in full. + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .arg("-c3") + .pipe_in("naïve\n".as_bytes().to_vec()) + .succeeds() + .stdout_only("ï\n"); + + // Complement of that character removes it and nothing else. + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .args(&["--complement", "-c3"]) + .pipe_in("naïve\n".as_bytes().to_vec()) + .succeeds() + .stdout_only("nave\n"); + + // A list straddling 'ï' joins the two picked characters. + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .args(&["-c1,4", "--output-delimiter=+"]) + .pipe_in("naïve\n".as_bytes().to_vec()) + .succeeds() + .stdout_only("n+v\n"); +} + +// Lines with no byte above 0x7F take a byte-wise shortcut in a multi-byte +// locale; mixing them with multi-byte ones checks both paths agree on offsets. +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] +fn test_cut_chars_utf8_mixed_ascii_lines() { + let input = "quokka\nfjärd\nwombat\ntøys\n"; + + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .arg("-c3-5") + .pipe_in(input) + .succeeds() + .stdout_only("okk\närd\nmba\nys\n"); + + // `-b -n` selects a character when its last byte falls in the range, so the + // accented lines cover a different span than the ASCII ones. + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .args(&["-b", "3-5", "-n"]) + .pipe_in(input) + .succeeds() + .stdout_only("okk\när\nmba\nøys\n"); +} + +#[test] +#[cfg(all(target_os = "linux", target_env = "gnu"))] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths (/proc) not visible")] +fn test_read_error() { + new_ucmd!() + .args(&["-c1", "/proc/self/mem"]) + .fails_with_code(1) + .stderr_is("cut: Input/output error\n"); +} + +#[cfg(unix)] +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[test] + fn test_snippet_points_at_the_inverted_range() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-f", "1,4-2", "/dev/null"]) + .fails_with_code(1); + + // One item of the list is at fault, not the whole of it. + let stderr = result.stderr_as_displayed(); + assert!( + stderr.starts_with( + "\ +cut: invalid decreasing range + ╭─[ cut:1:10 ] + │ + 1 │ cut -f 1,4-2 /dev/null + │ ─┬─ + │ ╰─── this range ends before it starts + │ + │ Help: a list is N, N-M, N- or -M, separated by commas, as in -f1,4-6,9- +───╯" + ), + "{stderr}" + ); + // The caret replaces the message, not the usage hint: a pipe and a + // terminal must not disagree on whether one was printed. + assert!( + stderr.ends_with("cut --help' for more information."), + "{stderr}" + ); + } + + #[test] + fn test_snippet_finds_fields_merged_value() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-F", "1,4-2", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("1 │ cut -F 1,4-2 /dev/null"), "{stderr}"); + assert!( + stderr.contains("this range ends before it starts"), + "{stderr}" + ); + } + + #[test] + fn test_snippet_points_at_the_zero_bound() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-f1,0,3", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + // Glued to the option, so the caret counts the two columns it takes. + assert!(stderr.contains("cut:1:9"), "{stderr}"); + assert!(stderr.contains("counting starts at 1"), "{stderr}"); + } + + #[test] + fn test_snippet_points_at_the_bound_that_is_not_a_number() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-c", "1-3,x", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + // The message names the item, so a bare underline is enough. + assert!( + stderr.contains("invalid byte/character position 'x'"), + "{stderr}" + ); + assert!(stderr.contains("cut:1:12"), "{stderr}"); + } + + #[test] + fn test_snippet_ignores_a_file_named_like_the_list() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("1,0"); + + // The file is named exactly like the list; the caret belongs to the + // value of -f. + let result = ucmd + .terminal_sim_stderr() + .args(&["-f", "1,0", "1,0"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("cut:1:10"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + let result = new_ucmd!() + .args(&["-f", "1,4-2", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + // The message reads as it always has, and nothing is drawn under it; + // the usage hint that follows is cut's own. + assert!( + stderr.starts_with("cut: invalid decreasing range\n"), + "{stderr}" + ); + assert!(!stderr.contains('\u{256d}'), "{stderr}"); + } +} diff --git a/tests/by-util/test_date.rs b/tests/by-util/test_date.rs index aee953b94e0..04625e08464 100644 --- a/tests/by-util/test_date.rs +++ b/tests/by-util/test_date.rs @@ -2,16 +2,16 @@ // // 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 juin décembre Januar Juni Dezember enero junio diciembre gennaio giugno dicembre junho dezembro lundi dimanche Montag Sonntag Samstag sábado febr MEST KST uueuu ueuu vasárnap június január distros -// spell-checker: ignore: uppercases + +// 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 febr MEST MESZ KST uueuu ueuu vasárnap június január distros +// spell-checker:ignore uppercases xffx use std::cmp::Ordering; use jiff::tz::TimeZone; use jiff::{Timestamp, ToSpan}; use regex::Regex; -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(unix)] use rustix::process::geteuid; use uutests::util::TestScenario; #[cfg(unix)] @@ -66,6 +66,86 @@ fn test_invalid_short_option() { .stderr_contains("unexpected argument '-w'"); } +#[test] +fn test_large_year_default_output() { + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .env("TZ", "UTC0") + .args(&["-d", "18978-01-01"]) + .succeeds() + .stdout_is("Thu Jan 1 00:00:00 UTC 18978\n"); +} + +#[test] +fn test_large_year_default_output_boundary() { + for (input, expected) in [ + ("9999-01-01", "Fri Jan 1 00:00:00 UTC 9999\n"), + ("10000-01-01", "Sat Jan 1 00:00:00 UTC 10000\n"), + ("10000-01-01 00:00 +1400", "Fri Dec 31 10:00:00 UTC 9999\n"), + ("9999-12-31 23:00 -1400", "Sat Jan 1 13:00:00 UTC 10000\n"), + ] { + new_ucmd!() + .env("LC_ALL", "C") + .env("TZ", "UTC0") + .args(&["-d", input]) + .succeeds() + .stdout_is(expected); + } + + new_ucmd!() + .env("LC_ALL", "C") + .env("TZ", "UTC0") + .args(&["-d", "10000-02-30"]) + .fails_with_code(1) + .stderr_contains("invalid date"); +} + +#[test] +fn test_date_rejects_input_that_cannot_take_a_timezone() { + // A trailing timezone abbreviation must not rescue an input that already + // carries zone information, or that cannot take a zone at all. GNU date + // rejects all of these. + for input in [ + "Jan 23 6:00PM GMT-1 EST", // offset plus abbreviation + "023-060 MEST", // time with offset, plus abbreviation + "2024-01-15 12:00 EST EST", // the same abbreviation twice + "@0 EST", // a timestamp cannot take a zone + "2024-01-15 12:00 UTC EST", // a named zone whose offset matches TZ + "2024-01-15 12:00 GMT EST", // likewise, spelled differently + "2024-01-15 12:00 +0000 EST", // a numeric offset matching TZ + "2024-01-15 12:00 -0500 EST", // a standalone negative offset + "UTC 2024-01-15 12:00 EST", // zone stated before the date + ] { + new_ucmd!() + .env("LC_ALL", "C") + .env("TZ", "UTC0") + .args(&["-d", input]) + .fails_with_code(1) + .stderr_contains("invalid date"); + } +} + +#[test] +fn test_date_accepts_gnu_timezone_abbreviations() { + // Abbreviations GNU date accepts, with the UTC time they map to. + for (input, expected) in [ + ("2024-01-15 12:00 MEZ", "11:00\n"), + ("2024-01-15 12:00 MESZ", "10:00\n"), + ("2024-01-15 12:00 MEST", "10:00\n"), + ("2024-01-15 12:00 KST", "03:00\n"), + ("2024-01-15 12:00 EST", "17:00\n"), + ("2024-01-15 12:00 IST", "06:30\n"), + ] { + new_ucmd!() + .env("LC_ALL", "C") + .env("TZ", "UTC0") + .args(&["-u", "-d", input, "+%H:%M"]) + .succeeds() + .stdout_is(expected); + } +} + #[test] fn test_format_option_not_to_capture_other_valid_arguments() { new_ucmd!() @@ -83,6 +163,14 @@ fn test_single_dash_as_date() { .stderr_contains("invalid date"); } +#[test] +fn test_single_dash_as_date_string() { + new_ucmd!() + .args(&["-u", "-d", "-", "+%T"]) + .succeeds() + .stdout_is("00:00:00\n"); +} + #[test] fn test_date_email() { for param in ["--rfc-email", "--rfc-e", "-R", "--rfc-2822", "--rfc-822"] { @@ -298,6 +386,10 @@ fn test_date_utc_with_d_flag() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_date_utc_vs_local() { let cases = [ ("-d", "2024-01-01 12:00", "+%H:%M %Z", "12:00 EST\n"), @@ -423,19 +515,21 @@ fn test_date_format_literal() { } #[test] -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(unix)] fn test_date_set_valid() { if geteuid().is_root() { new_ucmd!() .arg("--set") .arg("2020-03-12 13:30:00+08:00") + .env("LC_ALL", "C") + .env("TZ", "UTC0") .succeeds() - .no_output(); + .stdout_is("Thu Mar 12 05:30:00 UTC 2020\n"); } } #[test] -#[cfg(any(windows, all(unix, not(target_os = "macos"))))] +#[cfg(any(windows, unix))] fn test_date_set_invalid() { let result = new_ucmd!().arg("--set").arg("123abcd").fails(); result.no_stdout(); @@ -443,28 +537,65 @@ fn test_date_set_invalid() { } #[test] -#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +fn test_date_error_echoes_input_verbatim() { + // Error messages echo what the user typed: numeric-looking input must + // not be reformatted as a Fluent number (#14669, #14670). + for input in ["1e9", "+1e-2", "+9.e-0", "9.", "-0"] { + new_ucmd!() + .arg("-d") + .arg(input) + .fails() + .stderr_is(format!("date: invalid date '{input}'\n")); + } + // Same for the missing '+' message, which echoes the argument too. + new_ucmd!() + .args(&["--date", "1996-01-31", "1e9"]) + .fails_with_code(1) + .stderr_contains("the argument 1e9 lacks a leading '+'"); +} + +#[test] +#[cfg(all(unix, not(target_os = "android")))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: setting the system clock is not supported at all, not just permission-gated" +)] fn test_date_set_permissions_error() { if !(geteuid().is_root() || uucore::os::is_wsl_1()) { let result = new_ucmd!() .arg("--set") .arg("2020-03-11 21:45:00+08:00") + .env("LC_ALL", "C") + .env("TZ", "UTC0") .fails(); - result.no_stdout(); + // GNU prints the date even when the clock cannot be set. + result.stdout_is("Wed Mar 11 13:45:00 UTC 2020\n"); assert!(result.stderr_str().starts_with("date: cannot set date: ")); } } #[test] -#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +#[cfg(all(unix, not(target_os = "android")))] +#[cfg_attr( + wasi_runner, + ignore = "WASI: setting the system clock is not supported at all, not just permission-gated" +)] fn test_date_set_hyphen_prefixed_values() { // test -s flag accepts hyphen-prefixed values like "-3 days" if !(geteuid().is_root() || uucore::os::is_wsl_1()) { let test_cases = vec!["-1 hour", "-2 days", "-3 weeks", "-1 month"]; + // The echoed date is relative to "now", so match its shape only. + let re = Regex::new(r"^\w{3} \w{3} {1,2}\d{1,2} \d{2}:\d{2}:\d{2} UTC \d{4}\n$").unwrap(); for date_str in test_cases { - let result = new_ucmd!().arg("--set").arg(date_str).fails(); - result.no_stdout(); + let result = new_ucmd!() + .arg("--set") + .arg(date_str) + .env("LC_ALL", "C") + .env("TZ", "UTC0") + .fails(); + // GNU prints the date even when the clock cannot be set. + result.stdout_matches(&re); // permission error, not argument parsing error assert!( result.stderr_str().starts_with("date: cannot set date: "), @@ -476,40 +607,59 @@ fn test_date_set_hyphen_prefixed_values() { } #[test] -#[cfg(target_os = "macos")] -fn test_date_set_mac_unavailable() { - let result = new_ucmd!() +#[cfg(wasi_runner)] +fn test_date_set_unsupported_on_wasi() { + new_ucmd!() .arg("--set") .arg("2020-03-11 21:45:00+08:00") - .fails(); - result.no_stdout(); - assert!( - result - .stderr_str() - .starts_with("date: setting the date is not supported by macOS") - ); + .fails_with_code(1) + .stderr_is("date: setting the date is not supported by WASI\n"); } #[test] -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(unix)] fn test_date_set_valid_2() { if geteuid().is_root() { new_ucmd!() .arg("--set") .arg("Sat 20 Mar 2021 14:53:01 AWST") // spell-checker:disable-line + .env("LC_ALL", "C") + .env("TZ", "UTC0") .succeeds() - .no_output(); + .stdout_is("Sat Mar 20 06:53:01 UTC 2021\n"); } } #[test] -fn test_date_for_invalid_file() { - let result = new_ucmd!().arg("--file").arg("invalid_file").fails(); - result.no_stdout(); - assert_eq!( - result.stderr_str().trim(), - "date: invalid_file: No such file or directory", - ); +#[cfg(all(unix, not(target_os = "android")))] +fn test_date_set_echo_honors_format_and_utc() { + // The echo is printed even when the set fails (GNU does the same), so + // the format and `-u` handling can be checked without privileges. + if !(geteuid().is_root() || uucore::os::is_wsl_1()) { + // The echo goes through the user's format string. + new_ucmd!() + .args(&["--set", "2020-03-12 13:30:00+08:00", "+%F %T %Z"]) + .env("LC_ALL", "C") + .env("TZ", "UTC0") + .fails() + .stdout_is("2020-03-12 05:30:00 UTC\n"); + // `-u` echoes in UTC regardless of the local zone. + new_ucmd!() + .args(&["-u", "--set", "2020-03-12 13:30:00+08:00"]) + .env("LC_ALL", "C") + .env("TZ", "Europe/Helsinki") // spell-checker:disable-line + .fails() + .stdout_is("Thu Mar 12 05:30:00 UTC 2020\n"); + } +} + +#[test] +fn test_date_for_non_existing_file() { + new_ucmd!() + .arg("--file") + .arg("non_existing_file") + .fails() + .stderr_only("date: non_existing_file: No such file or directory\n"); } #[test] @@ -528,69 +678,206 @@ fn test_date_for_no_permission_file() { .unwrap(); file.set_permissions(std::fs::Permissions::from_mode(0o222)) .unwrap(); - let result = ucmd.arg("--file").arg(FILE).fails(); - result.no_stdout(); - assert_eq!( - result.stderr_str().trim(), - format!("date: {FILE}: Permission denied") - ); + + ucmd.arg("--file") + .arg(FILE) + .fails() + .stderr_only(format!("date: {FILE}: Permission denied\n")); } #[test] fn test_date_for_dir_as_file() { - let result = new_ucmd!().arg("--file").arg("/").fails(); - result.no_stdout(); - assert_eq!( - result.stderr_str().trim(), - "date: expected file, got directory '/'", - ); + new_ucmd!() + .arg("--file") + .arg("/") + .fails_with_code(1) + .stderr_only("date: expected file, got directory '/'\n"); } #[test] -fn test_date_for_file() { +fn test_date_for_empty_file() { let (at, mut ucmd) = at_and_ucmd!(); let file = "test_date_for_file"; at.touch(file); - ucmd.arg("--file").arg(file).succeeds(); + ucmd.arg("--file").arg(file).succeeds().no_output(); +} + +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] +fn test_date_for_file_with_non_utf8_path() { + use std::os::unix::ffi::OsStrExt; + + let (at, mut ucmd) = at_and_ucmd!(); + + let file = std::ffi::OsStr::from_bytes(b"file_\xFF\xFE.txt"); + std::fs::File::create(at.plus(file)).unwrap(); + + ucmd.arg("--file").arg(file).succeeds().no_output(); +} + +#[test] +fn test_date_multiple_files() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("a", "2022-02-22"); + at.write("b", "1999-09-19"); + + for (files, expected) in [ + (["a", "b"], "Sun Sep 19 00:00:00 UTC 1999\n"), + (["b", "a"], "Tue Feb 22 00:00:00 UTC 2022\n"), + ] { + scene + .ucmd() + .args(&["-u", "--file", files[0], "--file", files[1]]) + .succeeds() + .stdout_only(expected); + } +} + +#[test] +fn test_date_file_invalid_utf8_line() { + let (at, mut ucmd) = at_and_ucmd!(); + let file = "test_date_file_invalid_utf8"; + // A 0xFF byte makes the whole line invalid UTF-8 + at.write_bytes(file, b"Hello\xffx\n"); + + // GNU date: "date: invalid date 'Hello\377x'" on stderr, exit code 1 + let result = ucmd.args(&["-u", "-f", file]).fails_with_code(1); + result.no_stdout(); + result.stderr_contains("date: invalid date 'Hello\\377x'"); +} + +#[test] +fn test_date_file_invalid_utf8_line_continues() { + let (at, mut ucmd) = at_and_ucmd!(); + let file = "test_date_file_mixed_invalid_utf8"; + at.write_bytes( + file, + b"2024-01-15 12:00:00\nHello\xffx\n2024-01-16 13:00:00\n", + ); + + // GNU date reports the invalid line but still processes the other lines, + // exiting with code 1 + let result = ucmd.args(&["-u", "-f", file]).fails_with_code(1); + result.stdout_contains("Mon Jan 15 12:00:00 UTC 2024"); + result.stdout_contains("Tue Jan 16 13:00:00 UTC 2024"); + result.stderr_contains("date: invalid date 'Hello\\377x'"); +} + +#[test] +fn test_date_stdin_invalid_utf8_line() { + // `date -f -` reads from stdin through the same path + new_ucmd!() + .args(&["-u", "-f", "-"]) + .pipe_in(b"Hello\xffx\n".to_vec()) + .fails_with_code(1) + .stderr_contains("date: invalid date 'Hello\\377x'"); +} + +#[test] +fn test_date_file_line_ends_at_nul() { + // GNU-compat, everything after a NUL byte on a line is ignored + new_ucmd!() + .args(&["-u", "-f", "-"]) + .pipe_in(b"2024-01-15 12:00:00\0garbage\nbad\0\xff\n".to_vec()) + .fails_with_code(1) + .stdout_is("Mon Jan 15 12:00:00 UTC 2024\n") + .stderr_is("date: invalid date 'bad'\n"); } #[test] fn test_date_for_file_mtime() { + use std::time::{Duration, UNIX_EPOCH}; + let (at, mut ucmd) = at_and_ucmd!(); - let file = "reference_file"; - at.touch(file); - std::thread::sleep(std::time::Duration::from_millis(100)); - let result = ucmd.arg("--reference").arg(file).arg("+%s%N").succeeds(); - let mtime = at.metadata(file).modified().unwrap(); - let mtime_nanos = mtime - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - .to_string(); - assert_eq!(result.stdout_str().trim(), &mtime_nanos[..]); + + let reference_file = "reference_file"; + let f = at.make_file(reference_file); + let modification_date = UNIX_EPOCH.checked_add(Duration::from_secs(1234)).unwrap(); + f.set_modified(modification_date).unwrap(); + + ucmd.arg("--reference") + .arg(reference_file) + .arg("+%s") + .succeeds() + .stdout_only("1234\n"); +} + +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] +fn test_date_reference_is_non_utf8_path() { + use std::os::unix::ffi::OsStrExt; + use std::time::{Duration, UNIX_EPOCH}; + + let (at, mut ucmd) = at_and_ucmd!(); + + let reference_file = std::ffi::OsStr::from_bytes(b"reference_\xFF\xFE.txt"); + let f = std::fs::File::create(at.plus(reference_file)).unwrap(); + let modification_date = UNIX_EPOCH.checked_add(Duration::from_secs(1234)).unwrap(); + f.set_modified(modification_date).unwrap(); + + ucmd.arg("--reference") + .arg(reference_file) + .arg("+%s") + .succeeds() + .stdout_only("1234\n"); } #[test] -#[cfg(all(unix, not(target_os = "macos")))] +fn test_date_multiple_references() { + use std::time::{Duration, UNIX_EPOCH}; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + let create_file = |name: &str, secs: u64| { + let f = at.make_file(name); + let date = UNIX_EPOCH.checked_add(Duration::from_secs(secs)).unwrap(); + f.set_modified(date).unwrap(); + }; + + create_file("a", 1111); + create_file("b", 2222); + + // last ref "wins" + for (refs, expected) in [(["a", "b"], "2222\n"), (["b", "a"], "1111\n")] { + scene + .ucmd() + .args(&["--reference", refs[0], "--reference", refs[1]]) + .arg("+%s") + .succeeds() + .stdout_only(expected); + } +} + +#[test] +#[cfg(unix)] fn test_date_set_valid_3() { if geteuid().is_root() { new_ucmd!() .arg("--set") .arg("Sat 20 Mar 2021 14:53:01") // Local timezone + .env("LC_ALL", "C") + .env("TZ", "UTC0") .succeeds() - .no_output(); + .stdout_is("Sat Mar 20 14:53:01 UTC 2021\n"); } } #[test] -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(unix)] fn test_date_set_valid_4() { if geteuid().is_root() { new_ucmd!() .arg("--set") .arg("2020-03-11 21:45:00") // Local timezone + .env("LC_ALL", "C") + .env("TZ", "UTC0") .succeeds() - .no_output(); + .stdout_is("Wed Mar 11 21:45:00 UTC 2020\n"); } } @@ -718,9 +1005,9 @@ fn test_invalid_date_string() { new_ucmd!() .arg("-d") - // cSpell:disable + // spell-checker:disable .arg("this fooday") - // cSpell:enable + // spell-checker:enable .fails() .no_stdout() .stderr_contains("invalid date"); @@ -776,8 +1063,11 @@ fn test_date_parse_from_format() { 2023-04-01 12:00:00\n\ 2023-04-15 18:30:00", ); + // Pass the file relative to the command's working directory. On WASI the + // preopened sandbox root is not the same as the host absolute path, so an + // absolute `at.plus(FILE)` argument would not resolve inside the sandbox. ucmd.arg("-f") - .arg(at.plus(FILE)) + .arg(FILE) .arg("+%Y-%m-%d %H:%M:%S") .succeeds(); } @@ -805,6 +1095,10 @@ const JAN2: &str = "2024-01-02 12:00:00 +0000"; const JUL2: &str = "2024-07-02 12:00:00 +0000"; #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_date_tz() { fn test_tz(tz: &str, date: &str, output: &str) { println!("Test with TZ={tz}, date=\"{date}\"."); @@ -851,6 +1145,10 @@ fn test_date_tz_with_utc_flag() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_date_tz_various_formats() { fn test_tz(tz: &str, date: &str, output: &str) { println!("Test with TZ={tz}, date=\"{date}\"."); @@ -879,6 +1177,10 @@ fn test_date_tz_various_formats() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_date_tz_with_relative_time() { new_ucmd!() .env("TZ", "America/Vancouver") @@ -890,6 +1192,10 @@ fn test_date_tz_with_relative_time() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_date_utc_time() { // Test that -u flag shows correct UTC time // We get 2 UTC times just in case we're really unlucky and this runs around @@ -1342,7 +1648,7 @@ fn test_date_empty_string() { fn test_date_empty_string_variations() { // Test multiple variations of empty/whitespace strings // All should produce midnight (00:00:00) - let test_cases = vec!["", " ", " ", "\t", "\n", " \t ", "\t\n\t"]; + let test_cases = vec!["", " ", " ", "\t", "\n", " \t ", "\t\n\t", "\x0B", "\x0C"]; for input in test_cases { new_ucmd!() @@ -1366,6 +1672,30 @@ fn test_date_empty_string_variations() { } #[test] +fn test_date_whitespace_between_items() { + // GNU date accepts \v and \f as whitespace between items + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .env("TZ", "UTC0") + .args(&["-d", "Jan 23\x0B 2026 1:00AM"]) + .succeeds() + .stdout_is("Fri Jan 23 01:00:00 UTC 2026\n"); + + new_ucmd!() + .env("LANG", "C") + .env("LC_ALL", "C") + .env("TZ", "UTC0") + .args(&["-d", "Jan 23\x0C2026 1:00AM"]) + .succeeds() + .stdout_is("Fri Jan 23 01:00:00 UTC 2026\n"); +} + +#[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_date_relative_m9() { // Military timezone "m9" should be parsed as noon + 9 hours = 21:00 UTC // When displayed in TZ=UTC+9 (which is UTC-9), this shows as 12:00 local time @@ -1663,6 +1993,10 @@ fn test_date_locale_en_us_vs_c_difference() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_date_locale_hu_hungarian() { // Regression test for uutils/coreutils#11240: the GNU modifier fast-path // ("%-e") used to run before ICU localization, so "%b"/"%A" came out in @@ -1688,7 +2022,7 @@ fn test_date_locale_hu_hungarian() { } #[test] -#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] fn test_date_locale_fr_french() { // Test French locale (fr_FR.UTF-8) behavior // French typically uses 24-hour format and may have localized day/month names @@ -1746,7 +2080,7 @@ fn test_date_posix_format_specifiers() { } #[test] -#[cfg(any(target_os = "linux", target_vendor = "apple"))] +#[cfg(any(target_vendor = "apple", target_os = "linux"))] 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 @@ -1781,7 +2115,7 @@ fn test_date_format_b_french_locale() { } #[test] -#[cfg(any(target_os = "linux", target_vendor = "apple"))] +#[cfg(any(target_vendor = "apple", target_os = "linux"))] 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 @@ -1816,7 +2150,7 @@ fn test_date_format_a_french_locale() { } #[test] -#[cfg(any(target_os = "linux", target_vendor = "apple"))] +#[cfg(any(target_vendor = "apple", target_os = "linux"))] fn test_date_french_full_sentence() { let result = new_ucmd!() .env("LANG", "fr_FR.UTF-8") @@ -1842,7 +2176,7 @@ fn test_date_french_full_sentence() { /// 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"))] +#[cfg(any(target_vendor = "apple", target_os = "linux"))] fn test_date_format_x_locale_aware() { // With C locale, %x should output MM/DD/YY (US format) new_ucmd!() @@ -1954,6 +2288,10 @@ fn test_date_input_hhmm_ampm() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_date_input_trailing_tz_abbrev_rezones() { // `TZ=UTC+1 date -d '2024-01-01 EST'` should display the instant in UTC+1 // (GNU: 04:00:00 -01:00), not leave it in EST (the pre-fix uutils @@ -2023,7 +2361,6 @@ fn test_date_strftime_flag_on_composite() { } #[test] -#[ignore = "https://github.com/uutils/coreutils/issues/11656 — GNU date strips the `O` strftime modifier in C locale (e.g. `%Om` -> `%m`); uutils leaks it as literal `%om`."] fn test_date_strftime_o_modifier() { // In C locale the `O` modifier is a no-op (alternative numeric symbols). // GNU renders `%Om` as `06` for June; uutils renders it as the literal `%Om`. @@ -2032,9 +2369,9 @@ fn test_date_strftime_o_modifier() { .env("TZ", "UTC") .arg("-d") .arg("2024-06-15") - .arg("+%Om-%Oy-%Ol") + .arg("+%Om-%Oy-%Od") .succeeds() - .stdout_is("06-24-12\n"); + .stdout_is("06-24-15\n"); } #[test] @@ -2083,6 +2420,10 @@ fn test_date_parenthesis_vs_other_special_chars() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] 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 @@ -2150,6 +2491,10 @@ fn test_date_iranian_locale_solar_hijri_calendar() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_date_ethiopian_locale_calendar() { // Test Ethiopian locale uses Ethiopian calendar // Verify the Ethiopian calendar is used in the Ethiopian locale @@ -2297,6 +2642,10 @@ fn check_date(locale: &str, date: &str, fmt: &str, expected: &str) { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_locale_calendar_conversions() { // Persian (Solar Hijri) - Nowruz is March 20/21 for (d, e) in [ @@ -2348,6 +2697,10 @@ fn test_locale_calendar_conversions() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_locale_month_names() { // %B full month names: Jan, Jun, Dec for each locale for (loc, jan, jun, dec) in [ @@ -2368,6 +2721,10 @@ fn test_locale_month_names() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_locale_abbreviated_month_names() { // %b abbreviated month names: Feb, Jun, Dec for each locale // This test ensures we don't get double periods in locales like Hungarian @@ -2391,6 +2748,10 @@ fn test_locale_abbreviated_month_names() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] fn test_locale_day_names() { // %A full day names: Mon (26th), Sun (25th), Sat (24th) Jan 2026 for (loc, mon, sun, sat) in [ @@ -2406,6 +2767,21 @@ fn test_locale_day_names() { } } +#[test] +#[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] +fn test_locale_names_for_several_dates_in_one_run() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("dates", "2026-01-26\n2026-06-14\n2026-12-12\n"); + ucmd.env("LC_ALL", "fr_FR.UTF-8") + .args(&["-f", "dates", "+%A %a %B %b"]) + .succeeds() + .stdout_is("lundi lun. janvier janv\ndimanche dim. juin juin\nsamedi sam. décembre déc\n"); +} + #[test] fn test_percent_percent_not_replaced() { let cases = [ @@ -2447,41 +2823,41 @@ fn test_date_write_error_dev_full() { .stderr_contains("write error"); } -// Tests for GNU test leap-1: leap year overflow in date arithmetic +// Tests for leap year overflow in date arithmetic #[test] -fn test_date_leap1_leap_year_overflow() { - // GNU test leap-1: Adding years to Feb 29 should overflow to March 1 +fn test_date_leap_year_arithmetic_overflow() { + // Adding years to Feb 29 should overflow to March 1 // if target year is not a leap year new_ucmd!() - .args(&["--date", "02/29/1996 1 year", "+%Y-%m-%d"]) + .args(&["--date", "02/29/2000 1 year", "+%Y-%m-%d"]) .succeeds() - .stdout_is("1997-03-01\n"); + .stdout_is("2001-03-01\n"); // Additional cases: 2 years new_ucmd!() - .args(&["--date", "1996-02-29 + 2 years", "+%Y-%m-%d"]) + .args(&["--date", "2000-02-29 + 2 years", "+%Y-%m-%d"]) .succeeds() - .stdout_is("1998-03-01\n"); + .stdout_is("2002-03-01\n"); // Leap year to leap year should not overflow new_ucmd!() - .args(&["--date", "1996-02-29 + 4 years", "+%Y-%m-%d"]) + .args(&["--date", "2000-02-29 + 4 years", "+%Y-%m-%d"]) .succeeds() - .stdout_is("2000-02-29\n"); + .stdout_is("2004-02-29\n"); } -// Tests for GNU test rel-2b: month arithmetic precision +// Tests for month arithmetic precision #[test] -fn test_date_rel2b_month_arithmetic() { - // GNU test rel-2b: Subtracting months should maintain same day of month +fn test_date_month_subtraction_keeps_day() { + // Subtracting months should maintain same day of month new_ucmd!() .args(&[ "--date", - "1997-01-19 08:17:48 +0 7 months ago", + "2003-08-31 12:00:00 +0 7 months ago", "+%Y-%m-%d %T", ]) .succeeds() - .stdout_contains("1996-06-19"); + .stdout_contains("2003-01-31"); // Month overflow: Adding months should overflow to next month if day doesn't exist new_ucmd!() @@ -2490,37 +2866,41 @@ fn test_date_rel2b_month_arithmetic() { .stdout_is("1996-03-02\n"); } -// Tests for GNU test cross-TZ-mishandled: embedded timezone parsing +// Tests for embedded timezone parsing #[test] -fn test_date_cross_tz_mishandled() { - // GNU test cross-TZ-mishandled: Parse date with embedded timezone +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: timezone/locale database not visible" +)] +fn test_date_embedded_timezone_conversion() { // Date should be interpreted in embedded TZ, then displayed in environment TZ new_ucmd!() - .env("TZ", "PST8") + .env("TZ", "UTC0") .env("LC_ALL", "C") - .args(&["-d", r#"TZ="EST5" 1970-01-01 00:00"#]) + .args(&["-d", r#"TZ="CET-1" 1970-01-01 00:00"#]) .succeeds() .stdout_contains("Dec 31") - .stdout_contains("21:00:00") + .stdout_contains("23:00:00") .stdout_contains("1969"); } -// Tests for GNU test invalid-high-bit-set: invalid UTF-8 in date string +// Tests for invalid UTF-8 in date string #[test] #[cfg(unix)] -fn test_date_invalid_high_bit_set() { +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] +fn test_date_invalid_utf8_byte_rejected() { use std::os::unix::ffi::OsStrExt; - // GNU test invalid-high-bit-set: Invalid UTF-8 byte (0xb0) should produce + // Invalid UTF-8 byte (0xb0) should produce // GNU-compatible error message with octal escape sequence - let invalid_bytes = b"\xb0"; + let invalid_bytes = b"\xe0"; let invalid_arg = std::ffi::OsStr::from_bytes(invalid_bytes); new_ucmd!() .args(&[std::ffi::OsStr::new("-d"), invalid_arg]) .fails() .code_is(1) - .stderr_contains("invalid date '\\260'"); + .stderr_contains("invalid date '\\340'"); } // Tests for GNU format modifiers @@ -2595,7 +2975,7 @@ fn test_date_format_modifier_combined_flags() { #[test] fn test_date_format_modifier_case_precedence() { - // Test that ^ (uppercase) takes precedence over # (swap case) regardless of order + // On names, ^ (uppercase) takes precedence over # (swap case) regardless of order new_ucmd!() .env("TZ", "UTC") .env("LC_ALL", "C") @@ -2611,6 +2991,25 @@ fn test_date_format_modifier_case_precedence() { .stdout_is("JUNE\n"); } +#[test] +fn test_date_format_modifier_case_flags_per_conversion() { + for (format, expected) in [ + ("+%#c", "Sat Jun 15 13:05:03 2024\n"), + ("+%#r", "01:05:03 PM\n"), + ("+%^P", "pm\n"), + ("+%^#p", "pm\n"), + ("+%#^p", "pm\n"), + ("+%^#Z", "utc\n"), + ] { + new_ucmd!() + .env("TZ", "UTC") + .env("LC_ALL", "C") + .args(&["-d", "2024-06-15 13:05:03", format]) + .succeeds() + .stdout_is(expected); + } +} + #[test] fn test_date_format_modifier_multiple() { // Test multiple modifiers in one format string @@ -3106,3 +3505,116 @@ fn test_nanoseconds_width_prefix_ignored_issue12001() { // compare to 4 because of \n assert_eq!(result.stdout().len(), 4); } + +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore)] +fn test_write_error() { + use std::fs::OpenOptions; + + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + + new_ucmd!() + .set_stdout(dev_full) + .fails_with_code(1) + .stderr_is("date: write error: No space left on device\n"); +} + +#[test] +#[ignore = "GNU compat: see uutils/coreutils#14648"] +fn test_date_allow_missing_year() { + new_ucmd!().arg("01.01. 03:00 p.m.").succeeds(); +} + +#[test] +#[ignore = "GNU compat: see uutils/coreutils#14649"] +fn test_date_allow_spaces_after_month() { + new_ucmd!().arg("01.01. 2008 03:00 p.m.").succeeds(); +} + +#[test] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] +fn test_format_with_non_utf8_bytes() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + // Bytes that are not valid UTF-8 carry no conversion specifier, so they + // must reach the output untouched, just like any other literal text. + let format = OsString::from_vec(b"+\xc5[%Y]\xa7%%\xe4".to_vec()); + new_ucmd!() + .arg("-u") + .arg("-d") + .arg("2031-07-23T04:05:06") + .arg(format) + .succeeds() + .stdout_only_bytes(b"\xc5[2031]\xa7%\xe4\n"); +} + +#[test] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] +fn test_format_percent_before_non_utf8_byte() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + // A '%' whose specifier is an invalid byte is printed as-is. + let format = OsString::from_vec(b"+w%\xd0z".to_vec()); + new_ucmd!() + .arg("-u") + .arg("-d") + .arg("2031-07-23T04:05:06") + .arg(format) + .succeeds() + .stdout_only_bytes(b"w%\xd0z\n"); +} + +#[test] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] +fn test_format_with_gb18030_bytes() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + // A realistic legacy-charset format: GB18030 年 (0xC4EA), 月 (0xD4C2) and + // 日 (0xC8D5) around the specifiers, as zh_CN.gb18030 spells it. + let format = OsString::from_vec(b"+%Y\xc4\xea%-m\xd4\xc2%-d\xc8\xd5".to_vec()); + new_ucmd!() + .arg("-u") + .arg("-d") + .arg("2031-07-23T04:05:06") + .arg(format) + .succeeds() + .stdout_only_bytes(b"2031\xc4\xea7\xd4\xc223\xc8\xd5\n"); +} + +#[test] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] +fn test_non_utf8_operands_are_octal_escaped() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + // Bytes that don't decode must be reported in octal, not as U+FFFD, on + // each of the three paths that print back an operand. + let cases: [(&[&[u8]], &str); 3] = [ + (&[b"gr\xf6n"], "invalid date 'gr\\366n'"), + (&[b"+%Y", b"\xf1ao"], "extra operand '\\361ao'"), + ( + &[b"-d", b"2031-07-23", b"%Y\xd8"], + "the argument %Y\\330 lacks a leading '+'", + ), + ]; + + for (args, expected) in cases { + let args: Vec = args + .iter() + .map(|a| OsString::from_vec(a.to_vec())) + .collect(); + new_ucmd!() + .args(&args) + .fails() + .code_is(1) + .stderr_contains(expected); + } +} diff --git a/tests/by-util/test_dd.rs b/tests/by-util/test_dd.rs index 849c47e227f..9909dddf2c4 100644 --- a/tests/by-util/test_dd.rs +++ b/tests/by-util/test_dd.rs @@ -2,12 +2,13 @@ // // 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 FADV DONTNEED + +// 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 FADV DONTNEED Fsize SIGXFSZ rusage maxrss cdefg ncdefg cdefh use uutests::at_and_ucmd; use uutests::new_ucmd; use uutests::util::TestScenario; -#[cfg(all(unix, not(feature = "feat_selinux")))] +#[cfg(all(unix, not(feature = "selinux")))] use uutests::util::run_ucmd_as_root_with_stdin_stdout; #[cfg(not(windows))] use uutests::util::{UCommand, get_tests_binary}; @@ -19,7 +20,7 @@ use uucore::io::OwnedFileDescriptorOrHandle; use std::fs::{File, OpenOptions}; use std::io::{BufReader, Read, Write}; use std::path::PathBuf; -#[cfg(all(unix, not(target_os = "macos"), not(target_os = "freebsd"),))] +#[cfg(all(unix, not(target_vendor = "apple"), not(target_os = "freebsd"),))] use std::process::Command; use std::process::Stdio; #[cfg(not(windows))] @@ -107,7 +108,13 @@ fn version() { #[test] fn help() { - new_ucmd!().args(&["--help"]).succeeds(); + new_ucmd!() + .arg("--help") + .succeeds() + .stdout_contains("\nOperands:\n") + .stdout_contains("\nConversion options:\n") + .stdout_does_not_contain("###") + .stdout_does_not_contain("```"); } #[test] @@ -146,6 +153,18 @@ fn test_huge_block_size_is_rejected_without_panicking() { } } +#[test] +fn test_count_past_u64_is_rejected() { + // A number past u64 used to saturate, so count= silently copied the whole + // input and exited 0 where GNU rejects the operand. + for arg in ["count=99999999999999999999", "skip=18446744073709551616"] { + new_ucmd!() + .args(&["if=/dev/null", arg]) + .fails_with_code(1) + .stderr_contains("Value too large for defined data type"); + } +} + #[test] fn test_huge_obs_reports_memory_error_instead_of_aborting() { // Regression test for #12847: a valid but huge `obs` used to abort @@ -156,6 +175,19 @@ fn test_huge_obs_reports_memory_error_instead_of_aborting() { .stderr_contains("memory"); } +#[test] +// A petabyte `cbs` does not fit in a 32-bit `usize`. +#[cfg(all(target_os = "linux", target_pointer_width = "64"))] +fn test_huge_cbs_pads_without_allocating() { + // Regression test for #14814: a valid but huge `cbs` used to abort; like GNU, + // the padding is written as it goes, until /dev/full refuses it. + new_ucmd!() + .args(&["conv=block", "cbs=1PB", "of=/dev/full"]) + .pipe_in("x\n") + .fails_with_code(1) + .stderr_contains("No space left on device"); +} + #[test] fn test_stdin_stdout() { let input = build_ascii_block(521); @@ -780,6 +812,26 @@ fn test_partial_records_out() { .stderr_is("1+1 records in\n1+1 records out\n"); } +#[test] +fn test_block_record_across_reads() { + // With ibs=2, each record spans several reads. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("in", "1234\n56\n123456789\n78"); + ucmd.args(&["if=in", "conv=block", "cbs=6", "ibs=2", "obs=2"]) + .succeeds() + .stdout_is("1234 56 12345678 ") + .stderr_contains("1 truncated record"); +} + +#[test] +fn test_block_keeps_trailing_record_of_spaces() { + new_ucmd!() + .args(&["conv=block", "cbs=4"]) + .pipe_in("ab\n ") + .succeeds() + .stdout_is("ab "); +} + #[test] fn test_block_cbs16() { new_ucmd!() @@ -843,6 +895,25 @@ fn test_etoa_conv_spec_test() { .stdout_is_fixture_bytes("gnudd-conv-etoa-seq-byte-values.spec"); } +#[test] +fn test_etoa_and_lcase() { + // "Hello, World!" in EBCDIC. + new_ucmd!() + .args(&["conv=ascii,lcase", "status=none"]) + .pipe_in(b"\xc8\x85\x93\x93\x96\x6b\x40\xe6\x96\x99\x93\x84\x5a".to_vec()) + .succeeds() + .stdout_only_bytes(b"hello, world!"); +} + +#[test] +fn test_etoa_and_ucase() { + new_ucmd!() + .args(&["conv=ascii,ucase", "status=none"]) + .pipe_in(b"\xc8\x85\x93\x93\x96\x6b\x40\xe6\x96\x99\x93\x84\x5a".to_vec()) + .succeeds() + .stdout_only_bytes(b"HELLO, WORLD!"); +} + #[test] fn test_atoibm_conv_spec_test() { new_ucmd!() @@ -888,24 +959,13 @@ fn test_atoe_and_lcase_conv_spec_test() { .stdout_is_fixture_bytes("lcase-ebcdic.test"); } -// TODO I think uppercase and lowercase are unintentionally swapped in -// the code that parses the command-line arguments. See this line from -// `parseargs.rs`: -// -// (ConvFlag::FmtAtoI, ConvFlag::UCase) => Some(&ASCII_TO_IBM_UCASE_TO_LCASE), -// (ConvFlag::FmtAtoI, ConvFlag::LCase) => Some(&ASCII_TO_IBM_LCASE_TO_UCASE), -// -// If my reading is correct and that is a typo, then the -// UCASE_TO_LCASE and LCASE_TO_UCASE in those lines should be swapped, -// and the expected output for the following two tests should be -// updated accordingly. #[test] fn test_atoibm_and_ucase_conv_spec_test() { new_ucmd!() .args(&["conv=ibm,ucase"]) .pipe_in_fixture("seq-byte-values-b632a992d3aed5d8d1a59cc5a5a455ba.test") .succeeds() - .stdout_is_fixture_bytes("lcase-ibm.test"); + .stdout_is_fixture_bytes("ucase-ibm.test"); } #[test] @@ -914,7 +974,7 @@ fn test_atoibm_and_lcase_conv_spec_test() { .args(&["conv=ibm,lcase"]) .pipe_in_fixture("seq-byte-values-b632a992d3aed5d8d1a59cc5a5a455ba.test") .succeeds() - .stdout_is_fixture_bytes("ucase-ibm.test"); + .stdout_is_fixture_bytes("lcase-ibm.test"); } #[test] @@ -1257,6 +1317,24 @@ fn test_block_sync() { .stderr_is("2+1 records in\n0+1 records out\n1 truncated record\n"); } +#[test] +fn test_block_sync_small_ibs() { + // With ibs=3, "cdefg" spans two reads, and the last read "h" is padded + // with spaces by sync before being blocked. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("in", "ab\ncdefg\nh"); + ucmd.args(&[ + "if=in", + "ibs=3", + "cbs=4", + "conv=block,sync", + "status=noxfer", + ]) + .succeeds() + .stdout_is("ab cdefh ") + .stderr_is("3+1 records in\n0+1 records out\n1 truncated record\n"); +} + #[test] fn test_bytes_iseek_bytes_iflag() { new_ucmd!() @@ -1351,29 +1429,28 @@ fn test_invalid_number_arg_gnu_compatibility() { new_ucmd!() .args(&[format!("{command}=")]) .fails() - .stderr_is("dd: invalid number: ‘’\n"); + .stderr_is("dd: invalid number: ''\n"); new_ucmd!() .args(&[format!("{command}=29d")]) .fails() - .stderr_is("dd: invalid number: ‘29d’\n"); + .stderr_is("dd: invalid number: '29d'\n"); } } #[test] fn test_invalid_flag_arg_gnu_compatibility() { - let commands = vec!["iflag", "oflag"]; - - for command in commands { + // GNU names the direction the flag was given in. + for (command, direction) in [("iflag", "input"), ("oflag", "output")] { new_ucmd!() .args(&[format!("{command}=")]) .fails() - .usage_error("invalid input flag: ‘’"); + .usage_error(format!("invalid {direction} flag: ''")); new_ucmd!() .args(&[format!("{command}=29d")]) .fails() - .usage_error("invalid input flag: ‘29d’"); + .usage_error(format!("invalid {direction} flag: '29d'")); } } @@ -1495,7 +1572,10 @@ fn test_sync_delayed_reader() { .unwrap(); for _ in 0..8 { fifo.write_all(&[0xF; 8]).unwrap(); - sleep(Duration::from_millis(10)); + // Each write must be read as its own short record, so leave dd + // enough time to drain the pipe: if two writes pile up, it reads a + // full 16-byte block, pads nothing and the output no longer matches. + sleep(Duration::from_millis(100)); } } // Expected output is 0xFFFFFFFF00000000FFFFFFFF00000000... @@ -1616,7 +1696,7 @@ fn test_empty_count_number() { new_ucmd!() .args(&["count=B"]) .fails_with_code(1) - .stderr_only("dd: invalid number: ‘B’\n"); + .stderr_only("dd: invalid number: 'B'\n"); } /// Test for discarding system file cache. @@ -1632,7 +1712,7 @@ fn test_nocache_file() { #[test] #[cfg(unix)] -#[cfg(not(feature = "feat_selinux"))] +#[cfg(not(feature = "selinux"))] // Disabled on SELinux for now fn test_skip_past_dev() { // NOTE: This test intends to trigger code which can only be reached with root permissions. @@ -1657,7 +1737,7 @@ fn test_skip_past_dev() { #[test] #[cfg(unix)] -#[cfg(not(feature = "feat_selinux"))] +#[cfg(not(feature = "selinux"))] fn test_seek_past_dev() { // NOTE: This test intends to trigger code which can only be reached with root permissions. let ts = TestScenario::new(util_name!()); @@ -1680,7 +1760,7 @@ fn test_seek_past_dev() { } #[test] -#[cfg(all(unix, not(target_os = "macos"), not(target_os = "freebsd"),))] +#[cfg(all(unix, not(target_vendor = "apple"), not(target_os = "freebsd"),))] fn test_reading_partial_blocks_from_fifo() { // Create the FIFO. let ts = TestScenario::new(util_name!()); @@ -1720,7 +1800,7 @@ fn test_reading_partial_blocks_from_fifo() { } #[test] -#[cfg(all(unix, not(target_os = "macos"), not(target_os = "freebsd"),))] +#[cfg(all(unix, not(target_vendor = "apple"), not(target_os = "freebsd"),))] fn test_reading_partial_blocks_from_fifo_unbuffered() { // Create the FIFO. let ts = TestScenario::new(util_name!()); @@ -1761,6 +1841,124 @@ fn test_reading_partial_blocks_from_fifo_unbuffered() { assert!(output.stderr.starts_with(expected)); } +/// Regression test for : +/// two deliberately short reads (ibs=3 sees only 2 bytes each) must be +/// gathered into a single obs=6 output block without pulling stale bytes +/// from the ibs-aligned gap into the output. +/// +/// The writer below runs `printf` inside `sh`, where it is a builtin, so this +/// needs no `printf` feature. +#[test] +#[cfg(all(unix, not(target_vendor = "apple"), not(target_os = "freebsd")))] +fn test_reading_partial_blocks_from_fifo_gathered_into_larger_obs() { + // Create the FIFO. + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.mkfifo("fifo"); + let fifoname = at.plus_as_string("fifo"); + + // Start a `dd` process that reads from the fifo (so it will wait + // until the writer process starts). + let mut reader_command = Command::new(get_tests_binary()); + let child = reader_command + .args(["dd", "ibs=3", "obs=6", &format!("if={fifoname}")]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("LC_ALL", "C") + .env("LANG", "C") + .env("LANGUAGE", "C") + .spawn() + .unwrap(); + + // Start different processes to write to the FIFO, with a small + // pause in between. + let mut writer_command = Command::new("sh"); + let _ = writer_command + .args([ + "-c", + &format!("(printf \"ab\"; sleep 0.1; printf \"cd\") > {fifoname}"), + ]) + .spawn() + .unwrap() + .wait(); + + let output = child.wait_with_output().unwrap(); + assert_eq!(output.stdout, b"abcd"); + let expected = b"0+2 records in\n0+1 records out\n4 bytes copied"; + assert!(output.stderr.starts_with(expected)); +} + +// `O_DIRECT` requires the read buffer to satisfy the device's DMA alignment; +// with a misaligned buffer the kernel fails the read with EINVAL (#12085). +// Reading a regular file with `iflag=direct` exercises the same requirement +// on filesystems that support it. +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_iflag_direct_read_uses_aligned_buffer() { + use std::os::unix::fs::OpenOptionsExt; + + let (at, mut ucmd) = at_and_ucmd!(); + // A multiple of the block size: the EOF read then happens at an aligned + // file offset. A trailing partial block would leave the offset + // misaligned, which some kernels (e.g. f2fs on Android) reject with + // EINVAL instead of reporting end of file. + let data = build_ascii_block(2 * 4096); + at.write_bytes("direct-in.bin", &data); + + if OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECT) + .open(at.plus("direct-in.bin")) + .is_err() + { + print!("Test skipped; filesystem does not support O_DIRECT"); + return; + } + + ucmd.args(&[ + "if=direct-in.bin", + "of=direct-out.bin", + "iflag=direct", + "bs=4096", + ]) + .succeeds(); + assert_eq!(at.read_bytes("direct-out.bin"), data); +} + +// `dd` has to accept a `bs=` far larger than the data it will copy, the way +// GNU dd does, so the copy buffer must not fault in its pages. +#[test] +#[cfg(all(target_os = "linux", target_pointer_width = "64"))] +fn test_large_bs_does_not_fault_in_copy_buffer() { + // `wait4` reports the peak RSS of this one child, so a test running in + // parallel cannot inflate the reading the way `/proc/self` would. + fn peak_rss_kib(bs: &str) -> i64 { + let child = Command::new(get_tests_binary()) + .args(["dd", bs, "if=/dev/null", "of=/dev/null"]) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let pid = child.id() as libc::pid_t; + // Dropping a `Child` does not reap it, so `wait4` still finds it. + drop(child); + + let mut status = 0; + let mut usage: libc::rusage = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { libc::wait4(pid, &raw mut status, 0, &raw mut usage) }, + pid + ); + assert_eq!(status, 0, "dd {bs} exited with status {status}"); + usage.ru_maxrss + } + + let growth = peak_rss_kib("bs=4G") - peak_rss_kib("bs=4K"); + assert!( + growth < 64 << 10, + "a 4 GiB copy buffer raised peak RSS by {growth} KiB" + ); +} + #[test] #[cfg(any(target_os = "linux", target_os = "android"))] fn test_iflag_directory_fails_when_file_is_passed_via_std_in() { @@ -1907,8 +2105,7 @@ fn test_oflag_direct_partial_block() { "status=none".to_string(), ]) .succeeds() - .stdout_is("") - .stderr_is(""); + .no_output(); assert!(output_path.exists()); let output_size = output_path.metadata().unwrap().len() as usize; assert_eq!(output_size, input_size); @@ -1929,10 +2126,41 @@ fn test_skip_overflow() { .args(&["bs=1", "skip=9223372036854775808", "count=0"]) .fails() .stderr_contains( - "dd: invalid number: ‘9223372036854775808’: Value too large for defined data type", + "dd: invalid number: '9223372036854775808': Value too large for defined data type", ); } +#[test] +fn test_skip_blocks_times_ibs_overflow_does_not_wrap() { + // 17592186044416 * 1048576 == 2^64 would wrap around to 0, and + // must be rejected + let (at, mut ucmd) = at_and_ucmd!(); + at.write("in.f", "0123456789abcdef"); + ucmd.args(&["if=in.f", "skip=17592186044416", "ibs=1048576", "count=1"]) + .fails() + .no_stdout() + .stderr_contains("Value too large for defined data type"); +} + +#[test] +fn test_seek_blocks_times_obs_overflow_does_not_wrap() { + // Same as test_skip_blocks_times_ibs_overflow_does_not_wrap, + // but for `seek=`/`obs=`. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("out.f", "0123456789abcdef"); + ucmd.args(&[ + "if=/dev/null", + "of=out.f", + "seek=17592186044416", + "obs=1048576", + "conv=notrunc", + ]) + .fails() + .stderr_contains("Value too large for defined data type"); + // The output file must be untouched, not overwritten at offset 0. + assert_eq!(at.read("out.f"), "0123456789abcdef"); +} + #[test] #[cfg(target_os = "linux")] fn test_nocache_eof() { @@ -2138,7 +2366,262 @@ fn test_bs_not_positive() { .fails() .no_stdout() .code_is(1) - .stderr_is(format!("dd: invalid number: ‘{bs}’\n")); + .stderr_is(format!("dd: invalid number: '{bs}'\n")); } } } + +#[test] +fn test_count_bytes_with_expanding_block_conv() { + let (at, mut ucmd) = at_and_ucmd!(); + let mut input = vec![b'a'; 1000]; + input.extend([b'Z'; 24]); + at.write_bytes("input.txt", &input); + ucmd.args(&[ + "if=input.txt", + "of=output.bin", + "conv=block", + "cbs=1024", + "count=1000", + "iflag=count_bytes", + ]) + .succeeds(); + let output = at.read_bytes("output.bin"); + assert_eq!(bytecount::count(&output, b'a'), 1000); + assert!(!output.contains(&b'Z')); +} + +// A failed copy still has to report what it transferred, including complete +// and partial records. +#[test] +#[cfg(all(unix, not(target_vendor = "apple")))] +fn test_stats_are_reported_when_a_write_fails() { + use rustix::process::Resource; + + const CAP: u64 = 768 * 1024; + + let (at, mut ucmd) = at_and_ucmd!(); + let result = ucmd + .args(&["if=/dev/zero", "of=capped.bin", "bs=512K", "count=3"]) + .limit(Resource::Fsize, CAP, CAP) + .ignore_sigxfsz() + .fails(); + + // Under a 768 KiB cap, the first 512 KiB block is written in full, the + // second one is cut short at 256 KiB, and the third write fails. + result.stderr_contains("1+1 records out"); + result.stderr_contains("786432 bytes"); + assert_eq!(at.metadata("capped.bin").len(), CAP); +} + +// `conv=block` writes a huge record in several pieces: those written before +// the error still count. +#[test] +#[cfg(all(unix, not(target_vendor = "apple")))] +fn test_block_stats_are_reported_when_a_write_fails() { + use rustix::process::Resource; + + const CAP: u64 = 200 * 1024; + + let (at, mut ucmd) = at_and_ucmd!(); + let result = ucmd + .args(&["conv=block", "cbs=1M", "obs=64K", "of=capped.bin"]) + .pipe_in("x\n") + .limit(Resource::Fsize, CAP, CAP) + .ignore_sigxfsz() + .fails(); + + // Three 64 KiB pieces are written in full, and the fourth one is cut short. + result.stderr_contains("3+1 records out"); + result.stderr_contains("204800 bytes"); + assert_eq!(at.metadata("capped.bin").len(), CAP); +} + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_unrecognized_key() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["bsx=1"]) + .pipe_in("") + .fails_with_code(1); + + // The caret takes the key alone: the value is fine, it is the operand + // name that dd does not know. The usage hint that follows names the + // binary as it was invoked, so it is checked apart from the report. + let stderr = result.stderr_as_displayed(); + let (report, hint) = stderr.trim_end().rsplit_once('\n').unwrap(); + assert_eq!( + report, + "\ +dd: unrecognized operand 'bsx=1' + ╭─[ dd:1:4 ] + │ + 1 │ dd bsx=1 + │ ─── + │ + │ Help: an operand is KEY=VALUE, as in if=file bs=4k count=10 +───╯" + ); + assert!( + hint.ends_with("dd --help' for more information."), + "{stderr}" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_failing_flag_of_a_list() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["conv=ucase,zap"]) + .pipe_in("") + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + // Only the second flag is wrong, and the caret says so. + assert!(stderr.contains("dd:1:15"), "{stderr}"); + assert!(stderr.contains("1 │ dd conv=ucase,zap"), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_names_the_flags_rather_than_the_commas() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["conv=ucase,zap"]) + .pipe_in("") + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + // The list syntax parsed: it is the flag that is unknown, so the + // report names the conversions instead of explaining commas. + assert!(stderr.contains("not a known conversion"), "{stderr}"); + assert!(stderr.contains("conv= is one of ascii"), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_failing_flag_and_not_the_one_it_starts() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["conv=notrunc,not"]) + .pipe_in("") + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + // `not` also opens the `notrunc` in front of it, and the caret belongs + // on the flag that failed rather than on the one that parsed. + assert!(stderr.contains("dd:1:17"), "{stderr}"); + assert!( + stderr.contains("1 \u{2502} dd conv=notrunc,not"), + "{stderr}" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_value_of_a_count() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["count=8x"]) + .pipe_in("") + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("dd:1:10"), "{stderr}"); + assert!(stderr.contains("a number may be followed by"), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_keeps_the_try_help_hint_of_a_flag_message() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["iflag=nope"]) + .pipe_in("") + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("dd:1:10"), "{stderr}"); + // The caret replaces the message, not the usage hint: a pipe and a + // terminal must not disagree on whether one was printed. + assert!( + stderr + .trim_end() + .ends_with("dd --help' for more information."), + "{stderr}" + ); + } + + #[test] + fn test_plain_message_keeps_the_try_help_hint_of_a_flag_message() { + new_ucmd!() + .args(&["iflag=nope"]) + .pipe_in("") + .fails_with_code(1) + .stderr_contains("dd: invalid input flag: 'nope'") + .stderr_contains("--help' for more information."); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["bsx=1"]) + .pipe_in("") + .fails_with_code(1) + .stderr_contains("dd: unrecognized operand 'bsx=1'\n") + .stderr_contains("--help' for more information.") + .stderr_does_not_contain("╭─"); + } + + // The three below cover the shared switch in `uucore::diagnostics`. + + #[test] + fn test_report_is_drawn_into_a_pipe_when_asked_for() { + let result = new_ucmd!() + .env("UUTILS_DIAG", "always") + .args(&["bsx=1"]) + .pipe_in("") + .fails_with_code(1); + let stderr = result.stderr_str(); + + // No terminal anywhere, and the report is drawn all the same. + assert!(stderr.contains("dd:1:4"), "{stderr}"); + assert!(stderr.contains("1 \u{2502} dd bsx=1"), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_plain_message_at_a_terminal_when_asked_for() { + let result = new_ucmd!() + .env("UUTILS_DIAG", "never") + .terminal_sim_stderr() + .args(&["bsx=1"]) + .pipe_in("") + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!( + stderr.contains("dd: unrecognized operand 'bsx=1'"), + "{stderr}" + ); + assert!(!stderr.contains('\u{256d}'), "{stderr}"); + } + + #[test] + fn test_unknown_mode_leaves_the_terminal_in_charge() { + // A value nobody meant behaves as if the variable were unset. + new_ucmd!() + .env("UUTILS_DIAG", "sometimes") + .args(&["bsx=1"]) + .pipe_in("") + .fails_with_code(1) + .stderr_contains("dd: unrecognized operand 'bsx=1'\n") + .stderr_does_not_contain("╭─"); + } +} diff --git a/tests/by-util/test_df.rs b/tests/by-util/test_df.rs index 2ac91d1a11e..c4d0628530f 100644 --- a/tests/by-util/test_df.rs +++ b/tests/by-util/test_df.rs @@ -2,7 +2,9 @@ // // 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 binfmt + #![allow( clippy::similar_names, clippy::cast_possible_truncation, @@ -12,7 +14,7 @@ use std::collections::HashSet; -#[cfg(not(any(target_os = "freebsd", target_os = "windows")))] +#[cfg(not(any(target_os = "freebsd", windows)))] use uutests::at_and_ucmd; use uutests::new_ucmd; #[cfg(target_os = "linux")] @@ -91,9 +93,21 @@ fn test_df_output_arg() { .fails(); } +#[test] +#[cfg(windows)] +fn test_inodes_not_supported_windows() { + // The notice goes to stdout with exit 0, before any other option is validated. + for args in [&["-i"][..], &["-i", "--block-size=bogus"][..]] { + new_ucmd!() + .args(args) + .succeeds() + .stdout_only("df: doesn't support -i option\n"); + } +} + #[test] fn test_df_output() { - let expected = if cfg!(target_os = "macos") { + let expected = if cfg!(target_vendor = "apple") { vec![ "Filesystem", "Size", @@ -167,7 +181,7 @@ fn test_df_rounding() { #[test] fn test_df_output_overridden() { - let expected = if cfg!(target_os = "macos") { + let expected = if cfg!(target_vendor = "apple") { vec![ "Filesystem", "Size", @@ -201,7 +215,7 @@ fn test_df_output_overridden() { #[test] fn test_default_headers() { - let expected = if cfg!(target_os = "macos") { + let expected = if cfg!(target_vendor = "apple") { vec![ "Filesystem", "1K-blocks", @@ -328,8 +342,8 @@ fn test_type_option() { } #[test] -#[cfg(not(any(target_os = "freebsd", target_os = "openbsd", target_os = "windows")))] // FIXME: fix test for FreeBSD, OpenBSD & Win -#[cfg(not(feature = "feat_selinux"))] +#[cfg(not(any(target_os = "freebsd", target_os = "openbsd", windows)))] // FIXME: fix test for FreeBSD, OpenBSD & Win +#[cfg(not(feature = "selinux"))] fn test_type_option_with_file() { let fs_type = new_ucmd!() .args(&["--output=fstype", "."]) @@ -626,6 +640,16 @@ fn test_block_size_1024() { assert_eq!(get_header(1_000_000_000_000), "1TB-blocks"); } +#[test] +fn test_block_size_1m_option_with_output() { + for args in [["-m", "--output=size"], ["--output=size", "-m"]] { + let output = new_ucmd!().args(&args).succeeds().stdout_str_lossy(); + let header = output.lines().next().unwrap().trim(); + + assert_eq!(header, "1M-blocks"); + } +} + #[test] fn test_block_size_with_suffix() { fn get_header(block_size: &str) -> String { @@ -994,7 +1018,7 @@ fn test_output_file_all_filesystems() { } #[test] -#[cfg(not(any(target_os = "freebsd", target_os = "windows")))] // FIXME: fix test for FreeBSD & Win +#[cfg(not(any(target_os = "freebsd", windows)))] // FIXME: fix test for FreeBSD & Win fn test_output_file_specific_files() { // Create three files. let (at, mut ucmd) = at_and_ucmd!(); @@ -1013,7 +1037,7 @@ fn test_output_file_specific_files() { } #[test] -#[cfg(not(any(target_os = "freebsd", target_os = "windows")))] // FIXME: fix test for FreeBSD & Win +#[cfg(not(any(target_os = "freebsd", windows)))] // FIXME: fix test for FreeBSD & Win fn test_file_column_width_if_filename_contains_unicode_chars() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("äöü.txt"); @@ -1036,7 +1060,7 @@ fn test_output_field_no_more_than_once() { } #[test] -#[cfg(not(any(target_os = "freebsd", target_os = "windows")))] // FIXME: fix test for FreeBSD & Win +#[cfg(not(any(target_os = "freebsd", windows)))] // FIXME: fix test for FreeBSD & Win fn test_nonexistent_file() { new_ucmd!() .arg("does-not-exist") @@ -1099,13 +1123,11 @@ fn run_df_with_masked_proc(args: &str) -> Option<(bool, String, String)> { use std::process::Command; // Check if user namespaces are available - if !Command::new("unshare") + Command::new("unshare") .args(["-rm", "true"]) .status() - .is_ok_and(|s| s.success()) - { - return None; - } + .ok() + .filter(std::process::ExitStatus::success)?; let df_path = TestScenario::new("df").bin_path.clone(); let output = Command::new("unshare") @@ -1150,3 +1172,52 @@ fn test_df_masked_proc_fallback() { } } } + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_unknown_unit_of_block_size() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-B", "1fb"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_as_displayed(), + "\ +df: invalid suffix in --block-size argument '1fb' + ╭─[ df:1:8 ] + │ + 1 │ df -B 1fb + │ ─┬ + │ ╰── not a known unit + │ + │ Help: a size is a number and an optional unit: K, M, G and so on for 1024, KB, MB, GB for 1000 +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_a_zero_block_size() { + let result = new_ucmd!() + .terminal_sim_stderr() + .arg("--block-size=0") + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert_eq!(result.caret_column(), Some(17)); + assert!(!stderr.contains("not a known unit"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["-B", "1fb"]) + .fails_with_code(1) + .stderr_is("df: invalid suffix in --block-size argument '1fb'\n"); + } +} diff --git a/tests/by-util/test_dir.rs b/tests/by-util/test_dir.rs index 3e43e188130..08e519ea0e4 100644 --- a/tests/by-util/test_dir.rs +++ b/tests/by-util/test_dir.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use regex::Regex; use uutests::new_ucmd; use uutests::util::TestScenario; @@ -32,6 +33,55 @@ fn test_default_output() { .stdout_does_not_match(&Regex::new("[rwx-]{10}.*some-file1$").unwrap()); } +#[test] +fn test_default_format_overrides() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.touch("file"); + for (flag, expected) in [("-1", "file\n"), ("--zero", "file\0")] { + scene.ucmd().arg(flag).succeeds().stdout_only(expected); + } + for flag in ["--full-time", "--dired"] { + scene.ucmd().arg(flag).succeeds().stdout_contains("total 0"); + } +} + +#[test] +fn test_quoting_defaults() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.touch("a b"); + scene.ucmd().succeeds().stdout_only("a\\ b\n"); + scene.ucmd().arg("--zero").succeeds().stdout_only("a b\0"); + scene + .ucmd() + .env("QUOTING_STYLE", "literal") + .succeeds() + .stdout_only("a b\n"); + scene + .ucmd() + .env("QUOTING_STYLE", "literal") + .arg("-b") + .succeeds() + .stdout_only("a\\ b\n"); + scene + .ucmd() + .arg("--dired") + .succeeds() + .stdout_contains(" a\\ b\n"); +} + +#[test] +#[cfg(unix)] +fn test_literal_quoting_on_terminal() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.touch("a\nb"); + scene + .ucmd() + .env("QUOTING_STYLE", "literal") + .terminal_simulation(true) + .succeeds() + .stdout_only("a\r\nb\r\n"); +} + #[test] fn test_long_output() { let scene = TestScenario::new(util_name!()); @@ -83,3 +133,44 @@ fn test_version() { .no_stderr() .stdout_is(format!("dir {}\n", uucore::crate_version!())); } + +#[test] +#[cfg(target_os = "linux")] +fn test_write_error() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.touch("file"); + + scene + .ucmd() + .arg("file") + .set_stdout(std::fs::File::create("/dev/full").unwrap()) + .fails() + .stderr_is("dir: write error: No space left on device\n"); +} + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_unknown_unit_of_block_size() { + let result = new_ucmd!() + .terminal_sim_stderr() + .arg("--block-size=1fb") + .fails_with_code(2); + let stderr = result.stderr_as_displayed(); + + // The report names the utility as it was called, not `ls`. + assert!(stderr.contains("dir:1:"), "{stderr}"); + assert!(stderr.contains("not a known unit"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .arg("--block-size=1fb") + .fails_with_code(2) + .stderr_is("dir: invalid --block-size argument '1fb'\n"); + } +} diff --git a/tests/by-util/test_dircolors.rs b/tests/by-util/test_dircolors.rs index 871151fe3fc..3760124f286 100644 --- a/tests/by-util/test_dircolors.rs +++ b/tests/by-util/test_dircolors.rs @@ -2,13 +2,13 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore overridable colorterm + #[cfg(target_os = "linux")] use uutests::at_and_ucmd; use uutests::new_ucmd; -use dircolors::StrUtils; - #[test] #[cfg(target_os = "linux")] fn test_dircolors_non_utf8_paths() { @@ -30,20 +30,6 @@ fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); } -#[test] -fn test_str_utils() { - let s = " asd#zcv #hk\t\n "; - assert_eq!("asd#zcv", s.purify()); - - let s = "con256asd"; - assert!(s.fnmatch("*[2][3-6][5-9]?sd")); // spell-checker:disable-line - - let s = "zxc \t\nqwe jlk hjl"; // spell-checker:disable-line - let (k, v) = s.split_two(); - assert_eq!("zxc", k); - assert_eq!("qwe jlk hjl", v); -} - #[test] fn test1() { test_helper("test1", "gnome"); diff --git a/tests/by-util/test_dirname.rs b/tests/by-util/test_dirname.rs index 7c5684f17df..3ed660877ef 100644 --- a/tests/by-util/test_dirname.rs +++ b/tests/by-util/test_dirname.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use uutests::new_ucmd; #[test] @@ -263,3 +264,67 @@ fn test_dot_slash_component_preservation() { .succeeds() .stdout_is("/path/./to\n"); } + +#[test] +#[cfg(windows)] +fn test_win_backslash_as_separator() { + for (input, expected) in [ + // Relative paths + (r"foo\bar", "foo"), + (r"foo\bar\baz", r"foo\bar"), + // Mixed separators - whichever separator is "last" is removed + (r"C:\Users/foo", r"C:\Users"), + (r"C:/Users\foo", r"C:/Users"), + // Trailing backslashes stripped + (r"C:\Users\foo\", r"C:\Users"), + (r"\foo\bar\\", r"\foo"), + // Trailing `\.` pattern + (r"C:\foo\bar\.", r"C:\foo\bar"), + (r"\foo\.", r"\foo"), + // Repeated backslashes + (r"C:\foo\\bar", r"C:\foo"), + ] { + new_ucmd!() + .arg(input) + .succeeds() + .stdout_is(format!("{expected}\n")); + } + + // -z flag + new_ucmd!() + .args(&["-z", r"C:\Users\foo"]) + .succeeds() + .stdout_is("C:\\Users\u{0}"); +} + +#[test] +#[cfg(windows)] +fn test_win_path_prefixes() { + for (input, expected) in [ + // Disk: C:\foo → C:\ ; C:\ → C:\ ; C:foo → C: ; C: → C: + (r"C:\foo", "C:\\"), + (r"C:\", "C:\\"), + (r"C:foo", "C:"), + ("C:", "C:"), + // UNC: \\server\share\foo → \\server\share\ ; \\server\share → as-is + (r"\\server\share\foo", r"\\server\share\"), + (r"\\server\share", r"\\server\share"), + // VerbatimDisk: \\?\C:\foo → \\?\C:\ ; \\?\C:\ → \\?\C:\ + (r"\\?\C:\foo", r"\\?\C:\"), + (r"\\?\C:\", r"\\?\C:\"), + // VerbatimUNC: \\?\UNC\s\sh\foo → \\?\UNC\s\sh\ + (r"\\?\UNC\server\share\foo", r"\\?\UNC\server\share\"), + (r"\\?\UNC\server\share\", r"\\?\UNC\server\share\"), + // DeviceNS: \\.\dev\foo → \\.\dev\ ; \\.\dev → as-is + (r"\\.\PhysicalDrive0\foo", r"\\.\PhysicalDrive0\"), + (r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"), + // Root-only backslash (no prefix, just separator) + (r"\foo", "\\"), + ("\\", "\\"), + ] { + new_ucmd!() + .arg(input) + .succeeds() + .stdout_is(format!("{expected}\n")); + } +} diff --git a/tests/by-util/test_du.rs b/tests/by-util/test_du.rs index 08e920fdd2f..39cc4626997 100644 --- a/tests/by-util/test_du.rs +++ b/tests/by-util/test_du.rs @@ -4,15 +4,15 @@ // file that was distributed with this source code. // spell-checker:ignore (paths) atim sublink subwords azerty azeaze xcwww azeaz amaz azea qzerty tazerty tsublink testfile1 testfile2 filelist fpath testdir testfile -// spell-checker:ignore selfref ELOOP smallfile +// spell-checker:ignore selfref ELOOP smallfile xstrtoumax #[cfg(not(windows))] use regex::Regex; -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] use uutests::unwrap_or_return; use uutests::util::TestScenario; -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] use uutests::util::expected_result; use uutests::{at_and_ucmd, new_ucmd, util_name}; @@ -56,7 +56,7 @@ fn du_basics(s: &str) { assert_eq!(s, answer); } -#[cfg(target_os = "windows")] +#[cfg(windows)] fn du_basics(s: &str) { let answer = concat!( "0\t.\\subdir\\deeper\\deeper_dir\n", @@ -68,11 +68,7 @@ fn du_basics(s: &str) { assert_eq!(s, answer); } -#[cfg(all( - not(target_vendor = "apple"), - not(target_os = "windows"), - not(target_os = "openbsd") -))] +#[cfg(all(not(target_vendor = "apple"), not(target_os = "openbsd"), not(windows)))] fn du_basics(s: &str) { let answer = concat!( "8\t./subdir/deeper/deeper_dir\n", @@ -116,7 +112,7 @@ fn test_du_basics_subdir() { fn du_basics_subdir(s: &str) { assert_eq!(s, "4\tsubdir/deeper/deeper_dir\n8\tsubdir/deeper\n"); } -#[cfg(target_os = "windows")] +#[cfg(windows)] fn du_basics_subdir(s: &str) { assert_eq!(s, "0\tsubdir/deeper\\deeper_dir\n0\tsubdir/deeper\n"); } @@ -126,9 +122,9 @@ fn du_basics_subdir(s: &str) { } #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] fn du_basics_subdir(s: &str) { // MS-WSL linux has altered expected output @@ -522,7 +518,7 @@ fn test_du_soft_link() { println!("Output: {s}"); // Helper closure to assert output matches one of the valid sizes - #[cfg(any(target_vendor = "apple", target_os = "windows", target_os = "freebsd"))] + #[cfg(any(target_vendor = "apple", windows, target_os = "freebsd"))] let assert_valid_size = |output: &str, valid_sizes: &[&str]| { assert!( valid_sizes.contains(&output), @@ -541,7 +537,7 @@ fn test_du_soft_link() { assert_valid_size(s, &valid_sizes); } - #[cfg(target_os = "windows")] + #[cfg(windows)] { let valid_sizes = ["4\tsubdir/links\n", "8\tsubdir/links\n"]; assert_valid_size(s, &valid_sizes); @@ -555,11 +551,7 @@ fn test_du_soft_link() { assert_valid_size(s, &valid_sizes); } - #[cfg(all( - not(target_vendor = "apple"), - not(target_os = "windows"), - not(target_os = "freebsd") - ))] + #[cfg(all(not(target_vendor = "apple"), not(target_os = "freebsd"), not(windows)))] { // MS-WSL linux has altered expected output if uucore::os::is_wsl_1() { @@ -596,7 +588,7 @@ fn test_du_hard_link() { fn du_hard_link(s: &str) { assert_eq!(s, "12\tsubdir/links\n"); } -#[cfg(target_os = "windows")] +#[cfg(windows)] fn du_hard_link(s: &str) { assert_eq!(s, "8\tsubdir/links\n"); } @@ -606,10 +598,10 @@ fn du_hard_link(s: &str) { } #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "freebsd"), not(target_os = "openbsd"), - not(target_os = "android") + not(target_os = "android"), + not(windows) ))] fn du_hard_link(s: &str) { // MS-WSL linux has altered expected output @@ -642,7 +634,7 @@ fn test_du_d_flag() { fn du_d_flag(s: &str) { assert_eq!(s, "20\t./subdir\n24\t.\n"); } -#[cfg(target_os = "windows")] +#[cfg(windows)] fn du_d_flag(s: &str) { assert_eq!(s, "8\t.\\subdir\n8\t.\n"); } @@ -652,9 +644,9 @@ fn du_d_flag(s: &str) { } #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] fn du_d_flag(s: &str) { // MS-WSL linux has altered expected output @@ -716,7 +708,7 @@ fn test_du_dereference_args() { fn du_dereference(s: &str) { assert_eq!(s, "4\tsubdir/links/deeper_dir\n16\tsubdir/links\n"); } -#[cfg(target_os = "windows")] +#[cfg(windows)] fn du_dereference(s: &str) { assert_eq!(s, "0\tsubdir/links\\deeper_dir\n8\tsubdir/links\n"); } @@ -726,9 +718,9 @@ fn du_dereference(s: &str) { } #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] fn du_dereference(s: &str) { // MS-WSL linux has altered expected output @@ -740,10 +732,10 @@ fn du_dereference(s: &str) { } #[cfg(not(any( - target_os = "windows", target_os = "android", target_os = "freebsd", - target_os = "openbsd" + target_os = "openbsd", + windows )))] #[test] fn test_du_no_dereference() { @@ -792,13 +784,13 @@ fn test_du_inodes_basic() { let ts = TestScenario::new(util_name!()); let result = ts.ucmd().arg("--inodes").succeeds(); - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { let result_reference = unwrap_or_return!(expected_result(&ts, &["--inodes"])); assert_eq!(result.stdout_str(), result_reference.stdout_str()); } - #[cfg(target_os = "windows")] + #[cfg(windows)] assert_eq!( result.stdout_str(), concat!( @@ -823,9 +815,9 @@ fn test_du_inodes() { let result = ts.ucmd().arg("--separate-dirs").arg("--inodes").succeeds(); - #[cfg(target_os = "windows")] + #[cfg(windows)] result.stdout_contains("3\t.\\subdir\\links\n"); - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] result.stdout_contains("3\t./subdir/links\n"); result.stdout_contains("3\t.\n"); @@ -1155,7 +1147,232 @@ fn birth_supported() -> bool { m.created().is_ok() } -#[cfg(not(any(target_os = "windows", target_os = "openbsd")))] +#[cfg(feature = "touch")] +#[test] +fn test_du_time_directory() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.mkdir("d"); + at.touch("d/old"); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("202001010000") + .arg(at.plus("d/old")) + .succeeds(); + + at.touch("d/new"); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("202301010000") + .arg(at.plus("d/new")) + .succeeds(); + + // Backdate dir mtime to 2019-01-01 so the aggregated max comes from a child + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("201901010000") + .arg(at.plus("d")) + .succeeds(); + + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("d/old") + .succeeds(); + + result.stdout_only("0\t2020-01-01 00:00\td/old\n"); + + let result = ts.ucmd().env("TZ", "UTC").arg("--time").arg("d").succeeds(); + let stdout = result.stdout_str(); + + assert!( + stdout.contains("2023-01-01 00:00"), + "wrong time in: {stdout}" + ); + assert!(stdout.contains("\td\n"), "missing dir entry: {stdout}"); + + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("-a") + .arg("d") + .succeeds(); + let stdout = result.stdout_str(); + + #[cfg(not(windows))] + { + assert!(stdout.contains("2020-01-01 00:00\td/old\n"), "{stdout}"); + assert!(stdout.contains("2023-01-01 00:00\td/new\n"), "{stdout}"); + } + #[cfg(windows)] + { + assert!(stdout.contains("2020-01-01 00:00\td\\old\n"), "{stdout}"); + assert!(stdout.contains("2023-01-01 00:00\td\\new\n"), "{stdout}"); + } + assert!(stdout.contains("2023-01-01 00:00\td\n"), "{stdout}"); +} + +#[cfg(feature = "touch")] +#[test] +fn test_du_time_directory_nested() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.mkdir_all("d/sub"); + at.touch("d/old"); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("202001010000") + .arg(at.plus("d/old")) + .succeeds(); + + at.touch("d/sub/new"); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("202301010000") + .arg(at.plus("d/sub/new")) + .succeeds(); + + // Backdate dir mtime to 2019-01-01 so the aggregated max comes from a child + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("201901010000") + .arg(at.plus("d/sub")) + .succeeds(); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("201901010000") + .arg(at.plus("d")) + .succeeds(); + + // The root directory should show the max time from its subtree + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("-a") + .arg("d") + .succeeds(); + let stdout = result.stdout_str(); + + #[cfg(not(windows))] + { + assert!(stdout.contains("2020-01-01 00:00\td/old\n"), "{stdout}"); + assert!(stdout.contains("2023-01-01 00:00\td/sub/new\n"), "{stdout}"); + assert!(stdout.contains("2023-01-01 00:00\td/sub\n"), "{stdout}"); + } + #[cfg(windows)] + { + assert!(stdout.contains("2020-01-01 00:00\td\\old\n"), "{stdout}"); + assert!( + stdout.contains("2023-01-01 00:00\td\\sub\\new\n"), + "{stdout}" + ); + assert!(stdout.contains("2023-01-01 00:00\td\\sub\n"), "{stdout}"); + } + assert!(stdout.contains("2023-01-01 00:00\td\n"), "{stdout}"); +} + +#[cfg(feature = "touch")] +#[test] +fn test_du_time_atime() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.touch("f"); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-a") + .arg("-t") + .arg("202201010000") + .arg(at.plus("f")) + .succeeds(); + + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time=atime") + .arg("f") + .succeeds(); + result.stdout_only("0\t2022-01-01 00:00\tf\n"); +} + +#[cfg(feature = "touch")] +#[test] +fn test_du_time_unaffected_by_exclude() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.mkdir("d"); + at.touch("d/keep"); + at.touch("d/ignore"); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("202001010000") + .arg(at.plus("d/keep")) + .succeeds(); + + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("202301010000") + .arg(at.plus("d/ignore")) + .succeeds(); + + // Backdate dir mtime to 2019-01-01 so the aggregated max comes from a child + ts.ccmd("touch") + .env("TZ", "UTC") + .arg("-m") + .arg("-t") + .arg("201901010000") + .arg(at.plus("d")) + .succeeds(); + + // Exclude "ignore" so the reported time for "d" should reflect only "keep"'s mtime + let result = ts + .ucmd() + .env("TZ", "UTC") + .arg("--time") + .arg("--exclude=ignore") + .arg("d") + .succeeds(); + let stdout = result.stdout_str(); + + assert!( + stdout.contains("2020-01-01 00:00"), + "wrong time in: {stdout}" + ); + assert!(stdout.contains("\td\n"), "missing dir entry: {stdout}"); +} + +#[cfg(not(any(windows, target_os = "openbsd")))] #[cfg(feature = "chmod")] #[test] fn test_du_no_permission() { @@ -1186,7 +1403,7 @@ fn test_du_no_permission() { assert_eq!(result.stdout_str(), "0\tsubdir/links\n"); } -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] #[cfg(feature = "chmod")] #[test] fn test_du_no_exec_permission() { @@ -1285,9 +1502,19 @@ fn test_du_invalid_threshold() { ts.ucmd().arg(format!("--threshold={threshold}")).fails(); } +/// GNU du hands `--threshold` to `xstrtoumax` without trimming, so a padded +/// value is rejected rather than read as the size it surrounds. +#[test] +fn test_du_threshold_with_leading_whitespace() { + new_ucmd!() + .arg("--threshold= -1K") + .fails() + .stderr_only("du: invalid --threshold argument ' -1K'\n"); +} + #[test] fn test_du_threshold_error_handling() { - // Test missing threshold value - the specific case from GNU test + // Test missing threshold value new_ucmd!() .arg("--threshold") .fails() @@ -1308,7 +1535,7 @@ fn test_du_apparent_size() { let result = ucmd.args(&["--apparent-size", "--all", "a"]).succeeds(); - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { result.stdout_contains_line("1\ta/b/file2"); result.stdout_contains_line("1\ta/b/file1"); @@ -1316,7 +1543,7 @@ fn test_du_apparent_size() { result.stdout_contains_line("1\ta"); } - #[cfg(target_os = "windows")] + #[cfg(windows)] { result.stdout_contains_line("1\ta\\b\\file2"); result.stdout_contains_line("1\ta\\b\\file1"); @@ -1336,7 +1563,7 @@ fn test_du_bytes() { let result = ucmd.args(&["--bytes", "--all", "a"]).succeeds(); - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { result.stdout_contains_line("6\ta/b/file2"); result.stdout_contains_line("3\ta/b/file1"); @@ -1344,7 +1571,7 @@ fn test_du_bytes() { result.stdout_contains_line("9\ta"); } - #[cfg(target_os = "windows")] + #[cfg(windows)] { result.stdout_contains_line("6\ta\\b\\file2"); result.stdout_contains_line("3\ta\\b\\file1"); @@ -1370,7 +1597,7 @@ fn test_du_exclude() { .arg("--exclude=subdir") .arg("subdir") .succeeds() - .stdout_is(""); + .no_output(); ts.ucmd() .arg("--exclude=subdir") .arg("--verbose") @@ -1382,7 +1609,7 @@ fn test_du_exclude() { #[test] // Disable on Windows because we are looking for / // And the tests would be more complex if we have to support \ too -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] fn test_du_exclude_2() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1433,7 +1660,7 @@ fn test_du_exclude_2() { #[test] // Disable on Windows because we are looking for / // And the tests would be more complex if we have to support \ too -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] fn test_du_exclude_mix() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1488,7 +1715,7 @@ fn test_du_exclude_mix() { #[test] // Disable on Windows because we are looking for / // And the tests would be more complex if we have to support \ too -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] fn test_du_complex_exclude_patterns() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1598,7 +1825,7 @@ fn test_du_symlink_multiple_fail() { #[test] // Disable on Windows because of different path separators and handling of null characters -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] fn test_du_files0_from() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1663,8 +1890,7 @@ fn test_du_files0_from_missing_file_listed_twice() { ts.ucmd() .arg("--files0-from=filelist") .fails_with_code(1) - .stdout_is("") - .stderr_is( + .stderr_only( "du: cannot access 'missing': No such file or directory\n\ du: cannot access 'missing': No such file or directory\n", ); @@ -2007,7 +2233,7 @@ fn test_du_safe_traversal_with_symlinks() { assert!(!result.stdout_str().is_empty()); } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] fn test_du_inaccessible_directory() { // tested by tests/du/no-x let ts = TestScenario::new(util_name!()); @@ -2064,7 +2290,7 @@ fn test_du_long_symlink_chain() { } #[test] -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(all(unix, not(target_vendor = "apple")))] fn test_du_bind_mount_simulation() { // Simulate bind mount scenario using hard links where possible // Note: This test simulates what bind mounts do - making the same directory @@ -2108,7 +2334,7 @@ fn test_du_symlink_depth_tracking() { #[test] #[cfg(target_os = "linux")] fn test_du_long_path_from_unreadable() { - // Test the specific scenario from GNU's long-from-unreadable.sh test + // Test du behavior with unreadable directories // This verifies that du can handle very long paths when the current directory is unreadable use std::env; use std::fs; @@ -2117,7 +2343,7 @@ fn test_du_long_path_from_unreadable() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; - // Create a deep hierarchy similar to the GNU test + // Create a deep hierarchy // Use a more reasonable depth for unit tests let dir_name = "x".repeat(200); let mut current_path = String::new(); @@ -2166,7 +2392,7 @@ fn test_du_long_path_from_unreadable() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(all(unix, not(target_os = "android")))] fn test_du_hard_links_multiple_dirs_in_args() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -2183,7 +2409,7 @@ fn test_du_hard_links_multiple_dirs_in_args() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(all(unix, not(target_os = "android")))] fn test_du_hard_links_multiple_links_in_args() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -2202,7 +2428,7 @@ fn test_du_hard_links_multiple_links_in_args() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] fn test_du_symlinks_multiple_links_in_args() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -2481,6 +2707,30 @@ fn test_du_repeated_t() { .succeeds(); } +#[test] +fn test_du_negative_max_depth_is_rejected() { + // A negative depth must reach du's own parser instead of being taken for + // an option by the argument parser. + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.mkdir("deep"); + at.mkdir("deep/deeper"); + + for depth in ["-7", "-1"] { + ts.ucmd() + .args(&["-d", depth, "deep"]) + .fails_with_code(1) + .no_stdout() + .stderr_contains(format!("du: invalid maximum depth '{depth}'")); + } + + ts.ucmd() + .args(&["--max-depth=-3", "deep"]) + .fails_with_code(1) + .no_stdout() + .stderr_contains("du: invalid maximum depth '-3'"); +} + #[test] fn test_du_repeated_v() { new_ucmd!().args(&["-s", "-v", "-v"]).succeeds(); @@ -2581,3 +2831,110 @@ fn test_du_repeated_time_style() { .succeeds(); result.stdout_only("0\t2016-06-16 00:00:00.000000000 +0000\tdate_test\n"); } + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_unknown_unit_of_block_size() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-B", "1fb"]) + .fails_with_code(1); + + // The number parsed; only the unit did not. + assert_eq!( + result.stderr_as_displayed(), + "\ +du: invalid suffix in --block-size argument '1fb' + ╭─[ du:1:8 ] + │ + 1 │ du -B 1fb + │ ─┬ + │ ╰── not a known unit + │ + │ Help: a size is a number and an optional unit: K, M, G and so on for 1024, KB, MB, GB for 1000 +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_inside_an_attached_block_size() { + let result = new_ucmd!() + .terminal_sim_stderr() + .arg("--block-size=1fb") + .fails_with_code(1); + + assert_eq!(result.caret_column(), Some(18)); + assert!( + result.stderr_as_displayed().contains("not a known unit"), + "{}", + result.stderr_as_displayed() + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_a_zero_block_size() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-B", "0"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + // Zero parses as a number but is not a usable block size, so the + // whole value is underlined and nothing is said about the unit. + assert!( + stderr.starts_with("du: invalid --block-size argument '0'"), + "{stderr}" + ); + assert_eq!(result.caret_column(), Some(7)); + assert!(!stderr.contains("not a known unit"), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_the_sign_of_a_rejected_threshold() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-t", "-0"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + // '-0' is rejected for the sign and the zero together, so the caret + // covers both. + assert!( + stderr.starts_with("du: invalid --threshold argument '-0'"), + "{stderr}" + ); + assert_eq!(result.caret_column(), Some(7)); + } + + #[cfg(unix)] + #[test] + fn test_snippet_skips_the_sign_of_a_threshold_with_an_unknown_unit() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-t", "-1fb"]) + .fails_with_code(1); + + // Here only the unit is at fault, so the sign is not underlined. + assert_eq!(result.caret_column(), Some(9)); + assert!( + result.stderr_as_displayed().contains("not a known unit"), + "{}", + result.stderr_as_displayed() + ); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["-B", "1fb"]) + .fails_with_code(1) + .stderr_is("du: invalid suffix in --block-size argument '1fb'\n"); + } +} diff --git a/tests/by-util/test_echo.rs b/tests/by-util/test_echo.rs index 860b7250f8d..827005ba278 100644 --- a/tests/by-util/test_echo.rs +++ b/tests/by-util/test_echo.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (words) araba merci efjkow use regex::Regex; @@ -199,6 +200,77 @@ fn test_escape_vertical_tab() { .stdout_only("\x0B\n"); } +#[test] +fn test_escape_unicode_and_quote_not_recognized() { + // `echo -e` has no `\u`, `\U` or `\"` escape, unlike `printf`: the + // backslash and the character following it are printed as given. + for arg in [ + r"\u0041", + r"\U00000041", + r"\U0001F600", + r#"\""#, + r"a\u0041b", + r"a\U00000041b", + r#"a\"b"#, + r"\u\u", + ] { + new_ucmd!() + .args(&["-e", arg]) + .succeeds() + .stdout_only(format!("{arg}\n")); + } +} + +#[test] +fn test_escape_malformed_unicode_not_recognized() { + // Truncated, non-hexadecimal, surrogate and out-of-range payloads are not + // special either: nothing is consumed and no error is reported. + for arg in [ + r"\u", + r"\U", + r"\u00", + r"\U0000004", + r"\u00zz", + r"\U000000zz", + r"\uD800", + r"\U00110000", + ] { + new_ucmd!() + .args(&["-e", arg]) + .succeeds() + .stdout_only(format!("{arg}\n")); + } +} + +#[test] +fn test_disable_escapes_unicode_and_quote() { + let input_str = r#"\u0041 \U00000041 \""#; + new_ucmd!() + .arg("-E") + .arg(input_str) + .succeeds() + .stdout_only(format!("{input_str}\n")); +} + +#[test] +fn test_escape_recognized_sequences_still_expand() { + // The sequences the GNU manual does list for `echo -e` are unaffected. + new_ucmd!() + .args(&["-e", r"\a\b\e\f\n\r\t\v\\"]) + .succeeds() + .stdout_only("\x07\x08\x1B\x0C\n\r\t\x0B\\\n"); + + new_ucmd!() + .args(&["-e", r"\0101\101\x41"]) + .succeeds() + .stdout_only("AAA\n"); + + new_ucmd!() + .args(&["-e", r"a\cb", "c"]) + .succeeds() + .stdout_only("a"); +} + #[test] fn test_disable_escapes() { let input_str = "\\a \\\\ \\b \\r \\e \\f \\x41 \\n a\\cb \\u0100 \\t \\v"; @@ -610,7 +682,7 @@ fn nine_bit_octal() { } #[test] -#[cfg(target_family = "unix")] +#[cfg(unix)] #[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn non_utf_8() { use std::ffi::OsStr; diff --git a/tests/by-util/test_eintr_handling.rs b/tests/by-util/test_eintr_handling.rs index f195ff5823f..dbdd1a7264f 100644 --- a/tests/by-util/test_eintr_handling.rs +++ b/tests/by-util/test_eintr_handling.rs @@ -3,6 +3,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore EINTR worl + //! Tests for EINTR (ErrorKind::Interrupted) handling across utilities //! //! This module provides test utilities and integration tests to verify that @@ -20,7 +22,6 @@ //! //! # Note //! EINTR is a POSIX error code for interrupted system calls -//! cspell:ignore EINTR worl use std::io::{self, Read, Write}; diff --git a/tests/by-util/test_env.rs b/tests/by-util/test_env.rs index 8756550fa22..e550450aa5f 100644 --- a/tests/by-util/test_env.rs +++ b/tests/by-util/test_env.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (words) bamf chdir rlimit prlimit COMSPEC cout cerr FFFD winsize xpixel ypixel Secho sighandler + +// spell-checker:ignore (words) bamf chdir rlimit prlimit COMSPEC cout cerr FFFD winsize xpixel ypixel Secho sighandler putenv + #![allow(clippy::missing_errors_doc)] #[cfg(unix)] @@ -53,9 +55,9 @@ impl Target { .expect("failed to send signal") .wait(); // macOS CI needs a longer delay for process teardown after signal delivery - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] self.child.delay(500); - #[cfg(not(target_os = "macos"))] + #[cfg(not(target_vendor = "apple"))] self.child.delay(100); } fn is_alive(&mut self) -> bool { @@ -75,7 +77,7 @@ fn test_invalid_arg() { } #[test] -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] fn test_flags_after_command() { new_ucmd!() // This would cause an error if -u=v were processed because it's malformed @@ -84,14 +86,14 @@ fn test_flags_after_command() { .no_stderr() .stdout_is("-u=v\n"); + // spell-checker:disable new_ucmd!() // Ensure the string isn't split - // cSpell:disable .args(&["printf", "%s-%s", "-Sfoo bar"]) .succeeds() .no_stderr() .stdout_is("-Sfoo bar-"); - // cSpell:enable + // spell-checker:enable new_ucmd!() // Ensure -- is recognized @@ -159,9 +161,9 @@ fn test_env_permissions() { #[test] fn test_echo() { - #[cfg(target_os = "windows")] + #[cfg(windows)] let args = ["cmd", "/d/c", "echo"]; - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] let args = ["echo"]; let result = new_ucmd!().args(&args).arg("FOO-bar").succeeds(); @@ -169,7 +171,7 @@ fn test_echo() { assert_eq!(result.stdout_str().trim(), "FOO-bar"); } -#[cfg(target_os = "windows")] +#[cfg(windows)] #[test] fn test_if_windows_batch_files_can_be_executed() { let result = new_ucmd!().arg("./runBat.bat").succeeds(); @@ -274,53 +276,6 @@ fn test_debug2_part_of_string_arg() { ); } -#[test] -fn test_file_option() { - let out = new_ucmd!() - .arg("-f") - .arg("vars.conf.txt") - .succeeds() - .stdout_move_str(); - - assert_eq!( - out.lines() - .filter(|&line| line == "FOO=bar" || line == "BAR=bamf this") - .count(), - 2 - ); -} - -#[test] -fn test_combined_file_set() { - let out = new_ucmd!() - .arg("-f") - .arg("vars.conf.txt") - .arg("FOO=bar.alt") - .succeeds() - .stdout_move_str(); - - assert_eq!(out.lines().filter(|&line| line == "FOO=bar.alt").count(), 1); -} - -#[test] -fn test_combined_file_set_unset() { - let out = new_ucmd!() - .arg("-u") - .arg("BAR") - .arg("-f") - .arg("vars.conf.txt") - .arg("FOO=bar.alt") - .succeeds() - .stdout_move_str(); - - assert_eq!( - out.lines() - .filter(|&line| line == "FOO=bar.alt" || line.starts_with("BAR=")) - .count(), - 1 - ); -} - #[test] fn test_unset_invalid_variables() { use uucore::display::Quotable; @@ -375,6 +330,31 @@ fn test_empty_name() { .stderr_only("env: warning: no name specified for value 'xyz'\n"); } +// An empty name is dropped with a warning (GNU injects it via putenv, we +// can't with set_var); the other assignments must still go through. +#[test] +fn test_empty_name_does_not_affect_other_assignments() { + let result = new_ucmd!() + .args(&["-i", "MOTOR=idle", "=zap", "GEARBOX=locked"]) + .succeeds(); + result.stderr_is("env: warning: no name specified for value 'zap'\n"); + + // Windows sorts the environment block, so don't depend on the order. + let mut vars: Vec<_> = result.stdout_str().lines().collect(); + vars.sort_unstable(); + assert_eq!(vars, ["GEARBOX=locked", "MOTOR=idle"]); +} + +// "=" alone is name and value both empty; still just a warning, exit 0. +#[test] +fn test_empty_name_and_empty_value() { + new_ucmd!() + .args(&["-i", "="]) + .succeeds() + .no_stdout() + .stderr_is("env: warning: no name specified for value ''\n"); +} + #[test] fn test_null_delimiter() { let out = new_ucmd!() @@ -484,36 +464,7 @@ fn test_fail_change_directory() { assert!(out.contains("env: cannot change directory to ")); } -#[test] -fn test_chdir_happens_after_relative_file_loading() { - let scene = TestScenario::new(util_name!()); - scene.fixtures.mkdir("target"); - scene - .fixtures - .write("config.env", "CONFIG_SOURCE=from-root\n"); - scene - .fixtures - .write("target/config.env", "CONFIG_SOURCE=from-target\n"); - - let out = scene - .ucmd() - .args(&["--chdir", "target", "--file", "config.env", "-i"]) - .arg(uutests::util::get_tests_binary()) - .arg(util_name!()) - .succeeds() - .stdout_move_str(); - - assert!( - out.contains("CONFIG_SOURCE=from-root\n"), - "expected config file from invocation directory, got: {out:?}" - ); - assert!( - !out.contains("CONFIG_SOURCE=from-target\n"), - "unexpectedly loaded config from --chdir target directory: {out:?}" - ); -} - -#[cfg(not(target_os = "windows"))] // windows has no executable "echo", its only supported as part of a batch-file +#[cfg(not(windows))] // windows has no executable "echo", its only supported as part of a batch-file #[test] fn test_split_string_into_args_one_argument_no_quotes() { let scene = TestScenario::new(util_name!()); @@ -526,7 +477,7 @@ fn test_split_string_into_args_one_argument_no_quotes() { assert_eq!(out, "hello world\n"); } -#[cfg(not(target_os = "windows"))] // windows has no executable "echo", its only supported as part of a batch-file +#[cfg(not(windows))] // windows has no executable "echo", its only supported as part of a batch-file #[test] fn test_split_string_into_args_one_argument() { let scene = TestScenario::new(util_name!()); @@ -539,7 +490,7 @@ fn test_split_string_into_args_one_argument() { assert_eq!(out, "hello world\n"); } -#[cfg(not(target_os = "windows"))] // windows has no executable "echo", its only supported as part of a batch-file +#[cfg(not(windows))] // windows has no executable "echo", its only supported as part of a batch-file #[test] fn test_split_string_into_args_s_escaping_challenge() { let scene = TestScenario::new(util_name!()); @@ -563,7 +514,7 @@ fn test_split_string_into_args_s_escaped_c_not_allowed() { ); } -#[cfg(not(target_os = "windows"))] // no printf available +#[cfg(not(windows))] // no printf available #[test] fn test_split_string_into_args_s_whitespace_handling() { let scene = TestScenario::new(util_name!()); @@ -576,7 +527,7 @@ fn test_split_string_into_args_s_whitespace_handling() { assert_eq!(out, "xAx\nxBx\n"); } -#[cfg(not(target_os = "windows"))] // no printf available +#[cfg(not(windows))] // no printf available #[test] fn test_split_string_into_args_long_option_whitespace_handling() { let scene = TestScenario::new(util_name!()); @@ -589,7 +540,7 @@ fn test_split_string_into_args_long_option_whitespace_handling() { assert_eq!(out, "xAx\nxBx\n"); } -#[cfg(not(target_os = "windows"))] // no printf available +#[cfg(not(windows))] // no printf available #[test] fn test_split_string_option_forms_match_gnu_required_argument_handling() { let scene = TestScenario::new(util_name!()); @@ -613,7 +564,7 @@ fn test_split_string_option_forms_match_gnu_required_argument_handling() { .stdout_is("x:one\nx:two\n"); } -#[cfg(not(target_os = "windows"))] // no printf available +#[cfg(not(windows))] // no printf available #[test] fn test_split_string_into_args_debug_output_whitespace_handling() { let scene = TestScenario::new(util_name!()); @@ -632,21 +583,21 @@ fn test_split_string_into_args_debug_output_whitespace_handling() { } // FixMe: This test fails on MACOS: -// thread 'test_env::test_gnu_e20' panicked at 'assertion failed: `(left == right)` -// left: `"A=B C=D\n__CF_USER_TEXT_ENCODING=0x1F5:0x0:0x0\n"`, -// right: `"A=B C=D\n"`', tests/by-util/test_env.rs:369:5 -#[cfg(not(target_os = "macos"))] +// thread 'test_env::test_env_split_quoted_with_backslash_space' panicked at 'assertion failed: `(left == right)` +// left: `"X=Y Z=W\n__CF_USER_TEXT_ENCODING=0x1F5:0x0:0x0\n"`, +// right: `"X=Y Z=W\n"`', tests/by-util/test_env.rs:369:5 +#[cfg(not(target_vendor = "apple"))] #[test] -fn test_gnu_e20() { +fn test_env_split_quoted_with_backslash_space() { let scene = TestScenario::new(util_name!()); let env_bin = String::from(uutests::util::get_tests_binary()) + " " + util_name!(); let input = [ String::from("-i"), - String::from(r#"-SA="B\_C=D" "#) + env_bin.escape_default().to_string().as_str() + "", + String::from(r#"-SX="Y\_Z=W" "#) + env_bin.escape_default().to_string().as_str() + "", ]; - let mut output = "A=B C=D\n".to_string(); + let mut output = "X=Y Z=W\n".to_string(); // Workaround for the test to pass when coverage is being run. // If enabled, the binary called by env_bin will most probably be @@ -657,10 +608,17 @@ fn test_gnu_e20() { } let out = scene.ucmd().args(&input).succeeds(); - assert_eq!(out.stdout_str(), output); + // Windows hands PROCESSOR_ARCHITECTURE to every process it starts, so it + // shows up even in the empty environment `-i` asks for (seen on arm64). + let stdout: Vec<&str> = out + .stdout_str() + .lines() + .filter(|line| !line.starts_with("PROCESSOR_ARCHITECTURE=")) + .collect(); + assert_eq!(stdout, output.lines().collect::>()); } -#[cfg(not(target_os = "windows"))] // no printf available +#[cfg(not(windows))] // no printf available #[test] fn test_split_string_single_quotes_keep_unknown_backslash_sequences_literal() { let scene = TestScenario::new(util_name!()); @@ -696,7 +654,7 @@ fn test_split_string_single_quotes_keep_unknown_backslash_sequences_literal() { .stdout_is("\\9"); } -#[cfg(not(target_os = "windows"))] // no printf available +#[cfg(not(windows))] // no printf available #[test] fn test_split_string_backslash_a_behavior_matches_gnu_quoting_context() { let scene = TestScenario::new(util_name!()); @@ -830,8 +788,7 @@ fn test_env_overwrite_arg0() { ts.ucmd() .args(&["--argv0", "hijacked", "sh", "-c", "echo $0"]) .succeeds() - .stdout_is("hijacked\n") - .stderr_is(""); + .stdout_only("hijacked\n"); } // Do not assume that coreutils uses argv0 @@ -845,32 +802,28 @@ fn test_env_arg_argv0_overwrite() { .args(&["--argv0", "dirname"]) .args(&["--argv0", "hijacked", "sh", "-c", "echo $0"]) .succeeds() - .stdout_is("hijacked\n") - .stderr_is(""); + .stdout_only("hijacked\n"); // overwrite -a by -a ts.ucmd() .args(&["-a", "dirname"]) .args(&["-a", "hijacked", "sh", "-c", "echo $0"]) .succeeds() - .stdout_is("hijacked\n") - .stderr_is(""); + .stdout_only("hijacked\n"); // overwrite --argv0 by -a ts.ucmd() .args(&["--argv0", "dirname"]) .args(&["-a", "hijacked", "sh", "-c", "echo $0"]) .succeeds() - .stdout_is("hijacked\n") - .stderr_is(""); + .stdout_only("hijacked\n"); // overwrite -a by --argv0 ts.ucmd() .args(&["-a", "dirname"]) .args(&["--argv0", "hijacked", "sh", "-c", "echo $0"]) .succeeds() - .stdout_is("hijacked\n") - .stderr_is(""); + .stdout_only("hijacked\n"); } // Do not assume that coreutils uses argv0 @@ -884,31 +837,27 @@ fn test_env_arg_argv0_overwrite_mixed_with_string_args() { .args(&["-S--argv0 dirname"]) .args(&["--argv0", "hijacked", "sh", "-c", "echo $0"]) .succeeds() - .stdout_is("hijacked\n") - .stderr_is(""); + .stdout_only("hijacked\n"); // normal following string arg ts.ucmd() .args(&["-a", "dirname"]) .args(&["-S-a hijacked sh -c 'echo $0'"]) .succeeds() - .stdout_is("hijacked\n") - .stderr_is(""); + .stdout_only("hijacked\n"); // one large string arg ts.ucmd() .args(&["-S--argv0 dirname -a hijacked sh -c 'echo $0'"]) .succeeds() - .stdout_is("hijacked\n") - .stderr_is(""); + .stdout_only("hijacked\n"); // two string args ts.ucmd() .args(&["-S-a dirname"]) .args(&["-S--argv0 hijacked sh -c 'echo $0'"]) .succeeds() - .stdout_is("hijacked\n") - .stderr_is(""); + .stdout_only("hijacked\n"); // three args: normal, string, normal ts.ucmd() @@ -916,8 +865,7 @@ fn test_env_arg_argv0_overwrite_mixed_with_string_args() { .args(&["-S-a dirname"]) .args(&["-a", "hijacked", "sh", "-c", "echo $0"]) .succeeds() - .stdout_is("hijacked\n") - .stderr_is(""); + .stdout_only("hijacked\n"); } #[test] @@ -1481,7 +1429,7 @@ mod test_raw_string_parser { string_parser, }; - const LEN_OWL: usize = if cfg!(target_os = "windows") { 2 } else { 4 }; + const LEN_OWL: usize = if cfg!(windows) { 2 } else { 4 }; #[test] fn test_ascii_only_take_one_look_at_correct_data_and_end_behavior() { @@ -1694,7 +1642,7 @@ mod test_raw_string_parser { fn test_deal_with_invalid_encoding() { let owl_invalid_part; let (brace_1, brace_2); - #[cfg(target_os = "windows")] + #[cfg(windows)] { let mut buffer = [0u16; 2]; let owl = '🦉'.encode_utf16(&mut buffer); @@ -1702,7 +1650,7 @@ mod test_raw_string_parser { brace_1 = '<'.encode_utf16(&mut buffer).to_vec(); brace_2 = '>'.encode_utf16(&mut buffer).to_vec(); } - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { let mut buffer = [0u8; 4]; let owl = '🦉'.encode_utf8(&mut buffer); @@ -1972,7 +1920,7 @@ fn test_shebang_error() { } #[test] -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] fn test_reject_shell_style_variable_expansions() { for split in [ "-Secho $TEST_VAR_12345", @@ -1989,7 +1937,7 @@ fn test_reject_shell_style_variable_expansions() { } #[test] -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] fn test_simple_braced_variable() { new_ucmd!() .env("TEST_VAR_12345", "value") @@ -2139,3 +2087,102 @@ fn test_env_disallow_double_underscore_all() { .fails() .stderr_contains("invalid signal"); } + +#[cfg(unix)] +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[test] + fn test_snippet_points_at_the_unterminated_quote() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-S", "echo 'unterminated"]) + .fails_with_code(125); + + // The string is echoed back quoted, since it holds spaces; the caret + // still lands inside it, on the run that never closes. + assert_eq!( + result.stderr_as_displayed(), + "\ +env: no terminating quote in -S string at position 18 for quote ''' + ╭─[ env:1:9 ] + │ + 1 │ env -S \"echo 'unterminated\" + │ ────────────────── + │ + │ Help: -S quotes as the shell does: ' and \" come in pairs, and \\' escapes a quote +───╯" + ); + } + + #[test] + fn test_snippet_points_at_the_invalid_escape() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-S", "echo \\q"]) + .fails_with_code(125); + let stderr = result.stderr_as_displayed(); + + // The caret covers the backslash and the character it was meant to + // escape, not just the one the parser stopped on. + assert!(stderr.contains("invalid sequence '\\q'"), "{stderr}"); + assert!(stderr.contains(" 1 │ env -S 'echo \\q'"), "{stderr}"); + assert!(stderr.contains("\n │ ──"), "{stderr}"); + } + + #[test] + fn test_snippet_points_at_the_whole_variable_reference() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-S", "echo ${1FOO}"]) + .fails_with_code(125); + let stderr = result.stderr_as_displayed(); + + // Raised on the digit, but the caret covers the reference from its + // `$`, which is what the reader has to fix. + assert!(stderr.contains("only ${VARNAME} expansion"), "{stderr}"); + assert!( + stderr.contains("a variable name cannot start with a digit"), + "{stderr}" + ); + assert!(stderr.contains("\n │ ─┬─"), "{stderr}"); + } + + #[test] + fn test_snippet_follows_the_string_when_it_is_glued_to_the_option() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-Secho ${FOO"]) + .fails_with_code(125); + let stderr = result.stderr_as_displayed(); + + // `-Secho …` carries the string in the same argument, so the caret + // shifts by the two columns the option takes. + assert!(stderr.contains("this { is never closed"), "{stderr}"); + assert!(stderr.contains(" 1 │ env '-Secho ${FOO'"), "{stderr}"); + assert!(stderr.contains("\n │ ──┬──"), "{stderr}"); + } + + #[test] + fn test_snippet_points_into_the_right_argument() { + // The same text appears as the command env is asked to run; the caret + // belongs to the -S string, which is where the parse failed. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-S", "echo \\q", "echo \\q"]) + .fails_with_code(125); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("env:1:14"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + // The harness pipes stderr, so the report must not appear. + new_ucmd!() + .args(&["-S", "echo 'unterminated"]) + .fails_with_code(125) + .stderr_is("env: no terminating quote in -S string at position 18 for quote '''\n"); + } +} diff --git a/tests/by-util/test_expand.rs b/tests/by-util/test_expand.rs index 9a7d7c2cecd..49d19bbe90e 100644 --- a/tests/by-util/test_expand.rs +++ b/tests/by-util/test_expand.rs @@ -2,9 +2,11 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + +// spell-checker:ignore (ToDO) taaaa tbbbb tcccc + use uucore::display::Quotable; use uutests::new_ucmd; -// spell-checker:ignore (ToDO) taaaa tbbbb tcccc #[test] fn test_invalid_arg() { @@ -66,21 +68,15 @@ fn test_with_multiple_files() { } #[test] -fn test_tabs_space_separated_list() { - new_ucmd!() - .args(&["--tabs", "3 6 9"]) - .pipe_in("a\tb\tc\td\te") - .succeeds() - .stdout_is("a b c d e"); -} - -#[test] -fn test_tabs_mixed_style_list() { - new_ucmd!() - .args(&["--tabs", ", 3,6 9"]) - .pipe_in("a\tb\tc\td\te") - .succeeds() - .stdout_is("a b c d e"); +fn test_tabs() { + for list in ["3 6 9", "3,6,9", "3\t6\t9", ", \t3,\t6 9,"] { + new_ucmd!() + .arg("--tabs") + .arg(list) + .pipe_in("a\tb\tc\td\te") + .succeeds() + .stdout_is("a b c d e"); + } } #[test] @@ -280,12 +276,12 @@ fn test_tabs_with_too_large_size() { )] #[test] fn test_large_tab_stop_without_tabs_does_not_allocate() { - use rlimit::Resource; + use rustix::process::Resource; const AS_LIMIT: u64 = 200 * 1024 * 1024; new_ucmd!() - .limit(Resource::AS, AS_LIMIT, AS_LIMIT) + .limit(Resource::As, AS_LIMIT, AS_LIMIT) .arg("--tabs=267672676527678256") .pipe_in("hello\n") .succeeds() @@ -473,6 +469,24 @@ fn test_expand_non_utf8_paths() { .stdout_is("hello world\ntest line\n"); } +#[test] +fn test_wide_multibyte_char_width() { + // A tab following a wide character must expand to the next tab stop based + // on the character's display width, not its byte length. Regression for a + // bug where the UTF-8 sequence length was derived from the leading byte's + // codepoint value, so 3- and 4-byte characters were mis-measured. + // U+4E2D (中, 3 bytes, width 2): col 0->2, tab pads 6 spaces to column 8. + new_ucmd!() + .pipe_in("\u{4E2D}\t|".as_bytes()) + .succeeds() + .stdout_is("\u{4E2D} |"); + // U+1F600 (😀, 4 bytes, width 2) behaves the same. + new_ucmd!() + .pipe_in("\u{1F600}\t|".as_bytes()) + .succeeds() + .stdout_is("\u{1F600} |"); +} + #[test] fn test_buffered_reads_new_line_no_tabs_in_first_chunk() { // test includes tabs after 1 full chunk of no tabs with new line diff --git a/tests/by-util/test_expr.rs b/tests/by-util/test_expr.rs index 36f877da1cf..9cb2a118d9c 100644 --- a/tests/by-util/test_expr.rs +++ b/tests/by-util/test_expr.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore αbcdef ; (people) kkos // spell-checker:ignore aabcccd aabcd aabd abbb abbbd abbcabc abbcac abbcbbbd abbcbd // spell-checker:ignore abbccd abcabc abcac acabc andand bigcmp bignum emptysub @@ -329,8 +330,9 @@ fn test_regex_caret() { .args(&["ab[^c]", ":", "ab\\[^c]"]) .succeeds() .stdout_only("6\n"); + // Use `[^x]` to avoid Windows `wild` glob matching the runner's `C:\a` directory new_ucmd!() - .args(&["[^a]", ":", "\\[^a]"]) + .args(&["[^x]", ":", "\\[^x]"]) .succeeds() .stdout_only("4\n"); new_ucmd!() @@ -497,6 +499,14 @@ fn test_regex_catastrophic_backtracking() { .stdout_only("\n"); } +#[test] +fn test_regex_leftmost_longest_match_semantics() { + new_ucmd!() + .args(&["ab", ":", "a\\|ab"]) + .succeeds() + .stdout_only("2\n"); +} + #[test] fn test_substr() { new_ucmd!() @@ -571,6 +581,18 @@ fn test_invalid_substr() { .stdout_only("\n"); } +#[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: usize is 32-bit, the host usize::MAX does not parse" +)] +fn test_substr_large_length_capacity_overflow() { + new_ucmd!() + .args(&["substr", "abc", "1", &usize::MAX.to_string()]) + .succeeds() + .stdout_only("abc\n"); +} + #[test] fn test_escape() { new_ucmd!().args(&["+", "1"]).succeeds().stdout_only("1\n"); @@ -681,144 +703,144 @@ fn test_deeply_nested_length() { new_ucmd!().args(&args).succeeds().stdout_is("1\n"); } -/// Regroup the testcases of the GNU test expr.pl -mod gnu_expr { +/// Test cases for expr arithmetic and string operations +mod expr_arithmetic { use uutests::new_ucmd; #[test] - fn test_a() { + fn test_addition() { new_ucmd!() - .args(&["5", "+", "6"]) + .args(&["8", "+", "5"]) .succeeds() - .stdout_only("11\n"); + .stdout_only("13\n"); } #[test] - fn test_b() { + fn test_subtraction() { new_ucmd!() - .args(&["5", "-", "6"]) + .args(&["9", "-", "4"]) .succeeds() - .stdout_only("-1\n"); + .stdout_only("5\n"); } #[test] - fn test_c() { + fn test_multiplication() { new_ucmd!() - .args(&["5", "*", "6"]) + .args(&["7", "*", "6"]) .succeeds() - .stdout_only("30\n"); + .stdout_only("42\n"); } #[test] - fn test_d() { + fn test_integer_division() { new_ucmd!() - .args(&["100", "/", "6"]) + .args(&["120", "/", "8"]) .succeeds() - .stdout_only("16\n"); + .stdout_only("15\n"); } #[test] - fn test_e() { + fn test_modulo_remainder() { new_ucmd!() - .args(&["100", "%", "6"]) + .args(&["120", "%", "7"]) .succeeds() - .stdout_only("4\n"); + .stdout_only("1\n"); } #[test] - fn test_f() { + fn test_add_with_negative() { new_ucmd!() - .args(&["3", "+", "-2"]) + .args(&["6", "+", "-4"]) .succeeds() - .stdout_only("1\n"); + .stdout_only("2\n"); } #[test] - fn test_g() { + fn test_two_negatives_added() { new_ucmd!() - .args(&["-2", "+", "-2"]) + .args(&["-3", "+", "-5"]) .succeeds() - .stdout_only("-4\n"); + .stdout_only("-8\n"); } #[test] - fn test_opt1() { + fn test_double_dash_neg_large() { new_ucmd!() - .args(&["--", "-11", "+", "12"]) + .args(&["--", "-7", "+", "15"]) .succeeds() - .stdout_only("1\n"); + .stdout_only("8\n"); } #[test] - fn test_opt2() { + fn test_neg_large_no_dash() { new_ucmd!() - .args(&["-11", "+", "12"]) + .args(&["-7", "+", "15"]) .succeeds() - .stdout_only("1\n"); + .stdout_only("8\n"); } #[test] - fn test_opt3() { + fn test_double_dash_neg_small() { new_ucmd!() - .args(&["--", "-1", "+", "2"]) + .args(&["--", "-2", "+", "9"]) .succeeds() - .stdout_only("1\n"); + .stdout_only("7\n"); } #[test] - fn test_opt4() { + fn test_neg_small_no_dash() { new_ucmd!() - .args(&["-1", "+", "2"]) + .args(&["-2", "+", "9"]) .succeeds() - .stdout_only("1\n"); + .stdout_only("7\n"); } #[test] - fn test_opt5() { + fn test_double_dash_positive() { new_ucmd!() - .args(&["--", "2", "+", "2"]) + .args(&["--", "3", "+", "6"]) .succeeds() - .stdout_only("4\n"); + .stdout_only("9\n"); } #[test] - fn test_paren1() { + fn test_parens_simple_mod() { new_ucmd!() - .args(&["(", "100", "%", "6", ")"]) + .args(&["(", "120", "%", "7", ")"]) .succeeds() - .stdout_only("4\n"); + .stdout_only("1\n"); } #[test] - fn test_paren2() { + fn test_parens_mod_minus() { new_ucmd!() - .args(&["(", "100", "%", "6", ")", "-", "8"]) + .args(&["(", "120", "%", "7", ")", "-", "8"]) .succeeds() - .stdout_only("-4\n"); + .stdout_only("-7\n"); } #[test] - fn test_paren3() { + fn test_parens_div_mod_minus() { new_ucmd!() - .args(&["9", "/", "(", "100", "%", "6", ")", "-", "8"]) + .args(&["18", "/", "(", "120", "%", "7", ")", "-", "6"]) .succeeds() - .stdout_only("-6\n"); + .stdout_only("12\n"); } #[test] - fn test_paren4() { + fn test_parens_div_nested_mod() { new_ucmd!() - .args(&["9", "/", "(", "(", "100", "%", "6", ")", "-", "8", ")"]) + .args(&["24", "/", "(", "(", "120", "%", "7", ")", "+", "5", ")"]) .succeeds() - .stdout_only("-2\n"); + .stdout_only("4\n"); } #[test] - fn test_paren5() { + fn test_parens_add_mod() { new_ucmd!() - .args(&["9", "+", "(", "100", "%", "6", ")"]) + .args(&["9", "+", "(", "120", "%", "7", ")"]) .succeeds() - .stdout_only("13\n"); + .stdout_only("10\n"); } #[test] @@ -1553,6 +1575,7 @@ mod locale_aware { use uutests::new_ucmd; #[test] + #[cfg_attr(wasi_runner, ignore = "WASI: no locale data, every locale is C")] fn test_expr_collating() { for (loc, code, output) in [ ("C", 0, "1\n"), @@ -1570,9 +1593,9 @@ mod locale_aware { } } -/// This module reimplements the expr-multibyte.pl test +/// Tests for multibyte character arithmetic in expr #[cfg(target_os = "linux")] -mod gnu_expr_multibyte { +mod expr_multibyte_arithmetic { use uutests::new_ucmd; use uucore::os_str_from_bytes; @@ -1599,8 +1622,7 @@ mod gnu_expr_multibyte { } } - const EXPRESSION: &[u8] = - "\u{1F14}\u{03BA}\u{03C6}\u{03C1}\u{03B1}\u{03C3}\u{03B9}\u{03C2}".as_bytes(); + const EXPRESSION: &[u8] = "\u{6C49}\u{5B57}\u{6D4B}\u{8BD5}".as_bytes(); // 汉字测试 – 4 chars, 12 bytes #[derive(Debug, Default, Clone, Copy)] struct TestCase { @@ -1633,6 +1655,15 @@ mod gnu_expr_multibyte { } fn check_test_case(args: &[&[u8]], tc: &TestCase) { + // The WASI build ships no locale data, so every locale behaves like + // "C", and WASI can only carry valid UTF-8 through argv. Run just the + // cases that don't depend on either. + if std::env::var("UUTESTS_WASM_RUNNER").is_ok() + && (tc.locale != "C" || args.iter().any(|arg| std::str::from_utf8(arg).is_err())) + { + return; + } + let args = args .iter() .map(|arg: &&[u8]| os_str_from_bytes(arg).unwrap()) @@ -1655,7 +1686,7 @@ mod gnu_expr_multibyte { // sanity check #[test] - fn test_l1() { + fn test_mb_length_full() { let args: &[&[u8]] = &[b"length", b"abcdef"]; let cases = &[TestCase::FR.out("6"), TestCase::C.out("6")]; @@ -1666,9 +1697,9 @@ mod gnu_expr_multibyte { } // A single multibyte character in the beginning of the string \xCE\xB1 is - // UTF-8 for "U+03B1 GREEK SMALL LETTER ALPHA" + // UTF-8 for "U+03B1 (2-byte UTF-8 sequence, used as multibyte prefix test)" #[test] - fn test_l2() { + fn test_mb_length_ascii_prefix() { let args: &[&[u8]] = &[b"length", b"\xCE\xB1bcdef"]; let cases = &[TestCase::FR.out("6"), TestCase::C.out("7")]; @@ -1679,9 +1710,9 @@ mod gnu_expr_multibyte { } // A single multibyte character in the middle of the string \xCE\xB4 is - // UTF-8 for "U+03B4 GREEK SMALL LETTER DELTA" + // UTF-8 for "U+03B4 (2-byte UTF-8 sequence, used as multibyte middle test)" #[test] - fn test_l3() { + fn test_mb_length_ascii_middle() { let args: &[&[u8]] = &[b"length", b"abc\xCE\xB4ef"]; let cases = &[TestCase::FR.out("6"), TestCase::C.out("7")]; @@ -1693,7 +1724,7 @@ mod gnu_expr_multibyte { // A single multibyte character in the end of the string #[test] - fn test_l4() { + fn test_mb_length_ascii_suffix() { let args: &[&[u8]] = &[b"length", b"fedcb\xCE\xB1"]; let cases = &[TestCase::FR.out("6"), TestCase::C.out("7")]; @@ -1705,7 +1736,7 @@ mod gnu_expr_multibyte { // A invalid multibyte sequence #[test] - fn test_l5() { + fn test_mb_length_invalid_seq() { let args: &[&[u8]] = &[b"length", b"\xB1aaa"]; let cases = &[TestCase::FR.out("4"), TestCase::C.out("4")]; @@ -1717,7 +1748,7 @@ mod gnu_expr_multibyte { // An incomplete multibyte sequence at the end of the string #[test] - fn test_l6() { + fn test_mb_length_incomplete_end() { let args: &[&[u8]] = &[b"length", b"aaa\xCE"]; let cases = &[TestCase::FR.out("4"), TestCase::C.out("4")]; @@ -1729,10 +1760,10 @@ mod gnu_expr_multibyte { // An incomplete multibyte sequence at the end of the string #[test] - fn test_l7() { + fn test_mb_length_expression() { let args: &[&[u8]] = &[b"length", EXPRESSION]; - let cases = &[TestCase::FR.out("8"), TestCase::C.out("17")]; + let cases = &[TestCase::FR.out("4"), TestCase::C.out("12")]; for tc in cases { check_test_case(args, tc); @@ -2012,6 +2043,7 @@ mod gnu_expr_multibyte { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: no locale data, every locale is C")] fn test_emoji_operations() { new_ucmd!() .args(&["🚀", "=", "🚀"]) @@ -2039,3 +2071,183 @@ fn test_emoji_operations() { .succeeds() .stdout_only("1\n"); } + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_unexpected_argument() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["6", "+", "7", "spare"]) + .fails_with_code(2); + + // The whole report: the `expr: ` prefix of the plain form, the + // expression echoed back, a caret on `spare`, and the quoting advice. + assert_eq!( + result.stderr_as_displayed(), + "\ +expr: syntax error: unexpected argument 'spare' + ╭─[ expr:1:7 ] + │ + 1 │ 6 + 7 spare + │ ──┬── + │ ╰──── the expression was already complete here + │ + │ Help: the shell may have expanded an operator; quote it as '*' or escape it as \\* +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_dangling_operator() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["6", "*"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +expr: syntax error: missing argument after '*' + ╭─[ expr:1:3 ] + │ + 1 │ 6 * + │ ─ + │ + │ Help: every operator needs a value on both sides +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_non_integer_operand() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["oops", "+", "4"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +expr: non-integer argument + ╭─[ expr:1:1 ] + │ + 1 │ oops + 4 + │ ──── + │ + │ Help: arithmetic operators need integers; use = or != to compare strings instead +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_unclosed_parenthesis_points_at_the_last_operand() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["(", "6", "+", "7"]) + .fails_with_code(2); + + // No help line here: the label alone says what should follow. + assert_eq!( + result.stderr_as_displayed(), + "\ +expr: syntax error: expecting ')' after '7' + ╭─[ expr:1:7 ] + │ + 1 │ ( 6 + 7 + │ ─ +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_errors_without_a_position_stay_plain() { + // Division by zero is raised once the expression is already parsed and + // carries no operand, so there is nothing to point at. The terminal + // turns `\n` into `\r\n`, which is undone before comparing. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["6", "/", "0"]) + .fails_with_code(2); + assert_eq!( + result.stderr_str().replace("\r\n", "\n"), + "expr: division by zero\n" + ); + // An empty expression keeps its usage hint. + new_ucmd!() + .terminal_sim_stderr() + .fails_with_code(2) + .stderr_contains("missing operand") + .stderr_contains("for more information"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_operand_that_failed_not_its_twin() { + // Both operands read `a`, but only the right-hand one of `+` failed + // arithmetic; the caret must not drift to the first occurrence, a + // perfectly valid string operand of `=`. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["a", "=", "2", "+", "a"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +expr: non-integer argument + ╭─[ expr:1:9 ] + │ + 1 │ a = 2 + a + │ ─ + │ + │ Help: arithmetic operators need integers; use = or != to compare strings instead +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_computed_operand_stays_plain() { + // The failing operand is the result of `substr`, not an argument, so + // there is nothing to point at and the plain message is kept. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["substr", "abc", "1", "2", "+", "1"]) + .fails_with_code(2); + assert_eq!( + result.stderr_str().replace("\r\n", "\n"), + "expr: non-integer argument\n" + ); + } + + #[test] + fn test_plain_message_is_the_default() { + // The test harness pipes stderr, so the report must not appear. + new_ucmd!() + .args(&["6", "+", "7", "spare"]) + .fails_with_code(2) + .stderr_is("expr: syntax error: unexpected argument 'spare'\n"); + } +} + +#[test] +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] +fn test_exit_with_3_write_error() { + let dev_full = std::fs::OpenOptions::new() + .write(true) + .open("/dev/full") + .unwrap(); + + new_ucmd!() + .arg("2 + 2") + .set_stdout(dev_full) + .fails_with_code(3) + .stderr_is("expr: No space left on device\n"); +} diff --git a/tests/by-util/test_factor.rs b/tests/by-util/test_factor.rs index 8833c9d96d9..8e75b30bad5 100644 --- a/tests/by-util/test_factor.rs +++ b/tests/by-util/test_factor.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 (methods) hexdigest funcs nprimes cmdline +// spell-checker:ignore (methods) hexdigest funcs nprimes cmdline cofactor + #![allow( clippy::similar_names, clippy::cast_possible_truncation, @@ -32,7 +33,7 @@ fn test_invalid_arg() { #[test] fn test_invalid_negative_arg_shows_tip() { // Test that factor shows a tip when given an invalid negative argument - // This replicates the GNU test issue where "-1" was interpreted as an invalid option + // Test that "-1" is not misinterpreted as an invalid option new_ucmd!() .arg("-1") .fails() @@ -160,7 +161,7 @@ fn test_first_1000_integers_with_exponents() { .pipe_in(input_string.as_bytes()) .succeeds(); - // Using factor from GNU Coreutils 9.2 + // Known factorizations for verification // `seq 0 1000 | factor -h | sha1sum` => "45f5f758a9319870770bd1fec2de23d54331944d" let mut hasher = Sha1::new(); hasher.update(result.stdout()); @@ -1674,6 +1675,31 @@ fn succeeds_with_numbers_larger_than_u256() { ); } +// A 301-bit product of ten primes just above 2^30. This used to come back as +// "Factorization incomplete. Remainders exists." because the Pollard's rho +// budget underneath was shared by the whole factorization rather than by each +// cofactor, so everything past the fourth split was given up on. +#[test] +fn factors_many_primes_of_similar_size_completely() { + const N: &str = "2037036890754971402431340509217469262474509779036256996849055359081480084266333562576864437"; + new_ucmd!().arg(N).succeeds().stdout_is(format!( + "{N}: 1073741827 1073741831 1073741833 1073741839 1073741843 \ + 1073741857 1073741891 1073741909 1073741939 1073741953\n" + )); +} + +// A prime power is the one shape Pollard's rho cannot split on its own: it +// needs about sqrt(p) iterations on p^k. This is 34359738421^7. +#[test] +fn factors_a_wide_prime_power() { + const N: &str = "56539106683390492137844827055225747632151249167945695848217966183182073341"; + new_ucmd!() + .arg("-h") + .arg(N) + .succeeds() + .stdout_is(format!("{N}: 34359738421^7\n")); +} + #[test] fn handles_non_unicode_data() { let input = b"\0 \xFF\0\xFF\xAA\0\xAA\x44 a\n6 9\x003\xC024\t2\t\t4\x000+4\xFF \xF7\xC1"; @@ -1700,3 +1726,23 @@ fn invalid_cmdline_arg_continues() { .stdout_is("4: 2 2\n") .stderr_is("factor: 'a' is not a valid positive integer\n"); } + +#[cfg(unix)] +#[test] +fn test_stdin_clean_error_message() { + use std::fs; + use uutests::util::TestScenario; + use uutests::util_name; + + let scene = TestScenario::new(util_name!()); + let dir = scene.fixtures.plus("directory"); + fs::create_dir_all(&dir).unwrap(); + + let dir_as_file = fs::File::open(&dir).unwrap(); + + scene + .ucmd() + .set_stdin(dir_as_file) + .fails() + .stderr_is("factor: error reading input: Is a directory\n"); +} diff --git a/tests/by-util/test_false.rs b/tests/by-util/test_false.rs index cc0874256b1..8c672c8fa6f 100644 --- a/tests/by-util/test_false.rs +++ b/tests/by-util/test_false.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use regex::Regex; #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] use std::fs::OpenOptions; @@ -59,6 +60,6 @@ fn test_full() { .arg(option) .set_stdout(dev_full) .fails() - .stderr_contains("No space left on device"); + .stderr_is("false: No space left on device\n"); } } diff --git a/tests/by-util/test_fmt.rs b/tests/by-util/test_fmt.rs index 8953cfe8a4e..ecff20dd488 100644 --- a/tests/by-util/test_fmt.rs +++ b/tests/by-util/test_fmt.rs @@ -4,6 +4,7 @@ // file that was distributed with this source code. // spell-checker:ignore plass samp FFFD + #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStringExt; use uutests::new_ucmd; @@ -65,6 +66,25 @@ fn test_fmt_width_max_display_width() { .stdout_is("aa\nbb cc\ndd ee\n"); } +/// Regression for https://github.com/uutils/coreutils/issues/10095 +/// +/// GNU `fmt` measures `-w` in UTF-8 bytes (not Unicode display columns). +/// These cases match GNU coreutils 9.11. +#[test] +fn test_fmt_width_multibyte_gnu_compatible() { + new_ucmd!() + .args(&["-w", "10"]) + .pipe_in("漢 字 test 日 本 語") + .succeeds() + .stdout_is("漢 字\ntest 日\n本 語\n"); + + new_ucmd!() + .args(&["-w", "15"]) + .pipe_in("漢字 test 日本語") + .succeeds() + .stdout_is("漢字 test\n日本語\n"); +} + #[test] fn test_fmt_width_invalid() { new_ucmd!() @@ -321,6 +341,56 @@ fn prefix_equal_skip_prefix_equal_two() { } } +#[test] +fn prefix_ignores_leading_whitespace_without_exact_prefix() { + for prefix_args in [vec!["-p", "> "], vec!["--prefix", "> "]] { + new_ucmd!() + .args(&prefix_args) + .pipe_in(" > alpha\n > beta\n") + .succeeds() + .stdout_only(" > alpha beta\n"); + } +} + +#[test] +fn exact_prefix_requires_the_prefix_to_start_the_line() { + for prefix_args in [ + vec!["-x", "-p", "> "], + vec!["--exact-prefix", "--prefix", "> "], + ] { + new_ucmd!() + .args(&prefix_args) + .pipe_in(" > alpha\n > beta\n") + .succeeds() + .stdout_only(" > alpha\n > beta\n"); + } +} + +#[test] +fn skip_prefix_ignores_leading_whitespace_without_exact_skip_prefix() { + for prefix_args in [vec!["-P", "#"], vec!["--skip-prefix", "#"]] { + new_ucmd!() + .args(&prefix_args) + .pipe_in(" # note\n # more\n") + .succeeds() + .stdout_only(" # note\n # more\n"); + } +} + +#[test] +fn exact_skip_prefix_requires_the_prefix_to_start_the_line() { + for prefix_args in [ + vec!["-X", "-P", "#"], + vec!["--exact-skip-prefix", "--skip-prefix", "#"], + ] { + new_ucmd!() + .args(&prefix_args) + .pipe_in(" # note\n # more\n") + .succeeds() + .stdout_only(" # note # more\n"); + } +} + #[test] fn test_fmt_unicode_whitespace_handling() { // Character classification fix: Test that Unicode whitespace characters like non-breaking space @@ -436,3 +506,32 @@ fn test_fmt_width_multiplication_overflow() { .fails_with_code(1) .stderr_is("fmt: invalid width: '267672676527678256'\n"); } + +#[test] +fn test_fmt_goal_only_defaults_width_to_goal_plus_ten() { + // GNU defaults the width to goal + 10 when only --goal is given, so `-g G` + // has to lay a paragraph out exactly as `-w G+10 -g G` does. + for goal in [5, 10, 20, 30, 50, 65] { + let widened = new_ucmd!() + .args(&[ + "one-word-per-line.txt", + "-w", + &(goal + 10).to_string(), + "-g", + &goal.to_string(), + ]) + .succeeds() + .stdout_move_str(); + + new_ucmd!() + .args(&["one-word-per-line.txt", "-g", &goal.to_string()]) + .succeeds() + .stdout_is(&widened); + } + + // The whole 37-column paragraph therefore fits on one line at goal 30. + new_ucmd!() + .args(&["one-word-per-line.txt", "--goal", "30"]) + .succeeds() + .stdout_is("this is a file with one word per line\n"); +} diff --git a/tests/by-util/test_fold.rs b/tests/by-util/test_fold.rs index 1d8f3eb5978..319fdbb4daa 100644 --- a/tests/by-util/test_fold.rs +++ b/tests/by-util/test_fold.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore fullwidth refgh tefgh nefgh unflushed use bytecount::count; @@ -818,6 +819,36 @@ fn test_bytewise_fold_at_word_boundary_only_whitespace_preserve_final_newline() .stdout_is(" \n \n"); } +#[test] +fn test_bytewise_fold_line_of_exactly_width_is_not_folded() { + // A line that is exactly `width` bytes long already fits, so -s must not + // break it at its last blank. + new_ucmd!() + .args(&["-w7", "-s", "-b"]) + .pipe_in("aaa bbb\nccc ddd\n") + .succeeds() + .stdout_is("aaa bbb\nccc ddd\n"); +} + +#[test] +fn test_bytewise_fold_remainder_of_exactly_width_is_not_folded() { + // Same, for what is left of a line after a width-driven fold. + new_ucmd!() + .args(&["-w7", "-s", "-b"]) + .pipe_in("aaa bbb ccc\n") + .succeeds() + .stdout_is("aaa \nbbb ccc\n"); +} + +#[test] +fn test_bytewise_fold_line_longer_than_width_still_folds() { + new_ucmd!() + .args(&["-w7", "-s", "-b"]) + .pipe_in("aaa bbbb\n") + .succeeds() + .stdout_is("aaa \nbbbb\n"); +} + #[test] fn test_bytewise_backspace_should_be_preserved() { new_ucmd!() @@ -1055,5 +1086,122 @@ fn test_width_zero() { .arg("-w") .arg("0") .fails_with_code(1) - .stderr_is("fold: illegal width value\n"); + .stderr_is("fold: invalid number of columns: '0': Numerical result out of range\n"); +} + +#[test] +fn test_width_invalid() { + for width in ["xyz", "12x", "12.5", "1 2"] { + new_ucmd!() + .arg("-w") + .arg(width) + .fails_with_code(1) + .stderr_is(format!("fold: invalid number of columns: '{width}'\n")); + } +} + +#[test] +fn test_width_overflow() { + new_ucmd!() + .arg("-w") + .arg("999999999999999999999") + .fails_with_code(1) + .stderr_is( + "fold: invalid number of columns: '999999999999999999999': Numerical result out of range\n", + ); +} + +#[test] +fn test_negative_width_as_separate_value() { + // GNU consumes the argument after `-w` as its value even when it looks + // like an option, and reports that value as the invalid one. Passing a + // file too, because the width used to be mistaken for the obsolete + // `-WIDTH` form, leaving the file name to be read as the width. + for args in [ + vec!["-w", "-1"], + vec!["--width", "-1"], + vec!["-bw", "-1"], + vec!["-sw", "-1"], + ] { + new_ucmd!() + .args(&args) + .arg("lorem_ipsum.txt") + .fails_with_code(1) + .stderr_is("fold: invalid number of columns: '-1'\n"); + } +} + +#[test] +fn test_obsolete_width_after_double_dash_is_a_file() { + // After `--` there are no more options, so `-3` names a file. + new_ucmd!() + .arg("--") + .arg("-3") + .fails_with_code(1) + .stderr_is("fold: -3: No such file or directory\n"); +} + +#[test] +fn test_last_width_wins() { + // GNU processes options left to right, so the last width given wins, + // regardless of which spelling each one used. + for args in [ + vec!["-w3", "-5"], + vec!["-3", "-5"], + vec!["-w", "3", "-w", "5"], + vec!["-w3", "-w5"], + vec!["-5", "-w", "5"], + ] { + new_ucmd!() + .args(&args) + .pipe_in("aaaaaaaaaa\n") + .succeeds() + .stdout_is("aaaaa\naaaaa\n"); + } + + // ...and an obsolete width is overridden by a later `-w` just the same. + new_ucmd!() + .args(&["-5", "-w", "3"]) + .pipe_in("aaaaaa\n") + .succeeds() + .stdout_is("aaa\naaa\n"); +} + +#[test] +fn test_dash_operand_reads_stdin() { + // A bare `-` names stdin and leaves nothing after the dash for the + // width-value scan to index, which used to abort before reading input. + new_ucmd!() + .arg("-") + .pipe_in("hello\n") + .succeeds() + .stdout_is("hello\n"); + + // ...in every position, including as the value of -w, where GNU rejects + // it as a width rather than treating it as the operand. + new_ucmd!() + .args(&["-w", "3", "-"]) + .pipe_in("hello\n") + .succeeds() + .stdout_is("hel\nlo\n"); + new_ucmd!() + .args(&["-w", "-"]) + .pipe_in("hello\n") + // fold rejects the width before it reads stdin, so the write can + // race with its exit and hit a broken pipe. + .ignore_stdin_write_error() + .fails() + .stderr_contains("invalid number of columns: '-'"); +} + +/// A bare `-` has no trailing `w`, so it must not be mistaken for one of the +/// short spellings that consumes the next argument as its width -- that once +/// swallowed whatever followed the `-` operand instead of scanning it. +#[test] +fn test_dash_operand_does_not_swallow_the_next_argument() { + new_ucmd!() + .args(&["-", "-5"]) + .pipe_in("hello\n") + .succeeds() + .stdout_is("hello\n"); } diff --git a/tests/by-util/test_groups.rs b/tests/by-util/test_groups.rs index 984caef39a5..23ca9c77733 100644 --- a/tests/by-util/test_groups.rs +++ b/tests/by-util/test_groups.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 coreutil +//spell-checker:ignore coreutil use uutests::new_ucmd; use uutests::unwrap_or_return; diff --git a/tests/by-util/test_head.rs b/tests/by-util/test_head.rs index 0f4e9dae5dd..ca4eaae35d9 100644 --- a/tests/by-util/test_head.rs +++ b/tests/by-util/test_head.rs @@ -6,11 +6,11 @@ // spell-checker:ignore (words) bogusfile emptyfile abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstu #[cfg(all( - not(target_os = "windows"), - not(target_os = "macos"), + not(target_vendor = "apple"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] use std::io::Read; use uutests::new_ucmd; @@ -180,7 +180,7 @@ fn test_negative_byte_syntax() { .args(&["--bytes=-2"]) .pipe_in("a\n") .succeeds() - .stdout_is(""); + .no_output(); } #[test] @@ -222,6 +222,35 @@ fn test_negative_zero_bytes() { .succeeds() .stdout_is("qwerty"); } + +#[test] +fn test_zero_bytes_with_suffix() { + // --bytes=0K makes head stop before draining stdin, so the harness's write may fail + // with EPIPE. That is expected and must not fail the test. + // + // "0K" must be 0 bytes, not 1KiB (bare suffix parses as 1) + new_ucmd!() + .args(&["--bytes=0K"]) + .ignore_stdin_write_error() + .pipe_in("qwerty") + .ignore_stdin_write_error() + .succeeds() + .no_output(); + new_ucmd!() + .args(&["--bytes=00K"]) + .ignore_stdin_write_error() + .pipe_in("qwerty") + .ignore_stdin_write_error() + .succeeds() + .no_output(); + new_ucmd!() + .args(&["--bytes=+0K"]) + .ignore_stdin_write_error() + .pipe_in("qwerty") + .ignore_stdin_write_error() + .succeeds() + .no_output(); +} #[test] fn test_no_such_file_or_directory() { new_ucmd!() @@ -283,7 +312,7 @@ fn test_obsolete_extras() { .args(&["-5zv"]) .pipe_in("1\x002\x003\x004\x005\x006") .succeeds() - .stdout_is("==> standard input <==\n1\x002\x003\x004\x005\0"); + .stdout_is("==> 'standard input' <==\n1\x002\x003\x004\x005\0"); } #[test] @@ -303,7 +332,7 @@ fn test_multiple_files_with_stdin() { .stdout_is( "==> emptyfile.txt <== -==> standard input <== +==> 'standard input' <== hello ==> emptyfile.txt <== @@ -548,11 +577,11 @@ fn test_all_but_last_lines_large_file() { } #[cfg(all( - not(target_os = "windows"), - not(target_os = "macos"), + not(target_vendor = "apple"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] #[test] #[cfg_attr( @@ -652,11 +681,11 @@ fn test_validate_stdin_offset_lines() { } #[cfg(all( - not(target_os = "windows"), - not(target_os = "macos"), + not(target_vendor = "apple"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] #[test] #[cfg_attr( @@ -781,11 +810,11 @@ fn test_validate_stdin_offset_bytes() { } #[cfg(all( - not(target_os = "windows"), - not(target_os = "macos"), + not(target_vendor = "apple"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] #[test] #[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths (/proc) not visible")] @@ -798,11 +827,11 @@ fn test_read_backwards_bytes_proc_fs_version() { } #[cfg(all( - not(target_os = "windows"), - not(target_os = "macos"), + not(target_vendor = "apple"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] #[test] #[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths (/proc) not visible")] @@ -819,11 +848,11 @@ fn test_read_backwards_bytes_proc_fs_modules() { } #[cfg(all( - not(target_os = "windows"), - not(target_os = "macos"), + not(target_vendor = "apple"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] #[test] #[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths (/proc) not visible")] @@ -840,11 +869,11 @@ fn test_read_backwards_lines_proc_fs_modules() { } #[cfg(all( - not(target_os = "windows"), - not(target_os = "macos"), + not(target_vendor = "apple"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] #[test] #[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths (/sys) not visible")] @@ -1023,3 +1052,204 @@ fn test_unreadable_file_prints_no_header() { .stdout_does_not_contain("==> unreadable <==") .stderr_contains("cannot open 'unreadable' for reading: Permission denied"); } + +/// Regression for #13887: writing the `==> filename <==` verbose header to a +/// full/closed stdout must surface the write error instead of panicking inside +/// `print_verbatim(...).unwrap()`. A filename longer than the stdout buffer +/// forces the header write to flush mid-write so the failure surfaces inside +/// the filename write rather than at the next checked one. +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths (/dev) not visible")] +fn test_verbose_header_write_error_long_filename() { + use std::fs::File; + + let dev_full = + File::create("/dev/full").expect("Failed to open /dev/full - test must run on Linux"); + + let long_path = format!("/dev/{}null", "./".repeat(512)); + + new_ucmd!() + .arg("-v") + .arg(long_path) + .set_stdout(dev_full) + .fails() + .code_is(1) + .stderr_contains("No space left on device"); +} + +/// Regression for #11972: head must reject directories detected on the +/// open fd, not via a separate `Path::is_dir()` call. A symlink that +/// resolves to a directory must still be rejected — verifying the fd +/// check survives an indirection. +#[test] +#[cfg(unix)] +fn test_head_rejects_directory_through_symlink() { + use std::os::unix::fs::symlink; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkdir("real_dir"); + symlink("real_dir", at.plus("link_to_dir")).unwrap(); + + scene + .ucmd() + .arg("link_to_dir") + .fails_with_code(1) + .stderr_contains("Is a directory"); +} + +/// Regression for #11972: a symlink that points to a regular file must +/// still be readable by head (the fd-based check must distinguish the +/// fd's mode, not the symlink's). +#[test] +#[cfg(unix)] +fn test_head_follows_symlink_to_regular_file() { + use std::os::unix::fs::symlink; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.write("regular", "hello\n"); + symlink("regular", at.plus("link_to_regular")).unwrap(); + + scene + .ucmd() + .arg("link_to_regular") + .succeeds() + .stdout_is("hello\n"); +} + +#[cfg(unix)] +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[test] + fn test_snippet_points_at_the_unknown_unit() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-c", "1fb", "/dev/null"]) + .fails_with_code(1); + + // The number parsed; it is the unit that did not. + assert_eq!( + result.stderr_as_displayed(), + "\ +head: invalid number of bytes: '1fb' + ╭─[ head:1:10 ] + │ + 1 │ head -c 1fb /dev/null + │ ─┬ + │ ╰── not a known unit + │ + │ Help: a size is a number and an optional unit: K, M, G and so on for 1024, KB, MB, GB for 1000 +───╯" + ); + } + + #[test] + fn test_snippet_underlines_a_size_with_no_number() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--bytes=xyz", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + // Nothing usable was read, so the whole value is underlined, and the + // message already says what is wrong. + assert!(stderr.contains("head:1:14"), "{stderr}"); + assert!(!stderr.contains("not a known unit"), "{stderr}"); + } + + #[test] + fn test_snippet_preserves_obsolete_option_spelling() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-5", "-c", "1fb", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("1 │ head -5 -c 1fb /dev/null"), "{stderr}"); + assert!(!stderr.contains("head -n 5 -c"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["-c", "1fb", "/dev/null"]) + .fails_with_code(1) + .stderr_is("head: invalid number of bytes: '1fb'\n"); + } +} + +#[test] +fn test_invalid_count_keeps_its_leading_zeros() { + // Leading zeros are stripped only so the count is read as decimal rather + // than octal. That is internal, so GNU still names the argument as typed. + new_ucmd!() + .args(&["-c", "0fb", "/dev/null"]) + .fails_with_code(1) + .stderr_is("head: invalid number of bytes: '0fb'\n"); + new_ucmd!() + .args(&["-n", "00x", "/dev/null"]) + .fails_with_code(1) + .stderr_is("head: invalid number of lines: '00x'\n"); +} + +#[test] +fn test_lowercase_multiplier_suffixes_rejected() { + // GNU accepts a lowercase suffix only for "k" and "m"; every other + // multiplier must be uppercase. "b" is bare-only (no B/iB/D form). + for suffix in ["g", "t", "p", "e", "z", "y", "r", "q"] { + new_ucmd!() + .args(&["-c", &format!("2{suffix}")]) + .fails_with_code(1) + .stderr_is(format!("head: invalid number of bytes: '2{suffix}'\n")); + new_ucmd!() + .args(&["-n", &format!("2{suffix}")]) + .fails_with_code(1) + .stderr_is(format!("head: invalid number of lines: '2{suffix}'\n")); + } +} + +#[test] +fn test_accepted_multiplier_suffixes() { + for suffix in [ + "b", "k", "m", "K", "M", "G", "T", "P", "E", "Z", "Y", "R", "Q", "kB", "KiB", "kD", "MiB", + "GB", + ] { + new_ucmd!() + .args(&["-c", &format!("1{suffix}")]) + .pipe_in("x") + .ignore_stdin_write_error() + .succeeds(); + } +} + +#[test] +fn test_header_quotes_names_needing_it() { + // A name with a space must come back quoted, an ordinary one must not. + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.write("plain", "p\n"); + at.write("two words", "w\n"); + + ts.ucmd() + .args(&["-n1", "plain", "two words"]) + .succeeds() + .stdout_only("==> plain <==\np\n\n==> 'two words' <==\nw\n"); +} + +// Windows rejects control characters in file names, so this one is unix-only. +#[test] +#[cfg(unix)] +fn test_header_quotes_name_with_control_char() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.write("tab\there", "t\n"); + + ts.ucmd() + .args(&["-v", "-n1", "tab\there"]) + .succeeds() + .stdout_only("==> 'tab'$'\\t''here' <==\nt\n"); +} diff --git a/tests/by-util/test_hostid.rs b/tests/by-util/test_hostid.rs index 1b0c25ac2a5..a050e64f0d5 100644 --- a/tests/by-util/test_hostid.rs +++ b/tests/by-util/test_hostid.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use regex::Regex; use uutests::new_ucmd; diff --git a/tests/by-util/test_hostname.rs b/tests/by-util/test_hostname.rs index e4f30c3e525..dc0b7510192 100644 --- a/tests/by-util/test_hostname.rs +++ b/tests/by-util/test_hostname.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use uutests::new_ucmd; #[test] @@ -14,8 +15,6 @@ fn test_hostname() { assert!(ls_default_res.stdout().len() >= ls_domain_res.stdout().len()); } -// FixME: fails for "MacOS" => "failed to lookup address information" -#[cfg(not(any(target_os = "macos")))] #[test] fn test_hostname_ip() { let result = new_ucmd!().arg("-i").succeeds(); diff --git a/tests/by-util/test_id.rs b/tests/by-util/test_id.rs index 1a30b75c91c..30e4c1ac9a0 100644 --- a/tests/by-util/test_id.rs +++ b/tests/by-util/test_id.rs @@ -34,7 +34,7 @@ fn test_id_no_specified_user() { let exp_result = unwrap_or_return!(expected_result(&ts, &[])); let mut exp_stdout = exp_result.stdout_str().to_string(); - #[cfg(not(feature = "feat_selinux"))] + #[cfg(not(feature = "selinux"))] { // NOTE: strip 'context' part from exp_stdout if selinux not enabled: // example: @@ -151,6 +151,40 @@ fn test_id_real() { } } +#[test] +fn test_id_groups_ordering() { + // `groups=` is headed by the effective gid and `-G` by the real gid, so the + // two forms list the same set in a different order. Only observable when the + // real and effective gids differ, which needs a setgid wrapper, but the set + // and the `-r`-independence of `-G` hold either way. + let ts = TestScenario::new(util_name!()); + + let groups = ts.ucmd().arg("-G").succeeds().stdout_move_str(); + let mut from_flag: Vec<&str> = groups.split_whitespace().collect(); + assert!(!from_flag.is_empty()); + + // `-G` heads with the real gid + let rgid = ts.ucmd().args(&["-g", "-r"]).succeeds().stdout_move_str(); + assert_eq!(from_flag[0], rgid.trim_end()); + + let default = ts.ucmd().succeeds().stdout_move_str(); + let field = default.split(" groups=").nth(1).unwrap(); + let mut from_default: Vec<&str> = field + .split(&[' ', '\n'][..]) + .next() + .unwrap() + .split(',') + .map(|g| g.split('(').next().unwrap()) + .collect(); + + from_flag.sort_unstable(); + from_default.sort_unstable(); + assert_eq!(from_flag, from_default); + + // `-r` does not affect `-G`, matching GNU + ts.ucmd().args(&["-G", "-r"]).succeeds().stdout_is(&groups); +} + #[test] #[cfg(not(any(target_os = "linux", target_os = "android")))] fn test_id_pretty_print() { @@ -176,7 +210,7 @@ fn test_id_multiple_users() { VERSION_MIN_MULTIPLE_USERS )); - // Same typical users that GNU test suite is using. + // Typical users commonly found on Unix systems. let test_users = ["root", "man", "postfix", "sshd", &whoami()]; let ts = TestScenario::new(util_name!()); @@ -383,10 +417,7 @@ fn test_id_zero() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_id_context() { if !uucore::selinux::is_selinux_enabled() { println!("test skipped: Kernel has no support for SElinux context"); @@ -447,7 +478,7 @@ fn test_id_context() { #[test] fn test_id_no_specified_user_posixly() { - // gnu/tests/id/no-context.sh + // Test id output without security context let ts = TestScenario::new(util_name!()); let result = ts.ucmd().env("POSIXLY_CORRECT", "1").run(); @@ -456,10 +487,7 @@ fn test_id_no_specified_user_posixly() { result.success(); } - #[cfg(all( - any(target_os = "linux", target_os = "android"), - feature = "feat_selinux" - ))] + #[cfg(all(any(target_os = "linux", target_os = "android"), feature = "selinux"))] { if uucore::selinux::is_selinux_enabled() { let result = ts.ucmd().succeeds(); diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index 949c67b9896..3a5189d57cf 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -2,32 +2,22 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (words) helloworld nodir objdump n'source nconfined testdir -#[cfg(not(target_os = "openbsd"))] -use filetime::FileTime; -use std::fs::{self, File}; -#[cfg(target_os = "linux")] -use std::io::{BufRead, BufReader}; +// spell-checker:ignore (words) helloworld nodir n'source nconfined testdir ETXTBSY Cryptfs + +use rustix::process::{getegid, geteuid}; +use std::env::current_exe; +use std::fs; #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStringExt; use std::os::unix::fs::{MetadataExt, PermissionsExt}; -use std::path::PathBuf; -use std::process; -use std::sync::OnceLock; #[cfg(any(target_os = "linux", target_os = "android"))] use std::thread::sleep; use uucore::error::strip_errno; -use uucore::process::{getegid, geteuid}; -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use uucore::selinux::get_getfattr_output; -use uutests::at_and_ucmd; -use uutests::new_ucmd; use uutests::util::{TestScenario, is_ci, run_ucmd_as_root}; -use uutests::util_name; +use uutests::{at_and_ucmd, new_ucmd, util_name}; #[test] fn test_invalid_arg() { @@ -35,7 +25,7 @@ fn test_invalid_arg() { } #[test] -fn test_install_basic() { +fn test_install_basic_try_reflink() { let (at, mut ucmd) = at_and_ucmd!(); let dir = "target_dir"; let file1 = "source_file1"; @@ -44,7 +34,23 @@ fn test_install_basic() { at.touch(file1); at.touch(file2); at.mkdir(dir); + #[cfg(not(target_os = "linux"))] ucmd.arg(file1).arg(file2).arg(dir).succeeds().no_stderr(); + // mkfs.btrfs needs root. use strace instead. + #[cfg(target_os = "linux")] + if std::process::Command::new("strace") + .args(["-qqq", "-o", "strace.out", "-e", "trace=ioctl"]) + .arg(uutests::util::get_tests_binary()) + .args(["install", file1, file2, dir]) + .current_dir(at.as_string()) + .output() + .is_ok() + { + assert!(at.read("strace.out").contains("FICLONE")); + } else { + // missing strace + ucmd.arg(file1).arg(file2).arg(dir).succeeds().no_stderr(); + } assert!(at.file_exists(file1)); assert!(at.file_exists(file2)); @@ -402,7 +408,7 @@ fn test_install_target_new_file_with_group() { let (at, mut ucmd) = at_and_ucmd!(); let file = "file"; let dir = "target_dir"; - let gid = getegid(); + let gid = getegid().as_raw(); at.touch(file); at.mkdir(dir); @@ -429,7 +435,7 @@ fn test_install_target_new_file_with_owner() { let (at, mut ucmd) = at_and_ucmd!(); let file = "file"; let dir = "target_dir"; - let uid = geteuid(); + let uid = geteuid().as_raw(); at.touch(file); at.mkdir(dir); @@ -504,7 +510,12 @@ fn test_install_compare_preserve_timestamps() { at.write(source, "data"); at.write(dest, "data"); let old = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); - filetime::set_file_mtime(at.plus(source), FileTime::from_system_time(old)).unwrap(); + fs::OpenOptions::new() + .write(true) + .open(at.plus(source)) + .unwrap() + .set_modified(old) + .unwrap(); // With --preserve-timestamps, the timestamp difference forces the copy so // the destination ends up with the source's modification time. @@ -545,6 +556,22 @@ fn test_install_target_file_dev_null() { assert!(at.file_exists(file2)); } +#[test] +#[cfg(unix)] +fn test_install_replaces_special_target() { + let (at, mut ucmd) = at_and_ucmd!(); + let source = "source_file"; + let target = "target_fifo"; + + at.write(source, "contents"); + at.mkfifo(target); + + ucmd.arg(source).arg(target).succeeds().no_stderr(); + + assert!(at.file_exists(target)); + assert_eq!(at.read(target), "contents"); +} + #[test] fn test_install_nested_paths_copy_file() { let (at, mut ucmd) = at_and_ucmd!(); @@ -570,8 +597,8 @@ fn test_multiple_mode_arguments_override_not_error() { let dir = "source_dir"; let file = "source_file"; - let gid = getegid(); - let uid = geteuid(); + let gid = getegid().as_raw(); + let uid = geteuid().as_raw(); at.touch(file); at.mkdir(dir); @@ -713,7 +740,7 @@ fn test_install_copy_then_compare_file() { .no_stderr(); let mut file2_meta = at.metadata(file2); - let before = FileTime::from_last_modification_time(&file2_meta); + let before = file2_meta.modified().unwrap(); scene .ucmd() @@ -724,7 +751,7 @@ fn test_install_copy_then_compare_file() { .no_stderr(); file2_meta = at.metadata(file2); - let after = FileTime::from_last_modification_time(&file2_meta); + let after = file2_meta.modified().unwrap(); assert_eq!(before, after); } @@ -748,7 +775,7 @@ fn test_install_copy_then_compare_file_with_extra_mode() { .no_stderr(); let mut file2_meta = at.metadata(file2); - let before = FileTime::from_last_modification_time(&file2_meta); + let before = file2_meta.modified().unwrap(); sleep(std::time::Duration::from_millis(100)); scene @@ -764,7 +791,7 @@ fn test_install_copy_then_compare_file_with_extra_mode() { ); file2_meta = at.metadata(file2); - let after_install_sticky = FileTime::from_last_modification_time(&file2_meta); + let after_install_sticky = file2_meta.modified().unwrap(); assert_ne!(before, after_install_sticky); @@ -780,71 +807,62 @@ fn test_install_copy_then_compare_file_with_extra_mode() { .no_stderr(); file2_meta = at.metadata(file2); - let after_install_sticky_again = FileTime::from_last_modification_time(&file2_meta); + let after_install_sticky_again = file2_meta.modified().unwrap(); assert_ne!(after_install_sticky, after_install_sticky_again); } const STRIP_TARGET_FILE: &str = "helloworld_installed"; -#[cfg(not(target_os = "freebsd"))] -const SYMBOL_DUMP_PROGRAM: &str = "objdump"; -#[cfg(target_os = "freebsd")] -const SYMBOL_DUMP_PROGRAM: &str = "llvm-objdump"; -const STRIP_SOURCE_FILE_SYMBOL: &str = "main"; - -fn strip_source_file() -> PathBuf { - use std::io::Write as _; - static BINARY: OnceLock = OnceLock::new(); - BINARY - .get_or_init(|| { - let dir = std::env::temp_dir(); - let source = dir.join("hello.rs"); - let binary = dir.join("hello_bin"); - let mut file = File::create(&source).unwrap(); - file.write_all(b"fn main() {}").unwrap(); - process::Command::new("rustc") - .arg("-o") - .arg(&binary) - .arg(&source) - .status() - .unwrap(); - binary - }) - .clone() -} - -#[test] -#[cfg(not(target_os = "android"))] // missing strip binary -// FIXME test runs in a timeout with macos-latest on x86_64 in the CI -#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] +const STRIP_PROGRAM: &str = "#!/bin/sh\n: > \"$1\"\n"; + +/// Create the fake strip program from a child process. Tests run as threads of +/// one process: if this process held the script open for writing, a sibling +/// test forking at that moment would inherit the fd, and exec'ing the script +/// would then fail with ETXTBSY. +fn write_strip_program(scene: &TestScenario, name: &str) { + scene + .cmd("sh") + .env("STRIP_SCRIPT", STRIP_PROGRAM) + .arg("-c") + .arg(format!( + "printf '%s' \"$STRIP_SCRIPT\" > {name} && chmod 755 {name}" + )) + .succeeds(); +} + +#[test] fn test_install_and_strip() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; + + write_strip_program(&scene, "strip"); + at.write("source", "file contents"); + let path = format!( + "{}:{}", + at.plus(".").display(), + std::env::var("PATH").unwrap_or_default() + ); + scene .ucmd() + .env("PATH", path) .arg("-s") - .arg(strip_source_file()) + .arg("source") .arg(STRIP_TARGET_FILE) .succeeds() .no_stderr(); - let output = process::Command::new(SYMBOL_DUMP_PROGRAM) - .arg("-t") - .arg(at.plus(STRIP_TARGET_FILE)) - .output() - .unwrap(); - - let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(!stdout.contains(STRIP_SOURCE_FILE_SYMBOL)); + assert_eq!(at.metadata(STRIP_TARGET_FILE).len(), 0); } #[test] fn test_install_no_strip_with_program() { + let source = current_exe().unwrap(); TestScenario::new(util_name!()) .ucmd() .arg("--strip-program") .arg("false") - .arg(strip_source_file()) + .arg(source) .arg(STRIP_TARGET_FILE) .succeeds() .stderr_only( @@ -853,30 +871,24 @@ fn test_install_no_strip_with_program() { } #[test] -#[cfg(not(target_os = "android"))] // missing strip binary -// FIXME test runs in a timeout with macos-latest on x86_64 in the CI -#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] fn test_install_and_strip_with_program() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; + + write_strip_program(&scene, "strip-program"); + at.write("source", "file contents"); + scene .ucmd() .arg("-s") .arg("--strip-program") - .arg("/usr/bin/strip") - .arg(strip_source_file()) + .arg("./strip-program") + .arg("source") .arg(STRIP_TARGET_FILE) .succeeds() .no_stderr(); - let output = process::Command::new(SYMBOL_DUMP_PROGRAM) - .arg("-t") - .arg(at.plus(STRIP_TARGET_FILE)) - .output() - .unwrap(); - - let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(!stdout.contains(STRIP_SOURCE_FILE_SYMBOL)); + assert_eq!(at.metadata(STRIP_TARGET_FILE).len(), 0); } #[cfg(all(unix, feature = "chmod"))] @@ -977,13 +989,14 @@ fn test_install_on_invalid_link_at_destination_and_dev_null_at_source() { fn test_install_and_strip_with_invalid_program() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; + let source = current_exe().unwrap(); scene .ucmd() .arg("-s") .arg("--strip-program") .arg("/bin/date") - .arg(strip_source_file()) + .arg(source) .arg(STRIP_TARGET_FILE) .fails() .stderr_contains("strip program failed"); @@ -1013,16 +1026,17 @@ fn test_install_and_strip_with_signal_terminated_program() { fn test_install_and_strip_with_non_existent_program() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; + let source = current_exe().unwrap(); scene .ucmd() .arg("-s") .arg("--strip-program") .arg("/usr/bin/non_existent_program") - .arg(strip_source_file()) + .arg(source) .arg(STRIP_TARGET_FILE) .fails() - .stderr_contains("No such file or directory"); + .stderr_only("install: strip program failed: No such file or directory\n"); assert!(!at.file_exists(STRIP_TARGET_FILE)); } @@ -1185,7 +1199,28 @@ fn test_install_dir() { assert!(at.file_exists(format!("{dir}/{file1}"))); assert!(at.file_exists(format!("{dir}/{file2}"))); } -// + +#[test] +#[cfg(target_os = "linux")] +fn test_install_non_utf8_dir() { + use std::os::unix::ffi::OsStrExt; + + let (at, mut ucmd) = at_and_ucmd!(); + let dir = std::ffi::OsStr::from_bytes(b"target_dir_\xFF\xFE"); + let file = "source"; + + at.touch(file); + fs::create_dir(at.plus(dir)).unwrap(); + + ucmd.arg(file) + .arg("--target-directory") + .arg(dir) + .succeeds() + .no_output(); + + assert!(at.plus(dir).join(file).exists()); +} + // test backup functionality #[test] fn test_install_backup_short_no_args_files() { @@ -2078,7 +2113,7 @@ fn test_install_compare_group_ownership() { at.write(source, "test content"); - let user_group = process::Command::new("id") + let user_group = std::process::Command::new("id") .arg("-nrg") .output() .map_or_else( @@ -2181,6 +2216,32 @@ fn test_target_file_ends_with_slash() { .stderr_contains("failed to access 'dir/target_file/': Not a directory"); } +/// Without `-D`, a `-t` target that is not a directory is refused, even with a +/// single source, rather than installed over or created as a file. +#[test] +fn test_install_target_dir_not_a_directory() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.write("source", "new"); + at.write("regular", "old"); + at.symlink_file("nowhere", "dangling"); + + for (target, reason) in [ + ("regular", "Not a directory"), + ("dangling", "No such file or directory"), + ("missing", "No such file or directory"), + ] { + scene + .ucmd() + .args(&["-t", target, "source"]) + .fails() + .stderr_only(format!("install: failed to access '{target}': {reason}\n")); + } + assert_eq!(at.read("regular"), "old"); + assert!(at.is_symlink("dangling")); + assert!(!at.file_exists("missing")); +} + #[test] fn test_install_root_combined() { let ts = TestScenario::new(util_name!()); @@ -2475,10 +2536,7 @@ fn test_install_no_target_basic() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_selinux() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -2527,10 +2585,7 @@ fn test_selinux() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_selinux_invalid_args() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -2563,10 +2618,7 @@ fn test_selinux_invalid_args() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_selinux_default_context() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -2664,10 +2716,69 @@ fn test_install_non_utf8_paths() { ucmd.arg("-D").arg(source_file).arg(&target_path).succeeds(); } +/// A failed ownership change must not leave the setuid/setgid mode applied. +#[test] +fn test_install_failed_chown_does_not_leave_setuid() { + // Only meaningful when the chown can actually fail. + if geteuid().is_root() { + return; + } + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("src"); + + scene + .ucmd() + .args(&["-m", "4755", "-o", "root", "src", "dst"]) + .fails(); + + if at.file_exists("dst") { + let mode = at.metadata("dst").permissions().mode() & 0o7777; + assert_eq!( + mode & 0o6000, + 0, + "install left setuid/setgid set ({mode:o}) after the chown failed" + ); + } + + // The same for a directory created with -d. + scene + .ucmd() + .args(&["-d", "-m", "4755", "-o", "root", "newdir"]) + .fails(); + + if at.dir_exists("newdir") { + let mode = at.metadata("newdir").permissions().mode() & 0o7777; + assert_eq!( + mode & 0o6000, + 0, + "install -d left setuid/setgid set ({mode:o}) after the chown failed" + ); + } +} + +/// The mode must still be applied in full when no ownership change is requested. +#[test] +fn test_install_setuid_mode_applied_without_chown() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("src"); + + scene.ucmd().args(&["-m", "4755", "src", "dst"]).succeeds(); + assert_eq!(at.metadata("dst").permissions().mode() & 0o7777, 0o4755); + + scene + .ucmd() + .args(&["-d", "-m", "2755", "newdir"]) + .succeeds(); + assert_eq!(at.metadata("newdir").permissions().mode() & 0o7777, 0o2755); +} + #[test] fn test_install_unprivileged_option_u_skips_chown() { // This test only makes sense when not running as root. - if geteuid() == 0 { + if geteuid().is_root() { return; } @@ -2693,7 +2804,7 @@ fn test_install_unprivileged_option_u_skips_chown() { .no_stderr(); assert!(at.file_exists(dst_ok)); - assert_eq!(at.metadata(dst_ok).uid(), geteuid()); + assert_eq!(at.metadata(dst_ok).uid(), geteuid().as_raw()); } #[test] @@ -2837,61 +2948,33 @@ fn test_install_d_dangling_symlink_in_path_errors() { #[test] #[cfg(target_os = "linux")] fn test_install_set_owner_nonexistent_uid_and_gid() { - let file = File::open("/etc/login.defs").unwrap(); - let reader = BufReader::new(file); - let mut uid_min: u32 = 0; - let mut uid_max: u32 = 0; - let mut gid_min: u32 = 0; - let mut gid_max: u32 = 0; - for line in reader.lines() { - let line = line.unwrap(); - if line.starts_with("UID_MIN") { - let tokens: Vec<&str> = line.split_whitespace().collect(); - uid_min = tokens[1].parse().unwrap(); - } - if line.starts_with("UID_MAX") { - let tokens: Vec<&str> = line.split_whitespace().collect(); - uid_max = tokens[1].parse().unwrap(); - } - if line.starts_with("GID_MIN") { - let tokens: Vec<&str> = line.split_whitespace().collect(); - gid_min = tokens[1].parse().unwrap(); - } - if line.starts_with("GID_MAX") { - let tokens: Vec<&str> = line.split_whitespace().collect(); - gid_max = tokens[1].parse().unwrap(); + use std::collections::HashSet; + + // Collect the ids already present on the system so we can pick unused ones. + let mut used_uids: HashSet = HashSet::new(); + let mut used_gids: HashSet = HashSet::new(); + if let Ok(passwd) = fs::read_to_string("/etc/passwd") { + for line in passwd.lines() { + let fields: Vec<&str> = line.split(':').collect(); + if fields.len() < 4 { + continue; + } + if let Ok(uid) = fields[2].parse::() { + used_uids.insert(uid); + } + if let Ok(gid) = fields[3].parse::() { + used_gids.insert(gid); + } } } - let file = File::open("/etc/passwd").unwrap(); - let reader = BufReader::new(file); - - let mut uids: Vec = vec![]; - let mut gids: Vec = vec![]; - for line in reader.lines() { - let line = line.unwrap(); - let tokens: Vec<&str> = line.split(':').collect(); - let uid: u32 = tokens[2].parse().unwrap(); - if (uid_min..=uid_max).contains(&uid) { - uids.push(uid); - } - let gid: u32 = tokens[3].parse().unwrap(); - if (gid_min..=gid_max).contains(&gid) { - gids.push(gid); - } - } - uids.sort_unstable(); - - let next_uid = if let Some(uid) = uids.last() { - *uid + 1 - } else { - uid_min - }; - let next_gid = if let Some(gid) = gids.last() { - *gid + 1 - } else { - gid_min - }; + // Pick high ids that are not associated with any user/group. + let next_uid = (60_000..u32::MAX) + .find(|id| !used_uids.contains(id)) + .expect("no unused uid found"); + let next_gid = (60_000..u32::MAX) + .find(|id| !used_gids.contains(id)) + .expect("no unused gid found"); let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -2933,6 +3016,19 @@ fn test_install_proc_self_mem_as_dst() { .stderr_contains("cannot remove"); } +#[test] +#[cfg(target_os = "linux")] +fn test_install_dev_full_as_dst() { + let scene = TestScenario::new(util_name!()); + + scene + .ucmd() + .arg("/dev/null") + .arg("/dev/full") + .fails() + .stderr_contains("cannot remove '/dev/full'"); +} + #[test] fn test_install_backup_nil_same_file() { let scene = TestScenario::new(util_name!()); @@ -2959,3 +3055,212 @@ fn test_install_backup_nil_same_file() { assert_eq!(at.read(file), "content"); } } + +#[test] +fn test_install_backup_refuses_when_source_is_the_backup() { + // Installing `a~` over `a` renames `a` to `a~`, which would clobber the + // source before it is read. GNU refuses; so must we. + for mode in ["simple", "existing"] { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("a"); + at.write("a~", "source content"); + + scene + .ucmd() + .arg(format!("--backup={mode}")) + .arg("a~") + .arg("a") + .fails() + .stderr_is("install: backing up 'a' might destroy source; 'a~' not copied\n"); + + // The source must be intact. + assert_eq!(at.read("a~"), "source content"); + } +} + +#[test] +fn test_install_backup_numbered_allows_source_named_like_backup() { + // A numbered backup never reuses an existing name, so it cannot destroy + // the source and must not be refused. + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("a"); + at.write("a~", "source content"); + + scene + .ucmd() + .arg("--backup=numbered") + .arg("a~") + .arg("a") + .succeeds(); + assert_eq!(at.read("a~"), "source content"); + assert_eq!(at.read("a"), "source content"); +} + +#[test] +// Android denies hard links on the filesystem backing the test directory, so +// the setup cannot be built there; see the mv analogue. +#[cfg(not(target_os = "android"))] +fn test_install_backup_allows_hardlink_under_another_name() { + // `other` shares an inode with `a~` but its name is not `a` + suffix, so + // the backup rename cannot clobber it. GNU allows this. + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("a"); + at.write("a~", "source content"); + at.hard_link("a~", "other"); + + scene + .ucmd() + .arg("--backup=simple") + .arg("other") + .arg("a") + .succeeds(); + assert_eq!(at.read("a"), "source content"); +} + +#[test] +fn test_install_backup_refuses_regardless_of_spelling() { + // The guard compares files, not the strings naming them. + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("a"); + at.write("a~", "source content"); + + scene + .ucmd() + .arg("--backup=simple") + .arg("./a~") + .arg("a") + .fails() + .stderr_contains("might destroy source"); + assert_eq!(at.read("a~"), "source content"); +} + +#[test] +fn test_install_backup_custom_suffix_refuses() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("a"); + at.write("a.bak", "source content"); + + scene + .ucmd() + .args(&["-b", "--suffix=.bak", "a.bak", "a"]) + .fails() + .stderr_contains("might destroy source"); + assert_eq!(at.read("a.bak"), "source content"); +} + +// The mode is only parsed where a mode means something. +#[cfg(unix)] +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + #[test] + fn test_snippet_points_at_the_bad_operator() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("source"); + + let result = ucmd + .terminal_sim_stderr() + .args(&["-m", "u+rw?x", "source", "dest"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.contains("invalid operator"), "{stderr}"); + // The caret lands on `?`: three columns of `-m ` and four of mode. + assert_eq!(result.caret_column(), Some(8), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("source"); + + // The test harness pipes stderr, so the report must not appear. + let result = ucmd + .args(&["-m", "u+rw?x", "source", "dest"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.starts_with("install: "), "{stderr}"); + assert!(!stderr.contains(":1:"), "{stderr}"); + } +} + +#[test] +#[cfg(unix)] +fn test_install_d_parallel_mkdir_race() { + // Regression test for issue #12355: concurrent `install -D` invocations + // that share parent directories must all succeed instead of one losing + // the stat/mkdir race and failing with `cannot create directory`. + const PARALLEL: usize = 32; + const ROUNDS: usize = 10; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("s"); + + for round in 0..ROUNDS { + let mut children = Vec::with_capacity(PARALLEL); + for k in 0..PARALLEL { + let mut cmd = scene.ucmd(); + cmd.arg("-D").arg("s").arg(format!("o{round}/q/f{k}")); + children.push(cmd.run_no_wait()); + } + + for child in children { + child.wait().unwrap().success().no_stderr(); + } + + for k in 0..PARALLEL { + assert!(at.file_exists(format!("o{round}/q/f{k}"))); + } + } +} + +#[test] +#[cfg(target_os = "linux")] +fn test_install_target_without_splice_support() { + // Does eCryptfs not support splice on some kernel? + use std::process::Command; + if Command::new("strace") + .args(["-e", "inject=splice:error=EINVAL:when=2", "true"]) + .output() + .is_err() + { + return; // missing strace + } + let coreutils = uutests::util::get_tests_binary(); + Command::new("strace") + .args([ + "-e", + "inject=splice:error=EINVAL:when=2", + coreutils, + "install", + coreutils, + "target_file", + ]) + .output() + .unwrap(); + // properly copied with fallback from splice? + assert!(uucore::fs::are_files_identical(coreutils, "target_file").unwrap()); +} + +#[test] +fn test_install_will_not_overwrite_just_created() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("a"); + at.mkdir("b"); + at.mkdir("c"); + at.write("a/f", "a"); + at.write("b/f", "b"); + + ucmd.args(&["a/f", "b/f", "c/"]) + .fails() + .stderr_contains("will not overwrite just-created 'c/f' with 'b/f'"); + + assert_eq!(at.read("c/f"), "a"); +} diff --git a/tests/by-util/test_join.rs b/tests/by-util/test_join.rs index fbb6e88d3fb..1b34d2ecc08 100644 --- a/tests/by-util/test_join.rs +++ b/tests/by-util/test_join.rs @@ -2,7 +2,8 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (words) autoformat nocheck + +// spell-checker:ignore (words) autoformat nocheck FILENUM #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] use std::fs::OpenOptions; @@ -231,7 +232,21 @@ fn tab_multi_character() { .arg("-t") .arg("ab") .fails() - .stderr_is("join: multi-character tab ab\n"); + .stderr_is("join: multi-character tab 'ab'\n"); +} + +#[test] +fn tab_hyphen_leading_as_separate_arg() { + // A hyphen-leading separator value passed as its own argument (not + // attached with `-t-x`/`=`) must not be mistaken for a new, + // unrecognized flag. + new_ucmd!() + .arg("semicolon_fields_1.txt") + .arg("semicolon_fields_2.txt") + .arg("-t") + .arg("-x") + .fails() + .stderr_is("join: multi-character tab '-x'\n"); } #[test] @@ -253,6 +268,50 @@ fn default_format() { .stdout_only_fixture("default.expected"); } +#[test] +fn repeated_o_accumulates_fields() { + // GNU lets -o repeat; the fields accumulate in the order they appear, + // here giving the reverse of the default format. + new_ucmd!() + .arg("fields_1.txt") + .arg("fields_2.txt") + .arg("-o") + .arg("2.2") + .arg("-o") + .arg("1.1") + .succeeds() + .stdout_only("a 1\nb 2\nc 3\ne 5\nh 8\n"); +} + +#[test] +fn repeated_o_ignores_auto_when_mixed() { + // When -o is repeated and not every value is `auto`, each `auto` is + // ignored: only the explicit fields are printed, repeats included. + new_ucmd!() + .arg("fields_1.txt") + .arg("fields_2.txt") + .arg("-o") + .arg("auto") + .arg("-o") + .arg("2.2 2.2 1.1") + .succeeds() + .stdout_only("a a 1\nb b 2\nc c 3\ne e 5\nh h 8\n"); +} + +#[test] +fn repeated_o_auto_stays_auto() { + // `auto` still applies when every -o value is `auto`. + new_ucmd!() + .arg("fields_1.txt") + .arg("fields_2.txt") + .arg("-o") + .arg("auto") + .arg("-o") + .arg("auto") + .succeeds() + .stdout_only_fixture("default.expected"); +} + #[test] fn unpaired_lines_format() { new_ucmd!() @@ -338,6 +397,71 @@ fn missing_format_fields() { .stdout_only_fixture("missing_format_fields.expected"); } +#[test] +fn empty_fields_use_empty_filler() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + // A blank line splits into a single empty field, so the join field is + // present but zero length rather than missing. + at.write("blank", "hello\n\n\n"); + + ts.ucmd() + .args(&["-e", "EMPTY", "blank", "blank"]) + .succeeds() + .stdout_only("hello\nEMPTY\nEMPTY\nEMPTY\nEMPTY\n"); + + // The same holds for a line containing only whitespace. + at.write("spaces", " \n"); + + ts.ucmd() + .args(&["-e", "EMPTY", "-o", "0,1.1", "spaces", "spaces"]) + .succeeds() + .stdout_only("EMPTY EMPTY\n"); + + // A field between two adjacent separators is also present but empty. + at.write("gap", "a,,b\n"); + + ts.ucmd() + .args(&[ + "-t", + ",", + "-e", + "EMPTY", + "-o", + "0,1.1,1.2,1.3", + "gap", + "gap", + ]) + .succeeds() + .stdout_only("a,a,EMPTY,b\n"); + + ts.ucmd() + .args(&["-t", ",", "-e", "EMPTY", "gap", "gap"]) + .succeeds() + .stdout_only("a,EMPTY,b,EMPTY,b\n"); +} + +#[test] +fn empty_fields_kept_without_empty_filler() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.write("gap", "a,,b\n"); + + // Without -e an empty field stays empty. + ts.ucmd() + .args(&["-t", ",", "-o", "0,1.1,1.2,1.3", "gap", "gap"]) + .succeeds() + .stdout_only("a,a,,b\n"); + + // Passing an empty string to -e is equivalent to not passing it at all. + ts.ucmd() + .args(&["-t", ",", "-e", "", "-o", "0,1.1,1.2,1.3", "gap", "gap"]) + .succeeds() + .stdout_only("a,a,,b\n"); +} + #[test] fn nocheck_order() { new_ucmd!() @@ -597,3 +721,115 @@ fn test_locale_collation() { .stdout_contains("abc:d 2 y") .stdout_contains("ab:d 1 x"); } + +#[test] +fn test_incompatible_fields_reports_exact_field_number() { + // An out-of-range field clamps to usize::MAX, which used to overflow the + // one-based increment. Field numbers past 2^53 also used to be rounded on + // their way through the localization layer. + // + // `parse_field_number` uses `usize`, so a value at or above `usize::MAX` + // saturates to it. The saturation ceiling is therefore pointer-width + // dependent, and the expected text is built from `usize::MAX` rather than a + // hard-coded 64-bit literal. + let max_field = usize::MAX.to_string(); + + // A small field number takes the i64 number path through the localization + // layer and is platform-independent. + new_ucmd!() + .args(&["-j", "3", "-1", "5", "/dev/null", "/dev/null"]) + .fails() + .stderr_contains("incompatible join fields 3, 5"); + + // Values at or above `usize::MAX` saturate to `usize::MAX` on every + // platform; the localization layer must carry that ceiling as an exact + // decimal string rather than rounding it through Fluent's f64-backed number + // type. + for field in ["18446744073709551615", "99999999999999999999999"] { + new_ucmd!() + .args(&["-j", field, "-1", "5", "/dev/null", "/dev/null"]) + .fails() + .stderr_contains(format!("incompatible join fields {max_field}, 5")); + } + + // A value above f64 precision (2^53) but below `usize::MAX` is reported + // exactly on 64-bit (where `usize` holds it); on 32-bit it saturates to + // `usize::MAX` like the cases above. + #[cfg(target_pointer_width = "64")] + let expected_above: String = "9007199254740993".to_string(); + #[cfg(not(target_pointer_width = "64"))] + let expected_above: String = max_field.clone(); + new_ucmd!() + .args(&[ + "-j", + "9007199254740993", + "-1", + "5", + "/dev/null", + "/dev/null", + ]) + .fails() + .stderr_contains(format!("incompatible join fields {expected_above}, 5")); +} + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_failing_field_of_a_list() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-o", "1.2,2.x", "/dev/null", "/dev/null"]) + .fails_with_code(1); + + // The first field is fine; only the second one is at fault. + assert_eq!( + result.stderr_as_displayed(), + "\ +join: invalid field number: 'x' + ╭─[ join:1:13 ] + │ + 1 │ join -o 1.2,2.x /dev/null /dev/null + │ ─── + │ + │ Help: an output field is FILENUM.FIELD, as in -o 1.2,2.1; 0 stands for the join field +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_inside_a_glued_short_option() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-o1.2,0.4", "/dev/null", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("join:1:12"), "{stderr}"); + assert!(stderr.contains("invalid field specifier"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["-o", "1.2,2.x", "/dev/null", "/dev/null"]) + .fails_with_code(1) + .stderr_is("join: invalid field number: 'x'\n"); + } +} + +#[test] +fn test_hyphen_leading_field_number_is_reported_as_invalid() { + // GNU hands the argument after the option to that option even when it + // starts with a hyphen, so it is reported as an invalid field number + // rather than as an unknown option. + for opt in ["-1", "-2", "-j"] { + new_ucmd!() + .args(&[opt, "-1", "empty.txt", "empty.txt"]) + .fails() + .stderr_contains("invalid field number: '-1'"); + } +} diff --git a/tests/by-util/test_kill.rs b/tests/by-util/test_kill.rs index 009285a22b0..c4b642b6cf0 100644 --- a/tests/by-util/test_kill.rs +++ b/tests/by-util/test_kill.rs @@ -2,39 +2,72 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore IAMNOTASIGNAL RTMAX RTMIN SIGIO SIGRTMAX GHSA + +// spell-checker:ignore IAMNOTASIGNAL RTMAX RTMIN SIGIO SIGRTMAX GHSA CHLD SIGSTOP taskkill unreaped + use regex::Regex; +#[cfg(windows)] +use std::io::Write; +#[cfg(unix)] use std::os::unix::process::ExitStatusExt; -use std::process::{Child, Command}; +#[cfg(windows)] +use std::process::Stdio; +use std::process::{Child, Command, ExitStatus}; +#[cfg(windows)] +use uucore::process::Job; #[cfg(any(target_os = "linux", target_os = "android"))] use uucore::signals::realtime_signal_bounds; use uutests::new_ucmd; +use uutests::util::get_tests_binary; -// A child process the tests will try to kill. +// A child process the tests will try to kill: ` sleep 30` via the +// multicall test binary, which exists on every test platform (unlike `sleep` +// from PATH on Windows). The natural death after 30s avoids hanging failing +// tests. struct Target { child: Child, killed: bool, } impl Target { - // Creates a target that will naturally die after some time if not killed - // fast enough. - // This timeout avoids hanging failing tests. fn new() -> Self { Self { - child: Command::new("sleep") - .arg("30") + child: Command::new(get_tests_binary()) + .args(["sleep", "30"]) .spawn() .expect("cannot spawn target"), killed: false, } } - // Waits for the target to complete and returns the signal it received if any. - fn wait_for_signal(&mut self) -> Option { - let sig = self.child.wait().expect("cannot wait on target").signal(); + /// Wait for the target to exit, so `Drop` no longer has to kill it. + fn reap(&mut self) -> ExitStatus { + let status = self.child.wait().expect("cannot wait on target"); self.killed = true; - sig + status + } + + /// Reap the target and assert it was killed by `signal` (`128 + n` exit + /// code on windows). + fn assert_signaled(&mut self, signal: i32) { + let status = self.reap(); + #[cfg(unix)] + assert_eq!( + status.signal(), + Some(signal), + "target exited with {status:?} instead of dying from signal {signal}" + ); + #[cfg(windows)] + assert_eq!( + status.code(), + Some(128 + signal), + "target exited with {status:?} instead of dying from signal {signal}" + ); + } + + #[cfg(windows)] + fn assert_alive(&mut self) { + assert!(self.child.try_wait().expect("cannot poll target").is_none()); } fn pid(&self) -> u32 { @@ -52,6 +85,66 @@ impl Drop for Target { } } +/// A `cmd.exe` in a freshly created Job object, waiting for a command line on +/// its stdin pipe. +/// +/// The only safe way to run a *terminating* `kill 0` from a test: `kill 0` +/// signals the killer's immediate job, which here is `job` and nothing else, so +/// it cannot reach the test runner, another test's children, nextest's per-test +/// job or a CI agent — the ambient job is `job`'s *parent*, and a parent's +/// members are not in a child job's list. +/// +/// The gate is what makes that true rather than merely likely: `cmd` gets no +/// command until `assign` has succeeded, so the window between `CreateProcess` +/// and `AssignProcessToJobObject` contains nothing that could signal. Fail-safe +/// too — if anything panics before [`JobShell::run`], the pipe closes, `cmd` +/// sees EOF and exits having executed nothing. +#[cfg(windows)] +struct JobShell { + job: Job, + shell: Child, +} + +#[cfg(windows)] +impl JobShell { + fn new() -> Self { + let job = Job::new().expect("cannot create job object"); + let shell = Command::new("cmd") + // `/d` skips the AutoRun registry command, so a machine that has + // one cannot run it inside this job or write to the stderr the + // assertions quote. + .args(["/d", "/q"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("cannot spawn cmd"); + job.assign(&shell) + .expect("cannot assign cmd to the fresh job"); + Self { job, shell } + } + + /// So a group signal from inside reaches `child`. + fn adopt(&self, child: &Child) { + self.job + .assign(child) + .expect("cannot assign process to the fresh job"); + } + + /// Closes stdin afterwards so cmd exits at EOF rather than blocking forever + /// if the command fails to kill it. + fn run(mut self, command: &str) -> (Option, String) { + let mut stdin = self.shell.stdin.take().expect("cmd has no stdin pipe"); + write!(stdin, "{command}\r\n").expect("cannot write command"); + drop(stdin); + let output = self.shell.wait_with_output().expect("cannot wait on cmd"); + ( + output.status.code(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) + } +} + #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); @@ -266,10 +359,15 @@ fn test_kill_out_of_range_signal_is_rejected_not_sent() { .arg(format!("{}", target.pid())) .fails_with_code(1) .stderr_contains("invalid signal"); - // The target must have survived: kill it for real and confirm it was - // the SIGKILL we just sent, not an earlier stray SIGTERM. + // The target must have survived: kill it for real and confirm the + // exit came from std's kill (SIGKILL / code 1), not a stray uu-kill + // TERM (which would report 143 on windows). target.child.kill().expect("cannot kill surviving target"); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + let status = target.reap(); + #[cfg(unix)] + assert_eq!(status.signal(), Some(9)); + #[cfg(windows)] + assert_eq!(status.code(), Some(1)); } } @@ -277,7 +375,7 @@ fn test_kill_out_of_range_signal_is_rejected_not_sent() { fn test_kill_with_default_signal() { let mut target = Target::new(); new_ucmd!().arg(format!("{}", target.pid())).succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGTERM)); + target.assert_signaled(15); } #[test] @@ -287,7 +385,7 @@ fn test_kill_with_signal_number_old_form() { .arg("-9") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(9)); + target.assert_signaled(9); } #[test] @@ -298,7 +396,7 @@ fn test_kill_with_signal_name_old_form() { .arg(arg) .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } } @@ -319,7 +417,7 @@ fn test_kill_with_signal_prefixed_name_old_form() { .arg("-SIGKILL") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -330,7 +428,7 @@ fn test_kill_with_signal_number_new_form() { .arg("9") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(9)); + target.assert_signaled(9); } #[test] @@ -341,7 +439,7 @@ fn test_kill_with_signal_name_new_form() { .arg("KILL") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -352,7 +450,7 @@ fn test_kill_with_signal_name_new_form_ignore_case() { .arg("KiLl") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -363,7 +461,7 @@ fn test_kill_with_signal_prefixed_name_new_form() { .arg("SIGKILL") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -374,7 +472,7 @@ fn test_kill_with_signal_prefixed_name_new_form_ignore_case() { .arg("SiGKiLl") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -413,7 +511,7 @@ fn test_kill_with_signal_number_hidden_compatibility_option() { .arg("9") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(9)); + target.assert_signaled(9); } #[test] @@ -587,13 +685,165 @@ fn test_kill_signal_zero_nonexistent() { #[test] fn test_kill_signal_zero_current_process_group() { - // kill -0 0 should succeed (checks current process group) + // Signal 0 never terminates anything (windows opens group members with + // SYNCHRONIZE only), so this is safe un-isolated. It proves only that the + // enumeration does not error: probing yourself always succeeds, so it would + // pass on an empty pid list too. new_ucmd!().arg("-0").arg("0").succeeds(); } +#[cfg(windows)] +#[test] +fn test_kill_windows_help_has_platform_notes() { + // Every asserted phrase sits entirely inside one `kill-after-help-windows` + // continuation line: clap's `wrap_help` re-wraps lines longer than the + // terminal width but never joins short ones, so the .ftl line breaks + // survive verbatim and these assertions cannot straddle a break. + new_ucmd!() + .arg("--help") + .succeeds() + .stdout_contains("Windows notes") + .stdout_contains("PID 0 targets the Job object") + .stdout_contains("Outside a job") + .stdout_contains("a Job object is usually not yours"); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_int_and_hup_terminate_directly() { + for (name, sig) in [("INT", 2), ("HUP", 1)] { + let mut target = Target::new(); + new_ucmd!() + .args(&["-s", name, &target.pid().to_string()]) + .succeeds(); + target.assert_signaled(sig); + } +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_ignored_signals_are_noops() { + let mut target = Target::new(); + for sig in ["CHLD", "CONT"] { + new_ucmd!() + .args(&["-s", sig, &target.pid().to_string()]) + .succeeds(); + } + target.assert_alive(); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_stop_is_rejected() { + let mut target = Target::new(); + new_ucmd!() + .args(&["-s", "STOP", &target.pid().to_string()]) + .fails_with_code(1) + .stderr_contains("unsupported signal"); + target.assert_alive(); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_negative_pid_unsupported() { + // `-1` is the dangerous one on unix ("every process you may signal"); on + // windows it must be inert, and `u32::try_from` fails before any Win32 call. + new_ucmd!() + .args(&["-9", "-1"]) + .fails_with_code(1) + .stderr_contains("a negative PID"); + + let mut target = Target::new(); + new_ucmd!() + .arg("--") + .arg(format!("-{}", target.pid())) + .fails_with_code(1) + .stderr_contains("a negative PID"); + target.assert_alive(); +} + +/// `kill 0` must reach *other* members of the job, not just the caller. +/// +/// The victim is the proof: it is neither the killer's parent nor its child, so +/// the only path by which it can die is the job pid list. An implementation +/// that regressed to "signal only myself" would still make cmd exit 137, so the +/// exit code alone would not catch it. +#[cfg(windows)] +#[test] +fn test_kill_windows_pid_zero_terminates_the_whole_job() { + for (args, signal) in [("-9 0", 9), ("0", 15)] { + let shell = JobShell::new(); + let mut victim = Target::new(); + shell.adopt(&victim.child); + + // Only now can anything in the job run. + let (code, stderr) = shell.run(&format!("\"{}\" kill {args}", get_tests_binary())); + + // Read cmd's status first, so a broken kill surfaces its own message + // rather than only a hanging victim wait below. + assert_eq!( + code, + Some(128 + signal), + "cmd survived `kill {args}`; kill said: {stderr}" + ); + victim.assert_signaled(signal); + } +} + +// The "not in a job" fallback has no integration test on purpose: nothing can +// guarantee a spawned process is job-less. `CREATE_BREAKAWAY_FROM_JOB` detaches +// only from the immediate job, so under a nested chain (`cargo nextest` inside +// `cargo`) the child stays in an ancestor job and `kill -9 0` terminates the +// test runner — an earlier version of this test did exactly that. Covered by +// `with_self_last` in uucore instead, whose empty-member case is that case. + +#[cfg(windows)] +#[test] +fn test_kill_windows_nonexistent_pid_no_such_process() { + new_ucmd!() + .arg("999999999") + .fails_with_code(1) + .stderr_contains("No such process"); + new_ucmd!() + .args(&["-0", "999999999"]) + .fails_with_code(1) + .stderr_contains("No such process"); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_exited_target_with_held_handle() { + // The Child handle pins the exited process object: terminating still + // succeeds (unix kill-on-unreaped parity) while -0 reports it gone. + let mut target = Target::new(); + target.child.kill().expect("cannot kill target"); + target.reap(); + let pid = target.pid().to_string(); + new_ucmd!().arg(&pid).succeeds(); + new_ucmd!() + .args(&["-0", &pid]) + .fails_with_code(1) + .stderr_contains("No such process"); +} + +/// Some CI containers leave real-time signals ignored; children inherit that, +/// so `kill` succeeds but the target survives and exits 0 on its own. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn is_ignored(signal: i32) -> bool { + let mut action = std::mem::MaybeUninit::::uninit(); + // SAFETY: a null new-action only queries the current disposition. + unsafe { + libc::sigaction(signal, std::ptr::null(), action.as_mut_ptr()) == 0 + && action.assume_init().sa_sigaction == libc::SIG_IGN + } +} + #[cfg(any(target_os = "linux", target_os = "android"))] #[test] fn test_kill_realtime_signal() { + if is_ignored(libc::SIGRTMIN()) { + return; + } let mut target = Target::new(); // kill -s RTMIN should send SIGRTMIN and terminate the process new_ucmd!() @@ -601,7 +851,7 @@ fn test_kill_realtime_signal() { .arg("RTMIN") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGRTMIN())); + target.assert_signaled(libc::SIGRTMIN()); } #[cfg(any(target_os = "linux", target_os = "android"))] @@ -609,6 +859,9 @@ fn test_kill_realtime_signal() { fn test_kill_with_rtmax_offset() { let (_, rtmax) = realtime_signal_bounds().unwrap(); let sig: i32 = (rtmax as i32) - 7; + if is_ignored(sig) { + return; + } let mut target = Target::new(); new_ucmd!() @@ -616,7 +869,7 @@ fn test_kill_with_rtmax_offset() { .arg("SIGRTMAX-7") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(sig)); + target.assert_signaled(sig); } #[cfg(any(target_os = "linux", target_os = "android"))] @@ -624,6 +877,9 @@ fn test_kill_with_rtmax_offset() { fn test_kill_with_rtmin_offset() { let (rtmin, _) = realtime_signal_bounds().unwrap(); let sig: i32 = (rtmin as i32) + 7; + if is_ignored(sig) { + return; + } let mut target = Target::new(); new_ucmd!() @@ -631,5 +887,5 @@ fn test_kill_with_rtmin_offset() { .arg("SIGRTMIN+7") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(sig)); + target.assert_signaled(sig); } diff --git a/tests/by-util/test_link.rs b/tests/by-util/test_link.rs index 084f9daf80e..d8c942a69eb 100644 --- a/tests/by-util/test_link.rs +++ b/tests/by-util/test_link.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -72,3 +73,21 @@ fn test_link_three_arguments() { .fails() .stderr_contains("2 values required"); } + +#[test] +fn test_link_no_arguments() { + new_ucmd!().fails(); +} + +#[cfg(not(target_os = "android"))] +#[test] +fn test_link_dest_exists() { + let (at, mut ucmd) = at_and_ucmd!(); + let file = "test_link_dest_exists_src"; + let dest = "test_link_dest_exists_dst"; + + at.touch(file); + at.touch(dest); + + ucmd.args(&[file, dest]).fails().stderr_contains("exists"); +} diff --git a/tests/by-util/test_ln.rs b/tests/by-util/test_ln.rs index 7702b8b9de1..f53028cdb4c 100644 --- a/tests/by-util/test_ln.rs +++ b/tests/by-util/test_ln.rs @@ -2,9 +2,12 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + #![allow(clippy::similar_names)] use std::path::PathBuf; +#[cfg(all(unix, not(target_os = "android")))] +use std::time::Duration; use uutests::at_and_ucmd; use uutests::new_ucmd; use uutests::util::TestScenario; @@ -111,6 +114,127 @@ fn test_symlink_overwrite_force() { assert_eq!(at.resolve_link(link), file_b); } +/// A forced replace must be atomic, so a concurrent creator always loses. +/// Fails reliably if unlink-then-create ever comes back. +#[test] +// Android's app-private filesystem refuses hard links. +#[cfg(all(unix, not(any(target_os = "redox", target_os = "android"))))] +#[cfg_attr(wasi_runner, ignore)] +fn test_force_replace_never_leaves_the_destination_name_free() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + for symbolic in [true, false] { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("a"); + at.touch("b"); + if symbolic { + at.symlink_file("a", "link"); + } else { + at.hard_link("a", "link"); + } + + let target = at.plus("link"); + let stop = Arc::new(AtomicBool::new(false)); + let claimed = Arc::new(AtomicBool::new(false)); + + let (racer_stop, racer_claimed) = (stop.clone(), claimed.clone()); + let racer = std::thread::spawn(move || { + while !racer_stop.load(Ordering::Relaxed) { + // Succeeds only if the name is unoccupied at this instant. + if std::os::unix::fs::symlink("claimed-by-attacker", &target).is_ok() { + racer_claimed.store(true, Ordering::Relaxed); + return; + } + } + }); + + for _ in 0..100 { + let mut args = vec!["--force"]; + if symbolic { + args.push("-s"); + } + args.extend_from_slice(&["b", "link"]); + scene.ucmd().args(&args).succeeds(); + } + + stop.store(true, Ordering::Relaxed); + racer.join().unwrap(); + + assert!( + !claimed.load(Ordering::Relaxed), + "destination name was unoccupied during a forced replace (symbolic={symbolic})" + ); + } +} + +/// Replacing a destination that is already a link to the same inode leaves +/// `rename` with nothing to do, and the temporary must not survive that. +#[test] +// Android's app-private filesystem refuses hard links. +#[cfg(all(unix, not(any(target_os = "redox", target_os = "android"))))] +fn test_force_replace_same_inode_leaves_no_temp_file() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("a"); + at.hard_link("a", "b"); + + scene.ucmd().args(&["--force", "a", "b"]).succeeds(); + + let leftovers: Vec<_> = std::fs::read_dir(at.as_string()) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) + .filter(|name| name != "a" && name != "b") + .collect(); + assert!( + leftovers.is_empty(), + "forced replace left a temporary behind: {leftovers:?}" + ); +} + +/// The destination can sit inside a directory reached through a symlink, as +/// when the target argument is a symlink to a directory. The replace must +/// follow it the same way the first create attempt does. +#[test] +#[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: creating a link inside a symlinked directory is denied" +)] +fn test_force_replace_in_symlinked_directory() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("real"); + at.symlink_dir("real", "dirlink"); + at.symlink_file("old", "real/link"); + + ucmd.args(&["-s", "-f", "new", "dirlink/link"]).succeeds(); + + assert_eq!(at.resolve_link("real/link"), "new"); +} + +/// Same as above for a hard link, which goes through the same replace path. +#[test] +// Android's app-private filesystem refuses hard links. +#[cfg(all(unix, not(any(target_os = "redox", target_os = "android"))))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: creating a link inside a symlinked directory is denied" +)] +fn test_force_replace_hard_link_in_symlinked_directory() { + use std::os::unix::fs::MetadataExt; + + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("new"); + at.mkdir("real"); + at.symlink_dir("real", "dirlink"); + at.touch("real/link"); + + ucmd.args(&["-f", "new", "dirlink/link"]).succeeds(); + + assert_eq!(at.metadata("real/link").ino(), at.metadata("new").ino()); +} + #[test] fn test_symlink_overwrite_force_overrides_interactive() { let (at, mut ucmd) = at_and_ucmd!(); @@ -580,6 +704,19 @@ fn test_symlink_missing_destination() { )); } +#[test] +fn test_symlink_error_includes_destination() { + let (at, mut ucmd) = at_and_ucmd!(); + let file = "test_symlink_error_includes_destination"; + let link = "no_such_dir/test_symlink_error_includes_destination"; + + at.touch(file); + + ucmd.args(&["-s", file, link]).fails().stderr_is(format!( + "ln: failed to create symbolic link '{link}': No such file or directory\n" + )); +} + #[test] fn test_symlink_relative() { let (at, mut ucmd) = at_and_ucmd!(); @@ -840,6 +977,38 @@ fn test_backup_same_file() { .stderr_contains("n: 'file1' and './file1' are the same file"); } +#[test] +#[cfg(not(target_os = "android"))] +fn test_backup_existing_hard_linked_under_different_name() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("a"); + at.hard_link("a", "b"); + + ucmd.args(&["--backup", "a", "b"]).succeeds().no_stderr(); + + assert!(at.file_exists("a")); + assert!(at.file_exists("b")); + assert!(at.file_exists("b~")); +} + +#[test] +#[cfg(all(unix, not(target_os = "android")))] +fn test_backup_existing_hard_linked_target_is_fifo() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("a"); + at.hard_link("a", "b"); + at.mkfifo("b~"); + + ucmd.args(&["--backup", "a", "b"]) + .timeout(Duration::from_secs(10)) + .succeeds() + .no_stderr(); + + assert!(at.file_exists("a")); + assert!(at.file_exists("b")); + assert!(!at.is_fifo("b~")); +} + #[test] fn test_backup_force() { let scene = TestScenario::new(util_name!()); @@ -914,7 +1083,7 @@ fn test_hard_logical_dir_fail() { .ucmd() .args(&["-L", target, "hard-to-dir-link"]) .fails() - .stderr_contains("failed to create hard link 'link-to-dir'"); + .stderr_contains("failed to create hard link 'hard-to-dir-link'"); } #[test] @@ -1117,7 +1286,9 @@ fn test_ln_no_dereference_symbolic() { .ucmd() .args(&["-n", "x", "b"]) .fails() - .stderr_contains("Already exists"); + .stderr_contains("failed to create hard link 'b'") + // strerror(EEXIST), as GNU prints it + .stderr_contains("File exists"); assert!(!at.file_exists("a/x")); #[cfg(not(target_os = "android"))] { @@ -1143,3 +1314,15 @@ fn test_ln_backup_nonexistent_rollback() { assert!(!at.file_exists("dst~")); assert!(at.file_exists("dst")); } + +#[test] +fn test_hard_link_force_failed_link_keeps_destination() { + // Regression for #14550: a failed forced link must not delete the destination. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("dst", "keep\n"); + + ucmd.args(&["-f", "no_such_source", "dst"]).fails(); + + assert!(at.file_exists("dst")); + assert_eq!(at.read("dst"), "keep\n"); +} diff --git a/tests/by-util/test_logname.rs b/tests/by-util/test_logname.rs index 52ac49a0a2d..c3ff0458670 100644 --- a/tests/by-util/test_logname.rs +++ b/tests/by-util/test_logname.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use regex::Regex; use std::env; use uutests::new_ucmd; diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 56d74482758..2e7e5240731 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.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 (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 mdangling mentry awith acolons NOFILE NOTCAPABLE +// spell-checker:ignore (words) fakeroot setcap drwxr bcdlps mdangling mentry awith acolons Nofile NOTCAPABLE iproduct newfstatat isox + #![allow( clippy::similar_names, clippy::too_many_lines, @@ -12,7 +14,7 @@ use regex::Regex; #[cfg(unix)] -use rlimit::Resource; +use rustix::process::Resource; #[cfg(not(target_os = "openbsd"))] use std::collections::HashMap; #[cfg(target_os = "linux")] @@ -28,7 +30,7 @@ use uutests::new_ucmd; #[cfg(unix)] use uutests::unwrap_or_return; use uutests::util::TestScenario; -#[cfg(any(unix, feature = "feat_selinux"))] +#[cfg(any(unix, feature = "selinux"))] use uutests::util::expected_result; use uutests::{at_and_ucmd, util_name}; @@ -1001,6 +1003,37 @@ fn test_ls_commas() { .succeeds() .stdout_only("test-commas-1, test-commas-2, test-commas-3,\ntest-commas-4\n"); } + + at.touch("a"); + at.touch("bb"); + at.touch("c"); + for (args, expected) in [ + (vec!["-m", "-w5", "a", "bb"], "a, bb\n"), + (vec!["-m", "-w5", "a", "bb", "c"], "a,\nbb, c\n"), + ] { + scene.ucmd().args(&args).succeeds().stdout_only(expected); + } + + #[cfg(unix)] + { + at.touch("com,ma"); + for (style, expected) in [("shell", "'com,ma'"), ("escape", "com\\,ma")] { + scene + .ucmd() + .env("LC_ALL", "C") + .args(&["-m", &format!("--quoting-style={style}"), "com,ma"]) + .succeeds() + .stdout_only(format!("{expected}\n")); + } + + at.touch("n\nl"); + for (args, expected) in [ + (vec!["-m", "n\nl"], "n?l\n"), + (vec!["-m", "--show-control-chars", "n\nl"], "n\nl\n"), + ] { + scene.ucmd().args(&args).succeeds().stdout_only(expected); + } + } } #[test] @@ -1168,7 +1201,7 @@ fn test_ls_long() { #[cfg(not(windows))] #[test] -#[cfg(not(feature = "feat_selinux"))] +#[cfg(not(feature = "selinux"))] // Disabled on the SELinux runner for now fn test_ls_long_format() { let scene = TestScenario::new(util_name!()); @@ -1507,8 +1540,8 @@ fn test_ls_long_dangling_symlink_color() { assert_eq!(target_color, "34"); } -#[test] /// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle3`. +#[test] fn test_ls_dangling_symlink_or_and_missing_colors() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1572,8 +1605,8 @@ fn test_ls_symlink_to_dir_with_mi_colors() { assert_eq!(captures.name("target").unwrap().as_str(), "1;34"); } -#[test] /// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle4`. +#[test] fn test_ls_dangling_symlink_ln_or_priority() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1601,8 +1634,8 @@ fn test_ls_dangling_symlink_ln_or_priority() { assert_eq!(captures.name("target").unwrap().as_str(), "35"); } -#[test] /// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle5`. +#[test] fn test_ls_dangling_symlink_ln_and_missing_colors() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1630,8 +1663,8 @@ fn test_ls_dangling_symlink_ln_and_missing_colors() { assert_eq!(captures.name("target").unwrap().as_str(), "35"); } -#[test] /// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle7`. +#[test] fn test_ls_dangling_symlink_blank_or_still_emits_reset() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1652,8 +1685,8 @@ fn test_ls_dangling_symlink_blank_or_still_emits_reset() { ); } -#[test] /// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle9`. +#[test] fn test_ls_dangling_symlink_blank_or_in_directory_listing() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1675,8 +1708,8 @@ fn test_ls_dangling_symlink_blank_or_in_directory_listing() { ); } -#[test] /// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle8`. +#[test] fn test_ls_dangling_symlink_uses_ln_when_or_blank() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1697,8 +1730,8 @@ fn test_ls_dangling_symlink_uses_ln_when_or_blank() { ); } -#[test] /// Mirrors GNU `tests/ls/ls-misc.pl::sl-dangle6`. +#[test] fn test_ls_directory_dangling_symlink_uses_ln_when_or_blank() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1766,7 +1799,7 @@ fn test_ls_long_total_size() { } #[test] -#[cfg(not(feature = "feat_selinux"))] +#[cfg(not(feature = "selinux"))] // Disabled on the SELinux runner for now fn test_ls_long_formats() { let scene = TestScenario::new(util_name!()); @@ -2110,6 +2143,26 @@ fn test_ls_sort_name() { .stdout_is(".a\n.b\na\nb\n"); } +// https://github.com/uutils/coreutils/issues/11831 +// In a UTF-8 locale, GNU ls places "." and ".." before names starting with +// punctuation such as '#' due to locale-aware collation. +#[test] +fn test_ls_sort_dot_first_utf8_locale() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("#asdf"); + at.touch("bar"); + at.touch("foo"); + + scene + .ucmd() + .env("LANG", "en_US.UTF-8") + .env("LC_ALL", "en_US.UTF-8") + .arg("-1a") + .succeeds() + .stdout_is(".\n..\n#asdf\nbar\nfoo\n"); +} + #[test] fn test_ls_sort_width() { let scene = TestScenario::new(util_name!()); @@ -2344,15 +2397,17 @@ fn test_ls_time_styles() { .stdout_matches(&re_custom_format_recent) .stdout_matches(&re_custom_format_old); - //+FORMAT_RECENT\nFORMAT_OLD + //+FORMAT_OLD\nFORMAT_RECENT: FORMAT1 applies to old files, FORMAT2 to recent files. + let re_custom_format_recent_2 = + Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}--\d{2} test\n").unwrap(); let re_custom_format_old = - Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}--\d{2} test-old\n").unwrap(); + Regex::new(r"[a-z-]* \d* [\w.]* [\w.]* \d* \d{4}__\d{2} test-old\n").unwrap(); scene .ucmd() .arg("-l") .arg("--time-style=+%Y__%M\n%Y--%M") .succeeds() - .stdout_matches(&re_custom_format_recent) + .stdout_matches(&re_custom_format_recent_2) .stdout_matches(&re_custom_format_old); // Also fails due to not having full clap support for time_styles @@ -2480,13 +2535,13 @@ fn test_ls_time_recent_future() { .succeeds() .stdout_matches(&re_iso_old); - // Also test that we can set a format that varies for recent of older files. - //+FORMAT_RECENT\nFORMAT_OLD + // A two-line format assigns FORMAT1 to old files and FORMAT2 to recent files. + //+FORMAT_OLD\nFORMAT_RECENT f.set_modified(SystemTime::now()).unwrap(); scene .ucmd() .arg("-l") - .arg("--time-style=+RECENT\nOLD") + .arg("--time-style=+OLD\nRECENT") .succeeds() .stdout_contains("RECENT"); @@ -2496,11 +2551,11 @@ fn test_ls_time_recent_future() { scene .ucmd() .arg("-l") - .arg("--time-style=+RECENT\nOLD") + .arg("--time-style=+OLD\nRECENT") .succeeds() .stdout_contains("OLD"); - // RECENT format is still used if no "OLD" one provided. + // The single format is used for all files when no second one is provided. scene .ucmd() .arg("-l") @@ -2509,6 +2564,57 @@ fn test_ls_time_recent_future() { .stdout_contains("RECENT"); } +#[test] +fn test_ls_order_time_breaks_ties_by_name() { + // Every other sort in this utility falls back on the name, and GNU ls does + // the same for -t. Without the fallback, entries sharing a timestamp come + // out in whatever order the directory happened to be read in. + use filetime::{FileTime, set_file_times}; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + let names = ["zulu", "alpha", "Mike", "bravo"]; + for name in names { + at.touch(name); + at.append(name, "x"); + } + let same = FileTime::from_unix_time(1_700_000_000, 0); + for name in names { + set_file_times(at.plus_as_string(name), same, same).unwrap(); + } + + scene + .ucmd() + .env("LC_ALL", "C") + .arg("-t") + .succeeds() + .stdout_only("Mike\nalpha\nbravo\nzulu\n"); + + scene + .ucmd() + .env("LC_ALL", "C") + .arg("-tr") + .succeeds() + .stdout_only("zulu\nbravo\nalpha\nMike\n"); + + // The tie is broken with the same order the name sort uses, so a UTF-8 + // locale puts `alpha` before `Mike` where the C locale does the reverse. + #[cfg(unix)] + { + use uutests::util::is_locale_available; + let locale = "en_US.UTF-8"; + if is_locale_available(locale) { + scene + .ucmd() + .env("LC_ALL", locale) + .arg("-t") + .succeeds() + .stdout_only("alpha\nbravo\nMike\nzulu\n"); + } + } +} + #[test] fn test_ls_order_time() { let scene = TestScenario::new(util_name!()); @@ -2553,6 +2659,16 @@ fn test_ls_order_time() { let result = scene.ucmd().arg("--sort=time").arg("-r").succeeds(); result.stdout_only("test-1\ntest-2\ntest-3\ntest-4\n"); + // Long format selects the displayed time without enabling time sorting. + let name_order = Regex::new(r"(?s)test-1\n.*test-2\n.*test-3\n.*test-4\n").unwrap(); + for (time, format) in itertools::iproduct!(["-u", "-c"], ["-g", "--format=long", "--dired"]) { + scene + .ucmd() + .args(&[time, format]) + .succeeds() + .stdout_matches(&name_order); + } + let args: [&[&str]; 10] = [ &["-t", "-u"], &["-u"], //-t is optional: when -l is not set -u/--time controls sorting @@ -2721,6 +2837,31 @@ fn test_ls_recursive_1() { .stdout_is(out); } +#[test] +fn test_ls_recursive_all_with_version_sort_does_not_walk_up() { + // Regression test for https://github.com/uutils/coreutils/issues/13501: + // combining `-a` (show `.`/`..`) with `-R` (recursive) and `-v` + // (version/natural sort) used to make `ls` recurse into the listed + // `.`/`..` entries themselves, walking all the way up to the + // filesystem root instead of stopping at the leaf directories. + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkdir("a"); + at.mkdir("a/b"); + at.mkdir("a/b/c"); + + #[cfg(unix)] + let out = "a/b:\n.\n..\nc\n\na/b/c:\n.\n..\n"; + #[cfg(windows)] + let out = "a/b:\n.\n..\nc\n\na/b\\c:\n.\n..\n"; + scene + .ucmd() + .arg("-aRv") + .arg("a/b") + .succeeds() + .stdout_is(out); +} + /// The quoting module regroups tests that check the behavior of ls when /// quoting and escaping special characters with different quoting styles. #[cfg(unix)] @@ -3251,10 +3392,10 @@ mod quoting { ); } - #[cfg(not(any(target_vendor = "apple", target_os = "windows", target_os = "openbsd")))] - #[test] /// This test creates files with an UTF-8 encoded name and verify that it /// gets escaped depending on the used locale. + #[cfg(not(any(target_vendor = "apple", windows, target_os = "openbsd")))] + #[test] fn test_locale_aware_quoting() { let cases: &[(&[u8], _, _, &[&str])] = &[ ( @@ -3315,6 +3456,30 @@ mod quoting { .stdout_only(utf_8_ref); } } + + #[test] + fn test_c_dot_utf8_renders_utf8() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + let filename = "é"; + at.touch(filename); + + // C (no UTF-8): bytes 0xC3 0xA9 replaced with ?? + scene + .ucmd() + .env("LC_ALL", "C") + .args(&["--quoting-style=literal", "--hide-control-chars"]) + .succeeds() + .stdout_is("??\n"); + + // C.UTF-8: multi-byte UTF-8 character rendered literally + scene + .ucmd() + .env("LC_ALL", "C.UTF-8") + .args(&["--quoting-style=literal", "--hide-control-chars"]) + .succeeds() + .stdout_is("é\n"); + } } #[test] @@ -3386,7 +3551,7 @@ fn test_ls_color() { #[cfg(unix)] #[test] -#[cfg(not(feature = "feat_selinux"))] +#[cfg(not(feature = "selinux"))] // Disabled on the SELinux runner for now fn test_ls_inode() { let scene = TestScenario::new(util_name!()); @@ -3674,7 +3839,7 @@ fn test_ls_indicator_style() { } } -#[cfg(not(any(target_vendor = "apple", target_os = "windows", target_os = "openbsd")))] // Truncate not available on mac or win +#[cfg(not(any(target_vendor = "apple", windows, target_os = "openbsd")))] // Truncate not available on mac or win #[test] fn test_ls_human_si() { let scene = TestScenario::new(util_name!()); @@ -3912,6 +4077,25 @@ fn test_ls_version_sort() { ); } +#[test] +fn test_ls_version_sort_command_line_args() { + // Regression test for https://github.com/uutils/coreutils/issues/14859: + // command-line arguments sharing the same file name must be version-sorted + // by their full path, not just by the file name. + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + for dir in ["10", "18", "9.5"] { + at.mkdir(dir); + at.touch(format!("{dir}/file")); + } + + scene + .ucmd() + .args(&["-1v", "10/file", "18/file", "9.5/file"]) + .succeeds() + .stdout_only("9.5/file\n10/file\n18/file\n"); +} + #[test] fn test_ls_quoting_style() { let scene = TestScenario::new(util_name!()); @@ -4118,6 +4302,63 @@ fn test_ls_quoting_style_env_var_default() { .stdout_only(format!("{correct_c}\n")); } +// An unwritable stderr must not turn the QUOTING_STYLE warning into an abort: +// the diagnostic is best-effort, so a failed write is dropped and the listing +// still goes out. /dev/full is Linux-only. +#[cfg(target_os = "linux")] +#[test] +fn test_ls_invalid_quoting_style_env_var_with_unwritable_stderr() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("zeta"); + at.touch("alpha"); + + let dev_full = std::fs::OpenOptions::new() + .write(true) + .open("/dev/full") + .unwrap(); + + scene + .ucmd() + .env("QUOTING_STYLE", "not-a-style") + .set_stderr(dev_full) + .succeeds() + .stdout_is("alpha\nzeta\n"); +} + +#[test] +fn test_ls_invalid_quoting_style_env_var_warns() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("alpha"); + + scene + .ucmd() + .env("QUOTING_STYLE", "not-a-style") + .arg("alpha") + .succeeds() + .stdout_is("alpha\n") + .stderr_contains("ignoring invalid value of environment variable QUOTING_STYLE"); +} + +#[cfg(unix)] +#[test] +fn test_ls_invalid_quoting_style_env_var_non_utf8() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("alpha"); + let style = uucore::os_str_from_bytes(b"\xFF") + .expect("Only unix platforms can test non-unicode env values"); + + scene + .ucmd() + .env("QUOTING_STYLE", style) + .arg("alpha") + .succeeds() + .stdout_is("alpha\n") + .stderr_contains("ignoring invalid value of environment variable QUOTING_STYLE"); +} + #[test] fn test_ls_quoting_style_arg_overrides_env_var() { let scene = TestScenario::new(util_name!()); @@ -4958,10 +5199,7 @@ fn test_ls_long_self_referential_dir_lists_contents() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_ls_context1() { if !uucore::selinux::is_selinux_enabled() { println!("test skipped: Kernel has no support for SElinux context"); @@ -4976,10 +5214,7 @@ fn test_ls_context1() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_ls_context2() { if !uucore::selinux::is_selinux_enabled() { println!("test skipped: Kernel has no support for SElinux context"); @@ -4995,10 +5230,7 @@ fn test_ls_context2() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_ls_context_long() { if !uucore::selinux::is_selinux_enabled() { return; @@ -5017,10 +5249,7 @@ fn test_ls_context_long() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_ls_context_format() { if !uucore::selinux::is_selinux_enabled() { println!("test skipped: Kernel has no support for SElinux context"); @@ -5050,10 +5279,7 @@ fn test_ls_context_format() { } /// Helper function to validate `SELinux` context format -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn validate_selinux_context(context: &str) { assert!( context.contains(':'), @@ -5068,10 +5294,7 @@ fn validate_selinux_context(context: &str) { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_ls_selinux_context_format() { if !uucore::selinux::is_selinux_enabled() { println!("test skipped: Kernel has no support for SElinux context"); @@ -5104,10 +5327,7 @@ fn test_ls_selinux_context_format() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_ls_selinux_context_indicator() { if !uucore::selinux::is_selinux_enabled() { println!("test skipped: Kernel has no support for SElinux context"); @@ -5478,6 +5698,42 @@ fn test_ls_dired_implies_long() { .stdout_contains("//DIRED-OPTIONS// --quoting-style"); } +// Regression test for issue #14775. +#[test] +fn test_ls_dired_quoting_style_name() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkdir("dir"); + + for style in [ + "literal", + "shell", + "shell-always", + "shell-escape", + "shell-escape-always", + "c", + "escape", + "locale", + "clocale", + ] { + scene + .ucmd() + .env("LC_ALL", "C") + .args(&["-l", "--dired", &format!("--quoting-style={style}"), "dir"]) + .succeeds() + .stdout_contains(format!("//DIRED-OPTIONS// --quoting-style={style}")); + } + + for (opt, style) in [("-N", "literal"), ("-Q", "c"), ("-b", "escape")] { + scene + .ucmd() + .env("LC_ALL", "C") + .args(&["-l", "--dired", opt, "dir"]) + .succeeds() + .stdout_contains(format!("//DIRED-OPTIONS// --quoting-style={style}")); + } +} + #[test] fn test_ls_dired_hyperlink() { // we will have link but not the DIRED output @@ -5530,17 +5786,133 @@ fn test_ls_dired_order_format() { .stdout_contains("//DIRED//"); } +#[test] +fn test_ls_dired_format_precedence_is_positional() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.touch("a"); + + // A later format option wins over --dired ... + for format in ["-C", "--format=single-column"] { + scene + .ucmd() + .args(&["--dired", format, "a"]) + .succeeds() + .stdout_only("a\n"); + } + // ... and a later --dired wins over the format option. + let result = scene.ucmd().args(&["-C", "--dired", "a"]).succeeds(); + assert_eq!(dired_names(result.stdout_str()), ["a"]); + + // -1 after --dired has no effect, exactly as after -l. + let result = scene.ucmd().args(&["--dired", "-1", "a"]).succeeds(); + assert_eq!(dired_names(result.stdout_str()), ["a"]); + + // A later long-format option restores both the long listing and dired. + let result = scene.ucmd().args(&["--dired", "-C", "-g", "a"]).succeeds(); + assert_eq!(dired_names(result.stdout_str()), ["a"]); +} + +#[test] +fn test_ls_dired_position_vs_hyperlink() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.touch("a"); + + // A --hyperlink option after --dired does not move --dired: with + // hyperlinks left disabled, both the long format and the dired output + // survive an earlier -C ... + let result = scene + .ucmd() + .args(&["-C", "--dired", "--hyperlink=never", "a"]) + .succeeds(); + assert_eq!(dired_names(result.stdout_str()), ["a"]); + // ... and with hyperlinks enabled, only the dired output is cancelled. + scene + .ucmd() + .args(&["-C", "--dired", "--hyperlink", "a"]) + .succeeds() + .stdout_matches(&Regex::new(r"^-([r-][w-][xt-]){3}").unwrap()) + .stdout_contains("file://") + .stdout_does_not_contain("//DIRED//"); +} + +#[test] +fn test_ls_dired_lookalike_operand() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.touch("-D"); + + // An operand that merely looks like the option is not the option. + scene + .ucmd() + .args(&["--zero", "--", "-D"]) + .succeeds() + .stdout_only("-D\0"); +} + +#[cfg(unix)] +#[test] +fn test_ls_dired_terminal_keeps_default_quoting() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkdir("a b"); + at.touch("a b/x"); + + // On a terminal the default quoting style is shell-escape, with or without + // --dired, and the offsets cover the quoted names, directory headers + // included. + let result = scene + .ucmd() + .args(&["--dired", "-R", "a b"]) + .terminal_simulation(true) + .succeeds(); + // The pty turns every \n into \r\n, which the offsets do not account for. + let stdout = result.stdout_str().replace("\r\n", "\n"); + assert_eq!(dired_names(&stdout), ["x"]); + assert_eq!(subdired_names(&stdout), ["'a b'"]); + assert!(stdout.contains("//DIRED-OPTIONS// --quoting-style=shell-escape")); +} + +#[test] +fn test_ls_dired_offsets_follow_quoted_dir_headers() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkdir("a b"); + at.touch("a b/x"); + at.mkdir("it's"); + at.touch("it's/y"); + + // Quoting lengthens the directory headers; both offset lists must follow + // the rendered header rather than the raw path. + let result = scene + .ucmd() + .args(&[ + "--dired", + "-R", + "--quoting-style=shell-escape", + "a b", + "it's", + ]) + .succeeds(); + assert_eq!(dired_names(result.stdout_str()), ["x", "y"]); + assert_eq!(subdired_names(result.stdout_str()), ["'a b'", "\"it's\""]); +} + #[test] fn test_ls_dired_and_zero_are_incompatible() { let scene = TestScenario::new(util_name!()); + scene.fixtures.touch("a"); scene .ucmd() - .arg("--dired") - .arg("-l") - .arg("--zero") + .args(&["--dired", "-l", "--zero"]) .fails_with_code(2) .stderr_contains("--dired and --zero are incompatible"); + + // A later non-long format cancels dired, so --zero is allowed. + scene + .ucmd() + .args(&["--dired", "-C", "--zero", "a"]) + .succeeds() + .stdout_only("a\0"); } #[test] @@ -5780,6 +6152,127 @@ fn test_ls_dired_symlink_name_only() { assert_eq!(filenames, vec!["link", "target"]); } +/// Extracts the file names delimited by the //DIRED// byte offsets. +fn dired_names(output: &str) -> Vec { + names_at_offsets(output, "//DIRED//") +} + +/// Extracts the directory headers delimited by the //SUBDIRED// byte offsets. +fn subdired_names(output: &str) -> Vec { + names_at_offsets(output, "//SUBDIRED//") +} + +fn names_at_offsets(output: &str, tag: &str) -> Vec { + let dired_line = output + .lines() + .find(|&line| line.starts_with(tag)) + .unwrap_or_else(|| panic!("no {tag} line in the output")); + let positions: Vec = dired_line + .split_whitespace() + .skip(1) + .map(|s| s.parse().unwrap()) + .collect(); + assert_eq!(positions.len() % 2, 0); + positions + .chunks(2) + .map(|chunk| String::from_utf8(output.as_bytes()[chunk[0]..chunk[1]].to_vec()).unwrap()) + .collect() +} + +#[test] +fn test_ls_dired_name_boundaries() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("d"); + at.touch("d/target"); + at.relative_symlink_file("target", "d/link"); + at.mkdir("d/sub"); + at.touch("d/with space"); + + let quoted = ["link", "sub", "target", "'with space'"]; + let literal = ["link", "sub", "target", "with space"]; + // The quoting style is spelled out in every case: it decides both how the + // names are rendered and whether they get padded, so leaving it to the + // default would make the expectations depend on stdout being a terminal. + let cases: [(&[&str], [&str; 4]); 4] = [ + // The color escapes wrapped around a name are not part of it, + (&["--quoting-style=literal", "--color=always"], literal), + // neither is the indicator character appended by -F, + (&["--quoting-style=literal", "-F", "--color=never"], literal), + ( + &["--quoting-style=literal", "-F", "--color=always"], + literal, + ), + // nor the space padding unquoted names to align with quoted ones. + (&["--quoting-style=shell-escape"], quoted), + ]; + + for (args, expected) in cases { + let result = scene + .ucmd() + .arg("--dired") + .arg("-l") + .args(args) + .arg("d") + .succeeds(); + assert_eq!(dired_names(result.stdout_str()), expected, "with {args:?}"); + } +} + +#[test] +fn test_ls_dired_leading_info_offsets() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("bay"); + at.touch("bay/quill"); + at.touch("bay/parchment"); + + // -i and -s prepend an inode / block-size column to every long line; those + // bytes shift the names and must be reflected in the //DIRED// offsets. + for args in [&["-i"][..], &["-s"][..], &["-i", "-s"][..]] { + let result = scene + .ucmd() + .arg("--dired") + .arg("-l") + .arg("--quoting-style=literal") + .args(args) + .arg("bay") + .succeeds(); + assert_eq!( + dired_names(result.stdout_str()), + ["parchment", "quill"], + "with {args:?}" + ); + } +} + +#[test] +fn test_ls_dired_normal_style_offsets() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("d"); + at.touch("d/note"); + at.mkdir("d/box"); + + // A `no` style makes ls emit a reset before the very first line; those + // bytes count towards the //DIRED// offsets like any other output. + let result = scene + .ucmd() + .env("LS_COLORS", "no=35:di=36") + .arg("--dired") + .arg("-l") + .arg("--quoting-style=literal") + .arg("--color=always") + .arg("d") + .succeeds(); + + assert!(result.stdout_str().starts_with("\x1b[")); + assert_eq!(dired_names(result.stdout_str()), ["box", "note"]); +} + #[test] fn test_ls_dired_complex() { let scene = TestScenario::new(util_name!()); @@ -6218,12 +6711,12 @@ fn test_ls_hyperlink() { } } -// spell-checker: disable +// spell-checker:disable #[test] fn test_ls_hyperlink_encode_link() { let (at, mut ucmd) = at_and_ucmd!(); - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { at.touch("back\\slash"); at.touch("ques?tion"); @@ -6232,7 +6725,7 @@ fn test_ls_hyperlink_encode_link() { at.touch("sp ace"); let result = ucmd.arg("--hyperlink").succeeds(); - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] { assert!( result @@ -6256,7 +6749,7 @@ fn test_ls_hyperlink_encode_link() { .contains("sp%20ace\x1b\\sp ace\x1b]8;;\x1b\\") ); } -// spell-checker: enable +// spell-checker:enable #[test] fn test_ls_hyperlink_dirs() { @@ -6398,9 +6891,9 @@ fn test_ls_hyperlink_utf8_encoding() { let at = &scene.fixtures; at.touch("café.txt"); - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] at.touch("file:with:colons.txt"); - #[cfg(target_os = "windows")] + #[cfg(windows)] at.touch("file-with-colons.txt"); at.touch("file with spaces.txt"); @@ -6408,9 +6901,9 @@ fn test_ls_hyperlink_utf8_encoding() { let output = result.stdout_str(); assert!(output.contains("caf%c3%a9.txt")); - #[cfg(not(target_os = "windows"))] + #[cfg(not(windows))] assert!(output.contains("file%3awith%3acolons.txt")); - #[cfg(target_os = "windows")] + #[cfg(windows)] assert!(output.contains("file-with-colons.txt")); assert!(output.contains("file%20with%20spaces.txt")); @@ -6498,7 +6991,7 @@ fn test_term_colorterm() { ); } -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(all(unix, not(target_vendor = "apple")))] #[test] fn test_acl_display() { use std::process::Command; @@ -6554,7 +7047,7 @@ fn test_acl_display() { // Regression test for https://github.com/uutils/coreutils/issues/10980 // Each file with an ACL must not inflate the link-count column width. -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(all(unix, not(target_vendor = "apple")))] #[test] fn test_acl_padding_not_inflated() { use std::process::Command; @@ -6562,7 +7055,7 @@ fn test_acl_padding_not_inflated() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; - let uid = uucore::process::getuid(); + let uid = rustix::process::getuid().as_raw(); let names = ["file1", "file2", "file3", "file4", "file5"]; for name in &names { at.touch(name); @@ -6608,7 +7101,7 @@ fn test_acl_padding_not_inflated() { // setting is also configured). #[cfg(unix)] #[test] -#[cfg(not(feature = "feat_selinux"))] +#[cfg(not(feature = "selinux"))] // Disabled on the SELinux runner for now fn test_ls_color_norm() { let scene = TestScenario::new(util_name!()); @@ -6734,6 +7227,53 @@ fn test_ls_color_norm() { .stdout_contains(expected); } +// A style that renders to an empty ANSI sequence, such as the default +// foreground color, used to make `ls --color` panic while stripping the +// trailing reset from the style code. +#[test] +fn test_ls_color_empty_style() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("f"); + + scene + .ucmd() + .env("LS_COLORS", "no=39") + .arg("--color=always") + .arg("f") + .succeeds() + .stdout_only("\u{1b}[0mf\u{1b}[0m\n"); +} + +#[test] +fn test_ls_bad_ls_colors_is_an_error_not_a_warning() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("marker"); + + // A stray entry without '=' makes the whole variable unparsable; the + // diagnostic is an error, so it must not carry a "warning: " prefix. + scene + .ucmd() + .env("LS_COLORS", "di=1;35:stray") + .arg("--color=always") + .arg("marker") + .succeeds() + .stdout_is("marker\n") + .stderr_is("ls: unparsable value for LS_COLORS environment variable\n"); + + scene + .ucmd() + .env("LS_COLORS", "qq=1;35:stray") + .arg("--color=always") + .arg("marker") + .succeeds() + .stdout_is("marker\n") + .stderr_is( + "ls: unrecognized prefix: 'qq'\nls: unparsable value for LS_COLORS environment variable\n", + ); +} + #[test] fn test_ls_color_clear_to_eol() { let scene = TestScenario::new(util_name!()); @@ -6753,7 +7293,7 @@ fn test_ls_color_clear_to_eol() { .arg("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.foo") .succeeds(); // check that the wrapped name contains clear to end of line code - // cspell:disable-next-line + // spell-checker:disable-next-line result.stdout_contains("\x1b[0m\x1b[31;42mzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.foo\x1b[0m\x1b[K"); } @@ -6780,12 +7320,12 @@ fn test_suffix_case_sensitivity() { .arg("file2.Z") .succeeds(); result.stdout_contains( - /* cSpell:disable */ + // spell-checker:disable "\x1b[0m\x1b[01;35mimg1.jpg\x1b[0m\n\ \x1b[01;35mIMG2.JPG\x1b[0m\n\ \x1b[01;31mfile1.z\x1b[0m\n\ \x1b[01;31mfile2.Z\x1b[0m", - /* cSpell:enable */ + // spell-checker:enable ); // *.jpg is specified more than once with different cases and style, so @@ -6800,11 +7340,11 @@ fn test_suffix_case_sensitivity() { .arg("img3.JpG") .succeeds(); result.stdout_contains( - /* cSpell:disable */ + // spell-checker:disable "\x1b[0m\x1b[01;35mimg1.jpg\x1b[0m\n\ \x1b[01;35;46mIMG2.JPG\x1b[0m\n\ img3.JpG", - /* cSpell:enable */ + // spell-checker:enable ); // *.jpg is specified more than once with different cases but style is same, so @@ -6819,11 +7359,11 @@ fn test_suffix_case_sensitivity() { .arg("img3.JpG") .succeeds(); result.stdout_contains( - /* cSpell:disable */ + // spell-checker:disable "\x1b[0m\x1b[01;35mimg1.jpg\x1b[0m\n\ \x1b[01;35mIMG2.JPG\x1b[0m\n\ \x1b[01;35mimg3.JpG\x1b[0m", - /* cSpell:enable */ + // spell-checker:enable ); // last *.jpg gets more priority resulting in same style across @@ -6838,11 +7378,11 @@ fn test_suffix_case_sensitivity() { .arg("img3.JpG") .succeeds(); result.stdout_contains( - /* cSpell:disable */ + // spell-checker:disable "\x1b[0m\x1b[01;35;46mimg1.jpg\x1b[0m\n\ \x1b[01;35;46mIMG2.JPG\x1b[0m\n\ \x1b[01;35;46mimg3.JpG\x1b[0m", - /* cSpell:enable */ + // spell-checker:enable ); // last *.jpg gets more priority resulting in different style across @@ -6857,11 +7397,11 @@ fn test_suffix_case_sensitivity() { .arg("img3.JpG") .succeeds(); result.stdout_contains( - /* cSpell:disable */ + // spell-checker:disable "\x1b[0m\x1b[01;35mimg1.jpg\x1b[0m\n\ \x1b[01;35;46mIMG2.JPG\x1b[0m\n\ img3.JpG", - /* cSpell:enable */ + // spell-checker:enable ); } @@ -6974,7 +7514,7 @@ fn test_unknown_format_specifier() { .stdout_matches(&re_custom_format); } -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(all(unix, not(target_vendor = "apple")))] #[test] fn test_acl_display_symlink() { use std::process::Command; @@ -7028,6 +7568,44 @@ fn test_acl_display_symlink() { assert!(iter.all(|i| i == first)); } +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn test_acl_display_symlink_without_dereference() { + use std::process::Command; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + let dir_name = "dir"; + let link_name = "link"; + at.mkdir(dir_name); + + match Command::new("setfacl") + .args(["-d", "-m", "u:bin:rwx", &at.plus_as_string(dir_name)]) + .status() + .map(|status| status.code()) + { + Ok(Some(0)) => {} + Ok(_) => { + println!("test skipped: setfacl failed"); + return; + } + Err(e) => { + println!("test skipped: setfacl failed with {e}"); + return; + } + } + + at.symlink_dir(dir_name, link_name); + + scene + .ucmd() + .arg("-ld") + .arg(link_name) + .succeeds() + .stdout_does_not_contain("+"); +} + #[test] fn ls_emoji_alignment() { let scene = TestScenario::new(util_name!()); @@ -7552,13 +8130,13 @@ fn test_ls_recursive_no_fd_leak() { .ucmd() .arg("-R") .arg("1") - .limit(Resource::NOFILE, 20, 20) + .limit(Resource::Nofile, 20, 20) .succeeds() - .stderr_is(""); + .no_stderr(); } #[test] -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(all(unix, not(target_vendor = "apple")))] fn test_ls_non_utf8_hidden() { use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; let scene = TestScenario::new(util_name!()); @@ -7570,7 +8148,6 @@ fn test_ls_non_utf8_hidden() { } #[test] -#[cfg(target_os = "wasi")] fn test_ls_a_dotdot_no_error_on_wasi() { // On WASI the sandbox may block access to ".." at the preopened root. // ls -a should still succeed and show ".." without an error message. @@ -7585,7 +8162,6 @@ fn test_ls_a_dotdot_no_error_on_wasi() { } #[test] -#[cfg(target_os = "wasi")] fn test_ls_al_no_capabilities_insufficient_on_wasi() { // `ls -al` reads metadata for every entry including "..". Without the // WASI fallback, stat on ".." at the preopened root returns @@ -7610,3 +8186,256 @@ fn test_long_options_detached() { new_ucmd!().arg("--time").arg("mtime").succeeds(); new_ucmd!().arg("--block-size").arg("512").succeeds(); } + +// Without -R a directory can never be revisited, so ls should not spend a stat +// recording it for the loop detection that only recursion consults. +#[test] +#[cfg(target_os = "linux")] +fn test_no_extra_stat_without_recursion() { + use std::process::Command; + + let scene = TestScenario::new(util_name!()); + scene.fixtures.mkdir("some-dir"); + + let stats_of_some_dir = |args: &[&str]| -> Option { + let output = Command::new("strace") + .args(["-qq", "-e", "trace=stat,statx,lstat,newfstatat"]) + .arg(&scene.bin_path) + .arg(scene.util_name.as_str()) + .args(args) + .current_dir(scene.fixtures.as_string()) + .output() + .ok()?; + let trace = String::from_utf8_lossy(&output.stderr); + // No syscalls traced at all means strace could not do its job here. + if !trace.contains('(') { + return None; + } + Some(trace.lines().filter(|l| l.contains("\"some-dir\"")).count()) + }; + + let Some(count) = stats_of_some_dir(&["-F", "--color=always", "some-dir"]) else { + return; // strace unavailable, e.g. restricted ptrace in a container + }; + assert_eq!(count, 1, "expected a single stat of the directory operand"); +} + +#[cfg(target_os = "linux")] +#[test] +fn test_write_error() { + let ts = TestScenario::new(util_name!()); + + ts.fixtures.touch("dummy_file.txt"); + + let dev_full = std::fs::OpenOptions::new() + .write(true) + .open("/dev/full") + .unwrap(); + + ts.ucmd() + .set_stdout(dev_full) + .fails_with_code(2) + .stderr_is("ls: write error: No space left on device\n"); +} + +#[cfg(target_os = "linux")] +#[test] +fn test_dired_write_error() { + let ts = TestScenario::new(util_name!()); + + let dev_full = std::fs::OpenOptions::new() + .write(true) + .open("/dev/full") + .unwrap(); + + // Nothing is buffered for a failing operand, so the trailer is the first + // thing written: it must report the error rather than panic. + ts.ucmd() + .args(&["--dired", "nonexistent"]) + .set_stdout(dev_full) + .fails_with_code(2) + .stderr_is(concat!( + "ls: cannot access 'nonexistent': No such file or directory\n", + "ls: write error: No space left on device\n", + )); +} + +#[cfg(unix)] +#[test] +fn test_ls_long_stat_failure_is_reported() { + use rustix::process::geteuid; + use std::os::unix::fs::PermissionsExt; + + // root bypasses the directory search permission check this relies on + if geteuid().is_root() { + return; + } + + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("dir"); + at.touch("dir/file"); + at.symlink_file("/", "dir/link"); + // readable but not searchable: read_dir() succeeds while stat() on the + // entries fails with EACCES + at.set_mode("dir", 0o600); + + ucmd.args(&["-l", "dir"]) + .fails_with_code(1) + .stdout_contains("? file") + .stdout_contains("? link") + .stderr_contains("cannot access 'dir/file': Permission denied") + .stderr_contains("cannot access 'dir/link': Permission denied"); + + // restore so that the test directory can be cleaned up + std::fs::set_permissions(at.plus("dir"), std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_unknown_unit_of_block_size() { + let result = new_ucmd!() + .terminal_sim_stderr() + .arg("--block-size=1fb") + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +ls: invalid --block-size argument '1fb' + ╭─[ ls:1:18 ] + │ + 1 │ ls --block-size=1fb + │ ─┬ + │ ╰── not a known unit + │ + │ Help: a size is a number and an optional unit: K, M, G and so on for 1024, KB, MB, GB for 1000 +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_a_zero_block_size() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--block-size", "0"]) + .fails_with_code(2); + let stderr = result.stderr_as_displayed(); + + assert_eq!(result.caret_column(), Some(17)); + assert!(!stderr.contains("not a known unit"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .arg("--block-size=1fb") + .fails_with_code(2) + .stderr_is("ls: invalid --block-size argument '1fb'\n"); + } +} + +#[test] +fn test_time_style_unambiguous_prefixes() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.touch("test"); + for style in ["full-iso", "long-iso", "iso", "locale"] { + let expected = scene + .ucmd() + .args(&["-l", "--time-style", style, "test"]) + .succeeds() + .stdout_str() + .to_owned(); + let min_len = if style.starts_with("lo") { 3 } else { 1 }; + for len in min_len..=style.len() { + for prefix in ["", "posix-"] { + let value = format!("{prefix}{}", &style[..len]); + scene + .ucmd() + .args(&["-l", "--time-style", &value, "test"]) + .succeeds() + .stdout_is(&expected); + scene + .ucmd() + .env("TIME_STYLE", &value) + .args(&["-l", "test"]) + .succeeds() + .stdout_is(&expected); + } + } + } +} + +#[test] +fn test_time_style_ambiguous_and_invalid_prefixes() { + for value in [ + "l", + "lo", + "posix-l", + "posix-lo", + "posix-", + "full-isox", + "Locale", + ] { + new_ucmd!() + .args(&["-l", "--time-style", value]) + .fails() + .code_is(2) + .stderr_contains("invalid --time-style argument"); + new_ucmd!() + .env("TIME_STYLE", value) + .arg("-l") + .fails() + .code_is(2) + .stderr_contains("invalid --time-style argument"); + } +} + +#[test] +fn test_ls_dereference_looped_symlinks_recursive_nested() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("loop"); + at.mkdir("loop/a"); + at.relative_symlink_dir("..", "loop/a/back"); + + ucmd.args(&["-RL", "loop"]) + .fails_with_code(2) + .stderr_contains("not listing already-listed directory"); +} + +#[test] +fn test_ls_dereference_cross_branch_alias_after_first_branch_completes() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir_all("top/a/target"); + at.touch("top/a/target/file"); + at.mkdir_all("top/b"); + at.relative_symlink_dir("../a/target", "top/b/link"); + + let result = ucmd.args(&["-RL", "top"]).succeeds(); + result + .stdout_contains("target:\nfile") + .stdout_contains("link:\nfile") + .no_stderr(); +} + +#[test] +fn test_ls_dereference_sibling_alias_not_already_listed() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("top"); + at.mkdir("top/real"); + at.touch("top/real/file"); + at.relative_symlink_dir("real", "top/link"); + + let result = ucmd.args(&["-RL", "top"]).succeeds(); + result + .stdout_contains("real:\nfile") + .stdout_contains("link:\nfile") + .no_stderr(); +} diff --git a/tests/by-util/test_md5sum.rs b/tests/by-util/test_md5sum.rs index 0795a1f1dde..1d4d7d6d7fb 100644 --- a/tests/by-util/test_md5sum.rs +++ b/tests/by-util/test_md5sum.rs @@ -3,10 +3,12 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore checkfile, testf, ntestf + 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] @@ -341,6 +343,31 @@ fn test_conflicting_arg() { new_ucmd!().arg("--tag").arg("--text").fails_with_code(1); } +#[test] +#[cfg(windows)] +fn test_windows_path_separator_round_trip() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.mkdir("subdir"); + scene.fixtures.write("subdir/file.txt", "abc"); + + let result = scene + .ucmd() + .args(&["--text", "subdir\\file.txt"]) + .succeeds(); + + result + .no_stderr() + .stdout_is("900150983cd24fb0d6963f7d28e17f72 subdir/file.txt\n"); + + scene + .ucmd() + .args(&["--check", "--strict"]) + .pipe_in(result.stdout_str()) + .succeeds() + .no_stderr() + .stdout_is("subdir/file.txt: OK\n"); +} + #[test] #[cfg_attr(windows, ignore = "Disabled on windows")] fn test_with_escape_filename() { @@ -394,6 +421,17 @@ fn test_check_empty_line() { .stderr_contains("WARNING: 1 line is improperly formatted"); } +#[test] +#[cfg(windows)] +fn test_check_invalid_utf8_reports_read_error() { + new_ucmd!() + .args(&["--check", "-"]) + .pipe_in(b"invalid\xff\n") + .fails_with_code(1) + .no_stdout() + .stderr_is("md5sum: -: read error\n"); +} + #[test] #[cfg_attr(windows, ignore = "Disabled on windows")] fn test_check_with_escape_filename() { @@ -497,6 +535,18 @@ fn test_check_status_code() { .no_output(); } +#[test] +fn test_check_status_reports_malformed_input() { + // --status should still print "no properly formatted checksum lines found" + // when all input lines are invalid (issue #12867) + new_ucmd!() + .args(&["-c", "--status"]) + .pipe_in("I'mNotAHash\n") + .fails() + .no_stdout() + .stderr_contains("no properly formatted checksum lines found"); +} + #[test] fn test_sha1_with_md5sum_should_fail() { let scene = TestScenario::new(util_name!()); @@ -830,3 +880,20 @@ fn test_check_md5_comment_leading_space() { .stdout_contains("foo: OK") .stderr_contains("WARNING: 1 line is improperly formatted"); } + +#[test] +#[cfg(all(target_os = "linux", not(target_env = "musl")))] +fn test_read_error_does_not_stop_other_files() { + // https://github.com/uutils/coreutils/issues/13128 + // Reading /proc/self/mem from offset 0 fails with EIO. + let scene = TestScenario::new(util_name!()); + scene.fixtures.write("f", "hello\n"); + + scene + .ucmd() + .arg("/proc/self/mem") + .arg("f") + .fails_with_code(1) + .stdout_is("b1946ac92492d2347c6235b4d2611184 f\n") + .stderr_is("md5sum: /proc/self/mem: Input/output error\n"); +} diff --git a/tests/by-util/test_mkdir.rs b/tests/by-util/test_mkdir.rs index 5df3c778122..c0601a850d8 100644 --- a/tests/by-util/test_mkdir.rs +++ b/tests/by-util/test_mkdir.rs @@ -11,10 +11,7 @@ use libc::mode_t; #[cfg(not(windows))] use std::os::unix::fs::PermissionsExt; -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use uucore::selinux::get_getfattr_output; #[cfg(not(windows))] use uutests::at_and_ucmd; @@ -82,7 +79,7 @@ fn test_mkdir_non_unicode() { let target = uucore::os_str_from_bytes(b"some-\xc0-dir-\xf3") .expect("Only unix platforms can test non-unicode names"); - ucmd.arg(&target).succeeds(); + ucmd.arg(target).succeeds(); assert!(at.dir_exists(target)); } @@ -409,7 +406,7 @@ fn test_mkdir_acl_inheritance_with_restrictive_mask() { // Verify the child itself has an ACL (indicated by presence of xattr) assert!( - uucore::fsxattr::has_acl(at.plus("parent/child")), + uucore::fsxattr::has_acl(at.plus("parent/child"), true), "Child directory should have inherited ACL entries" ); } @@ -538,10 +535,7 @@ fn test_empty_argument() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_selinux() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -565,10 +559,7 @@ fn test_selinux() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_selinux_invalid() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -1081,3 +1072,108 @@ fn test_mkdir_concurrent_creation() { assert!(at.dir_exists(&path_str)); } } + +#[cfg(unix)] +#[test] +fn test_mkdir_inside_inexistent_dir() { + new_ucmd!() + .arg("a/b") + .fails_with_code(1) + .stderr_is("mkdir: cannot create directory 'a/b': No such file or directory\n"); +} + +// The mode is only parsed where a mode means something. +#[cfg(unix)] +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + #[test] + fn test_snippet_points_at_the_bad_operator() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-m", "u+rw?x", "some_dir"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.contains("invalid operator"), "{stderr}"); + // The caret lands on `?`: three columns of `-m ` and four of mode. + assert_eq!(result.caret_column(), Some(8), "{stderr}"); + } + + #[test] + fn test_snippet_points_into_the_second_clause() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-m", "u=r,g!w", "some_dir"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + // Clauses are parsed one at a time, but the caret is placed in the + // whole mode: `!` is its sixth character, after `-m `. + assert_eq!(result.caret_column(), Some(9), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + // The test harness pipes stderr, so the report must not appear. + let result = new_ucmd!() + .args(&["-m", "u+rw?x", "some_dir"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.starts_with("mkdir: "), "{stderr}"); + assert!(!stderr.contains(":1:"), "{stderr}"); + } +} + +#[test] +fn test_mkdir_concurrent_non_recursive() { + // Test concurrent mkdir operations without -p: exactly one process must succeed per round + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::thread; + + for round in 0..10 { + let scene = TestScenario::new(util_name!()); + let target_dir = scene.fixtures.plus(format!("concurrent_target_{round}")); + let path_str = target_dir.to_string_lossy().to_string(); + let bin_path = scene.bin_path.clone(); + + let winners = Arc::new(AtomicUsize::new(0)); + let mut handles = vec![]; + + for _ in 0..16 { + let path_clone = path_str.clone(); + let bin_path_clone = bin_path.clone(); + let winners_clone = Arc::clone(&winners); + + let handle = thread::spawn(move || { + let result = std::process::Command::new(&bin_path_clone) + .arg("mkdir") + .arg(&path_clone) + .current_dir(std::env::current_dir().unwrap()) + .output() + .expect("failed to run binary"); + if result.status.success() { + winners_clone.fetch_add(1, Ordering::SeqCst); + } + }); + handles.push(handle); + } + + for h in handles { + h.join().unwrap(); + } + + assert_eq!( + winners.load(Ordering::SeqCst), + 1, + "round {round}: expected exactly 1 winner for concurrent non-recursive mkdir" + ); + assert!( + scene + .fixtures + .dir_exists(format!("concurrent_target_{round}")) + ); + } +} diff --git a/tests/by-util/test_mkfifo.rs b/tests/by-util/test_mkfifo.rs index 02fa91b2a52..d2b41f72415 100644 --- a/tests/by-util/test_mkfifo.rs +++ b/tests/by-util/test_mkfifo.rs @@ -5,10 +5,7 @@ // spell-checker:ignore nconfined -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use uucore::selinux::get_getfattr_output; use uutests::new_ucmd; use uutests::util::TestScenario; @@ -21,7 +18,7 @@ fn test_invalid_arg() { #[test] fn test_create_fifo_missing_operand() { - new_ucmd!().fails().stderr_is("mkfifo: missing operand\n"); + new_ucmd!().fails(); // clap provided error message } #[test] @@ -83,7 +80,7 @@ fn test_create_one_fifo_already_exists() { .arg("abcdef") .arg("abcdef") .fails() - .stderr_is("mkfifo: cannot create fifo 'abcdef': File exists\n"); + .stderr_contains("mkfifo: cannot create fifo 'abcdef': File exists"); } #[test] @@ -156,9 +153,10 @@ fn test_create_fifo_permission_denied() { at.mkdir(no_exec_dir); at.set_mode(no_exec_dir, 0o644); - // 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"); + // The parent directory has no execute bit, so the kernel refuses to add + // an entry to it (EACCES). mkfifo should surface that reason instead of + // the old hardcoded "File exists". + let err_msg = format!("mkfifo: cannot create fifo '{named_pipe}': Permission denied\n"); scene .ucmd() @@ -170,10 +168,7 @@ fn test_create_fifo_permission_denied() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_mkfifo_selinux() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -197,10 +192,7 @@ fn test_mkfifo_selinux() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_mkfifo_selinux_invalid() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -248,3 +240,34 @@ fn test_mkfifo_permission_unchanged_when_failed() { let expected = "-rw-------"; assert_eq!(permissions, expected.to_string()); } + +// The mode is only parsed where a mode means something. +#[cfg(unix)] +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + #[test] + fn test_snippet_points_at_the_bad_operator() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-m", "+rw?", "some_pipe"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.contains("invalid mode"), "{stderr}"); + // The caret lands on `?`: three columns of `-m ` and four of mode. + assert_eq!(result.caret_column(), Some(7), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + // The test harness pipes stderr, so the report must not appear. + let result = new_ucmd!() + .args(&["-m", "+rw?", "some_pipe"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.starts_with("mkfifo: "), "{stderr}"); + assert!(!stderr.contains(":1:"), "{stderr}"); + } +} diff --git a/tests/by-util/test_mknod.rs b/tests/by-util/test_mknod.rs index a19a4e4d785..49fdbc243f9 100644 --- a/tests/by-util/test_mknod.rs +++ b/tests/by-util/test_mknod.rs @@ -7,10 +7,7 @@ use std::os::unix::fs::PermissionsExt; -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use uucore::selinux::get_getfattr_output; use uutests::new_ucmd; use uutests::util::TestScenario; @@ -187,10 +184,7 @@ fn test_mknod_mode_comma_separated() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_mknod_selinux() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -221,10 +215,7 @@ fn test_mknod_selinux() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_mknod_selinux_invalid() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -251,10 +242,7 @@ fn test_mknod_selinux_invalid() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_mknod_selinux_invalid_cleanup() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -270,3 +258,34 @@ fn test_mknod_selinux_invalid_cleanup() { // invalid context → node must not exist assert!(!at.file_exists(dest)); } + +// The mode is only parsed where a mode means something. +#[cfg(unix)] +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + #[test] + fn test_snippet_points_at_the_bad_operator() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-m", "u+rw?", "some_node", "p"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.contains("invalid mode"), "{stderr}"); + // The caret lands on `?`: three columns of `-m ` and four of mode. + assert_eq!(result.caret_column(), Some(8), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + // The test harness pipes stderr, so the report must not appear. + let result = new_ucmd!() + .args(&["-m", "u+rw?", "some_node", "p"]) + .fails_with_code(1); + let stderr = result.stderr_str(); + + assert!(stderr.starts_with("mknod: "), "{stderr}"); + assert!(!stderr.contains(":1:"), "{stderr}"); + } +} diff --git a/tests/by-util/test_mktemp.rs b/tests/by-util/test_mktemp.rs index 0fa2896bb6f..7187b5bf4c5 100644 --- a/tests/by-util/test_mktemp.rs +++ b/tests/by-util/test_mktemp.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (words) gpghome use uutests::at_and_ucmd; @@ -118,6 +119,7 @@ fn test_mktemp_mktemp() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_mktemp_mktemp_t() { let scene = TestScenario::new(util_name!()); @@ -399,6 +401,7 @@ fn test_mktemp_suffix() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_mktemp_tmpdir() { let scene = TestScenario::new(util_name!()); let dir = tempdir().unwrap(); @@ -462,6 +465,7 @@ fn test_mktemp_tmpdir() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_mktemp_empty_tmpdir() { let scene = TestScenario::new(util_name!()); let pathname = scene.fixtures.as_string(); @@ -482,6 +486,7 @@ fn test_mktemp_empty_tmpdir() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_mktemp_tmpdir_one_arg() { let scene = TestScenario::new(util_name!()); @@ -495,6 +500,7 @@ fn test_mktemp_tmpdir_one_arg() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_mktemp_directory_tmpdir() { let scene = TestScenario::new(util_name!()); @@ -599,6 +605,11 @@ fn test_respect_template_directory() { #[cfg(unix)] #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: path_create_directory has no mode parameter and chmod returns ENOSYS, \ + so directories can't be created with restricted permissions" +)] fn test_directory_permissions() { let (at, mut ucmd) = at_and_ucmd!(); let result = ucmd.args(&["-d", "XXX"]).succeeds(); @@ -869,6 +880,7 @@ fn test_nonexistent_tmpdir_env_var() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_empty_tmpdir_env_var() { #[cfg(not(any(windows, target_os = "android")))] { @@ -967,11 +979,13 @@ fn test_nonexistent_dir_prefix() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_default_missing_value() { new_ucmd!().arg("-d").arg("--tmpdir").succeeds(); } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_default_issue_4821_t_tmpdir() { let scene = TestScenario::new(util_name!()); let pathname = scene.fixtures.as_string(); @@ -987,6 +1001,7 @@ fn test_default_issue_4821_t_tmpdir() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_default_issue_4821_t_tmpdir_p() { let scene = TestScenario::new(util_name!()); let pathname = scene.fixtures.as_string(); @@ -1003,6 +1018,7 @@ fn test_default_issue_4821_t_tmpdir_p() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_t_ensure_tmpdir_has_higher_priority_than_p() { let scene = TestScenario::new(util_name!()); let pathname = scene.fixtures.as_string(); @@ -1099,7 +1115,8 @@ fn test_missing_short_tmpdir_flag() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: wasmtime rejects non-UTF-8 arguments")] fn test_non_utf8_template() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; @@ -1114,6 +1131,7 @@ fn test_non_utf8_template() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: wasmtime rejects non-UTF-8 arguments")] fn test_non_utf8_tmpdir_path() { use std::os::unix::ffi::OsStrExt; let (at, mut ucmd) = at_and_ucmd!(); @@ -1128,6 +1146,7 @@ fn test_non_utf8_tmpdir_path() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: wasmtime rejects non-UTF-8 arguments")] fn test_non_utf8_tmpdir_long_option() { use std::os::unix::ffi::OsStrExt; let (at, mut ucmd) = at_and_ucmd!(); @@ -1146,13 +1165,14 @@ fn test_non_utf8_tmpdir_long_option() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: wasmtime rejects non-UTF-8 arguments")] fn test_invalid_utf8_suffix() { use std::os::unix::ffi::OsStrExt; let (at, mut ucmd) = at_and_ucmd!(); // Create invalid UTF-8 bytes for suffix - // This mimics the GNU test which tests mktemp with bad unicode characters + // Test mktemp with bad unicode characters let invalid_utf8 = std::ffi::OsStr::from_bytes(b"\xC3|\xED\xBA\xAD"); // Test that mktemp handles invalid UTF-8 in suffix gracefully @@ -1167,6 +1187,7 @@ fn test_invalid_utf8_suffix() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: wasmtime rejects non-UTF-8 arguments")] fn test_non_utf8_tmpdir_directory_creation() { use std::os::unix::ffi::OsStrExt; let (at, mut ucmd) = at_and_ucmd!(); @@ -1183,6 +1204,7 @@ fn test_non_utf8_tmpdir_directory_creation() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_mktemp_hidden_file_single_dot() { let scene = TestScenario::new(util_name!()); let dir = tempdir().unwrap(); @@ -1209,3 +1231,41 @@ fn test_mktemp_hidden_file_single_dot() { template_name.len() ); } + +// When the name cannot be printed the caller never learns it, so the file must +// not be left behind for nothing to clean up. +#[test] +#[cfg(target_os = "linux")] +fn test_write_error_removes_the_temporary_file() { + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.arg("keep-none-XXXX") + .set_stdout(std::fs::File::create("/dev/full").unwrap()) + .fails() + .stderr_is("mktemp: write error: No space left on device\n"); + + let leftovers: Vec<_> = std::fs::read_dir(at.as_string()) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with("keep-none-")) + .collect(); + assert!(leftovers.is_empty(), "left behind: {leftovers:?}"); +} + +#[test] +#[cfg(target_os = "linux")] +fn test_write_error_removes_the_temporary_directory() { + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["-d", "keep-none-XXXX"]) + .set_stdout(std::fs::File::create("/dev/full").unwrap()) + .fails() + .stderr_is("mktemp: write error: No space left on device\n"); + + let leftovers: Vec<_> = std::fs::read_dir(at.as_string()) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with("keep-none-")) + .collect(); + assert!(leftovers.is_empty(), "left behind: {leftovers:?}"); +} diff --git a/tests/by-util/test_more.rs b/tests/by-util/test_more.rs index 1df3e06b085..0a538c64802 100644 --- a/tests/by-util/test_more.rs +++ b/tests/by-util/test_more.rs @@ -79,6 +79,10 @@ fn test_valid_arg() { &["--from-line", "0"], &["-P", "something"], &["--pattern", "-1"], + &["-f"], + &["--logical"], + &["-l"], + &["--no-pause"], ]; for args in args_list { test_alive(args); @@ -254,3 +258,15 @@ fn test_from_line_option() { assert!(!output.contains("line1")); quit_more(&mut controller, child); } + +#[test] +#[cfg(unix)] +fn test_lines_option_max_u16_no_overflow() { + // Regression test for https://github.com/uutils/coreutils/pull/12970 + // When -n is u16::MAX (65535), adding 1 for the banner line used to + // overflow. Verify that more starts and displays content without crashing. + let (child, mut controller, output) = + run_more_with_pty(&["-n", "65535"], "test.txt", "line1\nline2\nline3\n"); + assert!(output.contains("line1")); + quit_more(&mut controller, child); +} diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index ecea9030dc3..b2bf30e8732 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -2,18 +2,15 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// -// spell-checker:ignore mydir hardlinked tmpfs notty unwriteable -use filetime::FileTime; +// spell-checker:ignore mydir hardlinked tmpfs notty unwriteable myfolder SRCDATA DSTDATA REALDATA realfile +// spell-checker:ignore dirattr dirvalue setfattr getfattr + use rstest::rstest; use std::io::Write; #[cfg(not(windows))] use std::path::Path; -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use uucore::selinux::get_getfattr_output; use uutests::new_ucmd; #[cfg(unix)] @@ -122,7 +119,7 @@ fn test_mv_move_file_into_file_with_target_arg() { .arg(file1) .arg(file2) .fails() - .stderr_is(format!("mv: target directory '{file1}': Not a directory\n")); + .stderr_only(format!("mv: target directory '{file1}': Not a directory\n")); assert!(at.file_exists(file1)); } @@ -142,7 +139,7 @@ fn test_mv_move_multiple_files_into_file() { .arg(file2) .arg(file3) .fails() - .stderr_is(format!("mv: target '{file3}': Not a directory\n")); + .stderr_only(format!("mv: target '{file3}': Not a directory\n")); assert!(at.file_exists(file1)); assert!(at.file_exists(file2)); @@ -335,7 +332,7 @@ fn test_mv_interactive_no_clobber_force_last_arg_wins() { .ucmd() .args(&[file_a, file_b, "-n", "-f", "-i"]) .fails() - .stderr_is(format!("mv: overwrite '{file_b}'? ")); + .stderr_only(format!("mv: overwrite '{file_b}'? ")); at.write(file_a, "aa"); @@ -469,6 +466,36 @@ fn test_mv_replace_symlink_with_file() { assert!(at.file_exists("a")); } +#[test] +#[cfg(all(unix, not(target_os = "android")))] +fn test_mv_file_to_broken_symlink_file() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.touch("file"); + at.symlink_file("missing-target", "broken"); + + ucmd.arg("file").arg("broken").succeeds().no_stderr(); + + assert!(at.file_exists("broken")); + assert!(!at.is_symlink("broken")); + assert!(!at.file_exists("file")); +} + +#[test] +#[cfg(all(unix, not(target_os = "android")))] +fn test_mv_file_to_broken_symlink_directory() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.touch("file"); + at.symlink_dir("missing-target", "broken"); + + ucmd.arg("file").arg("broken").succeeds().no_stderr(); + + assert!(at.file_exists("broken")); + assert!(!at.is_symlink("broken")); + assert!(!at.file_exists("file")); +} + #[test] #[cfg(all(unix, not(target_os = "android")))] fn test_mv_file_to_symlink_directory() { @@ -520,7 +547,7 @@ fn test_mv_same_file() { ucmd.arg(file_a) .arg(file_a) .fails() - .stderr_is(format!("mv: '{file_a}' and '{file_a}' are the same file\n")); + .stderr_only(format!("mv: '{file_a}' and '{file_a}' are the same file\n")); } #[test] @@ -537,7 +564,7 @@ fn test_mv_same_hardlink() { ucmd.arg(file_a) .arg(file_b) .fails() - .stderr_is(format!("mv: '{file_a}' and '{file_b}' are the same file\n")); + .stderr_only(format!("mv: '{file_a}' and '{file_b}' are the same file\n")); } #[test] @@ -568,7 +595,7 @@ fn test_mv_same_symlink() { ucmd.arg(file_b) .arg(file_a) .fails() - .stderr_is(format!("mv: '{file_b}' and '{file_a}' are the same file\n")); + .stderr_only(format!("mv: '{file_b}' and '{file_a}' are the same file\n")); let (at2, mut ucmd2) = at_and_ucmd!(); at2.touch(file_a); @@ -598,7 +625,7 @@ fn test_mv_same_symlink() { .arg(file_c) .arg(file_a) .fails() - .stderr_is(format!("mv: '{file_c}' and '{file_a}' are the same file\n")); + .stderr_only(format!("mv: '{file_c}' and '{file_a}' are the same file\n")); } #[test] @@ -611,7 +638,7 @@ fn test_mv_same_broken_symlink() { ucmd.arg("broken") .arg("broken") .fails() - .stderr_is("mv: 'broken' and 'broken' are the same file\n"); + .stderr_only("mv: 'broken' and 'broken' are the same file\n"); } #[test] @@ -703,13 +730,91 @@ fn test_mv_same_hardlink_backup_simple_destroy() { .stderr_contains("backing up 'test_mv_same_file_a' might destroy source"); } +/// The guard must compare the files, not how the operands were spelled. +/// Comparing strings let `'a~'` versus `'./a'` slip through, and mv then +/// destroyed the source and exited 0 with no diagnostic. +#[test] +#[cfg(all(unix, not(target_os = "android")))] +fn test_mv_backup_simple_guard_ignores_spelling() { + for target in ["./test_mv_spell_a", "test_mv_spell_a"] { + let (at, mut ucmd) = at_and_ucmd!(); + let source = "test_mv_spell_a~"; + at.write(source, "SRCDATA"); + at.write("test_mv_spell_a", "DSTDATA"); + + ucmd.arg(source) + .arg(target) + .arg("--backup=simple") + .fails() + .stderr_contains("might destroy source"); + + assert_eq!( + at.read(source), + "SRCDATA", + "mv destroyed the source when the target was spelled {target}" + ); + } +} + +/// ...but it must not fire for unrelated operands that merely look similar. +#[test] +#[cfg(all(unix, not(target_os = "android")))] +fn test_mv_backup_simple_guard_allows_unrelated_source() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("test_mv_spell_src", "SRCDATA"); + at.write("test_mv_spell_dst", "DSTDATA"); + + ucmd.arg("test_mv_spell_src") + .arg("test_mv_spell_dst") + .arg("--backup=simple") + .succeeds(); + + assert_eq!(at.read("test_mv_spell_dst"), "SRCDATA"); + assert_eq!(at.read("test_mv_spell_dst~"), "DSTDATA"); +} + +/// A symlink source is a distinct file from the backup it points at, so the +/// backup rename cannot destroy it. GNU allows this. +#[test] +#[cfg(all(unix, not(target_os = "android")))] +fn test_mv_backup_simple_guard_allows_symlink_source() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("test_mv_sym_a", "DSTDATA"); + at.write("test_mv_sym_real", "REALDATA"); + at.symlink_file("test_mv_sym_real", "test_mv_sym_a~"); + + ucmd.arg("test_mv_sym_a~") + .arg("test_mv_sym_a") + .arg("--backup=simple") + .succeeds(); +} + +/// A hard link under another name shares the backup's inode but keeps the data +/// alive after the rename, so the guard must not fire. GNU allows this. +#[test] +#[cfg(all(unix, not(target_os = "android")))] +fn test_mv_backup_simple_guard_allows_hardlink_source() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("test_mv_hl_a", "DSTDATA"); + at.write("test_mv_hl_a~", "SRCDATA"); + at.hard_link("test_mv_hl_a~", "test_mv_hl_b"); + + ucmd.arg("test_mv_hl_b") + .arg("test_mv_hl_a") + .arg("--backup=simple") + .succeeds(); + + assert_eq!(at.read("test_mv_hl_a"), "SRCDATA"); + assert_eq!(at.read("test_mv_hl_a~"), "DSTDATA"); +} + #[test] fn test_mv_same_file_not_dot_dir() { let (at, mut ucmd) = at_and_ucmd!(); let dir = "test_mv_errors_dir"; at.mkdir(dir); - ucmd.arg(dir).arg(dir).fails().stderr_is(format!( + ucmd.arg(dir).arg(dir).fails().stderr_only(format!( "mv: cannot move '{dir}' to a subdirectory of itself, '{dir}/{dir}'\n", )); } @@ -721,7 +826,7 @@ fn test_mv_same_file_dot_dir() { ucmd.arg(".") .arg(".") .fails() - .stderr_is("mv: '.' and '.' are the same file\n"); + .stderr_only("mv: '.' and '.' are the same file\n"); } #[test] @@ -1092,6 +1197,7 @@ fn test_mv_backup_conflicting_options() { #[test] fn test_mv_update_option() { + use std::fs::{File, FileTimes}; let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; let file_a = "test_mv_update_option_file_a"; @@ -1100,10 +1206,22 @@ fn test_mv_update_option() { at.touch(file_a); at.touch(file_b); let ts = time::OffsetDateTime::now_utc(); - let now = FileTime::from_unix_time(ts.unix_timestamp(), ts.nanosecond()); - let later = FileTime::from_unix_time(ts.unix_timestamp() + 3600, ts.nanosecond()); - filetime::set_file_times(at.plus_as_string(file_a), now, now).unwrap(); - filetime::set_file_times(at.plus_as_string(file_b), now, later).unwrap(); + let now = ts.into(); + let later = (ts + time::Duration::seconds(3600)).into(); + let times_now = FileTimes::new().set_accessed(now).set_modified(now); + let times_later = FileTimes::new().set_accessed(now).set_modified(later); + File::options() + .write(true) + .open(at.plus_as_string(file_a)) + .unwrap() + .set_times(times_now) + .unwrap(); + File::options() + .write(true) + .open(at.plus_as_string(file_b)) + .unwrap() + .set_times(times_later) + .unwrap(); scene .ucmd() @@ -1438,15 +1556,34 @@ fn test_mv_overwrite_nonempty_dir() { at.mkdir(dir_a); at.mkdir(dir_b); at.touch(dummy); - // Not same error as GNU; the error message is a rust builtin - // TODO: test (and implement) correct error message (or at least decide whether to do so) - // Current: "mv: couldn't rename path (Directory not empty; from=a; to=b)" - // GNU: "mv: cannot move 'a' to 'b': Directory not empty" - // Verbose output for the move should not be shown on failure - let result = ucmd.arg("-vT").arg(dir_a).arg(dir_b).fails(); - result.no_stdout(); - assert!(!result.stderr_str().is_empty()); + ucmd.arg("-vT") + .arg(dir_a) + .arg(dir_b) + .fails() + .stderr_contains("cannot overwrite"); + + assert!(at.dir_exists(dir_a)); + assert!(at.dir_exists(dir_b)); +} + +#[test] +fn test_mv_overwrite_nonempty_dir_into_dir() { + let (at, mut ucmd) = at_and_ucmd!(); + let dir_a = "test_mv_overwrite_nonempty_dir_into_dir_a"; + let dir_b = "test_mv_overwrite_nonempty_dir_into_dir_b"; + let target_dir = format!("{dir_b}/{dir_a}"); + let dummy = format!("{target_dir}/file"); + + at.mkdir(dir_a); + at.mkdir(dir_b); + at.mkdir(&target_dir); + at.touch(&dummy); + + ucmd.arg(dir_a) + .arg(dir_b) + .fails() + .stderr_contains("cannot overwrite"); assert!(at.dir_exists(dir_a)); assert!(at.dir_exists(dir_b)); @@ -1485,7 +1622,6 @@ fn test_mv_errors() { at.touch(file_b); // $ mv -T -t a b - // mv: cannot combine --target-directory (-t) and --no-target-directory (-T) scene .ucmd() .arg("-T") @@ -1498,29 +1634,26 @@ fn test_mv_errors() { // $ at.touch file && at.mkdir dir // $ mv -T file dir - // err == mv: cannot overwrite directory 'dir' with non-directory scene .ucmd() .arg("-T") .arg(file_a) .arg(dir) .fails() - .stderr_is(format!( + .stderr_only(format!( "mv: cannot overwrite directory '{dir}' with non-directory\n" )); // $ at.mkdir dir && at.touch file // $ mv dir file - // err == mv: cannot overwrite non-directory 'file' with directory 'dir' - assert!( - !scene - .ucmd() - .arg(dir) - .arg(file_a) - .fails() - .stderr_str() - .is_empty() - ); + scene + .ucmd() + .arg(dir) + .arg(file_a) + .fails() + .stderr_only(format!( + "mv: cannot overwrite non-directory '{file_a}' with directory '{dir}'\n" + )); } #[test] @@ -1607,8 +1740,7 @@ fn test_mv_arg_interactive_skipped() { .pipe_in("N\n") .ignore_stdin_write_error() .fails() - .stderr_is("mv: overwrite 'b'? ") - .no_stdout(); + .stderr_only("mv: overwrite 'b'? "); } #[test] @@ -1800,7 +1932,13 @@ fn test_mv_dir_into_path_slash() { assert!(at.dir_exists("f/b")); } -#[cfg(all(unix, not(any(target_os = "macos", target_os = "openbsd"))))] +#[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "netbsd" +))] #[test] fn test_acl() { use std::process::Command; @@ -1886,6 +2024,28 @@ fn test_move_should_not_fallback_to_copy() { assert!(!at.file_exists(target_file)); } +// A directory containing two symlinks that point back at an ancestor must not +// send the hardlink pre-scan into an exponential walk. +#[test] +#[cfg(unix)] +fn test_mv_dir_with_symlink_cycles_terminates() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("dir"); + at.mkdir("dest"); + at.write("dir/file", "content"); + at.relative_symlink_dir(".", "dir/loop1"); + at.relative_symlink_dir(".", "dir/loop2"); + + ucmd.arg("dir").arg("dest/").succeeds().no_output(); + + assert!(at.dir_exists("dest/dir")); + assert_eq!(at.read("dest/dir/file"), "content"); + assert!(at.is_symlink("dest/dir/loop1")); + assert!(at.is_symlink("dest/dir/loop2")); + assert!(!at.dir_exists("dir")); +} + // Todo: // $ at.touch a b @@ -1907,6 +2067,40 @@ mod inter_partition_copying { use uutests::util::TestScenario; use uutests::util_name; + // setuid/setgid must survive a cross-device move when ownership is + // preserved. The mover owns the file here, so the chown is a no-op and the + // bits are legitimate; only a failed chown justifies stripping them, and + // that needs a second uid, so it is exercised out of band rather than here. + #[test] + pub(crate) fn test_mv_inter_partition_keeps_setuid_when_ownership_preserved() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("src", "src contents"); + set_permissions(at.plus("src"), PermissionsExt::from_mode(0o6755)) + .expect("Unable to set setuid/setgid on src"); + + let other_fs_tempdir = + TempDir::new_in("/dev/shm/").expect("Unable to create temp directory"); + let dest = other_fs_tempdir.path().join("dest"); + + scene + .ucmd() + .arg("src") + .arg(dest.to_str().unwrap()) + .succeeds(); + + let mode = fs::metadata(&dest) + .expect("destination should exist") + .permissions() + .mode() + & 0o7777; + assert_eq!( + mode, 0o6755, + "setuid/setgid must be kept when ownership was preserved, got {mode:o}" + ); + } + // Ensure that the copying code used in an inter-partition move unlinks the destination symlink. #[test] pub(crate) fn test_mv_unlinks_dest_symlink() { @@ -2176,7 +2370,7 @@ mod inter_partition_copying { ); } - // Test the exact GNU test scenario: hardlinks within directories being moved + // Test hardlinks within directories being moved #[test] #[cfg(unix)] pub(crate) fn test_mv_preserves_hardlinks_in_directories_across_partitions() { @@ -2452,6 +2646,85 @@ mod inter_partition_copying { let moved_fifo = other_fs_tempdir.path().join("dir/fifo"); assert!(moved_fifo.symlink_metadata().unwrap().file_type().is_fifo()); } + + // A symlink pointing at a hardlinked sibling must not be mistaken for a + // member of that hardlink group. Keying the inode map on metadata() (which + // follows symlinks) instead of symlink_metadata() made mv "preserve" the + // hardlink by linking the regular files to the copied *symlink*, leaving + // self-referential symlinks and destroying the content. + #[test] + #[cfg(unix)] + pub(crate) fn test_mv_symlink_to_hardlinked_sibling_across_partitions() { + use std::os::unix::fs::MetadataExt; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("dir"); + at.write("dir/realfile", "important data"); + at.hard_link("dir/realfile", "dir/realfile2"); + // Two symlinks so the test does not depend on readdir order: one sorts + // before the hardlink group, one after. + at.relative_symlink_file("realfile", "dir/aaa_link"); + at.relative_symlink_file("realfile", "dir/zzz_link"); + + let other_fs_tempdir = + TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm"); + + scene + .ucmd() + .arg("dir") + .arg(other_fs_tempdir.path().to_str().unwrap()) + .succeeds() + .no_output(); + + let moved_dir = other_fs_tempdir.path().join("dir"); + let moved_realfile = moved_dir.join("realfile"); + let moved_realfile2 = moved_dir.join("realfile2"); + + for file in [&moved_realfile, &moved_realfile2] { + assert!( + file.symlink_metadata().unwrap().file_type().is_file(), + "{} should still be a regular file, not a symlink", + file.display() + ); + assert_eq!( + fs::read_to_string(file).unwrap(), + "important data", + "{} lost its content", + file.display() + ); + } + + let realfile_metadata = fs::metadata(&moved_realfile).unwrap(); + assert_eq!( + realfile_metadata.ino(), + fs::metadata(&moved_realfile2).unwrap().ino(), + "realfile and realfile2 should still be hardlinked" + ); + assert_eq!( + realfile_metadata.nlink(), + 2, + "the hardlink group should not have gained the symlinks" + ); + + for link in ["aaa_link", "zzz_link"] { + let moved_link = moved_dir.join(link); + assert!( + moved_link + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + "{link} should still be a symlink" + ); + assert_eq!( + fs::read_link(&moved_link).unwrap(), + std::path::Path::new("realfile"), + "{link} should still point at realfile" + ); + } + } } #[test] @@ -2576,7 +2849,7 @@ fn test_special_file_different_filesystem() { } /// Test cross-device move with permission denied error -/// This test mimics the scenario from the GNU part-fail test where +/// Test partial failure handling where /// a cross-device move fails due to permission errors when removing the target file #[test] #[cfg(target_os = "linux")] @@ -2660,11 +2933,48 @@ fn test_mv_cross_device_refuses_planted_symlink_dest() { ); } +/// Directory counterpart of `test_mv_cross_device_refuses_planted_symlink_dest`. +/// +/// The cross-device directory fallback removes the destination and recreates it. +/// Recreation must fail closed: `create_dir_all` would accept a symlink-to-directory +/// left at the path and move the whole source tree through it, outside the destination. #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_dir_refuses_symlink_at_recreated_dest() { + use tempfile::TempDir; + use uutests::util::TestScenario; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + let src_dir = + TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm"); + let src = src_dir.path().join("srcdir"); + std::fs::create_dir(&src).expect("create src"); + std::fs::write(src.join("payload"), "PAYLOAD_FROM_SRC").expect("write payload"); + + at.mkdir("victim"); + at.write("victim/guard", "PROTECTED_DATA"); + at.symlink_dir("victim", "target"); + + // Whether this succeeds or fails, the invariant is that nothing from the + // source is written inside `victim`. + scene + .ucmd() + .arg("-T") + .arg(src.to_str().unwrap()) + .arg("target") + .run(); + + assert!( + !at.file_exists("victim/payload"), + "cross-device dir move escaped the destination through a symlink" + ); + assert_eq!(at.read("victim/guard"), "PROTECTED_DATA"); +} + +#[test] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_mv_selinux_context() { let test_cases = [ ("-Z", None), @@ -2705,13 +3015,13 @@ fn test_mv_selinux_context() { // Verify SELinux context was set using getfattr let context_value = get_getfattr_output(&at.plus_as_string(dest)); - if !context_value.is_empty() { - if let Some(expected) = expected_context { - assert!( - context_value.contains(expected), - "Expected context to contain '{expected}', got: {context_value}" - ); - } + if !context_value.is_empty() + && let Some(expected) = expected_context + { + assert!( + context_value.contains(expected), + "Expected context to contain '{expected}', got: {context_value}" + ); } // Clean up files @@ -2889,6 +3199,132 @@ fn test_mv_xattr_enotsup_silent() { } } +/// Cross-device mv of a directory must preserve the directory's own xattrs. +/// The fd-based xattr path has to open the destination read-only: a directory +/// cannot be opened for writing, so a write-mode open would silently drop them. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_dir_xattr_preserved() { + use std::process::Command; + use tempfile::TempDir; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("src_dir"); + at.write("src_dir/file.txt", "content"); + + if !Command::new("setfattr") + .args([ + "-n", + "user.dirattr", + "-v", + "dirvalue", + &at.plus_as_string("src_dir"), + ]) + .status() + .is_ok_and(|s| s.success()) + { + println!("test skipped: setfattr failed"); + return; + } + + let other_fs_tempdir = + TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm"); + let dst_path = other_fs_tempdir.path().join("dst_dir"); + + scene + .ucmd() + .arg(at.plus_as_string("src_dir")) + .arg(dst_path.to_str().unwrap()) + .succeeds() + .no_stderr(); + + let out = Command::new("getfattr") + .args([ + "-n", + "user.dirattr", + "--only-values", + dst_path.to_str().unwrap(), + ]) + .output() + .expect("failed to run getfattr on the moved directory"); + assert!( + out.status.success(), + "directory xattr was not preserved across devices: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(out.stdout, b"dirvalue"); +} + +/// An xattr the destination refuses must not stop the other xattrs from being +/// copied in a cross-device move. Each refused one is reported, but the move +/// still succeeds. tmpfs takes large values while ext4 caps a value at one +/// block, so the large attributes fail there and the small ones must survive. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_xattr_failure_keeps_the_rest() { + use rustc_hash::FxHashMap; + use std::ffi::{OsStr, OsString}; + use tempfile::TempDir; + use uucore::fsxattr::{apply_xattrs, retrieve_xattrs}; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + let too_big = vec![b'x'; 8000]; + at.touch("probe"); + let probe = FxHashMap::from_iter([(OsString::from("user.probe"), too_big.clone())]); + if apply_xattrs(at.plus("probe"), probe).is_ok() { + println!("test skipped: the destination filesystem accepts large xattr values"); + return; + } + + // tmpfs lists attributes sorted by name, so interleaving the names puts a + // refused attribute before a kept one whichever way the list is sorted. + let attrs: FxHashMap> = [ + ("user.a_kept", b"first".to_vec()), + ("user.b_too_big", too_big.clone()), + ("user.c_kept", b"middle".to_vec()), + ("user.d_too_big", too_big), + ("user.e_kept", b"last".to_vec()), + ] + .into_iter() + .map(|(name, value)| (OsString::from(name), value)) + .collect(); + + let src_dir = + TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm"); + let file = src_dir.path().join("file"); + let dir = src_dir.path().join("dir"); + std::fs::write(&file, "content").unwrap(); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("file"), "content").unwrap(); + if apply_xattrs(&file, attrs.clone()).is_err() { + println!("test skipped: /dev/shm does not accept user xattrs"); + return; + } + apply_xattrs(dir.join("file"), attrs.clone()).unwrap(); + + // A single file, then a file copied as part of a directory. + for (src, dest, moved) in [(&file, "file", "file"), (&dir, "dir", "dir/file")] { + scene + .ucmd() + .arg(src) + .arg(dest) + .succeeds() + .stderr_contains("mv: setting attribute 'user.b_too_big': ") + .stderr_contains("mv: setting attribute 'user.d_too_big': "); + + assert!(!src.exists()); + let copied = retrieve_xattrs(at.plus(moved)).unwrap(); + for name in ["user.a_kept", "user.c_kept", "user.e_kept"] { + let name = OsStr::new(name); + assert_eq!(copied.get(name), attrs.get(name), "{name:?} on {moved}"); + } + } +} + /// Cross-device mv of a symlink onto an existing file must replace the /// destination atomically, matching GNU. #[test] @@ -3265,3 +3701,200 @@ fn test_mv_cross_device_preserves_ownership_recursive() { "nested file gid should be preserved after cross-device move" ); } + +#[test] +fn test_mv_backup_existing_mode_protects_source() { + // `--backup=existing` falls back to a simple backup when no numbered + // backup is present, so it can destroy the source just as `simple` does. + // GNU refuses in every mode but `numbered`. + for mode in ["simple", "existing"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("a"); + at.write("a~", "source content"); + + ucmd.arg(format!("--backup={mode}")) + .arg("a~") + .arg("a") + .fails() + .stderr_contains("might destroy source"); + + assert_eq!(at.read("a~"), "source content"); + } +} + +#[test] +fn test_mv_backup_existing_mode_protects_source_even_with_numbered_present() { + // GNU's check always uses the simple suffix once the mode is not + // `numbered`, so an existing `a.~1~` does not make this safe. + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("a"); + at.write("a~", "source content"); + at.write("a.~1~", "old numbered backup"); + + ucmd.arg("--backup=existing") + .arg("a~") + .arg("a") + .fails() + .stderr_contains("might destroy source"); + + assert_eq!(at.read("a~"), "source content"); +} + +#[test] +fn test_mv_backup_numbered_allows_source_named_like_backup() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("a"); + at.write("a~", "source content"); + + ucmd.arg("--backup=numbered").arg("a~").arg("a").succeeds(); + assert_eq!(at.read("a"), "source content"); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn test_mv_exchange_file_and_dir() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("leaf", "payload"); + at.mkdir("branch"); + + ucmd.arg("-T") + .arg("--exchange") + .arg("leaf") + .arg("branch") + .succeeds() + .no_output(); + + // After the swap the names trade places. + assert!(at.dir_exists("leaf")); + assert!(at.file_exists("branch")); + assert_eq!(at.read("branch"), "payload"); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn test_mv_exchange_verbose() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("first", "1"); + at.write("second", "2"); + + ucmd.arg("--exchange") + .arg("-v") + .arg("first") + .arg("second") + .succeeds() + .stdout_contains("exchanged"); + + assert_eq!(at.read("first"), "2"); + assert_eq!(at.read("second"), "1"); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn test_mv_exchange_wrong_operand_count() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.write("only", "x"); + + // A single operand is rejected (clap itself requires at least 2 values). + scene + .ucmd() + .arg("--exchange") + .arg("only") + .fails() + .code_is(1) + .stderr_contains("requires at least 2 values"); + + // Three or more operands are rejected when the last one isn't a directory. + scene.fixtures.write("two", "y"); + scene.fixtures.write("three", "z"); + scene + .ucmd() + .arg("--exchange") + .arg("only") + .arg("two") + .arg("three") + .fails() + .code_is(1) + .stderr_contains("target directory") + .stderr_contains("Not a directory"); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn test_mv_exchange_multiple_operands_into_directory() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("a", "src-a"); + at.write("b", "src-b"); + at.write("c", "src-c"); + at.mkdir("myfolder"); + at.write("myfolder/a", "dst-a"); + at.write("myfolder/b", "dst-b"); + at.write("myfolder/c", "dst-c"); + + ucmd.arg("--exchange") + .arg("-v") + .arg("a") + .arg("b") + .arg("c") + .arg("myfolder/") + .succeeds() + .stdout_contains("exchanged 'a' <-> 'myfolder/a'") + .stdout_contains("exchanged 'b' <-> 'myfolder/b'") + .stdout_contains("exchanged 'c' <-> 'myfolder/c'"); + + assert_eq!(at.read("a"), "dst-a"); + assert_eq!(at.read("b"), "dst-b"); + assert_eq!(at.read("c"), "dst-c"); + assert_eq!(at.read("myfolder/a"), "src-a"); + assert_eq!(at.read("myfolder/b"), "src-b"); + assert_eq!(at.read("myfolder/c"), "src-c"); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn test_mv_exchange_same_file() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("only", "data"); + + ucmd.arg("--exchange") + .arg("only") + .arg("only") + .fails() + .code_is(1) + .stderr_contains("are the same file"); + + assert_eq!(at.read("only"), "data"); +} + +#[cfg(not(any(target_os = "linux", target_os = "android")))] +#[test] +fn test_mv_exchange_not_supported() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("left", "L"); + at.write("right", "R"); + + ucmd.arg("--exchange") + .arg("left") + .arg("right") + .fails() + .code_is(1) + .stderr_contains("not supported"); + + assert_eq!(at.read("left"), "L"); + assert_eq!(at.read("right"), "R"); +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn test_mv_exchange_missing_target() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("present", "data"); + + ucmd.arg("--exchange") + .arg("present") + .arg("absent") + .fails() + .code_is(1) + .stderr_contains("cannot move") + .stderr_contains("present") + .stderr_contains("absent"); +} diff --git a/tests/by-util/test_nice.rs b/tests/by-util/test_nice.rs index d08558f6aa1..1e1a69dca3c 100644 --- a/tests/by-util/test_nice.rs +++ b/tests/by-util/test_nice.rs @@ -2,10 +2,14 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore getpriority setpriority + +#[cfg(unix)] use uutests::new_ucmd; #[test] +#[cfg(unix)] #[cfg(not(target_os = "android"))] fn test_get_current_niceness() { // Test that the nice command with no arguments returns the default nice value @@ -14,6 +18,7 @@ fn test_get_current_niceness() { } #[test] +#[cfg(unix)] #[cfg(not(target_os = "android"))] fn test_nice_adj_negative() { // This assumes the test suite is run as a normal (non-root) user, and as @@ -28,6 +33,7 @@ fn test_nice_adj_negative() { } #[test] +#[cfg(unix)] fn test_adjustment_with_no_command_should_error() { new_ucmd!() .args(&["-n", "19"]) @@ -36,11 +42,13 @@ fn test_adjustment_with_no_command_should_error() { } #[test] +#[cfg(unix)] fn test_command_with_no_adjustment() { new_ucmd!().args(&["echo", "a"]).succeeds().stdout_is("a\n"); } #[test] +#[cfg(unix)] fn test_command_with_no_args() { new_ucmd!() .args(&["-n", "19", "echo"]) @@ -49,6 +57,7 @@ fn test_command_with_no_args() { } #[test] +#[cfg(unix)] fn test_command_with_args() { new_ucmd!() .args(&["-n", "19", "echo", "a", "b", "c"]) @@ -57,6 +66,7 @@ fn test_command_with_args() { } #[test] +#[cfg(unix)] fn test_command_where_command_takes_n_flag() { new_ucmd!() .args(&["-n", "19", "echo", "-n", "a"]) @@ -65,11 +75,13 @@ fn test_command_where_command_takes_n_flag() { } #[test] +#[cfg(unix)] fn test_invalid_argument() { new_ucmd!().arg("--invalid").fails_with_code(125); } #[test] +#[cfg(unix)] fn test_bare_adjustment() { new_ucmd!() .args(&["-1", "echo", "-n", "a"]) @@ -78,6 +90,7 @@ fn test_bare_adjustment() { } #[test] +#[cfg(unix)] fn test_trailing_empty_adjustment() { new_ucmd!() .args(&["-n", "1", "-n"]) @@ -89,6 +102,7 @@ fn test_trailing_empty_adjustment() { } #[test] +#[cfg(unix)] fn test_nice_huge() { new_ucmd!() .args(&[ @@ -101,12 +115,14 @@ fn test_nice_huge() { } #[test] +#[cfg(unix)] fn test_nice_huge_negative() { new_ucmd!().args(&["-n", "-9999999999", "true"]).succeeds(); //.stderr_contains("Permission denied"); Depending on platform? } #[test] +#[cfg(unix)] fn test_sign_middle() { new_ucmd!() .args(&["-n", "-2+4", "true"]) diff --git a/tests/by-util/test_nl.rs b/tests/by-util/test_nl.rs index c08c6a7b243..ad4fd2b1ff6 100644 --- a/tests/by-util/test_nl.rs +++ b/tests/by-util/test_nl.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 binvalid finvalid hinvalid iinvalid linvalid nabcabc nabcabcabc ninvalid vinvalid winvalid dabc näää févr use uutests::{at_and_ucmd, new_ucmd, util::TestScenario, util_name}; @@ -183,10 +183,32 @@ fn test_number_width_zero() { new_ucmd!() .arg(arg) .fails() - .stderr_contains("Invalid line number field width: ‘0’: Numerical result out of range"); + .stderr_contains("is not in 1..=2147483647"); } } +#[test] +fn test_number_width_too_large() { + // Values > i32::MAX must be rejected to match GNU nl behavior and avoid + // a capacity-overflow panic in " ".repeat(number_width + 1). + for arg in ["-w2147483648", "--number-width=2147483648"] { + new_ucmd!() + .arg(arg) + .pipe_in("") + .fails() + .stderr_contains("is not in 1..=2147483647"); + } +} + +#[test] +fn test_number_width_max_i32() { + // i32::MAX (2147483647) is the largest value GNU nl accepts; it must not panic. + new_ucmd!() + .args(&["-w", "2147483647"]) + .pipe_in("") + .succeeds(); +} + #[test] fn test_invalid_number_width() { for arg in ["-winvalid", "--number-width=invalid"] { @@ -209,7 +231,7 @@ fn test_number_separator() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] #[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_number_separator_non_utf8() { use std::{ffi::OsString, os::unix::ffi::OsStringExt}; @@ -644,7 +666,7 @@ fn test_section_delimiter() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] #[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_section_delimiter_non_utf8() { use std::{ffi::OsString, os::unix::ffi::OsStringExt}; @@ -711,7 +733,7 @@ fn test_one_char_section_delimiter() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] #[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_one_byte_section_delimiter() { use std::{ffi::OsString, os::unix::ffi::OsStringExt}; @@ -754,23 +776,23 @@ fn test_one_byte_section_delimiter() { } #[test] -fn test_non_ascii_one_char_section_delimiter() { +fn test_multi_byte_one_char_section_delimiter() { for arg in ["-dä", "--section-delimiter=ä"] { new_ucmd!() .arg(arg) - .pipe_in("a\näää\nb") // header section + .pipe_in("a\nä:ä:ä:\nb") // header section .succeeds() .stdout_is(" 1\ta\n\n b\n"); new_ucmd!() .arg(arg) - .pipe_in("a\nää\nb") // body section + .pipe_in("a\nä:ä:\nb") // body section .succeeds() .stdout_is(" 1\ta\n\n 1\tb\n"); new_ucmd!() .arg(arg) - .pipe_in("a\nä\nb") // footer section + .pipe_in("a\nä:\nb") // footer section .succeeds() .stdout_is(" 1\ta\n\n b\n"); } @@ -930,3 +952,13 @@ fn test_repeated_section_delimiter_flag() { .succeeds() .stdout_is("\n a\n b\n c\n"); } + +#[test] +#[cfg(target_os = "linux")] +fn test_no_skip_after_error() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("f", "hello"); + ucmd.args(&["/proc/self/mem", "f"]) + .fails() + .stdout_is(" 1\thello\n"); +} diff --git a/tests/by-util/test_nohup.rs b/tests/by-util/test_nohup.rs index d140add83f6..2d42225823a 100644 --- a/tests/by-util/test_nohup.rs +++ b/tests/by-util/test_nohup.rs @@ -2,12 +2,14 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore winsize Openpty openpty xpixel ypixel ptyprocess + +#[cfg(unix)] use std::thread::sleep; -use uutests::at_and_ucmd; use uutests::new_ucmd; -use uutests::util::TestScenario; -use uutests::util_name; +#[cfg(unix)] +use uutests::{at_and_ucmd, util::TestScenario, util_name}; // General observation: nohup.out will not be created in tests run by cargo test // because stdin/stdout is not attached to a TTY. @@ -29,11 +31,11 @@ fn test_nohup_exit_codes() { #[test] #[cfg(any( + target_vendor = "apple", target_os = "linux", target_os = "android", target_os = "freebsd", - target_os = "openbsd", - target_vendor = "apple" + target_os = "openbsd" ))] fn test_nohup_multiple_args_and_flags() { let (at, mut ucmd) = at_and_ucmd!(); @@ -48,11 +50,11 @@ fn test_nohup_multiple_args_and_flags() { #[test] #[cfg(any( + target_vendor = "apple", target_os = "linux", target_os = "android", target_os = "freebsd", - target_os = "openbsd", - target_vendor = "apple" + target_os = "openbsd" ))] fn test_nohup_with_pseudo_terminal_emulation_on_stdin_stdout_stderr_get_replaced() { let ts = TestScenario::new(util_name!()); @@ -81,14 +83,40 @@ fn test_nohup_with_pseudo_terminal_emulation_on_stdin_stdout_stderr_get_replaced // When stdin is not a TTY (e.g., a pipe), nohup preserves it. // This behavior is already tested indirectly through other tests. +// When stdin is a terminal, the replacement must be unreadable so that a +// command which mistakenly reads from it gets an error instead of a silent +// EOF (GNU opens /dev/null write-only). Since nohup execs the command, the +// exit status is the command's own. +#[test] +#[cfg(unix)] +fn test_nohup_replaced_stdin_is_not_readable() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + ts.ucmd() + .terminal_simulation(true) + .arg("cat") + .fails_with_code(1) + .stderr_contains("nohup: ignoring input and appending output to 'nohup.out'"); + + sleep(std::time::Duration::from_millis(10)); + + // cat's error message goes to stderr, which nohup redirected into nohup.out + let content = std::fs::read_to_string(at.plus_as_string("nohup.out")).unwrap(); + assert!( + content.contains("Bad file descriptor"), + "expected a read error from cat in nohup.out, got: {content:?}" + ); +} + // Test that nohup creates nohup.out in current directory #[test] #[cfg(any( + target_vendor = "apple", target_os = "linux", target_os = "android", target_os = "freebsd", - target_os = "openbsd", - target_vendor = "apple" + target_os = "openbsd" ))] fn test_nohup_creates_output_in_cwd() { let ts = TestScenario::new(util_name!()); @@ -111,11 +139,11 @@ fn test_nohup_creates_output_in_cwd() { // Test that nohup appends to existing nohup.out #[test] #[cfg(any( + target_vendor = "apple", target_os = "linux", target_os = "android", target_os = "freebsd", - target_os = "openbsd", - target_vendor = "apple" + target_os = "openbsd" ))] fn test_nohup_appends_to_existing_file() { let ts = TestScenario::new(util_name!()); @@ -219,11 +247,11 @@ fn test_nohup_command_not_found() { // Test stderr is redirected to stdout #[test] #[cfg(any( + target_vendor = "apple", target_os = "linux", target_os = "android", target_os = "freebsd", - target_os = "openbsd", - target_vendor = "apple" + target_os = "openbsd" ))] fn test_nohup_stderr_to_stdout() { let ts = TestScenario::new(util_name!()); @@ -248,3 +276,90 @@ fn test_nohup_stderr_to_stdout() { assert!(content.contains("stdout message")); assert!(content.contains("stderr message")); } + +// The command's own exit status passes through nohup untouched, since the +// command replaces nohup's process image via exec. +#[test] +#[cfg(any( + target_vendor = "apple", + target_os = "linux", + target_os = "android", + target_os = "freebsd", + target_os = "openbsd" +))] +fn test_nohup_propagates_command_exit_code() { + new_ucmd!().args(&["true"]).succeeds(); + new_ucmd!().args(&["sh", "-c", "exit 3"]).fails_with_code(3); +} + +// A freshly created nohup.out must be readable only by its owner (0600), +// so other users on a shared host cannot read the detached job's logs. +#[test] +#[cfg(unix)] +fn test_nohup_new_output_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + ts.ucmd() + .terminal_simulation(true) + .args(&["echo", "secret output"]) + .succeeds(); + + sleep(std::time::Duration::from_millis(10)); + + let mode = at.metadata("nohup.out").permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "new nohup.out should have mode 0600, got {mode:o}" + ); +} + +// If nohup.out already exists its permissions are left alone, and new +// output is still appended to it. +#[test] +#[cfg(unix)] +fn test_nohup_existing_output_file_keeps_permissions() { + use std::os::unix::fs::PermissionsExt; + + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + at.write("nohup.out", "old content\n"); + at.set_mode("nohup.out", 0o644); + + ts.ucmd() + .terminal_simulation(true) + .args(&["echo", "new content"]) + .succeeds(); + + sleep(std::time::Duration::from_millis(10)); + + let mode = std::fs::metadata(at.plus("nohup.out")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o644, + "existing nohup.out should keep mode 0644, got {mode:o}" + ); + let content = std::fs::read_to_string(at.plus_as_string("nohup.out")).unwrap(); + assert!(content.contains("old content")); + assert!(content.contains("new content")); +} + +#[test] +fn test_nohup_help_and_version() { + use regex::Regex; + + new_ucmd!() + .arg("--help") + .succeeds() + .stdout_contains("nohup COMMAND"); + new_ucmd!() + .arg("--version") + .succeeds() + .stdout_matches(&Regex::new(r"^nohup \(uutils coreutils\) (\d+\.\d+\.\d+)\n$").unwrap()); +} diff --git a/tests/by-util/test_nproc.rs b/tests/by-util/test_nproc.rs index 588e7a7150b..0c48b0e190f 100644 --- a/tests/by-util/test_nproc.rs +++ b/tests/by-util/test_nproc.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore incorrectnumber + use uutests::new_ucmd; use uutests::util::TestScenario; use uutests::util_name; diff --git a/tests/by-util/test_numfmt.rs b/tests/by-util/test_numfmt.rs index a66f72d27da..7262f9732be 100644 --- a/tests/by-util/test_numfmt.rs +++ b/tests/by-util/test_numfmt.rs @@ -143,6 +143,74 @@ fn test_negative_padding() { .stdout_is("1000 \n1100000 \n100000000"); } +#[test] +fn test_negative_padding_as_separate_arg() { + // A negative padding value passed as its own argument (not attached + // with `=`) must not be mistaken for a new, unrecognized flag. + new_ucmd!() + .args(&["--from=si", "--padding", "-8"]) + .pipe_in("1K\n1.1M\n0.1G") + .succeeds() + .stdout_is("1000 \n1100000 \n100000000"); +} + +#[test] +fn test_suffix_hyphen_leading_as_separate_arg() { + // A hyphen-leading suffix value passed as its own argument (not + // attached with `=`) must not be mistaken for a new, unrecognized + // flag. + new_ucmd!() + .args(&["--suffix", "-x"]) + .pipe_in("5\n") + .succeeds() + .stdout_is("5-x\n"); +} + +#[test] +fn test_delimiter_hyphen_leading_as_separate_arg() { + // A hyphen-leading delimiter value passed as its own argument (not + // attached with `-d-x`/`=`) must not be mistaken for a new, + // unrecognized flag. + new_ucmd!() + .args(&["-d", "-x", "--field=1"]) + .fails() + .stderr_contains("the delimiter must be a single character"); +} + +#[test] +fn test_unit_size_hyphen_leading_as_separate_arg() { + // A hyphen-leading unit size passed as its own argument must reach + // our own validation instead of being read as an unknown flag. + for opt in ["--from-unit", "--to-unit"] { + new_ucmd!() + .args(&[opt, "-1"]) + .pipe_in("") + .fails() + .stderr_contains("invalid unit size: '-1'"); + } +} + +#[test] +fn test_unit_hyphen_leading_as_separate_arg() { + // Same for the --from/--to units. + for opt in ["--from", "--to"] { + new_ucmd!() + .args(&[opt, "-x"]) + .pipe_in("") + .fails() + .stderr_contains("invalid argument '-x' for '--"); + } +} + +#[test] +fn test_unit_separator_hyphen_leading_as_separate_arg() { + new_ucmd!() + .args(&["--to=si", "--unit-separator", "-"]) + .pipe_in("1000\n") + .succeeds() + .stdout_is("1.0-k\n"); +} + #[test] fn test_header() { new_ucmd!() @@ -185,6 +253,26 @@ fn test_header_error_if_negative() { .stderr_is("numfmt: invalid header value '-3'\n"); } +#[test] +fn test_reject_leading_plus_and_scientific_notation() { + // GNU numfmt rejects a leading '+' as an invalid number and rejects scientific + // notation as an invalid suffix; Rust's parsers accept both, so guard against it. + for input in ["+5", "+5K", "+1e-3"] { + new_ucmd!() + .arg("--from=auto") + .pipe_in(format!("{input}\n")) + .fails_with_code(2) + .stderr_is(format!("numfmt: invalid number: '{input}'\n")); + } + for input in ["1e-3", "1e+3", "5e-5", "1.0e-2", "-1e-5"] { + new_ucmd!() + .arg("--from=auto") + .pipe_in(format!("{input}\n")) + .fails_with_code(2) + .stderr_is(format!("numfmt: invalid suffix in input: '{input}'\n")); + } +} + #[test] fn test_negative() { new_ucmd!() @@ -431,6 +519,22 @@ fn test_format_selected_field() { .stdout_only("1K 2000 3K\n"); } +#[test] +fn test_field_with_multibyte_whitespace_separator() { + // A multibyte Unicode whitespace separator (U+3000) before a selected field + // is normalized to a single space rather than sliced mid-character. + new_ucmd!() + .args(&["--field", "2", "1 2"]) + .succeeds() + .stdout_only("1 2\n"); + + new_ucmd!() + .args(&["--from=auto", "--field", "2"]) + .pipe_in("1K\u{3000}2K\n") + .succeeds() + .stdout_only("1K 2000\n"); +} + #[test] fn test_format_selected_fields() { new_ucmd!() @@ -878,7 +982,13 @@ fn test_to_unit() { #[test] fn test_invalid_unit_size() { let commands = vec!["from", "to"]; - let invalid_sizes = vec!["A", "0", "18446744073709551616"]; + let invalid_sizes = vec![ + "A", + "0", + "18446744073709551616", + "18446744073709551615K", + "18014398509481984Ki", + ]; for command in commands { for invalid_size in &invalid_sizes { @@ -1004,6 +1114,41 @@ fn test_format_with_zero_padding_and_suffix() { .stdout_is("001234 ?\n"); } +// GNU zero-pads the number only: "Optional zero (%010f) width will zero pad +// the number". The unit from --to and the --suffix text stay outside the width. +#[test] +fn test_format_with_zero_padding_excludes_unit_suffix() { + let cases = [ + (vec!["--format=%05.1f", "--to=si", "1234567"], "001.3M"), + (vec!["--format=%06.1f", "--to=si", "1234"], "0001.3k"), + ( + vec!["--format=%08.1f", "--to=iec-i", "1234567"], + "000001.2Mi", + ), + ( + vec!["--format=%08.1f", "--to=si", "--", "-1234567"], + "-00001.3M", + ), + (vec!["--format=%03.1f", "--to=si", "1234567"], "1.3M"), + ( + vec!["--format=%05.1f", "--to=si", "--suffix=B", "1234567"], + "001.3MB", + ), + (vec!["--format=%05.1f", "--suffix=B", "1.5"], "001.5B"), + ( + vec!["--format=%08.1f", "--to=si", "--padding=12", "1234567"], + " 000001.3M", + ), + ]; + + for (args, expected) in cases { + new_ucmd!() + .args(&args) + .succeeds() + .stdout_only(format!("{expected}\n")); + } +} + #[test] fn test_format_with_precision() { let values = vec![("0.99", "1.0"), ("1", "1.0"), ("1.01", "1.1")]; @@ -1149,6 +1294,39 @@ fn test_format_with_invalid_precision() { } } +#[test] +fn test_format_error_escapes_special_characters() { + // A control character in --format used to be written to stderr verbatim, + // which split the error across lines or moved the cursor. Each error that + // quotes the format is covered here. + let cases = [ + ("ab\ncd", r"format 'ab\ncd' has no % directive"), + ("a\nb%", r"format 'a\nb%' ends in %"), + ("a\nb%f%", r"format 'a\nb%f%' has too many % directives"), + ("a\tb%f%", r"format 'a\tb%f%' has too many % directives"), + ("a\\b%f%", r"format 'a\\b%f%' has too many % directives"), + ("a'b%f%", r"format 'a\'b%f%' has too many % directives"), + // A space is printable and must stay a space, not become "\ ". + ("a b%f%", r"format 'a b%f%' has too many % directives"), + ( + "a\nb%q", + r"invalid format 'a\nb%q', directive must be %[0]['][-][N][.][N]f", + ), + ( + "a\nb%99999999999999999999f", + r"invalid format 'a\nb%99999999999999999999f' (width overflow)", + ), + ("a\nb%.-f", r"invalid precision in format 'a\nb%.-f'"), + ]; + + for (invalid_format, expected) in cases { + new_ucmd!() + .arg(format!("--format={invalid_format}")) + .fails_with_code(1) + .stderr_contains(expected); + } +} + #[test] fn test_format_grouping_conflicts_with_to_option() { new_ucmd!() @@ -1415,9 +1593,9 @@ fn test_null_byte_input_multiline() { // GNU rejects `-9923868` as an invalid short option (leading `-9`) and // requires `--` separator; uutils accepts it as a negative positional number. #[test] -fn test_negative_number_without_double_dash_gnu_compat_issue_11653() { +fn test_numfmt_negative_treated_as_option() { new_ucmd!() - .args(&["--to=iec", "-9923868"]) + .args(&["--to=iec", "-8765432"]) .fails_with_code(1) .stderr_contains("unexpected argument"); } @@ -1426,11 +1604,11 @@ fn test_negative_number_without_double_dash_gnu_compat_issue_11653() { // GNU rejects `-9923868` as an invalid short option (leading `-9`) and // requires `--` separator; uutils accepts it as a negative positional number. #[test] -fn test_negative_number_with_double_dash_gnu_compat_issue_11653() { +fn test_numfmt_negative_after_double_dash_ok() { new_ucmd!() - .args(&["--to=iec", "--", "-9923868"]) + .args(&["--to=iec", "--", "-8765432"]) .succeeds() - .stdout_is("-9.5M\n"); + .stdout_is("-8.4M\n"); } // https://github.com/uutils/coreutils/issues/11654 @@ -1447,9 +1625,9 @@ fn test_large_integer_precision_loss_issue_11654() { // uutils accepts scientific notation (`1e9`, `5e-3`, ...); GNU rejects it // as "invalid suffix in input". #[test] -fn test_scientific_notation_rejected_by_gnu_issue_11655() { +fn test_numfmt_scientific_notation_rejected() { new_ucmd!() - .arg("1e9") + .arg("2e8") .fails_with_code(2) .stderr_contains("invalid suffix in input"); } @@ -1503,6 +1681,57 @@ fn test_format_precision_zero_with_to_scale_issue_11667() { .stdout_is("5M\n"); } +// `--format=%.0f` with `--to=` must round the scaled value with the +// selected `--round` method, instead of first keeping a fractional digit and +// then rounding that to nearest. +#[test] +fn test_format_precision_zero_with_to_scale_honors_round_method() { + let cases = [ + (vec!["--to=si", "--format=%.0f", "1234567"], "2M"), + (vec!["--to=si", "--format=%.0f", "--", "-1234567"], "-2M"), + (vec!["--to=iec", "--format=%.0f", "1234567"], "2M"), + ( + vec!["--to=si", "--format=%.0f", "--round=up", "1234567"], + "2M", + ), + ( + vec!["--to=si", "--format=%.0f", "--round=down", "1934567"], + "1M", + ), + ( + vec!["--to=si", "--format=%.0f", "--round=nearest", "1234567"], + "1M", + ), + ( + vec![ + "--to=si", + "--format=%.0f", + "--round=towards-zero", + "1934567", + ], + "1M", + ), + // rounding carries into the next suffix + (vec!["--to=si", "--format=%.0f", "999999"], "1M"), + (vec!["--to=iec", "--format=%.0f", "1048575"], "1M"), + ( + vec!["--to=si", "--format=%.0f", "--round=nearest", "999999"], + "1M", + ), + ( + vec!["--to=si", "--format=%.0f", "--round=down", "999999"], + "999k", + ), + ]; + + for (args, expected) in cases { + new_ucmd!() + .args(&args) + .succeeds() + .stdout_only(format!("{expected}\n")); + } +} + #[test] fn test_invalid_utf8_input() { // 0xFF is invalid UTF-8 @@ -1636,12 +1865,27 @@ fn test_float_precision_greater_than_16bits() { .stdout_is("1\n"); } +#[test] +fn test_float_precision_greater_than_16bits_without_scaling() { + new_ucmd!() + .args(&["--format=%.65536f", "1.5"]) + .succeeds() + .stdout_is("2\n"); +} + +#[test] +fn test_float_precision_greater_than_16bits_with_suffix() { + new_ucmd!() + .args(&["--to=si", "--format=%.65536f", "1000000"]) + .succeeds() + .stdout_is("1M\n"); +} + #[test] fn test_format_precision_too_large_on_zero() { new_ucmd!() .args(&["--format=%.40f", "0"]) - .fails() - .code_is(2) + .fails_with_code(2) .stderr_only( "numfmt: value/precision too large to be printed: '0e+0/40' (consider using --to)\n", ); @@ -1660,3 +1904,591 @@ fn test_header_detached() { .succeeds() .stdout_is("1\n2\n"); } + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_bad_directive() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--format=%q", "1000"]) + .fails_with_code(1); + + // The whole report: the `numfmt: ` prefix of the plain form, the + // argument list echoed back, a caret on the `q` inside `--format=%q`, + // and the format syntax advice. + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid format '%q', directive must be %[0]['][-][N][.][N]f + ╭─[ numfmt:1:18 ] + │ + 1 │ numfmt --format=%q 1000 + │ ┬ + │ ╰── f is the only conversion numfmt has; %d, %e, %g and the other C conversions are not accepted + │ + │ Help: a format is [PREFIX]%[0]['][-][WIDTH][.PRECISION]f[SUFFIX], as in \"%'-10.2f\" +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_stray_percent() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--format=%f%", "1000"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: format '%f%' has too many % directives + ╭─[ numfmt:1:19 ] + │ + 1 │ numfmt --format=%f% 1000 + │ ┬ + │ ╰── a literal % must be written %% + │ + │ Help: a format is [PREFIX]%[0]['][-][WIDTH][.PRECISION]f[SUFFIX], as in \"%'-10.2f\" +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_the_oversized_precision() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--format=%.88888888888888888888f", "1000"]) + .fails_with_code(1); + + // The underline covers the whole run of digits, not one character. + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid precision in format '%.88888888888888888888f' + ╭─[ numfmt:1:19 ] + │ + 1 │ numfmt --format=%.88888888888888888888f 1000 + │ ──────────┬───────── + │ ╰─────────── this number is too large + │ + │ Help: a format is [PREFIX]%[0]['][-][WIDTH][.PRECISION]f[SUFFIX], as in \"%'-10.2f\" +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_marks_a_format_without_a_directive() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--format=qwe", "1000"]) + .fails_with_code(1); + + // Nothing inside is wrong on its own, so the whole format is marked. + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: format 'qwe' has no % directive + ╭─[ numfmt:1:17 ] + │ + 1 │ numfmt --format=qwe 1000 + │ ─── + │ + │ Help: a format is [PREFIX]%[0]['][-][WIDTH][.PRECISION]f[SUFFIX], as in \"%'-10.2f\" +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_an_unknown_unit() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--from=xyz", "1000"]) + .fails_with_code(1); + + // The value is wrong as a whole, so it is underlined as a whole and + // the help lists what --from would have taken instead. + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid argument 'xyz' for '--from' + ╭─[ numfmt:1:15 ] + │ + 1 │ numfmt --from=xyz 1000 + │ ─── + │ + │ Help: --from and --to take none, si, iec or iec-i, and --from also takes auto +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_says_auto_belongs_to_from() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--to", "auto", "1000"]) + .fails_with_code(1); + + // 'auto' exists, it is just not a unit --to can scale to; the label + // says so rather than leaving the help to imply it is unknown. + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid argument 'auto' for '--to' + ╭─[ numfmt:1:13 ] + │ + 1 │ numfmt --to auto 1000 + │ ──┬─ + │ ╰─── auto guesses the unit of the input, so only --from takes it + │ + │ Help: --from and --to take none, si, iec or iec-i, and --from also takes auto +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_an_unknown_unit_size() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--from-unit=Q", "1000"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid unit size: 'Q' + ╭─[ numfmt:1:20 ] + │ + 1 │ numfmt --from-unit=Q 1000 + │ ─ + │ + │ Help: a unit size is a number, a K, M, G, T, P or E multiplier, or both, as in 512, K or 2Ki +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_says_a_unit_size_cannot_be_zero() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--to-unit", "0", "1000"]) + .fails_with_code(1); + + // A zero is spelled like a valid size, so the label says what the + // syntax in the help cannot. + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid unit size: '0' + ╭─[ numfmt:1:18 ] + │ + 1 │ numfmt --to-unit 0 1000 + │ ┬ + │ ╰── a unit size must be at least 1 + │ + │ Help: a unit size is a number, a K, M, G, T, P or E multiplier, or both, as in 512, K or 2Ki +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_says_why_a_zero_padding_is_refused() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--padding=0", "1000"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid padding value '0' + ╭─[ numfmt:1:18 ] + │ + 1 │ numfmt --padding=0 1000 + │ ┬ + │ ╰── a padding is a width in characters, so 0 asks for nothing + │ + │ Help: --padding takes a non-zero whole number; a negative one left-aligns, as in --padding=-10 +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_a_header_count() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--header=x", "1000"]) + .fails_with_code(1); + + // Nothing about 'x' is worth a label the message does not already + // carry, so the caret is bare and the help says what is taken. + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid header value 'x' + ╭─[ numfmt:1:17 ] + │ + 1 │ numfmt --header=x 1000 + │ ─ + │ + │ Help: --header takes the number of leading lines to pass through unchanged, at least 1 +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_suffix_of_a_bad_number() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--to=si", "12abc"]) + .fails_with_code(2); + + // The number numfmt could read is left alone; the caret starts where + // it stopped. + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid suffix in input: '12abc' + ╭─[ numfmt:1:18 ] + │ + 1 │ numfmt --to=si 12abc + │ ─── + │ + │ Help: without --from a number must be plain; --from=auto reads a K, M or Gi suffix +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_marks_an_input_that_is_not_a_number() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["abc"]) + .fails_with_code(2); + + // Nothing of it parsed, so all of it is marked, and advice about + // suffixes would be beside the point. + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid number: 'abc' + ╭─[ numfmt:1:8 ] + │ + 1 │ numfmt abc + │ ─── +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_i_a_unit_does_not_take() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--from=si", "1Ki"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid suffix in input '1Ki': 'i' + ╭─[ numfmt:1:20 ] + │ + 1 │ numfmt --from=si 1Ki + │ ─ + │ + │ Help: the suffixes are K, M, G, T, P, E, Z, Y, R and Q, with an optional i under --from=auto or iec-i +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_input_from_stdin_keeps_its_plain_message() { + // There is no command line to point at, so the message stays as it is. + let result = new_ucmd!() + .terminal_sim_stderr() + .pipe_in("12abc\n") + .fails_with_code(2); + + assert_eq!( + result.stderr_str().trim_end(), + "numfmt: invalid suffix in input: '12abc'" + ); + } + + #[cfg(unix)] + #[test] + fn test_a_line_of_fields_keeps_its_plain_message() { + // The failure is inside one field of the argument; the offsets are of + // the whole line, so no caret is drawn. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--field=2", "x 12abc"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_str().trim_end(), + "numfmt: invalid suffix in input: '12abc'" + ); + } + + #[cfg(unix)] + #[test] + fn test_other_option_errors_keep_their_plain_message() { + // The format is fine; the failure is elsewhere and must not be + // decorated with a caret into --format. The pseudo-terminal turns the + // newline into CRLF, hence the trim. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--format=%f", "--delimiter=ab", "1000"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_str().trim_end(), + "numfmt: the delimiter must be a single character" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_ignores_another_option_with_the_same_value() { + // --delimiter carries the same text as --format; the caret must land + // inside --format, not the option that merely shares its suffix. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--delimiter=%q", "--format=%q", "1000"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid format '%q', directive must be %[0]['][-][N][.][N]f + ╭─[ numfmt:1:33 ] + │ + 1 │ numfmt --delimiter=%q --format=%q 1000 + │ ┬ + │ ╰── f is the only conversion numfmt has; %d, %e, %g and the other C conversions are not accepted + │ + │ Help: a format is [PREFIX]%[0]['][-][WIDTH][.PRECISION]f[SUFFIX], as in \"%'-10.2f\" +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_marks_a_whitespace_only_number() { + // There is no number to point past, so the whitespace is marked whole + // rather than the two ends of it crossing. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&[" "]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid number: '' + ╭─[ numfmt:1:9 ] + │ + 1 │ numfmt ' ' + │ ─ +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_ignores_an_option_value_with_the_number_text() { + // --suffix takes a detached `q`, the same text as the operand; the + // caret must land on the operand, not on the value that precedes it. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--suffix", "q", "--from", "si", "q"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid number: '' + ╭─[ numfmt:1:29 ] + │ + 1 │ numfmt --suffix q --from si q + │ ─ +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_says_nothing_about_suffixes_without_a_number() { + // A lone sign is the start of a number but not one yet, so advice about + // what may follow a number does not apply. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--from=si", "--", "-"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid number: '-' + ╭─[ numfmt:1:21 ] + │ + 1 │ numfmt --from=si -- - + │ ─ +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_names_no_conversion_when_the_format_has_none() { + // Nothing stands where the conversion belongs, so there is no + // conversion to name — only a directive that never ends. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--format=%10", "1000"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid format '%10', directive must be %[0]['][-][N][.][N]f + ╭─[ numfmt:1:17 ] + │ + 1 │ numfmt --format=%10 1000 + │ ─── + │ + │ Help: a format is [PREFIX]%[0]['][-][WIDTH][.PRECISION]f[SUFFIX], as in \"%'-10.2f\" +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_leaves_the_suffix_out_of_the_bad_conversion() { + // What follows the conversion would have been a valid suffix, so only + // the character standing in for `f` is underlined. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--format=%d bytes", "1000"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid format '%d bytes', directive must be %[0]['][-][N][.][N]f + ╭─[ numfmt:1:19 ] + │ + 1 │ numfmt '--format=%d bytes' 1000 + │ ┬ + │ ╰── f is the only conversion numfmt has; %d, %e, %g and the other C conversions are not accepted + │ + │ Help: a format is [PREFIX]%[0]['][-][WIDTH][.PRECISION]f[SUFFIX], as in \"%'-10.2f\" +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_blames_the_suffix_not_the_zero_in_a_unit_size() { + // `0x` fails on the unknown `x`, not on the zero, so the label about a + // size of at least 1 would be beside the point. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--from-unit=0x", "1"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_as_displayed(), + "\ +numfmt: invalid unit size: '0x' + ╭─[ numfmt:1:20 ] + │ + 1 │ numfmt --from-unit=0x 1 + │ ── + │ + │ Help: a unit size is a number, a K, M, G, T, P or E multiplier, or both, as in 512, K or 2Ki +───╯" + ); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + // The test harness pipes stderr, so the report must not appear. + new_ucmd!() + .args(&["--format=%q", "1000"]) + .fails_with_code(1) + .stderr_only("numfmt: invalid format '%q', directive must be %[0]['][-][N][.][N]f\n"); + } +} + +#[cfg(unix)] +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod field_diagnostics { + use super::*; + + #[test] + fn test_snippet_points_at_the_inverted_field_range() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--field", "1,3-2", "--to=si", "1"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("numfmt:1:18"), "{stderr}"); + assert!( + stderr.contains("this range ends before it starts"), + "{stderr}" + ); + } + + #[test] + fn test_snippet_points_at_the_zero_field() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--field=0", "--to=si", "1"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("numfmt:1:16"), "{stderr}"); + assert!(stderr.contains("fields are numbered from 1"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["--field=0", "--to=si", "1"]) + .fails_with_code(1) + .stderr_contains("range '0' was invalid: fields and positions are numbered from 1"); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn test_write_error_is_reported_and_fatal() { + // A full output device must be diagnosed, not panic, whatever --invalid says. + for extra in [&["--to=si"][..], &["--invalid=ignore"][..]] { + let mut ucmd = new_ucmd!(); + ucmd.args(extra) + .pipe_in("81920\n4096\n1024\n") + .set_stdout(std::fs::File::create("/dev/full").unwrap()) + .fails_with_code(1) + .stderr_is("numfmt: write error: No space left on device\n"); + } +} diff --git a/tests/by-util/test_od.rs b/tests/by-util/test_od.rs index 09ffd3ebc2f..8868bca7caa 100644 --- a/tests/by-util/test_od.rs +++ b/tests/by-util/test_od.rs @@ -266,7 +266,7 @@ fn test_tf_explicit_float_still_uses_4_bytes() { .arg("-tfF") .run_piped_stdin(&input[..]) .success() - .stdout_only(" 1.0000000 2.0000000\n"); + .stdout_only(" 1 2\n"); } #[test] @@ -363,7 +363,7 @@ fn test_f32() { ]; // 0x807f0000 -1.1663108E-38 let expected_output = unindent( " - 0000000 -1.2345679 12345678 -9.8765427e+37 -0 + 0000000 -1.2345679 12345678 -9.876543e+37 -0 0000020 NaN 1e-40 -1.1663108e-38 0000034 ", @@ -392,7 +392,7 @@ fn test_f64() { " 0000000 12345678912345678 0 0000020 -2.2250738585072014e-308 5e-324 - 0000040 -2.0000000000000000 + 0000040 -2 0000050 ", ); @@ -441,6 +441,23 @@ fn test_width() { .stdout_only(expected_output); } +#[test] +fn test_large_width_ascii_dump() { + // A line is padded up to the full width before the ascii dump, so the + // output for a single byte is 4 * WIDTH + 21 characters wide. Checks that + // such a line comes out intact; the memory behavior at widths that cannot + // be buffered at all is covered by the GNU test suite (od/big-w.sh). + const WIDTH: usize = 4_000_000; + + let mut cmd = new_ucmd!(); + let result = cmd + .args(&[format!("-w{WIDTH}"), "-tcz".into()]) + .run_piped_stdin(&b"x"[..]); + let stdout = result.success().stdout_str(); + assert_eq!(stdout.len(), 4 * WIDTH + 21); + assert!(stdout.ends_with(" >x<\n0000001\n")); +} + #[test] fn test_invalid_width() { let input: [u8; 4] = [0x00, 0x00, 0x00, 0x00]; @@ -540,6 +557,20 @@ fn test_very_wide_hex_byte_output() { .stdout_only(" 42\n"); } +#[test] +fn test_very_wide_hex_byte_ascii_dump() { + const WIDTH: usize = 100_000; + let expected = format!(" 41{} >A<\n", " ".repeat(WIDTH * 3 - 3)); + + new_ucmd!() + .arg("-An") + .arg(format!("-w{WIDTH}")) + .arg("-tx1z") + .pipe_in("A") + .succeeds() + .stdout_only(expected); +} + #[test] fn test_suppress_duplicates() { let input: [u8; 41] = [ @@ -566,6 +597,7 @@ fn test_suppress_duplicates() { .arg("-w4") .arg("-O") .arg("-x") + .arg("--endian=little") .run_piped_stdin(&input[..]) .success() .stdout_only(expected_output); @@ -577,8 +609,8 @@ fn test_big_endian() { let expected_output = unindent( " - 0000000 -2.0000000000000000 - -2.0000000 0 + 0000000 -2 + -2 0 c0000000 00000000 c000 0000 0000 0000 0000010 @@ -637,7 +669,7 @@ fn test_alignment_Fx() { let expected_output = unindent( " - 0000000 -2.0000000000000000 + 0000000 -2 0000 0000 0000 c000 0000010 ", @@ -934,6 +966,46 @@ fn test_skip_bytes_error() { .failure(); } +// The skip is measured over all the inputs joined together, and the message +// says so, matching GNU od. +#[test] +fn test_skip_bytes_past_end_message() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("a", "abc"); + at.write("b", "de"); + ucmd.arg("-j6") + .arg("a") + .arg("b") + .fails_with_code(1) + .no_stdout() + .stderr_only("od: cannot skip past end of combined input\n"); +} + +// A seekable special file such as /dev/null can be skipped past its (empty) +// end without error, matching GNU od. +#[cfg(unix)] +#[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: /dev/null is not a seekable device" +)] +fn test_skip_bytes_past_end_of_seekable_device() { + new_ucmd!() + .arg("-j1") + .arg("/dev/null") + .succeeds() + .stdout_only("0000001\n"); +} + +// Skipping past the end of a regular file is an error and must not print a +// trailing offset line, matching GNU od. +#[test] +fn test_skip_bytes_past_end_no_offset() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("f", "hello"); + ucmd.arg("-j10").arg("f").fails().no_stdout(); +} + #[test] #[cfg_attr( wasi_runner, @@ -1218,6 +1290,7 @@ fn test_od_options_after_filename() { .arg("-An") .arg("-t") .arg("x2") + .arg("--endian=little") .succeeds() .stdout_only(" 1c68 fdbb\n"); } @@ -1362,7 +1435,7 @@ fn test_hex_lowercase() { } #[test] -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] #[cfg_attr(wasi_runner, ignore)] fn test_is_a_directory() { let scene = TestScenario::new(util_name!()); @@ -1394,3 +1467,82 @@ fn test_od_strings_with_n_flag() { .success() .stdout_only("0000000 foo\n0000004 bar\n"); } + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_unknown_unit_of_read_bytes() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-N", "3zz", "/dev/null"]) + .fails_with_code(1); + + // The number parsed; only the unit did not. The headline keeps the + // option spelled the way it was typed. + assert_eq!( + result.stderr_as_displayed(), + "\ +od: invalid suffix in -N argument '3zz' + ╭─[ od:1:8 ] + │ + 1 │ od -N 3zz /dev/null + │ ─┬ + │ ╰── not a known unit + │ + │ Help: a size is a number and an optional unit: K, M, G and so on for 1024, KB, MB, GB for 1000 +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_inside_a_width_value() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--width=4qq", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("od:1:13"), "{stderr}"); + assert!(stderr.contains("not a known unit"), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_a_hexadecimal_offset_that_does_not_parse() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-j", "0x1zz", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + // Nothing usable was read, so the whole value is underlined. + assert!(stderr.contains("od:1:7"), "{stderr}"); + assert!(!stderr.contains("not a known unit"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["-N", "3zz", "/dev/null"]) + .fails_with_code(1) + .stderr_is("od: invalid suffix in -N argument '3zz'\n"); + } +} + +#[test] +fn test_hyphen_leading_byte_count_is_reported_as_invalid() { + // GNU hands the argument after the option to that option even when it + // starts with a hyphen, so it is reported as an invalid argument rather + // than as an unknown option. + for opt in ["-N", "-j"] { + new_ucmd!() + .args(&[opt, "-1"]) + .pipe_in("") + .fails() + .stderr_contains(format!("invalid {opt} argument '-1'")); + } +} diff --git a/tests/by-util/test_paste.rs b/tests/by-util/test_paste.rs index 260aa21d605..020fceb80aa 100644 --- a/tests/by-util/test_paste.rs +++ b/tests/by-util/test_paste.rs @@ -4,6 +4,7 @@ // file that was distributed with this source code. // spell-checker:ignore bsdutils toybox + #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStringExt; use uutests::at_and_ucmd; @@ -166,6 +167,19 @@ fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); } +#[test] +fn test_delimiter_hyphen_leading_as_separate_arg() { + // A hyphen-leading delimiter value passed as its own argument (not + // attached with `-d-x`/`=`) must not be mistaken for a new, + // unrecognized flag. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("f1", "a\nb\n"); + at.write("f2", "1\n2\n"); + ucmd.args(&["-d", "-x", "f1", "f2"]) + .succeeds() + .stdout_is("a-1\nb-2\n"); +} + #[test] fn test_combine_pairs_of_lines() { for s in ["-s", "--serial"] { @@ -378,13 +392,13 @@ fn test_backslash_zero_delimiter() { } #[test] -fn test_gnu_escape_sequences() { +fn test_paste_delimiter_escape_sequences() { let cases: &[(&str, u8)] = &[(r"\b", 0x08), (r"\f", 0x0C), (r"\r", 0x0D), (r"\v", 0x0B)]; for &(esc, byte) in cases { - let expected = [b'1', byte, b'2', byte, b'3', b'\n']; + let expected = [b'a', byte, b'b', byte, b'c', b'\n']; new_ucmd!() .args(&["-s", "-d", esc]) - .pipe_in("1\n2\n3\n") + .pipe_in("a\nb\nc\n") .succeeds() .stdout_only_bytes(expected); } @@ -393,9 +407,12 @@ fn test_gnu_escape_sequences() { // As of 2024-10-09, only bsdutils (https://github.com/dcantrell/bsdutils, derived from FreeBSD) and toybox handle // multibyte delimiter characters in the way a user would likely expect. BusyBox and GNU Core Utilities do not. #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: the guest does not inherit LC_ALL")] fn test_multi_byte_delimiter() { for option_style in ["-d", "--delimiters"] { new_ucmd!() + // A multibyte delimiter is stepped per character in a UTF-8 locale. + .env("LC_ALL", "C.UTF-8") .args(&[option_style, "!ß@", "-s"]) .pipe_in( "\ @@ -417,6 +434,7 @@ fn test_multi_byte_delimiter() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: the guest does not inherit LC_ALL")] fn test_data() { for example in EXAMPLE_DATA { let (at, mut ucmd) = at_and_ucmd!(); @@ -427,7 +445,9 @@ fn test_data() { ins.push(file); } println!("{}", example.name); - ucmd.args(example.args) + // Some examples use multibyte delimiters, which need a UTF-8 locale. + ucmd.env("LC_ALL", "C.UTF-8") + .args(example.args) .args(&ins) .succeeds() .stdout_is(example.out); @@ -504,3 +524,88 @@ fn test_dev_zero_closed_pipe() { .run() .fails_silently(); } + +#[test] +fn test_repeated_delimiter_takes_the_last() { + // GNU lets a later -d override an earlier one rather than erroring. + new_ucmd!() + .args(&["-d", ",", "-d", ":", "-s", "-"]) + .pipe_in("a\nb\n") + .succeeds() + .stdout_is("a:b\n"); +} + +#[test] +fn test_crlf_input_in_serial_mode() { + let expected = if cfg!(windows) { + "1,2,3\n" + } else { + "1\r,2\r,3\r\n" + }; + + new_ucmd!() + .args(&["-s", "-d", ","]) + .pipe_in(b"1\r\n2\r\n3\r\n") + .succeeds() + .stdout_is(expected); +} + +#[test] +fn test_crlf_input_in_parallel_mode() { + let expected = if cfg!(windows) { + "1\t3\n2\t4\n" + } else { + "1\r\t3\r\n2\r\t4\r\n" + }; + + let (at, mut ucmd) = at_and_ucmd!(); + + at.write_bytes("a", b"1\r\n2\r\n"); + at.write_bytes("b", b"3\r\n4\r\n"); + + ucmd.arg("a").arg("b").succeeds().stdout_is(expected); +} + +#[test] +fn test_crlf_kept_when_input_is_zero_terminated() { + new_ucmd!() + .args(&["-s", "-z", "-d", ","]) + .pipe_in(b"1\r\n2\r\0") + .succeeds() + .stdout_is("1\r\n2\r\0"); +} + +#[test] +fn test_crlf_input_of_a_single_file_is_copied() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write_bytes("a", b"1\r\n2\r\n"); + + ucmd.arg("a").succeeds().stdout_is("1\r\n2\r\n"); +} + +#[test] +fn test_crlf_kept_when_last_line_ends_with_cr_and_no_newline() { + let expected = if cfg!(windows) { + "1,2\r\n" + } else { + "1\r,2\r\n" + }; + + new_ucmd!() + .args(&["-s", "-d", ","]) + .pipe_in(b"1\r\n2\r") + .succeeds() + .stdout_is(expected); +} + +#[test] +fn test_crlf_delimiter_not_eaten_by_an_empty_line() { + let expected = if cfg!(windows) { "1\r\n" } else { "1\r\r\n" }; + + new_ucmd!() + .args(&["-s", "-d", "\\r"]) + .pipe_in(b"1\r\n\n") + .succeeds() + .stdout_is(expected); +} diff --git a/tests/by-util/test_pathchk.rs b/tests/by-util/test_pathchk.rs index 47fb1e01d1b..96ccc1c3212 100644 --- a/tests/by-util/test_pathchk.rs +++ b/tests/by-util/test_pathchk.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStringExt; #[cfg(unix)] @@ -13,7 +14,7 @@ fn test_no_args() { new_ucmd!() .fails() .no_stdout() - .stderr_contains("pathchk: missing operand"); + .stderr_contains("the following required arguments were not provided"); // clap provided message } #[test] @@ -171,7 +172,23 @@ fn test_posix_all() { .no_stdout(); // fail on empty path - new_ucmd!().args(&["-p", "-P", ""]).fails().no_stdout(); + new_ucmd!() + .args(&["-p", "-P", ""]) + .fails() + .stderr_only("pathchk: empty file name\n"); +} + +#[test] +#[cfg(unix)] +fn test_empty_path_portability_message() { + // In every portability mode (-p, -P, --portability) GNU reports an empty + // file name with the program-name prefix and before any filesystem check. + for flag in ["-p", "-P", "--portability"] { + new_ucmd!() + .args(&[flag, ""]) + .fails() + .stderr_only("pathchk: empty file name\n"); + } } #[test] @@ -180,3 +197,13 @@ fn test_pathchk_non_utf8_paths() { let filename = std::ffi::OsString::from_vec(vec![0xFF, 0xFE]); new_ucmd!().arg(&filename).succeeds(); } + +#[test] +#[cfg(target_os = "linux")] +fn test_not_a_directory_clean() { + // https://github.com/uutils/coreutils/issues/13888 + new_ucmd!() + .arg("/dev/full/") + .fails() + .stderr_is("pathchk: /dev/full/: Not a directory\n"); +} diff --git a/tests/by-util/test_pinky.rs b/tests/by-util/test_pinky.rs index 03447fd0462..684677f37d4 100644 --- a/tests/by-util/test_pinky.rs +++ b/tests/by-util/test_pinky.rs @@ -19,14 +19,14 @@ fn test_invalid_arg() { #[test] #[cfg(not(target_os = "openbsd"))] fn test_long_format() { - use pinky::Capitalize; + use pinky::capitalize; let login = "root"; let pw: Passwd = Passwd::locate(login).unwrap(); let user_info = pw.user_info.unwrap_or_default(); let user_dir = pw.user_dir.unwrap_or_default(); let user_shell = pw.user_shell.unwrap_or_default(); - let real_name = user_info.replace('&', &pw.name.capitalize()); + let real_name = user_info.replace('&', &capitalize(&pw.name)); let ts = TestScenario::new(util_name!()); ts.ucmd().arg("-l").arg(login).succeeds().stdout_is(format!( "Login name: {login:<28}In real life: {real_name}\nDirectory: {user_dir:<29}Shell: {user_shell}\n\n" @@ -81,49 +81,43 @@ fn test_short_format_i() { assert_eq!(v_actual, v_expect); } +/// Drop the clock-dependent tokens from a `pinky` output split on whitespace. +/// +/// The idle column holds a live `HH:MM` duration, so it can tick over between the +/// run of our binary and the run of the reference one, which made these tests flaky. +/// Remove the `Idle` header and every `HH:MM`-looking token so only the stable +/// fields are compared. #[cfg(unix)] -#[test] #[cfg(not(target_os = "openbsd"))] -fn test_lookup() { - // 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() +fn strip_volatile_fields(fields: &[&str]) -> Vec { + fn is_clock_value(s: &str) -> bool { + let bytes = s.as_bytes(); + bytes.len() == 5 + && bytes[2] == b':' + && bytes[..2].iter().chain(&bytes[3..]).all(u8::is_ascii_digit) } + fields + .iter() + .filter(|s| **s != "Idle" && !is_clock_value(s)) + .map(|s| (*s).to_string()) + .collect() +} + +#[cfg(unix)] +#[test] +#[cfg(not(target_os = "openbsd"))] +fn test_lookup() { let args = ["--lookup"]; let ts = TestScenario::new(util_name!()); let actual = ts.ucmd().args(&args).succeeds().stdout_move_str(); 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(); - 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); + assert_eq!( + strip_volatile_fields(&v_actual), + strip_volatile_fields(&v_expect) + ); } #[cfg(unix)] @@ -138,7 +132,10 @@ fn test_short_format_q() { let expect = unwrap_or_return!(expected_result(&ts, &args)).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); + assert_eq!( + strip_volatile_fields(&v_actual), + strip_volatile_fields(&v_expect) + ); } #[cfg(unix)] @@ -150,5 +147,8 @@ fn test_no_flag() { 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); + assert_eq!( + strip_volatile_fields(&v_actual), + strip_volatile_fields(&v_expect) + ); } diff --git a/tests/by-util/test_pr.rs b/tests/by-util/test_pr.rs index 7eedc1c51f8..7d4096622fc 100644 --- a/tests/by-util/test_pr.rs +++ b/tests/by-util/test_pr.rs @@ -2,7 +2,8 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) Sdivide ading + +// spell-checker:ignore (ToDO) Sdivide ading tfre use jiff::{Timestamp, ToSpan}; use regex::Regex; @@ -81,6 +82,18 @@ fn test_number_lines_empty_value_is_rejected() { .stderr_contains("pr: '-n' extra characters or invalid number in the argument"); } +#[test] +fn test_number_lines_without_value_numbers_lines() { + // A bare -n/--number-lines numbers lines with the default 5-wide, tab format. + for arg in ["-n", "--number-lines"] { + new_ucmd!() + .args(&["-t", arg]) + .pipe_in("a\nb\n") + .succeeds() + .stdout_is(" 1\ta\n 2\tb\n"); + } +} + #[test] fn test_without_any_options() { let test_file_path = "test_one_page.log"; @@ -197,8 +210,7 @@ fn test_with_valid_page_ranges() { scenario .args(&["--pages=20:5", test_file_path]) .fails() - .stderr_is("pr: invalid --pages argument '20:5'\n") - .stdout_is(""); + .stderr_only("pr: invalid --pages argument '20:5'\n"); new_ucmd!() .args(&["--pages=1:5", test_file_path]) .succeeds(); @@ -206,18 +218,23 @@ fn test_with_valid_page_ranges() { new_ucmd!() .args(&["--pages=-1:5", test_file_path]) .fails() - .stderr_is("pr: invalid --pages argument '-1:5'\n") - .stdout_is(""); + .stderr_only("pr: invalid --pages argument '-1:5'\n"); new_ucmd!() .args(&["--pages=1:-5", test_file_path]) .fails() - .stderr_is("pr: invalid --pages argument '1:-5'\n") - .stdout_is(""); + .stderr_only("pr: invalid --pages argument '1:-5'\n"); new_ucmd!() .args(&["--pages=5:1", test_file_path]) .fails() - .stderr_is("pr: invalid --pages argument '5:1'\n") - .stdout_is(""); + .stderr_only("pr: invalid --pages argument '5:1'\n"); +} + +#[test] +fn test_start_page_exceeds_page_count() { + new_ucmd!() + .args(&["--pages=2", "hosts.log"]) + .succeeds() + .stderr_only("pr: starting page number 2 exceeds page count 1\n"); } #[test] @@ -284,8 +301,7 @@ fn test_with_suppress_error_option() { scenario .args(&["--pages=20:5", "-r", test_file_path]) .fails() - .stderr_is("") - .stdout_is(""); + .no_output(); } #[test] @@ -304,10 +320,10 @@ fn test_with_stdin() { } #[test] -fn test_with_column() { +fn test_with_columns() { let test_file_path = "column.log"; let expected_test_file_path = "column.log.expected"; - for arg in ["-3", "--column=3"] { + for arg in ["-3", "--columns=3"] { let mut scenario = new_ucmd!(); let value = file_last_modified_time(&scenario, test_file_path); scenario @@ -321,19 +337,19 @@ fn test_with_column() { } #[test] -fn test_with_column_across_option() { +fn test_with_columns_and_across_option() { let test_file_path = "column.log"; let expected_test_file_path = "column_across.log.expected"; let mut scenario = new_ucmd!(); let value = file_last_modified_time(&scenario, test_file_path); scenario - .args(&["--pages=3:5", "--column=3", "-a", "-n", test_file_path]) + .args(&["--pages=3:5", "--columns=3", "-a", "-n", test_file_path]) .succeeds() .stdout_is_templated_fixture(expected_test_file_path, &[("{last_modified_time}", &value)]); } #[test] -fn test_with_column_across_option_and_column_separator() { +fn test_with_columns_across_option_and_column_separator() { let test_file_path = "column.log"; for (arg, expected) in [ ("-s|", "column_across_sep.log.expected"), @@ -342,7 +358,14 @@ fn test_with_column_across_option_and_column_separator() { let mut scenario = new_ucmd!(); let value = file_last_modified_time(&scenario, test_file_path); scenario - .args(&["--pages=3:5", "--column=3", arg, "-a", "-n", test_file_path]) + .args(&[ + "--pages=3:5", + "--columns=3", + arg, + "-a", + "-n", + test_file_path, + ]) .succeeds() .stdout_is_templated_fixture(expected, &[("{last_modified_time}", &value)]); } @@ -393,19 +416,17 @@ fn test_with_mpr() { } #[test] -fn test_with_mpr_and_column_options() { +fn test_with_mpr_and_columns_options() { let test_file_path = "column.log"; new_ucmd!() - .args(&["--column=2", "-m", "-n", test_file_path]) + .args(&["--columns=2", "-m", "-n", test_file_path]) .fails() - .stderr_is("pr: cannot specify number of columns when printing in parallel\n") - .stdout_is(""); + .stderr_only("pr: cannot specify number of columns when printing in parallel\n"); new_ucmd!() .args(&["-a", "-m", "-n", test_file_path]) .fails() - .stderr_is("pr: cannot specify both printing across and printing in parallel\n") - .stdout_is(""); + .stderr_only("pr: cannot specify both printing across and printing in parallel\n"); } #[test] @@ -419,7 +440,7 @@ fn test_with_offset_space_option() { "-o", "5", "--pages=3:5", - "--column=3", + "--columns=3", "-a", "-n", test_file_path, @@ -439,16 +460,112 @@ fn test_offset_too_large() { )); } +#[test] +fn test_start_line_number_too_large() { + let arg = "18446744073709551615"; + new_ucmd!() + .args(&["-n", "-N", arg]) + .fails_with_code(1) + .stderr_is(format!( + "pr: '-N NUMBER' invalid starting line number: '{arg}': Value too large for defined data type\n" + )); +} + +#[test] +fn test_page_length_too_large() { + let arg = "9999999999999999999"; + new_ucmd!() + .args(&["-l", arg, "-3"]) + .fails_with_code(1) + .stderr_is(format!( + "pr: '-l PAGE_LENGTH' invalid number of lines: '{arg}': Value too large for defined data type\n" + )); +} + +#[test] +fn test_number_width_too_large() { + let arg = "18446744073709551615"; + new_ucmd!() + .args(&["-n", arg]) + .fails_with_code(1) + .stderr_is(format!( + "pr: '-n' extra characters or invalid number in the argument: '{arg}': Value too large for defined data type\nTry 'pr --help' for more information.\n" + )); +} + +#[test] +fn test_page_width_too_large() { + let arg = "18446744073709551615"; + new_ucmd!() + .args(&["-W", arg]) + .fails_with_code(1) + .stderr_is(format!( + "pr: '-W PAGE_WIDTH' invalid number of characters: '{arg}': Value too large for defined data type\n" + )); +} + +#[test] +fn test_column_width_too_large() { + let arg = "18446744073709551615"; + new_ucmd!() + .args(&["-w", arg, "-2"]) + .fails_with_code(1) + .stderr_is(format!( + "pr: '-w PAGE_WIDTH' invalid number of characters: '{arg}': Value too large for defined data type\n" + )); +} + +#[test] +fn test_column_count_too_large() { + let arg = "9999999999999999999"; + new_ucmd!() + .args(&["--columns", arg]) + .fails_with_code(1) + .stderr_is(format!( + "pr: invalid number of columns: '{arg}': Value too large for defined data type\n" + )); + + // The legacy -COLUMN operand form behaves the same. + new_ucmd!() + .args(&[format!("-{arg}")]) + .fails_with_code(1) + .stderr_is(format!( + "pr: invalid number of columns: '{arg}': Value too large for defined data type\n" + )); +} + +#[test] +fn test_large_number_width_does_not_panic() { + // Widths above u16::MAX used to panic with "Formatting argument out + // of range"; GNU pads the number out to the full width instead. + // With -t (no page headers/footers) GNU emits no page padding, so the + // output is exactly the numbered line. + new_ucmd!() + .args(&["-t", "-n", "70000"]) + .pipe_in("x\n") + .succeeds() + .stdout_is(format!("{}1\tx\n", " ".repeat(69999))); +} + +#[test] +fn test_large_page_width_does_not_panic() { + // A page width above u16::MAX used to panic in the header layout. + new_ucmd!() + .args(&["-W", "200000"]) + .pipe_in("x\n") + .succeeds(); +} + #[cfg(target_os = "linux")] #[test] fn test_offset_large_value_does_not_abort_under_memory_limit() { - use rlimit::Resource; + use rustix::process::Resource; use std::process::Stdio; const AS_LIMIT: u64 = 200 * 1024 * 1024; new_ucmd!() - .limit(Resource::AS, AS_LIMIT, AS_LIMIT) + .limit(Resource::As, AS_LIMIT, AS_LIMIT) .set_stdout(Stdio::null()) .args(&["-t", "-o", "999999999"]) .pipe_in("hi\n") @@ -551,15 +668,17 @@ fn test_with_join_lines_option() { let test_file_1 = "hosts.log"; let test_file_2 = "test.log"; let expected_file_path = "joined.log.expected"; - let mut scenario = new_ucmd!(); let start = Timestamp::now(); - scenario - .args(&["+1:2", "-J", "-m", test_file_1, test_file_2]) - .succeeds() - .stdout_is_templated_fixture_any( - expected_file_path, - &valid_last_modified_template_vars(start), - ); + + for join_lines_arg in ["-J", "--join-lines"] { + new_ucmd!() + .args(&["+1:2", join_lines_arg, "-m", test_file_1, test_file_2]) + .succeeds() + .stdout_is_templated_fixture_any( + expected_file_path, + &valid_last_modified_template_vars(start), + ); + } } #[test] @@ -585,7 +704,7 @@ fn test_header_formatting_with_custom_date_format() { let test_file_path = "test_one_page.log"; - // Set a specific date format like in the GNU test + // Set a specific date format for consistent output let output = new_ucmd!() .args(&["-D", "+%Y-%m-%d %H:%M:%S %z (%Z)", test_file_path]) .succeeds() @@ -758,6 +877,71 @@ fn test_columns() { .stdout_matches(®ex); } +#[test] +fn test_columns_partly_filled_page() { + // Three lines do not divide evenly into two columns. GNU pr puts the + // ceiling of 3 / 2 in the first column and what is left in the second, so + // "c" has to show up next to "a". + // + // Command line: `printf "a\nb\nc\n" | pr -t -2 -w 20`. + new_ucmd!() + .args(&["-t", "-2", "-w", "20"]) + .pipe_in("a\nb\nc\n") + .succeeds() + .stdout_is("a \tc \nb \n"); +} + +#[test] +fn test_columns_partly_filled_page_across() { + // Same input read across instead of down. + // + // Command line: `printf "a\nb\nc\n" | pr -t -a -2 -w 20`. + new_ucmd!() + .args(&["-t", "-a", "-2", "-w", "20"]) + .pipe_in("a\nb\nc\n") + .succeeds() + .stdout_is("a \tb \nc \n"); +} + +#[test] +fn test_columns_fewer_lines_than_columns() { + // Fewer lines than columns still has to print those lines. + // + // Command line: `printf "a\n" | pr -t -3 -w 20`. + new_ucmd!() + .args(&["-t", "-3", "-w", "20"]) + .pipe_in("a\n") + .succeeds() + .stdout_is("a \n"); + + // Command line: `printf "a\nb\n" | pr -t -3 -w 20`. + new_ucmd!() + .args(&["-t", "-3", "-w", "20"]) + .pipe_in("a\nb\n") + .succeeds() + .stdout_is("a \tb \n"); +} + +#[test] +fn test_columns_last_page() { + // Nine lines over pages that hold four each leave the ninth alone on page + // three, which must not come out as an empty page. + // + // Command line: `seq 9 | pr -2 -l 12 -w 20 -D DATE`. + let header = "\n\nDATE Page "; + let expected = format!( + "{header}1\n\n\n1 \t3 \n2 \t4 \n{blank}\ + {header}2\n\n\n5 \t7 \n6 \t8 \n{blank}\ + {header}3\n\n\n9 \n\n\n\n\n\n\n", + blank = "\n".repeat(5), + ); + new_ucmd!() + .args(&["-2", "-l", "12", "-w", "20", "-D", "DATE"]) + .pipe_in("1\n2\n3\n4\n5\n6\n7\n8\n9\n") + .succeeds() + .stdout_is(expected); +} + #[test] fn test_merge() { // Create the two files to merge. @@ -840,6 +1024,34 @@ fn test_simple_expand_tab() { .stdout_matches(&output_regex); } +#[test] +fn test_expand_tab_at_end_of_short_flag_cluster() { + // `-e` closing a cluster of value-less short flags carries no attached argument, so it has + // to fall back to the defaults rather than report a missing value. + for (arg, expected) in [ + ("-tre", "oi\n"), + ("-tre8", "oi\n"), + ("-tfre", "oi\n\x0c"), + ("-tfre8", "oi\n\x0c"), + ] { + new_ucmd!() + .arg(arg) + .pipe_in("oi\n") + .succeeds() + .stdout_only(expected); + } +} + +#[test] +fn test_expand_tab_does_not_consume_following_operand() { + // A bare `-e` must leave the file operand alone. + new_ucmd!() + .args(&["-t", "-e"]) + .pipe_in("a\tb\n") + .succeeds() + .stdout_only("a b\n"); +} + #[test] fn test_simple_expand_tab_with_digit_argument() { let whitespace = " ".repeat(50); @@ -905,7 +1117,7 @@ fn test_simple_expand_tab_with_both_arguments() { } } -/* cSpell:disable */ +// spell-checker:disable #[test] fn test_invalid_expand_tab_arguments() { let test_file_path = "empty_test_file"; @@ -932,7 +1144,7 @@ fn test_invalid_expand_tab_arguments() { .stderr_contains(format!("pr: '-e' extra characters or invalid number in the argument: ‘{error_msg_field}’\nTry 'pr --help' for more information.")); } } -/* cSpell:enable */ +// spell-checker:enable #[test] fn test_expand_tab_does_not_consume_next_argument() { @@ -945,9 +1157,9 @@ fn test_expand_tab_does_not_consume_next_argument() { #[test] fn test_zero_columns() { new_ucmd!() - .arg("--column=0") + .arg("--columns=0") .fails_with_code(1) - .stderr_contains("pr: invalid --column argument '0'"); + .stderr_contains("pr: invalid --columns argument '0'"); } #[test] @@ -955,7 +1167,7 @@ fn test_zero_columns_shortcut() { new_ucmd!() .arg("-0") .fails_with_code(1) - .stderr_contains("pr: invalid --column argument '0'"); + .stderr_contains("pr: invalid --columns argument '0'"); } #[test] @@ -1039,6 +1251,34 @@ fn test_zero_page_width() { .stderr_is("pr: invalid --page-width argument '0'\n"); } +#[test] +fn test_page_length_ten_implies_omit_header() { + // `pr --help` states it twice: a page length of 10 or less implies `-t`. + // At exactly 10 the header and trailer were kept and then subtracted from + // the page, which left no room for content: `-h` printed empty pages and + // `-t` printed nothing at all. + new_ucmd!() + .args(&["-l", "10", "-h", "hdr"]) + .pipe_in("a\nb\nc\n") + .succeeds() + .stdout_only("a\nb\nc\n"); + + new_ucmd!() + .args(&["-l", "10", "-t"]) + .pipe_in("a\nb\nc\n") + .succeeds() + .stdout_only("a\nb\nc\n"); +} + +#[test] +fn test_page_length_eleven_keeps_header() { + new_ucmd!() + .args(&["-l", "11", "-h", "hdr"]) + .pipe_in("a\nb\nc\n") + .succeeds() + .stdout_contains("hdr"); +} + #[test] fn test_zero_length() { new_ucmd!() @@ -1071,3 +1311,15 @@ fn test_merge_empty_input() { .succeeds() .no_output(); } + +#[test] +fn test_missing_file_error_message() { + // A nonexistent operand must be reported as "pr: : ", like + // GNU pr, with the raw "(os error N)" suffix stripped. The exact message + // text is platform dependent, so only assert the portable parts. + new_ucmd!() + .arg("nonexistent_file") + .fails_with_code(1) + .stderr_contains("pr: nonexistent_file: ") + .stderr_does_not_contain("(os error"); +} diff --git a/tests/by-util/test_printenv.rs b/tests/by-util/test_printenv.rs index 28ca045bd5c..9b5c027aafd 100644 --- a/tests/by-util/test_printenv.rs +++ b/tests/by-util/test_printenv.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use uutests::new_ucmd; #[test] @@ -26,7 +27,7 @@ fn test_get_var() { #[test] fn test_ignore_equal_var() { - // tested by gnu/tests/misc/printenv.sh + // Basic printenv functionality new_ucmd!().env("a=b", "c").arg("a=b").fails().no_stdout(); } diff --git a/tests/by-util/test_printf.rs b/tests/by-util/test_printf.rs index fda7f7d158e..7af849e0071 100644 --- a/tests/by-util/test_printf.rs +++ b/tests/by-util/test_printf.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 fffffffffffffffc +// spell-checker:ignore fffffffffffffffc DFFF + use uutests::new_ucmd; #[test] @@ -269,6 +270,22 @@ fn sub_num_int_char_const_in() { .stdout_only("emoji is 128579"); } +#[test] +fn char_constant_warning_preserves_prior_failure() { + new_ucmd!() + .args(&["%d\n", "bad", "'ab"]) + .fails_with_code(1) + .stdout_is("0\n97\n") + .stderr_is("printf: 'bad': expected a numeric value\nprintf: warning: b: character(s) following character constant have been ignored\n"); + + new_ucmd!() + .args(&["%d\n", "bad", "'ab"]) + .env("POSIXLY_CORRECT", "1") + .fails_with_code(1) + .stdout_is("0\n97\n") + .stderr_is("printf: 'bad': expected a numeric value\n"); +} + #[test] fn sub_num_thousands() { // For "C" locale, the thousands separator is ignored but should @@ -439,6 +456,19 @@ fn sub_min_width_negative() { .stdout_only("hello world "); } +#[test] +fn sub_string_char_width_above_u16_max_no_panic() { + // A %s/%c field width above u16::MAX must not panic (#12593, #12900). + new_ucmd!() + .args(&["%100000c", "A"]) + .succeeds() + .stdout_only(format!("{}A", " ".repeat(99999))); + new_ucmd!() + .args(&["%-100000s", "hi"]) + .succeeds() + .stdout_only(format!("hi{}", " ".repeat(99998))); +} + #[test] fn sub_str_max_chars_input() { new_ucmd!() @@ -660,6 +690,16 @@ fn stop_after_additional_escape() { .stdout_only("ABC"); } +#[test] +fn stop_after_additional_escape_in_b_string() { + // A `\c` inside a %b argument must stop the entire invocation, not just + // that argument's own expansion. + new_ucmd!() + .args(&["A%bB\\n", "x\\cy"]) + .succeeds() + .stdout_only("Ax"); +} + #[test] fn sub_float_leading_zeroes() { new_ucmd!() @@ -841,6 +881,40 @@ fn partial_char() { ); } +#[test] +fn partial_char_posixly_correct() { + // GNU suppresses the "character(s) following character constant" warning + // when POSIXLY_CORRECT is set. Only the presence of the variable matters, + // its value is irrelevant. + for value in ["1", "", "0"] { + for arg in ["'AB", "'ABC", "\"AB", "'-1"] { + new_ucmd!() + .args(&["%d", arg]) + .env("POSIXLY_CORRECT", value) + .succeeds() + .no_stderr(); + } + } + + // Without POSIXLY_CORRECT the warning is still emitted. + for (arg, rest) in [("'AB", "B"), ("'ABC", "BC"), ("\"AB", "B"), ("'-1", "1")] { + new_ucmd!().args(&["%d", arg]).succeeds().stderr_is(format!( + "printf: warning: {rest}: character(s) following character constant have been ignored\n" + )); + } +} + +#[test] +fn value_not_completely_converted_ignores_posixly_correct() { + // POSIXLY_CORRECT only silences the character-constant warning; the + // unrelated "value not completely converted" error is unaffected. + new_ucmd!() + .args(&["%d", "42abc"]) + .env("POSIXLY_CORRECT", "1") + .fails_with_code(1) + .stderr_contains("value not completely converted"); +} + #[test] fn sub_alternative_lower_hex_0() { new_ucmd!().args(&["%#x", "0"]).succeeds().stdout_only("0"); @@ -1378,7 +1452,7 @@ fn mb_input() { } #[test] -#[cfg(target_family = "unix")] +#[cfg(unix)] #[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn mb_invalid_unicode() { use std::ffi::OsStr; @@ -1467,7 +1541,7 @@ fn positional_format_specifiers() { } #[test] -#[cfg(target_family = "unix")] +#[cfg(unix)] #[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn non_utf_8_input() { use std::ffi::OsStr; @@ -1507,10 +1581,22 @@ fn test_emoji_formatting() { .stdout_only("Status: Success 🚀 🎯 Count: 42\n"); } +#[cfg(target_os = "linux")] +#[test] +fn test_write_error_omits_errno() { + let dev_full = std::fs::File::create("/dev/full").expect("failed to open /dev/full"); + + new_ucmd!() + .arg("\n") + .set_stdout(dev_full) + .fails_with_code(1) + .stderr_only("printf: write error: No space left on device\n"); +} + #[test] fn test_large_width_format() { // Test that extremely large width specifications fail gracefully with an error - // rather than panicking. This tests the fix for the printf-surprise.sh GNU test. + // rather than panicking. // When printf tries to format with a width of 20 million, it should return // an error message and exit code 1, not panic with exit code 101. let test_cases = [ @@ -1524,10 +1610,22 @@ fn test_large_width_format() { .args(&[format, arg]) .fails_with_code(1) .stderr_contains("write error") - .stdout_is(""); + .no_stdout(); } } +#[test] +fn test_numeric_field_width_above_u16_max() { + const WIDTH: usize = 65_536; + + let result = new_ucmd!().args(&["%65536d", "5"]).succeeds(); + let stdout = result.stdout(); + + assert_eq!(stdout.len(), WIDTH); + assert!(stdout[..WIDTH - 1].iter().all(|&byte| byte == b' ')); + assert_eq!(stdout[WIDTH - 1], b'5'); +} + #[test] fn test_extreme_field_width_overflow() { // Test the specific case that was causing panic due to integer overflow @@ -1538,6 +1636,22 @@ fn test_extreme_field_width_overflow() { .stderr_contains("printf: write error"); //could contains additional message like "formatting width too large" not in GNU, thats fine. } +#[test] +fn test_asterisk_width_i64_min_no_panic() { + // Regression test for https://github.com/uutils/coreutils/issues/13766 + // An `i64::MIN` '*' width used to panic with "attempt to negate with overflow". + // It must not panic: on 64-bit it fails with a write error, on 32-bit the + // width is clamped to 0 and printf succeeds. + let result = new_ucmd!() + .args(&["|%*d|", &i64::MIN.to_string(), "1"]) + .run(); + assert!( + result.succeeded() || result.code() == 1, + "printf must not panic on an i64::MIN '*' width (got exit code {})", + result.code() + ); +} + #[test] fn test_q_string_control_chars_with_quotes() { // Test %q with control characters and single quotes combined. @@ -1550,3 +1664,246 @@ fn test_q_string_control_chars_with_quotes() { .succeeds() .stdout_only("''$'\\001'\\'''$'\\001'"); } + +// Output with no trailing newline stays in the buffer until the process exits, +// so the failure is only visible when it is flushed. +#[test] +#[cfg(target_os = "linux")] +fn test_unterminated_write_error_is_reported() { + new_ucmd!() + .arg("greeting") + .set_stdout(std::fs::File::create("/dev/full").unwrap()) + .fails() + .stderr_is("printf: write error: No space left on device\n"); +} + +// Producing no output at all is not a write failure. +#[test] +#[cfg(target_os = "linux")] +fn test_empty_output_succeeds_on_full_device() { + new_ucmd!() + .arg("") + .set_stdout(std::fs::File::create("/dev/full").unwrap()) + .succeeds() + .no_output(); +} + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + #[cfg(unix)] + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_bad_spec() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["%5.2c", "q"]) + .fails_with_code(1); + + // The caret covers the whole rejected spec, % included. + assert_eq!( + result.stderr_as_displayed(), + "\ +printf: %5.2c: invalid conversion specification + ╭─[ printf:1:8 ] + │ + 1 │ printf %5.2c q + │ ───── + │ + │ Help: %d, %s, %x, %f and the other C conversions are accepted, plus %b and %q; a literal % is written %% +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_incomplete_hex_escape() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&[r"a\xzb", "q"]) + .fails_with_code(1); + + // The caret covers `\x` alone — the characters around it are fine. + assert_eq!( + result.stderr_as_displayed(), + "\ +printf: missing hexadecimal number in escape + ╭─[ printf:1:9 ] + │ + 1 │ printf a\\xzb q + │ ── + │ + │ Help: \\x takes one or two hexadecimal digits, \\u takes four and \\U takes eight +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_bad_code_point() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&[r"x\ud800y"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_as_displayed(), + "\ +printf: invalid universal character name \\ud800 + ╭─[ printf:1:9 ] + │ + 1 │ printf x\\ud800y + │ ────── + │ + │ Help: code points between D800 and DFFF or above 10FFFF are not Unicode characters +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_inside_a_quoted_format() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["hello %s and %z", "world"]) + .fails_with_code(1); + + // A format with a space in it is echoed back quoted. The quotes only + // wrap it, so the caret still finds the one spec at fault rather than + // underlining the whole operand. + assert_eq!( + result.stderr_as_displayed(), + "\ +printf: %z: invalid conversion specification + ╭─[ printf:1:22 ] + │ + 1 │ printf 'hello %s and %z' world + │ ── + │ + │ Help: %d, %s, %x, %f and the other C conversions are accepted, plus %b and %q; a literal % is written %% +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_a_format_that_looks_like_an_option() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-%z", "world"]) + .fails_with_code(1); + + // printf takes hyphen values, so `-%z` is the format and not an + // option; the caret belongs on it rather than on the argument after. + assert_eq!( + result.stderr_as_displayed(), + "\ +printf: %z: invalid conversion specification + ╭─[ printf:1:9 ] + │ + 1 │ printf -%z world + │ ── + │ + │ Help: %d, %s, %x, %f and the other C conversions are accepted, plus %b and %q; a literal % is written %% +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_keeps_an_operand_that_draws_like_the_report() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["╰%z"]) + .fails_with_code(1); + + // The report is drawn with box characters, and so is this operand; + // echoing the arguments must not lose the line to that collision. + assert_eq!( + result.stderr_as_displayed(), + "\ +printf: %z: invalid conversion specification + ╭─[ printf:1:9 ] + │ + 1 │ printf ╰%z + │ ── + │ + │ Help: %d, %s, %x, %f and the other C conversions are accepted, plus %b and %q; a literal % is written %% +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_piped_stderr_keeps_the_plain_message() { + new_ucmd!() + .args(&["%5.2c", "q"]) + .fails_with_code(1) + .stderr_only("printf: %5.2c: invalid conversion specification\n"); + } +} + +#[test] +fn leading_double_dash_ends_the_options() { + new_ucmd!() + .args(&["--", "%s\n", "a"]) + .succeeds() + .stdout_only("a\n"); +} + +#[test] +fn double_dash_after_the_format_is_an_argument() { + new_ucmd!() + .args(&["%s\n", "--"]) + .succeeds() + .stdout_only("--\n"); + + new_ucmd!() + .args(&["%s %s\n", "--", "--"]) + .succeeds() + .stdout_only("-- --\n"); + + // Only the first `--` terminates the options; the second one is data. + new_ucmd!() + .args(&["--", "%s\n", "--"]) + .succeeds() + .stdout_only("--\n"); +} + +#[test] +fn double_dash_as_the_format_is_printed_literally() { + new_ucmd!() + .args(&["--", "--", "x"]) + .succeeds() + .stdout_is("--") + .stderr_contains("warning: ignoring excess arguments, starting with 'x'"); +} + +#[test] +fn help_and_version_past_the_format_are_arguments() { + new_ucmd!() + .args(&["%s", "--help"]) + .succeeds() + .stdout_only("--help"); + + new_ucmd!() + .args(&["%s", "--version"]) + .succeeds() + .stdout_only("--version"); + + new_ucmd!() + .args(&["--", "--help"]) + .succeeds() + .stdout_only("--help"); +} + +#[test] +fn test_precision_above_formatter_limit() { + // A precision larger than u16::MAX must still be honoured in full. + let result = new_ucmd!().args(&["%.70123f", "3.25"]).succeeds(); + let out = result.stdout_str(); + assert_eq!(out.len(), 70_125); + assert!(out.starts_with("3.25")); + assert!(out[4..].bytes().all(|b| b == b'0')); +} diff --git a/tests/by-util/test_ptx.rs b/tests/by-util/test_ptx.rs index 5e3985c0fd8..223e4182b7c 100644 --- a/tests/by-util/test_ptx.rs +++ b/tests/by-util/test_ptx.rs @@ -2,13 +2,17 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore roff // spell-checker:ignore funnnnnnnnnnnnnnnnn + use uutests::new_ucmd; #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); + new_ucmd!().arg("-g").arg("0").fails_with_code(1); // clap provided message + new_ucmd!().arg("-w").arg("0").fails_with_code(1); // clap provided message } #[test] fn test_reference_format_for_stdin() { @@ -373,6 +377,33 @@ fn test_unicode_in_after_chunk_does_not_panic() { .stdout_contains("We've got +11"); } +#[test] +fn test_unicode_in_before_chunk_does_not_panic() { + // Regression test for issue #10893: a panic in get_output_chunks() when the + // computed max_before_size used char counts but the assert compared against + // before.len() (byte length). A multibyte char in the before chunk could + // trigger: `assertion failed: max_before_size >= before.len()`. + new_ucmd!() + .args(&["-w", "10"]) + .pipe_in("éé word\n") + .succeeds() + .no_stderr(); +} + +#[test] +fn test_unicode_tail_chunk_sizing() { + // The tail chunk budget (max_tail_size) subtracts the size of the before + // chunk. It must use the before chunk's char count, not its byte length, + // otherwise a multibyte before chunk shrinks the tail too much and drops a + // word that fits. Here "cc" wraps into the tail before "aé bé KEY"; with the + // byte-based budget it was dropped. + new_ucmd!() + .args(&["-w", "20"]) + .pipe_in("aé bé KEY cc dd ee ff gg\n") + .succeeds() + .stdout_contains("cc/ aé"); +} + #[test] fn test_duplicate_input_files() { new_ucmd!() @@ -433,3 +464,39 @@ fn test_missing_file_error_contains_filename() { .fails() .stderr_is("ptx: 'zxc': No such file or directory\n"); } + +#[test] +fn test_sentence_regex_trailing_backslash() { + // GNU treats a trailing lone backslash as a literal one instead of erroring. + new_ucmd!() + .args(&["-S", "paris\\"]) + .pipe_in("") + .succeeds() + .no_output(); + new_ucmd!() + .args(&["-S", "london\\\\\\"]) + .pipe_in("") + .succeeds() + .no_output(); +} + +#[test] +fn test_invalid_utf8_input_is_not_an_error() { + new_ucmd!() + .pipe_in(b"ab\xFFcd\n".to_vec()) + .succeeds() + .no_stderr(); +} + +#[test] +fn test_nullable_word_regexp_no_empty_matches() { + let expected = concat!( + " aa bb cc\n", + " aa bb cc\n" + ); + new_ucmd!() + .args(&["-W", "[ab]{0,}"]) + .pipe_in("aa bb cc\n") + .succeeds() + .stdout_only(expected); +} diff --git a/tests/by-util/test_pwd.rs b/tests/by-util/test_pwd.rs index a6e1bd798bb..e0448179f75 100644 --- a/tests/by-util/test_pwd.rs +++ b/tests/by-util/test_pwd.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (words) symdir somefakedir use std::path::PathBuf; diff --git a/tests/by-util/test_readlink.rs b/tests/by-util/test_readlink.rs index f27ad99e7db..e4cbc5ad9b0 100644 --- a/tests/by-util/test_readlink.rs +++ b/tests/by-util/test_readlink.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 regfile parentdir use uutests::util::{TestScenario, get_root_path}; @@ -20,7 +20,7 @@ fn test_no_args() { new_ucmd!() .fails_with_code(1) .no_stdout() - .stderr_contains("readlink: missing operand"); + .stderr_contains("the following required arguments were not provided"); // clap provided message } #[test] @@ -38,6 +38,48 @@ fn test_resolve() { ucmd.arg("bar").succeeds().stdout_contains("foo\n"); } +#[test] +fn test_keeps_going_after_an_operand_that_cannot_be_read() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("foo"); + at.relative_symlink_file("foo", "bar"); + at.touch("baz"); + at.relative_symlink_file("baz", "qux"); + + // GNU prints the links it could read and reports the failure through + // the exit status only, so nothing about `nope` stops `qux`. + scene + .ucmd() + .args(&["bar", "nope", "qux"]) + .fails_with_code(1) + .stdout_is("foo\nbaz\n") + .no_stderr(); + + scene + .ucmd() + .args(&["-v", "bar", "nope", "qux"]) + .fails_with_code(1) + .stdout_is("foo\nbaz\n") + .stderr_contains("nope: No such file or directory"); +} + +#[test] +fn test_canonicalize_existing_keeps_going_after_a_missing_operand() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("a"); + at.touch("c"); + let actual = ucmd + .args(&["-e", "a", "b", "c"]) + .fails_with_code(1) + .stdout_move_str(); + let expect = path_concat!(at.root_dir_resolved(), "a") + + "\n" + + &path_concat!(at.root_dir_resolved(), "c") + + "\n"; + assert_eq!(actual, expect); +} + #[test] fn test_canonicalize() { let (at, mut ucmd) = at_and_ucmd!(); @@ -397,6 +439,22 @@ fn test_canonicalize_trailing_slash_symlink_loop() { } } +#[test] +fn test_canonicalize_growing_symlink_loop() { + // `link6 -> link6/more` grows the path on every expansion; it must fail + // instead of looping forever. + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.relative_symlink_file("link6/more", "link6"); + for mode in ["-f", "-e", "-m"] { + scene + .ucmd() + .args(&[mode, "link6"]) + .fails_with_code(1) + .no_stdout(); + } +} + #[test] #[cfg(not(windows))] fn test_delimiters() { diff --git a/tests/by-util/test_realpath.rs b/tests/by-util/test_realpath.rs index 136f7fa6d8d..1e143ecefe2 100644 --- a/tests/by-util/test_realpath.rs +++ b/tests/by-util/test_realpath.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore nusr + use uutests::new_ucmd; use uutests::path_concat; use uutests::util::{TestScenario, get_root_path}; @@ -164,9 +166,15 @@ fn test_realpath_logical_mode() { fn test_realpath_dangling() { let (at, mut ucmd) = at_and_ucmd!(); at.symlink_file("nonexistent-file", "link"); - ucmd.arg("link") - .succeeds() - .stdout_contains(at.plus_as_string("nonexistent-file\n")); + let output = ucmd.arg("link").succeeds().stdout_move_str(); + let printed = Path::new(output.trim_end_matches('\n')); + assert_eq!(printed.file_name().unwrap(), "nonexistent-file"); + // Canonicalize the printed parent dir before comparing, since on Windows + // CI the temp dir may be reported using its short (8.3) alias, which is + // not guaranteed to match the long-form path from `root_dir_resolved()`. + let printed_dir = printed.parent().unwrap().canonicalize().unwrap(); + let expect_dir = Path::new(&at.root_dir_resolved()).canonicalize().unwrap(); + assert_eq!(printed_dir, expect_dir); } #[test] @@ -180,6 +188,42 @@ fn test_realpath_loop() { .stderr_contains("Too many levels of symbolic links"); } +#[test] +fn test_realpath_growing_loop() { + // `foo -> foo/bar` never repeats the same remaining path, so it must be + // stopped by the limit on followed symlinks rather than loop forever. + let scene = TestScenario::new(util_name!()); + scene.fixtures.relative_symlink_file("foo/bar", "foo"); + for args in [&[][..], &["-e"], &["-m"], &["-L"], &["-P"]] { + scene + .ucmd() + .args(args) + .arg("foo") + .fails_with_code(1) + .no_stdout() + .stderr_contains("Too many levels of symbolic links"); + } +} + +#[test] +fn test_realpath_symlink_follow_limit() { + // Like the kernel, give up after following 40 symlinks. + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.touch("target"); + at.relative_symlink_file("target", "link1"); + for i in 2..=41 { + at.relative_symlink_file(&format!("link{}", i - 1), &format!("link{i}")); + } + let expect = path_concat!(at.root_dir_resolved(), "target") + "\n"; + scene.ucmd().arg("link40").succeeds().stdout_only(expect); + scene + .ucmd() + .arg("link41") + .fails_with_code(1) + .stderr_contains("Too many levels of symbolic links"); +} + #[test] fn test_realpath_default_allows_final_non_existent() { let p = Path::new("").join(GIBBERISH); @@ -208,6 +252,14 @@ fn test_realpath_existing_error() { new_ucmd!().arg("-e").arg(GIBBERISH).fails(); } +#[test] +fn test_realpath_existing_error_quiet() { + new_ucmd!() + .args(&["-q", "-e", GIBBERISH]) + .fails_with_code(1) + .no_output(); +} + #[test] fn test_realpath_missing() { let p = Path::new("").join(GIBBERISH).join(GIBBERISH); @@ -472,6 +524,32 @@ fn test_realpath_trailing_slash() { .stderr_contains("No such file or directory\n"); } +#[test] +#[cfg(unix)] +fn test_realpath_trailing_slash_unreadable_directory() { + // A trailing slash only asserts that the operand is a directory, which is + // a stat-level check needing search permission on the parent, not read + // permission on the directory itself. + // https://github.com/uutils/coreutils/issues/13151 + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkdir("dir"); + at.set_mode("dir", 0o100); + + scene + .ucmd() + .arg("dir/") + .succeeds() + .stdout_contains(format!("{MAIN_SEPARATOR}dir\n")); + scene + .ucmd() + .args(&["-e", "dir/"]) + .succeeds() + .stdout_contains(format!("{MAIN_SEPARATOR}dir\n")); + + at.set_mode("dir", 0o700); +} + #[test] fn test_realpath_empty() { new_ucmd!().fails_with_code(1); diff --git a/tests/by-util/test_rm.rs b/tests/by-util/test_rm.rs index e43cfcbb3b2..78f1ab1bd6f 100644 --- a/tests/by-util/test_rm.rs +++ b/tests/by-util/test_rm.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore rootlink dotdot rootfile deleteme keepme topfile + +// spell-checker:ignore rootlink dotdot rootfile deleteme keepme topfile mkdirat RDONLY FDCWD SIGSEGV rootbind submounts rprivate rbind overlayfs ENAMETOOLONG + #![allow(clippy::stable_sort_primitive)] use std::process::Stdio; @@ -195,6 +197,21 @@ fn test_recursive() { assert!(!at.file_exists(file_b)); } +#[cfg(unix)] +#[test] +fn test_recursive_large_sparse_file() { + let (at, mut ucmd) = at_and_ucmd!(); + let dir = "large_sparse_dir"; + let file = "large_sparse_dir/file"; + + at.mkdir(dir); + at.make_file(file).set_len(1u64 << 31).unwrap(); + + ucmd.arg("-r").arg(dir).succeeds().no_stderr(); + + assert!(!at.dir_exists(dir)); +} + #[test] fn test_one_file_system_same_device() { // Cross-device skipping needs a mount point (root privileges), so here we @@ -334,6 +351,94 @@ fn test_verbose() { .stdout_only(format!("removed '{file_a}'\nremoved '{file_b}'\n")); } +// Regression test for issue #10551: `rm -v` must report a verbose output +// write failure on stderr and exit 1 instead of panicking. +#[test] +#[cfg(target_os = "linux")] +fn test_verbose_write_error_does_not_panic() { + use std::fs::OpenOptions; + + let (at, mut ucmd) = at_and_ucmd!(); + let file = "test_rm_verbose_write_error_file"; + + at.touch(file); + + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + + ucmd.arg("-v") + .arg(file) + .set_stdout(dev_full) + .fails() + .code_is(1) + .stderr_contains("No space left on device") + .stderr_does_not_contain("panicked"); + + assert!(!at.file_exists(file)); +} + +// Directory variant of the #10551 regression test. +#[test] +#[cfg(target_os = "linux")] +fn test_verbose_write_error_does_not_panic_dir() { + use std::fs::OpenOptions; + + let (at, mut ucmd) = at_and_ucmd!(); + let dir = "test_rm_verbose_write_error_dir"; + let file = "test_rm_verbose_write_error_dir/a"; + + at.mkdir(dir); + at.touch(file); + + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + + ucmd.arg("-r") + .arg("-v") + .arg(dir) + .set_stdout(dev_full) + .fails() + .code_is(1) + .stderr_contains("No space left on device") + .stderr_does_not_contain("panicked"); + + // A broken stdout must not stop the removal itself. + assert!(!at.file_exists(file)); + assert!(!at.dir_exists(dir)); +} + +// A broken standard output must be reported once, not once per removed entry. +#[test] +#[cfg(target_os = "linux")] +fn test_verbose_write_error_reported_once() { + use std::fs::OpenOptions; + + let (at, mut ucmd) = at_and_ucmd!(); + let dir = "test_rm_verbose_write_error_once"; + + at.mkdir(dir); + for name in ["alpha", "bravo", "charlie", "delta"] { + at.touch(format!("{dir}/{name}")); + } + + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + + let result = ucmd + .arg("-r") + .arg("-v") + .arg(dir) + .set_stdout(dev_full) + .fails(); + result.code_is(1); + assert_eq!( + result + .stderr_str() + .matches("No space left on device") + .count(), + 1 + ); + + assert!(!at.dir_exists(dir)); +} + #[test] #[cfg(not(windows))] // on unix symlink_dir is a file @@ -357,9 +462,11 @@ fn test_symlink_dir() { let at = &scene.fixtures; let dir = "test_rm_symlink_dir_directory"; + let file = "test_rm_symlink_dir_directory/file"; let link = "test_rm_symlink_dir_link"; at.mkdir(dir); + at.touch(file); at.symlink_dir(dir, link); scene @@ -371,6 +478,9 @@ fn test_symlink_dir() { assert!(at.dir_exists(link)); scene.ucmd().arg("-r").arg(link).succeeds(); + assert!(!at.dir_exists(link)); + assert!(at.dir_exists(dir)); + assert!(at.file_exists(file)); } #[test] @@ -1129,13 +1239,10 @@ fn test_inaccessible_dir_recursive() { } #[test] -#[cfg(any(target_os = "linux", target_os = "wasi"))] +#[cfg(target_os = "linux")] fn test_non_utf8_paths() { use std::ffi::OsStr; - #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStrExt; - #[cfg(target_os = "wasi")] - use std::os::wasi::ffi::OsStrExt; let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -1190,10 +1297,83 @@ fn test_rm_recursive_long_path_safe_traversal() { assert!(!at.dir_exists("rm_deep")); } +/// A hierarchy thousands of levels deep used to make `rm -r` recurse until the +/// stack ran out, which killed it with SIGSEGV and left the tree in place. +/// +/// Linux only: building and removing 32768 levels is slow enough elsewhere to +/// run out of patience before it runs out of stack, and should the removal +/// fail, tearing the tree down again falls to `fs::remove_dir_all`, whose own +/// recursion then overflows the test harness's stack. +#[test] +#[cfg(target_os = "linux")] +fn test_rm_recursive_very_deep_hierarchy() { + const DEPTH: u32 = 32 * 1024; + + use std::time::Duration; + + use nix::errno::Errno; + use nix::fcntl::{OFlag, openat}; + use nix::sys::stat::{Mode, mkdirat}; + + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + // The path of the deepest directory is far longer than PATH_MAX, so it has + // to be built one level at a time with openat/mkdirat. + at.mkdir("deep"); + let flags = OFlag::O_RDONLY | OFlag::O_DIRECTORY; + let mut fd = openat(nix::fcntl::AT_FDCWD, &at.plus("deep"), flags, Mode::empty()).unwrap(); + // The recursion only ran out of stack around 5000 levels unoptimized and + // 20000 optimized, so a shallower tree would not test anything. Some + // filesystems resolve the whole path internally and refuse to go past + // PATH_MAX, which caps the depth near 2000: there is nothing to test on + // those, so give up rather than pretend. + let mut depth = 0; + while depth < DEPTH { + match mkdirat(&fd, "a", Mode::from_bits_truncate(0o755)) { + Ok(()) => {} + Err(Errno::ENAMETOOLONG) => break, + Err(e) => panic!("mkdirat at depth {depth} failed: {e}"), + } + fd = match openat(&fd, "a", flags, Mode::empty()) { + Ok(fd) => fd, + Err(Errno::ENAMETOOLONG) => break, + Err(e) => panic!("openat at depth {depth} failed: {e}"), + }; + depth += 1; + } + + // Release the deepest descriptor, so nothing holds the tree open while rm + // walks it. + drop(fd); + + if depth < DEPTH { + println!("this filesystem stops nesting at {depth} levels; skipping"); + // Still tear the tree down here: the harness cleanup recurses too. + ts.ucmd() + .arg("-rf") + .arg("deep") + .timeout(Duration::from_secs(240)) + .succeeds(); + return; + } + + // 32768 levels are 65536 metadata operations; the default 30s is not much + // once the binary under test is built with coverage instrumentation. + ts.ucmd() + .arg("-rf") + .arg("deep") + .timeout(Duration::from_secs(240)) + .succeeds() + .no_output(); + + assert!(!at.dir_exists("deep")); +} + #[cfg(all(not(windows), feature = "chmod"))] #[test] fn test_rm_directory_not_executable() { - // Test from GNU rm/rm2.sh + // Test removing files with specific permission scenarios // Exercise code paths when directories have no execute permission let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -1233,7 +1413,7 @@ fn test_rm_directory_not_executable() { #[cfg(all(not(windows), feature = "chmod"))] #[test] fn test_rm_directory_not_writable() { - // Test from GNU rm/rm1.sh + // Test basic recursive removal // Exercise code paths when directories have no write permission let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -1423,6 +1603,78 @@ fn test_preserve_root_symlink_removal_without_trailing_slash() { assert!(!at.symlink_exists("rootlink")); } +/// `--preserve-root` must refuse a bind mount of `/`. +/// +/// A bind mount of `/` is an ordinary directory - not a symlink, not a cycle - +/// so `canonicalize()` yields the mountpoint and every name-based check waves it +/// through. Only `(st_dev, st_ino)` identifies it, and that is what the failsafe +/// compares. +/// +/// The mount is of the real root, so nothing here may be able to delete anything +/// even with the guard gone: `-i` reads stdin at EOF and so answers "no" to the +/// first "descend into directory?" prompt, before any unlink. **Never make this +/// `-rf`.** The mount itself lives in a throwaway user + mount namespace. +#[cfg(target_os = "linux")] +#[test] +fn test_preserve_root_bind_mount_of_root() { + use std::process::Command; + + let ts = TestScenario::new(util_name!()); + + // Unprivileged user namespaces are disabled in some kernels and sandboxes. + let can_unshare = Command::new("unshare") + .args(["--user", "--map-root-user", "--mount", "true"]) + .status() + .is_ok_and(|status| status.success()); + if !can_unshare { + println!("TEST SKIPPED: no unprivileged mount namespace available"); + return; + } + + // Mount point lives in this test's own temp dir, so parallel runs cannot + // collide and it goes away with the scenario. + let mount_point = ts.fixtures.plus_as_string("rootbind"); + + // `mount --bind /` is refused inside a user namespace because of locked + // submounts; --make-rprivate + --rbind produces the same (st_dev, st_ino). + // The mount setup can still be blocked (e.g. `mount(2)` denied in a cross + // container even though `unshare` starts); if it fails, exit 99 so the test + // skips instead of failing for the wrong reason. + let script = format!( + "mkdir -p {mp} + {{ mount --make-rprivate / && mount --rbind / {mp}; }} \ + || {{ echo MOUNT_SETUP_FAILED >&2; exit 99; }} + exec {bin} rm -ri --preserve-root {mp} < /dev/null", + mp = shell_quote(&mount_point), + bin = shell_quote(&ts.bin_path.to_string_lossy()) + ); + let output = Command::new("unshare") + .args(["--user", "--map-root-user", "--mount", "sh", "-c", &script]) + .env("LC_ALL", "C") + .env("LANG", "C") + .env("LANGUAGE", "C") + .output() + .expect("failed to spawn unshare"); + + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.code() == Some(99) || stderr.contains("MOUNT_SETUP_FAILED") { + println!("TEST SKIPPED: could not set up a bind mount in the namespace: {stderr}"); + return; + } + assert!( + stderr.contains("it is dangerous to operate recursively on") + && stderr.contains("(same as '/')"), + "--preserve-root did not refuse a bind mount of /: {stderr}" + ); + assert!(!output.status.success()); +} + +/// Wrap `s` in single quotes for `sh -c`, escaping any single quote in it. +#[cfg(target_os = "linux")] +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + /// Test that literal "/" is still properly protected. #[test] fn test_preserve_root_literal_root() { diff --git a/tests/by-util/test_rmdir.rs b/tests/by-util/test_rmdir.rs index 669884488a0..5de44899e90 100644 --- a/tests/by-util/test_rmdir.rs +++ b/tests/by-util/test_rmdir.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use uutests::at_and_ucmd; use uutests::new_ucmd; diff --git a/tests/by-util/test_runcon.rs b/tests/by-util/test_runcon.rs index d6da94bf1af..0814cb55a54 100644 --- a/tests/by-util/test_runcon.rs +++ b/tests/by-util/test_runcon.rs @@ -2,12 +2,10 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore (jargon) xattributes -#![cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#![cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use uutests::new_ucmd; @@ -99,10 +97,7 @@ fn invalid() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn plain_context() { let ctx = "unconfined_u:unconfined_r:unconfined_t:s0-s0"; new_ucmd!().args(&[ctx, "/bin/true"]).succeeds(); @@ -121,10 +116,7 @@ fn plain_context() { } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn custom_context() { let t_ud = "unconfined_t"; let u_ud = "unconfined_u"; diff --git a/tests/by-util/test_seq.rs b/tests/by-util/test_seq.rs index 5e749e07236..7637fe6db0b 100644 --- a/tests/by-util/test_seq.rs +++ b/tests/by-util/test_seq.rs @@ -2,7 +2,9 @@ // // 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)] @@ -295,7 +297,7 @@ fn test_separator_and_terminator() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] fn test_separator_non_utf8() { use std::{ffi::OsString, os::unix::ffi::OsStringExt}; @@ -318,7 +320,7 @@ fn test_separator_non_utf8() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] fn test_terminator_non_utf8() { use std::{ffi::OsString, os::unix::ffi::OsStringExt}; @@ -1015,17 +1017,17 @@ fn test_parse_valid_hexadecimal_float_three_args() { } #[test] -fn test_parse_float_gnu_coreutils() { - // some values from GNU coreutils tests +fn test_seq_float_precision_edge_cases() { + // Known sequence values for verification new_ucmd!() - .args(&[".89999", "1e-7", ".8999901"]) + .args(&[".64999", "1e-7", ".6499901"]) .succeeds() - .stdout_only("0.8999900\n0.8999901\n"); + .stdout_only("0.6499900\n0.6499901\n"); new_ucmd!() - .args(&["0", "0.000001", "0.000003"]) + .args(&["0", "0.000002", "0.000006"]) .succeeds() - .stdout_only("0.000000\n0.000001\n0.000002\n0.000003\n"); + .stdout_only("0.000000\n0.000002\n0.000004\n0.000006\n"); } #[test] @@ -1172,3 +1174,74 @@ fn test_equalize_widths_corner_cases() { .succeeds() .stdout_is("1.0625\n2.06252\n"); } + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_failing_conversion() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-f", "%5.2c", "1", "3"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_as_displayed(), + "\ +seq: %5.2c: invalid conversion specification + ╭─[ seq:1:8 ] + │ + 1 │ seq -f %5.2c 1 3 + │ ───── + │ + │ Help: a format holds exactly one float conversion: %f, %e, %g or %a, as in -f%.3f +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_inside_a_glued_short_option() { + // `-f%q` is split for clap, but the report echoes what was typed. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-f%q", "1", "3"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("1 │ seq -f%q 1 3"), "{stderr}"); + assert!(stderr.contains("seq:1:7"), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_a_format_with_no_directive() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--format=abc", "1", "3"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("seq:1:14"), "{stderr}"); + assert!(stderr.contains("has no % directive"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["-f", "%5.2c", "1", "3"]) + .fails_with_code(1) + .stderr_is("seq: %5.2c: invalid conversion specification\n"); + } +} + +#[test] +fn test_format_precision_above_formatter_limit() { + let result = new_ucmd!().args(&["-f", "%.66000f", "4", "4"]).succeeds(); + let out = result.stdout_str(); + assert_eq!(out.len(), 66_003); // "4." + 66000 zeros + newline + assert!(out.starts_with("4.")); + assert!(out[2..].trim_end().bytes().all(|b| b == b'0')); +} diff --git a/tests/by-util/test_sha1sum.rs b/tests/by-util/test_sha1sum.rs index 8377bbe9e6a..80096a8eff2 100644 --- a/tests/by-util/test_sha1sum.rs +++ b/tests/by-util/test_sha1sum.rs @@ -3,10 +3,12 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore checkfile, testf, ntestf + 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] @@ -134,8 +136,7 @@ fn test_check_sha1() { .arg("-c") .arg(at.subdir.join("testf.sha1")) .succeeds() - .stdout_is("testf: OK\n") - .stderr_is(""); + .stdout_only("testf: OK\n"); } #[test] diff --git a/tests/by-util/test_sha224sum.rs b/tests/by-util/test_sha224sum.rs index d6abc05962e..a7009a0c4eb 100644 --- a/tests/by-util/test_sha224sum.rs +++ b/tests/by-util/test_sha224sum.rs @@ -3,8 +3,10 @@ // 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 + +use uutests::new_ucmd; + macro_rules! get_hash( ($str:expr) => ( $str.split(' ').collect::>()[0] diff --git a/tests/by-util/test_sha256sum.rs b/tests/by-util/test_sha256sum.rs index 81698cfc26b..a1404a9ccd8 100644 --- a/tests/by-util/test_sha256sum.rs +++ b/tests/by-util/test_sha256sum.rs @@ -3,10 +3,12 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore checkfile, testf, ntestf, heic + use uutests::new_ucmd; use uutests::util::TestScenario; use uutests::util_name; -// spell-checker:ignore checkfile, testf, ntestf, heic + macro_rules! get_hash( ($str:expr) => ( $str.split(' ').collect::>()[0] diff --git a/tests/by-util/test_sha384sum.rs b/tests/by-util/test_sha384sum.rs index 7b51995e478..53edccbad65 100644 --- a/tests/by-util/test_sha384sum.rs +++ b/tests/by-util/test_sha384sum.rs @@ -3,8 +3,10 @@ // 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 + +use uutests::new_ucmd; + macro_rules! get_hash( ($str:expr) => ( $str.split(' ').collect::>()[0] diff --git a/tests/by-util/test_sha512sum.rs b/tests/by-util/test_sha512sum.rs index 61c876f3215..9836cf4df4f 100644 --- a/tests/by-util/test_sha512sum.rs +++ b/tests/by-util/test_sha512sum.rs @@ -3,8 +3,10 @@ // 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 + +use uutests::new_ucmd; + macro_rules! get_hash( ($str:expr) => ( $str.split(' ').collect::>()[0] diff --git a/tests/by-util/test_shred.rs b/tests/by-util/test_shred.rs index 28812b106f4..c3a57ab3cb9 100644 --- a/tests/by-util/test_shred.rs +++ b/tests/by-util/test_shred.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 wipesync +// spell-checker:ignore wipesync couldnt use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -292,6 +292,18 @@ fn test_random_source_regular_file() { assert_eq!(at.read_bytes(file), many_bytes[(4096 * 2)..(4096 * 3)]); } +#[test] +fn test_random_source_open_error_includes_cause() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.touch("target"); + + ucmd.arg("--random-source=missing") + .arg("target") + .fails() + .stderr_only("shred: missing: No such file or directory\n"); +} + #[test] fn test_random_source_dir() { let (at, mut ucmd) = at_and_ucmd!(); @@ -350,7 +362,7 @@ fn test_shred_non_utf8_paths() { } #[test] -fn test_gnu_shred_passes_20() { +fn test_shred_twenty_passes_with_known_random_source() { let (at, mut ucmd) = at_and_ucmd!(); let us_data = vec![0x55; 102_400]; // 100K of 'U' bytes @@ -359,8 +371,7 @@ fn test_gnu_shred_passes_20() { let file = "f"; at.write(file, "1"); // Single byte file - // Test 20 passes with deterministic random source - // This should produce the exact same sequence as GNU shred + // Run with a deterministic random source so the pass sequence is reproducible let result = ucmd .arg("-v") .arg("-u") @@ -370,7 +381,6 @@ fn test_gnu_shred_passes_20() { .arg(file) .succeeds(); - // Verify the exact pass sequence matches GNU's behavior let expected_passes = [ "pass 1/20 (random)", "pass 2/20 (ffffff)", @@ -408,7 +418,7 @@ fn test_gnu_shred_passes_20() { } #[test] -fn test_gnu_shred_passes_different_counts() { +fn test_shred_nineteen_passes_first_and_last_are_random() { let (at, mut ucmd) = at_and_ucmd!(); let us_data = vec![0x55; 102_400]; @@ -458,3 +468,103 @@ fn test_shred_trailing_slash_on_dir() { .fails() .stderr_contains("Is a directory"); } + +#[test] +#[cfg(unix)] +fn test_shred_inaccessible_file_reports_real_error() { + // A file whose parent directory lacks search permission must not be + // misreported as "No such file or directory": `Path::exists()` and + // `Path::is_file()` collapse the permission error into `false`. + use std::fs::{Permissions, set_permissions}; + use std::os::unix::fs::PermissionsExt; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("locked"); + at.touch("locked/file"); + set_permissions(at.plus_as_string("locked"), Permissions::from_mode(0o000)).unwrap(); + + let result = scene.ucmd().arg("locked/file").fails(); + result.stderr_contains("Permission denied"); + assert!( + !result.stderr_str().contains("No such file"), + "shred misreported an inaccessible file as missing: {}", + result.stderr_str() + ); + + // Restore search permission so the fixture directory can be cleaned up. + set_permissions(at.plus_as_string("locked"), Permissions::from_mode(0o755)).unwrap(); +} + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_unknown_unit() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("wipe_me"); + + let result = ucmd + .terminal_sim_stderr() + .args(&["-s", "4vv", "wipe_me"]) + .fails_with_code(1); + + // The number parsed; only the unit did not. + assert_eq!( + result.stderr_as_displayed(), + "\ +shred: invalid file size: '4vv' + ╭─[ shred:1:11 ] + │ + 1 │ shred -s 4vv wipe_me + │ ─┬ + │ ╰── not a known unit + │ + │ Help: a size is a number and an optional unit: K, M, G and so on for 1024, KB, MB, GB for 1000 +───╯" + ); + + // The file must be left alone when the size does not parse. + assert!(at.file_exists("wipe_me")); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_a_size_with_no_number() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("wipe_me"); + + let result = ucmd + .terminal_sim_stderr() + .args(&["--size=vv", "wipe_me"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + // Nothing usable was read, so the whole value is underlined. + assert!(stderr.contains("shred:1:14"), "{stderr}"); + assert!(!stderr.contains("not a known unit"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("wipe_me"); + + ucmd.args(&["-s", "4vv", "wipe_me"]) + .fails_with_code(1) + .stderr_is("shred: invalid file size: '4vv'\n"); + } +} + +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths (/proc) not visible")] +fn test_couldnt_rename() { + new_ucmd!() + .args(&["-u", "/proc/self/mem"]) + .fails_with_code(1) + .stderr_is("shred: /proc/self/mem: Couldn't rename to '/proc/self/000': No such file or directory\n"); +} diff --git a/tests/by-util/test_shuf.rs b/tests/by-util/test_shuf.rs index 1246297b596..95c3df3983b 100644 --- a/tests/by-util/test_shuf.rs +++ b/tests/by-util/test_shuf.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) unwritable GHSA +// spell-checker:ignore (ToDO) unwritable GHSA EAGAIN + use std::fmt::Write; use uutests::at_and_ucmd; @@ -14,6 +15,32 @@ fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); } +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore)] +fn test_getrandom_fail() { + // getrandom is missing from legacy kernel + use std::process::Command; + + let Ok(out) = Command::new("strace") + .args([ + "-o", + "/dev/null", + "-e", + "inject=getrandom:error=EAGAIN", + uutests::util::get_tests_binary(), + "shuf", + "-i", + "1234-1235", + ]) + .output() + else { + return; // missing strace + }; + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stdout).contains("1234")); +} + #[test] fn test_output_is_random_permutation() { let input_seq = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; @@ -421,8 +448,8 @@ fn test_echo_invalid_unicode_in_arguments() { assert!(result.stdout().contains(&b'\xFF')); } -#[cfg(any(unix, target_os = "wasi"))] -#[cfg(not(target_os = "macos"))] +#[cfg(unix)] +#[cfg(not(target_vendor = "apple"))] #[test] #[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_invalid_unicode_in_filename() { diff --git a/tests/by-util/test_sleep.rs b/tests/by-util/test_sleep.rs index 5fd1d65789f..d685c6e2d4a 100644 --- a/tests/by-util/test_sleep.rs +++ b/tests/by-util/test_sleep.rs @@ -2,10 +2,12 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + +// spell-checker:ignore dont SIGBUS SIGSEGV sigsegv sigbus infd + use rstest::rstest; use uucore::display::Quotable; -// spell-checker:ignore dont SIGBUS SIGSEGV sigsegv sigbus infd use uutests::new_ucmd; #[cfg(unix)] @@ -89,7 +91,9 @@ fn test_sleep_zero_duration() { #[test] fn test_sleep_no_argument() { - new_ucmd!().fails().usage_error("missing operand"); + new_ucmd!() + .fails() + .stderr_contains("the following required arguments were not provided"); // clap provided message } #[test] @@ -354,7 +358,7 @@ fn test_cmd_result_signal_when_kill_then_signal() { #[cfg(unix)] #[rstest] -#[case::signal_only_part_of_name("IGKILL")] // spell-checker: disable-line +#[case::signal_only_part_of_name("IGKILL")] // spell-checker:disable-line #[case::signal_just_sig("SIG")] #[case::signal_value_too_high("100")] #[case::signal_value_negative("-1")] diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index 74b1e377ef9..75147da862e 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.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 (words) ints (linux) NOFILE dfgi abmon avril +// spell-checker:ignore (words) ints (linux) Nofile dfgi abmon avril + #![allow(clippy::cast_possible_wrap)] use std::env; @@ -67,6 +68,33 @@ fn test_buffer_sizes() { } } +#[test] +#[cfg(not(target_os = "wasi"))] +fn test_buffer_size_limits_both_chunks() { + let line = format!("{}\n", "b".repeat(1022)); + let input = line.repeat(1200); + + for args in [vec![], vec!["-k1,1"]] { + new_ucmd!() + .args(&["-u", "-T", "missing-directory"]) + .args(&args) + .pipe_in(input.as_bytes()) + .succeeds() + .stdout_only(&line); + + // The input fits in two buffers of 768 KiB each, but exceeds the + // requested total. Spilling must fail when no temp directory exists. + new_ucmd!() + .args(&["-u", "-T", "missing-directory", "-S", "768K"]) + .args(&args) + .pipe_in(input.as_bytes()) + .ignore_stdin_write_error() + .fails_with_code(2) + .no_stdout() + .stderr_contains("cannot create temporary file"); + } +} + #[test] fn test_invalid_buffer_size() { new_ucmd!() @@ -89,6 +117,18 @@ fn test_invalid_buffer_size() { .fails_with_code(2) .stderr_only("sort: invalid --buffer-size argument '0x123%'\n"); + // A percentage can fit in a u128 while its product with the total + // physical memory does not; the parser must report it as too large + // rather than panicking or silently wrapping. + #[cfg(target_os = "linux")] + new_ucmd!() + .arg("-S") + .arg("340282366920938463463374607431768211455%") + .fails_with_code(2) + .stderr_only( + "sort: --buffer-size argument '340282366920938463463374607431768211455%' too large\n", + ); + new_ucmd!() .arg("-n") .arg("-S") @@ -303,6 +343,30 @@ fn test_numeric_with_trailing_invalid_chars() { ); } +#[test] +fn test_numeric_with_trailing_chars_and_same_number() { + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .arg("-n") + .pipe_in("1 x\n1 ./b\n1 a\n") + .succeeds() + .stdout_is("1 a\n1 ./b\n1 x\n"); + + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .arg("-r") + .pipe_in("1 ./b\n1 a\n1 x\n") + .succeeds() + .stdout_is("1 x\n1 ./b\n1 a\n"); + + new_ucmd!() + .env("LC_ALL", "en_US.UTF-8") + .arg("-nr") + .pipe_in("1 ./b\n1 a\n1 x\n") + .succeeds() + .stdout_is("1 x\n1 ./b\n1 a\n"); +} + #[test] fn test_numeric_sort_rejects_leading_plus_sign() { // GNU sort -n does not treat '+' as a number sign; lines sort lexicographically. @@ -313,6 +377,56 @@ fn test_numeric_sort_rejects_leading_plus_sign() { .stdout_is("+1\n+10\n+2\n"); } +#[test] +fn test_numeric_sort_uses_the_key_not_the_whole_line() { + // `-n` compares a number parsed from the line as a whole, which only + // stands for the key when the key is the whole line. + + // Character offset: the key is "9" and "1", so 21 comes first. Comparing + // the lines instead puts 19 first. + new_ucmd!() + .args(&["-n", "-k1.2"]) + .pipe_in("19\n21\n") + .succeeds() + .stdout_is("21\n19\n"); + + // Field: with '.' as the separator the key is "9" and "1" again, while + // both lines parse as numbers on their own. + new_ucmd!() + .args(&["-n", "-t.", "-k2"]) + .pipe_in("1.9\n2.1\n") + .succeeds() + .stdout_is("2.1\n1.9\n"); + + // A key that reverses on its own: the shortcut only knows the global + // ordering, so it has to go the long way. + new_ucmd!() + .args(&["-n", "-k1r"]) + .pipe_in("19\n21\n3\n") + .succeeds() + .stdout_is("3\n21\n19\n"); + + // Equal keys fall back to comparing the lines byte by byte, not + // numerically. + new_ucmd!() + .args(&["-n", "-k1.2"]) + .pipe_in("1\n10\n2\n20\n3\n") + .succeeds() + .stdout_is("1\n10\n2\n20\n3\n"); + + // The whole-line cases keep working. + new_ucmd!() + .arg("-n") + .pipe_in("19\n21\n3\n") + .succeeds() + .stdout_is("3\n19\n21\n"); + new_ucmd!() + .args(&["-n", "-k1"]) + .pipe_in("19\n21\n3\n") + .succeeds() + .stdout_is("3\n19\n21\n"); +} + #[test] fn test_check_zero_terminated_failure() { new_ucmd!() @@ -377,6 +491,56 @@ fn test_random_shuffle_two_runs_not_the_same() { } } +#[test] +fn test_random_source_shorter_than_the_salt() { + // GNU wants 128 bits out of the source and reports end of file below that, + // instead of shuffling with whatever it could read. + for len in [0, 1, 15] { + let (at, mut ucmd) = at_and_ucmd!(); + at.write_bytes("source", &vec![b'x'; len]); + at.write("input", "b\na\nc\n"); + + ucmd.args(&["-R", "--random-source=source", "input"]) + .fails_with_code(2) + .no_stdout() + .stderr_is("sort: 'source': end of file\n"); + } +} + +#[test] +fn test_random_source_of_exactly_the_salt_length() { + use uutests::util_name; + + let ts = TestScenario::new(util_name!()); + ts.fixtures.write_bytes("source", &[b'x'; 16]); + ts.fixtures.write("input", "b\na\nc\n"); + + let first = ts + .ucmd() + .args(&["-R", "--random-source=source", "input"]) + .succeeds() + .stdout_move_str(); + let second = ts + .ucmd() + .args(&["-R", "--random-source=source", "input"]) + .succeeds() + .stdout_move_str(); + + assert_eq!(first, second); + assert_eq!(first.lines().count(), 3); +} + +#[test] +fn test_random_source_is_not_read_without_random_sort() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write_bytes("source", b""); + at.write("input", "b\na\nc\n"); + + ucmd.args(&["--random-source=source", "input"]) + .succeeds() + .stdout_is("a\nb\nc\n"); +} + #[test] fn test_random_ignore_case() { let input = "ABC\nABc\nAbC\nAbc\naBC\naBc\nabC\nabc\n"; @@ -625,16 +789,20 @@ fn test_month_default2() { /// Query the system for abbreviated month names via `locale abmon`. /// Returns a vector of 12 month abbreviations in order (Jan..Dec), /// or None if the command fails or returns unexpected output. -#[cfg(any(target_vendor = "apple", target_os = "openbsd"))] +#[cfg(any( + target_vendor = "apple", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] fn get_system_abmon(locale: &str) -> Option> { let output = Command::new("locale") .env("LC_ALL", locale) .arg("abmon") .output() - .ok()?; - if !output.status.success() { - return None; - } + .ok() + .filter(|s| s.status.success())?; let text = String::from_utf8(output.stdout).ok()?; let months: Vec = text .trim() @@ -650,7 +818,13 @@ fn get_system_abmon(locale: &str) -> Option> { } /// Build shuffled input and sorted expected output from month names. -#[cfg(any(target_vendor = "apple", target_os = "openbsd"))] +#[cfg(any( + target_vendor = "apple", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] fn month_sort_input_expected(months: &[String]) -> (String, String) { // Shuffled order: May, Dec, Jan, Jun, Feb, Mar, Apr, Jul, Aug, Sep, Oct, Nov let shuffle_order = [4, 11, 0, 5, 1, 2, 3, 6, 7, 8, 9, 10]; @@ -672,16 +846,28 @@ fn test_month_sort_french_locale() { return; } // spell-checker:disable - // On macOS/OpenBSD, abbreviated month names vary across OS versions (different CLDR data), + // On BSD-like, abbreviated month names vary across OS versions (different CLDR data), // so we query the system dynamically. On other platforms, glibc values are stable. - #[cfg(any(target_vendor = "apple", target_os = "openbsd"))] + #[cfg(any( + target_vendor = "apple", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] let (input, expected) = { let Some(months) = get_system_abmon(locale) else { return; }; month_sort_input_expected(&months) }; - #[cfg(not(any(target_vendor = "apple", target_os = "openbsd")))] + #[cfg(not(any( + target_vendor = "apple", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + )))] let (input, expected) = ( "mai\ndéc.\njanv.\njuin\nfévr.\nmars\navril\njuil.\naoût\nsept.\noct.\nnov.\n".to_string(), "janv.\nfévr.\nmars\navril\nmai\njuin\njuil.\naoût\nsept.\noct.\nnov.\ndéc.\n".to_string(), @@ -703,14 +889,26 @@ fn test_month_sort_hungarian_locale() { return; } // spell-checker:disable - #[cfg(any(target_vendor = "apple", target_os = "openbsd"))] + #[cfg(any( + target_vendor = "apple", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] let (input, expected) = { let Some(months) = get_system_abmon(locale) else { return; }; month_sort_input_expected(&months) }; - #[cfg(not(any(target_vendor = "apple", target_os = "openbsd")))] + #[cfg(not(any( + target_vendor = "apple", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + )))] let (input, expected) = ( "máj\ndec\njan\njún\nfebr\nmárc\nápr\njúl\naug\nszept\nokt\nnov\n".to_string(), "jan\nfebr\nmárc\nápr\nmáj\njún\njúl\naug\nszept\nokt\nnov\ndec\n".to_string(), @@ -737,14 +935,26 @@ fn test_month_sort_french_embedded_blanks() { // Pick three locale months (indices 2=March, 3=April, 5=June) and verify // that inserting blanks into April's name causes a non-match. // On glibc these are "mars", "avril", "juin"; on other systems they vary. - #[cfg(any(target_vendor = "apple", target_os = "openbsd"))] + #[cfg(any( + target_vendor = "apple", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] let months = { let Some(m) = get_system_abmon(locale) else { return; }; m }; - #[cfg(not(any(target_vendor = "apple", target_os = "openbsd")))] + #[cfg(not(any( + target_vendor = "apple", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + )))] let months = vec![ "janv.", "févr.", "mars", "avril", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc.", @@ -785,8 +995,14 @@ fn test_month_sort_japanese_locale() { if !is_locale_available(locale) { return; } - // On macOS/OpenBSD, abbreviated month names may differ, so query dynamically. - #[cfg(any(target_vendor = "apple", target_os = "openbsd"))] + // On BSD-like, abbreviated month names may differ, so query dynamically. + #[cfg(any( + target_vendor = "apple", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] let (input, expected) = { let Some(months) = get_system_abmon(locale) else { return; @@ -794,7 +1010,13 @@ fn test_month_sort_japanese_locale() { month_sort_input_expected(&months) }; // Japanese abbreviated months are numeric (1月..12月) on glibc - #[cfg(not(any(target_vendor = "apple", target_os = "openbsd")))] + #[cfg(not(any( + target_vendor = "apple", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + )))] let (input, expected) = ( "5月\n12月\n1月\n6月\n2月\n3月\n4月\n7月\n8月\n9月\n10月\n11月\n".to_string(), "1月\n2月\n3月\n4月\n5月\n6月\n7月\n8月\n9月\n10月\n11月\n12月\n".to_string(), @@ -1098,6 +1320,20 @@ fn test_multiple_files() { .stdout_only_fixture("multiple_files.expected"); } +/// A file that does not end with a separator must not be fused onto the next file. +#[test] +fn test_unterminated_file_not_fused_across_chunk_boundary() { + // "a\nb\nc" is 5 bytes, so `-S 5b` puts the chunk boundary exactly at its EOF. + for buffer_size in ["1b", "2b", "3b", "4b", "5b", "6b", "7b", "8b", "16b"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("first.txt", "a\nb\nc"); + at.write("second.txt", "z\n"); + ucmd.args(&["-S", buffer_size, "first.txt", "second.txt"]) + .succeeds() + .stdout_only("a\nb\nc\nz\n"); + } +} + #[test] fn test_merge_interleaved() { new_ucmd!() @@ -1137,6 +1373,76 @@ fn test_merge_preserves_long_lines() { assert_eq!(stdout.as_slice(), input.as_bytes()); } +// Regression test: a failing write must not take the merge reader thread down with it. +// The write error used to propagate straight out of `write_all_to`, dropping the chunk +// receivers while the reader was still sending, and `chunks::read` unwraps that send. +#[test] +#[cfg(target_os = "linux")] +fn test_merge_write_error_does_not_panic() { + use std::fs::File; + + // The inputs have to be long enough that the reader is still feeding us chunks when + // the write fails, otherwise it has already finished and there is nothing to race. + const LINES: usize = 100_000; + let mut input = String::with_capacity(LINES * 7); + for line in 0..LINES { + writeln!(input, "{line:06}").unwrap(); + } + + let ts = TestScenario::new("sort"); + ts.fixtures.write("a.txt", &input); + ts.fixtures.write("b.txt", &input); + + // Whether the reader is mid-send when the write fails is a race, so repeat: a single + // attempt reproduced the panic only about a quarter of the time. With the fix the + // reader is always shut down in an orderly fashion, so this cannot fail spuriously. + for _ in 0..20 { + let dev_full = File::create("/dev/full").expect("Failed to open /dev/full"); + ts.ucmd() + .args(&["-m", "a.txt", "b.txt"]) + .set_stdout(dev_full) + .fails() + .code_is(1) + .stderr_contains("No space left on device") + .stderr_does_not_contain("panicked"); + } +} + +// A read error must be reported with context and without the raw io::Error suffix. +// It used to print `sort: Input/output error (os error 5)`. +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore)] +fn test_read_error_message() { + // Reading /proc/self/mem from offset 0 fails with EIO. + let result = new_ucmd!().arg("/proc/self/mem").fails_with_code(2); + result.no_stdout(); + // The strerror text for EIO differs between C libraries: glibc says + // "Input/output error" while musl says "I/O error". + let stderr = result.stderr_str(); + assert!( + stderr == "sort: read failed: Input/output error\n" + || stderr == "sort: read failed: I/O error\n", + "unexpected stderr: {stderr}" + ); +} + +#[test] +#[cfg(target_os = "linux")] +fn test_merge_flush_error_is_reported() { + use std::fs::File; + + let ts = TestScenario::new("sort"); + ts.fixtures.write("input.txt", "line\n"); + + let dev_full = File::create("/dev/full").expect("Failed to open /dev/full"); + ts.ucmd() + .args(&["-m", "input.txt"]) + .set_stdout(dev_full) + .fails() + .stderr_is("sort: write failed: 'standard output': No space left on device\n"); +} + #[test] fn test_merge_unique() { new_ucmd!() @@ -1185,8 +1491,7 @@ fn test_pipe() { new_ucmd!() .pipe_in("one\ntwo\nfour") .succeeds() - .stdout_is("four\none\ntwo\n") - .stderr_is(""); + .stdout_only("four\none\ntwo\n"); } #[test] @@ -1209,10 +1514,34 @@ fn test_check() { .arg(diagnose_arg) .arg("multiple_files.expected") .succeeds() - .stderr_is(""); + .no_output(); } } +#[test] +fn test_check_disorder_echoes_line_verbatim() { + // The line reported in a "disorder" diagnostic is the raw offending input line. + // It must never be reinterpreted/reformatted as a number, even though it looks + // like one (GNU sort echoes it verbatim too). + new_ucmd!() + .args(&["-n", "-c"]) + .pipe_in("2\n1.10\n") + .fails() + .stderr_only("sort: -:2: disorder: 1.10\n"); + + new_ucmd!() + .args(&["-n", "-c"]) + .pipe_in("5\nnan\n") + .fails() + .stderr_only("sort: -:2: disorder: nan\n"); + + new_ucmd!() + .args(&["-g", "-c"]) + .pipe_in("1e10\n1e5\n") + .fails() + .stderr_only("sort: -:2: disorder: 1e5\n"); +} + #[test] fn test_check_silent() { for silent_arg in [ @@ -1228,7 +1557,7 @@ fn test_check_silent() { .arg(silent_arg) .arg("check_fail.txt") .fails() - .stdout_is(""); + .no_output(); new_ucmd!() .arg(silent_arg) .arg("empty.txt") @@ -1237,6 +1566,33 @@ fn test_check_silent() { } } +#[test] +fn test_check_stops_early_without_panicking() { + // `--check` returns as soon as it finds the first disorder. That used to drop the + // channel while the reader thread was still sending chunks, so the reader panicked + // on a `SendError`. Under `panic = "abort"` (our release profile) that aborts the + // whole process instead of exiting 1. + // + // The disorder sits midway through an input that is large relative to the buffer + // size, so the reader is certain to be blocked on a full channel when we stop. + // Note this cannot fail spuriously: once the receiver outlives the reader, no + // panic is possible. + const LINES_BEFORE: usize = 20_000; + let mut input = "c\n".repeat(LINES_BEFORE); + input.push_str("a\n"); + input.push_str(&"c\n".repeat(LINES_BEFORE)); + + let (at, mut ucmd) = at_and_ucmd!(); + at.write("disorder.txt", &input); + + ucmd.args(&["-c", "--buffer-size=1K", "disorder.txt"]) + .fails_with_code(1) + .stderr_only(format!( + "sort: disorder.txt:{}: disorder: a\n", + LINES_BEFORE + 1 + )); +} + #[test] fn test_check_unique() { new_ucmd!() @@ -1456,14 +1812,14 @@ fn test_merge_batch_size() { // #[cfg(any(target_os = "linux", target_os = "android"))] #[cfg(target_os = "linux")] fn test_merge_batch_size_with_limit() { - use rlimit::Resource; + use rustix::process::Resource; // Currently need... // 3 descriptors for stdin, stdout, stderr // 2 descriptors for CTRL+C handling logic (to be reworked at some point) // 2 descriptors for the input files (i.e. batch-size of 2). let limit_fd = 3 + 2 + 2; new_ucmd!() - .limit(Resource::NOFILE, limit_fd, limit_fd) + .limit(Resource::Nofile, limit_fd, limit_fd) .arg("--batch-size=2") .arg("-m") .arg("--unique") @@ -1477,6 +1833,91 @@ fn test_merge_batch_size_with_limit() { .stdout_only_fixture("merge_ints_interleaved.expected"); } +#[test] +// TODO(#7542): Re-enable on Android once we figure out why setting limit is broken. +#[cfg(target_os = "linux")] +fn test_batch_size_above_fd_limit_is_rejected() { + use rustix::process::Resource; + // Only stdin, stdout and stderr are unavailable for merge inputs, so the + // largest acceptable --batch-size is the soft limit minus 3, here 27 - 3. + let limit_fd = 27; + let (at, mut ucmd) = at_and_ucmd!(); + at.write("gamma.txt", "delta\nalpha\n"); + ucmd.limit(Resource::Nofile, limit_fd, limit_fd) + .arg("--batch-size=31") + .arg("gamma.txt") + .fails_with_code(2) + .stderr_contains("--batch-size argument '31' too large") + // 24 is forced by the limit above: 27 - 3 reserved descriptors. + .stderr_contains("maximum --batch-size argument with current rlimit is 24"); +} + +#[test] +#[cfg(target_os = "linux")] +fn test_batch_size_at_fd_limit_is_accepted() { + use rustix::process::Resource; + let limit_fd = 27; + let (at, mut ucmd) = at_and_ucmd!(); + at.write("gamma.txt", "delta\nalpha\n"); + // 24 is the largest value the limit above allows, and sorting must still happen. + ucmd.limit(Resource::Nofile, limit_fd, limit_fd) + .arg("--batch-size=24") + .arg("gamma.txt") + .succeeds() + .stdout_only("alpha\ndelta\n"); +} + +#[test] +#[cfg(target_os = "linux")] +fn test_merge_more_files_than_fd_limit() { + use rustix::process::Resource; + let (at, mut ucmd) = at_and_ucmd!(); + // 40 single-line files cannot all be open at once with a soft limit of 24, + // so sort has to merge them in several batches through temporary files. + let count = 40; + let mut names = Vec::new(); + for i in 0..count { + let name = format!("fdlimit_{i:02}.txt"); + at.write(&name, &format!("{i:02}\n")); + names.push(name); + } + let mut expected = String::new(); + for i in 0..count { + writeln!(expected, "{i:02}").unwrap(); + } + let limit_fd = 24; + ucmd.limit(Resource::Nofile, limit_fd, limit_fd) + .arg("-m") + .args(&names) + .succeeds() + .stdout_only(expected); +} + +#[test] +#[cfg(target_os = "linux")] +fn test_more_files_than_fd_limit() { + use rustix::process::Resource; + let (at, mut ucmd) = at_and_ucmd!(); + // The inputs are read one after another, so sorting must not need more open + // file descriptors than the soft limit allows, no matter how many inputs there are. + let count = 40; + let mut names = Vec::new(); + for i in 0..count { + let name = format!("fdlimit_{i:02}.txt"); + at.write(&name, &format!("{:02}\n", count - 1 - i)); + names.push(name); + } + let mut expected = String::new(); + for i in 0..count { + writeln!(expected, "{i:02}").unwrap(); + } + let limit_fd = 24; + ucmd.limit(Resource::Nofile, limit_fd, limit_fd) + .args(&names) + .succeeds() + .stdout_only(expected); +} + #[test] fn test_sigpipe_panic() { let mut cmd = new_ucmd!(); @@ -1487,6 +1928,30 @@ fn test_sigpipe_panic() { child.wait().unwrap().no_stderr(); } +#[test] +#[cfg(unix)] +fn test_fifo_without_trailing_newline() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkfifo("FIFO"); + + let mut child = ucmd.arg("FIFO").run_no_wait(); + let fifo = at.plus("FIFO"); + let writer = std::thread::spawn(move || std::fs::write(fifo, "hello").unwrap()); + writer.join().unwrap(); + + for _ in 0..50 { + if child.is_not_alive() { + break; + } + child.delay(100); + } + if child.is_alive() { + child.kill(); + panic!("sort did not exit after the FIFO writer closed"); + } + child.wait().unwrap().success().stdout_is("hello\n"); +} + #[test] fn test_conflict_check_out() { let cases = [ @@ -1557,6 +2022,18 @@ fn test_verifies_input_files() { .stderr_is("sort: cannot read: nonexistent_file: No such file or directory\n"); } +#[test] +#[cfg(unix)] +fn test_verifies_input_files_without_opening_them() { + // Opening a FIFO blocks until a writer shows up, so if the input check opened + // the inputs, the missing second file would never be reported. + let (at, mut ucmd) = at_and_ucmd!(); + at.mkfifo("FIFO"); + ucmd.args(&["FIFO", "nonexistent_file"]) + .fails_with_code(2) + .stderr_only("sort: cannot read: nonexistent_file: No such file or directory\n"); +} + #[test] fn test_separator_null() { new_ucmd!() @@ -1566,6 +2043,37 @@ fn test_separator_null() { .stdout_only("a\0z\0z\nz\0b\0a\nz\0a\0b\n"); } +#[test] +fn test_separator_attached_equals() { + // `-t=` must select `=` itself as the separator (clap strips a leading + // `=` from attached short-option values), matching GNU sort. #14120 + new_ucmd!() + .args(&["-t=", "-k", "2"]) + .pipe_in("a=b=c\nb=a=d\n") + .succeeds() + .stdout_only("b=a=d\na=b=c\n"); +} + +#[test] +fn test_separator_attached_equals_double() { + // `-t==` selects the two-character separator `==`, which GNU rejects. + new_ucmd!() + .args(&["-t==", "-k", "2"]) + .pipe_in("") + .fails() + .stderr_contains("separator must be exactly one character long: '=='"); +} + +#[test] +fn test_separator_attached_equals_multi_char() { + // `-t=a` selects the two-character separator `=a`, which GNU rejects. + new_ucmd!() + .args(&["-t=a", "-k", "2"]) + .pipe_in("") + .fails() + .stderr_contains("separator must be exactly one character long: '=a'"); +} + #[test] fn test_output_is_input() { let input = "a\nb\nc\n"; @@ -1579,6 +2087,40 @@ fn test_output_is_input() { assert_eq!(at.read("file"), input); } +#[test] +fn test_output_file_is_truncated() { + // The output file is opened without O_TRUNC (so it can also be an input), + // then truncated before writing: no leftover bytes may survive the sort. + let (at, mut ucmd) = at_and_ucmd!(); + at.write( + "shrinking", + "zzz-leftover-one\nzzz-leftover-two\nzzz-leftover-three\n", + ); + + ucmd.args(&["-o", "shrinking"]) + .pipe_in("kiwi\napple\n") + .succeeds() + .no_output(); + + assert_eq!(at.read("shrinking"), "apple\nkiwi\n"); +} + +#[test] +fn test_merge_output_file_is_truncated() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("pears", "damson\nquince\n"); + at.write( + "merged", + "zzz-leftover-one\nzzz-leftover-two\nzzz-leftover-three\n", + ); + + ucmd.args(&["-m", "-o", "merged", "pears"]) + .succeeds() + .no_output(); + + assert_eq!(at.read("merged"), "damson\nquince\n"); +} + #[test] #[cfg(unix)] fn test_output_device() { @@ -1660,6 +2202,51 @@ fn test_tmp_files_deleted_on_sigint() { assert!(read_dir(at.plus("tmp_dir")).unwrap().next().is_none()); } +#[test] +#[cfg(unix)] +fn test_tmp_files_are_private() { + use rustix::process::{Pid, Signal, kill_process}; + use std::os::unix::fs::PermissionsExt as _; + use std::{fs::read_dir, time::Duration}; + + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("scratch"); + let input = (0..200_000) + .map(|i| (i * 7919 % 200_003).to_string()) + .collect::>() + .join("\n"); + at.write("unsorted.txt", &input); + // A one byte buffer forces `sort` to spill its chunks to disk immediately. + let child = ucmd + .args(&["unsorted.txt", "-S", "1", "-T", "scratch"]) + .umask(0o002) + .run_no_wait(); + + let mut modes = Vec::new(); + for i in 0..6 { + std::thread::sleep(Duration::from_millis(50 << i)); + if let Some(dir) = read_dir(at.plus("scratch")).unwrap().flatten().next() { + modes = read_dir(dir.path()) + .unwrap() + .flatten() + .map(|f| f.metadata().unwrap().permissions().mode() & 0o777) + .collect(); + if !modes.is_empty() { + assert_eq!(dir.metadata().unwrap().permissions().mode() & 0o777, 0o700); + break; + } + } + } + assert!(!modes.is_empty(), "sort did not spill any chunk to disk"); + assert!( + modes.iter().all(|&m| m == 0o600), + "chunks are readable: {modes:?}" + ); + + kill_process(Pid::from_raw(child.id() as i32).unwrap(), Signal::INT).unwrap(); + child.wait().unwrap().code_is(2); +} + #[test] fn test_same_sort_mode_twice() { new_ucmd!().args(&["-k", "2n,2n", "empty.txt"]).succeeds(); @@ -1708,19 +2295,19 @@ fn test_failed_write_is_reported() { } #[test] -// Test for GNU tests/sort/sort.pl "o2" -fn test_multiple_output_files() { +// Test sort with output file (o2) +fn test_error_on_multiple_output_flags() { new_ucmd!() - .args(&["-o", "foo", "-o", "bar"]) + .args(&["-o", "alpha", "-o", "beta"]) .fails_with_code(2) .stderr_is("sort: multiple output files specified\n"); } #[test] -// Test for GNU tests/sort/sort.pl "o3" -fn test_duplicate_output_files_allowed() { +// Test sort with output file (o3) +fn test_same_output_flag_twice_ok() { new_ucmd!() - .args(&["-o", "foo", "-o", "foo"]) + .args(&["-o", "output", "-o", "output"]) .pipe_in("") .succeeds() .no_stderr(); @@ -1750,8 +2337,8 @@ fn test_output_file_with_leading_dash() { } #[test] -// Test for GNU tests/sort/sort-files0-from.pl "f-extra-arg" -fn test_files0_from_extra_arg() { +// Test files0-from with extra argument +fn test_files0_from_rejects_extra_positional() { new_ucmd!() .args(&["--files0-from", "-", "foo"]) .fails_with_code(2) @@ -1762,8 +2349,8 @@ fn test_files0_from_extra_arg() { } #[test] -// Test for GNU tests/sort/sort-files0-from.pl "missing" -fn test_files0_from_missing() { +// Test files0-from with missing file +fn test_files0_from_nonexistent_file_fails() { new_ucmd!() .args(&["--files0-from", "missing_file"]) .fails_with_code(2) @@ -1776,8 +2363,8 @@ fn test_files0_from_missing() { } #[test] -// Test for GNU tests/sort/sort-files0-from.pl "minus-in-stdin" -fn test_files0_from_minus_in_stdin() { +// Test files0-from reading from stdin +fn test_files0_from_reads_stdin_via_dash() { new_ucmd!() .args(&["--files0-from", "-"]) .pipe_in("-") @@ -1788,8 +2375,8 @@ fn test_files0_from_minus_in_stdin() { } #[test] -// Test for GNU tests/sort/sort-files0-from.pl "empty" -fn test_files0_from_empty() { +// Test files0-from with empty file +fn test_files0_from_empty_input_file() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("file"); @@ -1801,17 +2388,27 @@ fn test_files0_from_empty() { #[test] #[cfg(unix)] -fn test_files0_read_error() { +fn test_files0_from_non_utf8_filename() { + new_ucmd!() + .args(&["--files0-from", "-"]) + .pipe_in(vec![0xff_u8]) + .fails_with_code(2) + .stderr_contains("sort: cannot read"); +} + +#[test] +#[cfg(unix)] +fn test_files0_from_unreadable_source() { new_ucmd!() .args(&["--files0-from", "."]) .fails_with_code(2) .stderr_only("sort: cannot read: .: Is a directory\n"); } -#[cfg(target_os = "linux")] +#[cfg(unix)] #[test] -// Test for GNU tests/sort/sort-files0-from.pl "empty-non-regular" -fn test_files0_from_empty_non_regular() { +// Test files0-from with non-regular empty file +fn test_files0_from_dev_null_is_empty() { new_ucmd!() .args(&["--files0-from", "/dev/null"]) .fails_with_code(2) @@ -1819,8 +2416,8 @@ fn test_files0_from_empty_non_regular() { } #[test] -// Test for GNU tests/sort/sort-files0-from.pl "nul-1" -fn test_files0_from_nul() { +// Test files0-from with NUL-separated input (case 1) +fn test_files0_from_single_nul_is_invalid() { new_ucmd!() .args(&["--files0-from", "-"]) .pipe_in("\0") @@ -1829,8 +2426,8 @@ fn test_files0_from_nul() { } #[test] -// Test for GNU tests/sort/sort-files0-from.pl "nul-2" -fn test_files0_from_nul2() { +// Test files0-from with NUL-separated input (case 2) +fn test_files0_from_double_nul_is_invalid() { new_ucmd!() .args(&["--files0-from", "-"]) .pipe_in("\0\0") @@ -1839,65 +2436,65 @@ fn test_files0_from_nul2() { } #[test] -// Test for GNU tests/sort/sort-files0-from.pl "1" -fn test_files0_from_1() { +// Test files0-from basic single file +fn test_files0_from_single_entry() { let (at, mut ucmd) = at_and_ucmd!(); - at.touch("file"); - at.append("file", "a"); + at.touch("words"); + at.append("words", "mango\nkiwi"); ucmd.args(&["--files0-from", "-"]) - .pipe_in("file") + .pipe_in("words") .succeeds() - .stdout_only("a\n"); + .stdout_only("kiwi\nmango\n"); } #[test] -// Test for GNU tests/sort/sort-files0-from.pl "1a" -fn test_files0_from_1a() { +// Test files0-from basic single file variant +fn test_files0_from_single_entry_trailing_nul() { let (at, mut ucmd) = at_and_ucmd!(); - at.touch("file"); - at.append("file", "a"); + at.touch("words"); + at.append("words", "mango\nkiwi"); ucmd.args(&["--files0-from", "-"]) - .pipe_in("file\0") + .pipe_in("words\0") .succeeds() - .stdout_only("a\n"); + .stdout_only("kiwi\nmango\n"); } #[test] -// Test for GNU tests/sort/sort-files0-from.pl "2" -fn test_files0_from_2() { +// Test files0-from with two files +fn test_files0_from_two_entries() { let (at, mut ucmd) = at_and_ucmd!(); - at.touch("file"); - at.append("file", "a"); + at.touch("words"); + at.append("words", "mango\nkiwi"); ucmd.args(&["--files0-from", "-"]) - .pipe_in("file\0file") + .pipe_in("words\0words") .succeeds() - .stdout_only("a\na\n"); + .stdout_only("kiwi\nkiwi\nmango\nmango\n"); } #[test] -// Test for GNU tests/sort/sort-files0-from.pl "2a" -fn test_files0_from_2a() { +// Test files0-from with two files variant +fn test_files0_from_two_entries_trailing_nul() { let (at, mut ucmd) = at_and_ucmd!(); - at.touch("file"); - at.append("file", "a"); + at.touch("words"); + at.append("words", "mango\nkiwi"); ucmd.args(&["--files0-from", "-"]) - .pipe_in("file\0file\0") + .pipe_in("words\0words\0") .succeeds() - .stdout_only("a\na\n"); + .stdout_only("kiwi\nkiwi\nmango\nmango\n"); } #[test] -// Test for GNU tests/sort/sort-files0-from.pl "non-utf8" -#[cfg(all(unix, not(target_os = "macos")))] -fn test_files0_from_non_utf8() { +// Test files0-from with non-UTF-8 filenames +#[cfg(all(unix, not(target_vendor = "apple")))] +fn test_files0_from_non_utf8_content() { use std::os::unix::ffi::OsStringExt; let (at, mut ucmd) = at_and_ucmd!(); @@ -1914,20 +2511,20 @@ fn test_files0_from_non_utf8() { } #[test] -// Test for GNU tests/sort/sort-files0-from.pl "zero-len" -fn test_files0_from_zero_length() { +// Test files0-from with zero-length filename +fn test_files0_from_zero_length_entry_fails() { new_ucmd!() .args(&["--files0-from", "-"]) - .pipe_in("g\0\0b\0\0") + .pipe_in("x\0\0y\0\0") .fails_with_code(2) .stderr_only("sort: -:2: invalid zero-length file name\n"); } #[test] -// Test for GNU tests/sort/sort-float.sh -fn test_g_float() { - let input = "0\n-3.3621031431120935063e-4932\n3.3621031431120935063e-4932\n"; - let output = "-3.3621031431120935063e-4932\n0\n3.3621031431120935063e-4932\n"; +// Test sort with floating point numbers +fn test_sort_general_numeric_extremes() { + let input = "0\n-1.7976931348623157e+308\n1.7976931348623157e+308\n"; + let output = "-1.7976931348623157e+308\n0\n1.7976931348623157e+308\n"; new_ucmd!() .args(&["-g"]) .pipe_in(input) @@ -1969,10 +2566,8 @@ fn test_human_numeric_blank_thousands_sep_locale() { .arg("thousands_sep") .env("LC_ALL", locale) .output() - .ok()?; - if !output.status.success() { - return None; - } + .ok() + .filter(|s| s.status.success())?; let sep = String::from_utf8_lossy(&output.stdout); let sep = sep.trim_end_matches(&['\n', '\r'][..]); if sep.is_empty() || sep.len() != 1 || !sep.chars().all(char::is_whitespace) { @@ -2088,7 +2683,7 @@ fn test_g_float_hex() { .stdout_is(output); } -/* spell-checker: disable */ +// spell-checker:disable #[test] fn test_french_translations() { // Test that French translations work for clap error messages @@ -2384,62 +2979,62 @@ fn test_debug_key_annotations() { fn test_debug_key_annotations_locale() { let ts = TestScenario::new("sort"); - if let Ok(locale_fr_utf8) = env::var("LOCALE_FR_UTF8") { - if locale_fr_utf8 != "none" { - let probe = ts - .ucmd() - .args(&["-g", "--debug", "/dev/null"]) - .env("LC_NUMERIC", &locale_fr_utf8) - .env("LC_MESSAGES", "C") - .run(); - if probe - .stderr_str() - .contains("numbers use .*,.* as a decimal point") - { - let mut locale_output = String::new(); - locale_output.push_str( - &ts.ucmd() - .env("LC_ALL", "C") - .args(&["--debug", "-k2g", "-k1b,1"]) - .pipe_in(" 1²---++3 1,234 Mi\n") - .succeeds() - .stdout_move_str(), - ); - locale_output.push_str( - &ts.ucmd() - .env("LC_ALL", &locale_fr_utf8) - .args(&["--debug", "-k2g", "-k1b,1"]) - .pipe_in(" 1²---++3 1,234 Mi\n") - .succeeds() - .stdout_move_str(), - ); - locale_output.push_str( - &ts.ucmd() - .env("LC_ALL", &locale_fr_utf8) - .args(&[ - "--debug", "-k1,1n", "-k1,1g", "-k1,1h", "-k2,2n", "-k2,2g", "-k2,2h", - "-k3,3n", "-k3,3g", "-k3,3h", - ]) - .pipe_in("+1234 1234Gi 1,234M\n") - .succeeds() - .stdout_move_str(), - ); - - let normalized = locale_output - .lines() - .map(|line| { - if line.starts_with("^^ ") { - "^ no match for key".to_string() - } else { - line.to_string() - } - }) - .collect::>() - .join("\n") - + "\n"; - - assert_eq!(normalized, EXPECTED_DEBUG_KEY_ANNOTATION_LOCALE); - } + if let Ok(locale_fr_utf8) = env::var("LOCALE_FR_UTF8") + && locale_fr_utf8 != "none" + { + let probe = ts + .ucmd() + .args(&["-g", "--debug", "/dev/null"]) + .env("LC_NUMERIC", &locale_fr_utf8) + .env("LC_MESSAGES", "C") + .run(); + if probe + .stderr_str() + .contains("numbers use .*,.* as a decimal point") + { + let mut locale_output = String::new(); + locale_output.push_str( + &ts.ucmd() + .env("LC_ALL", "C") + .args(&["--debug", "-k2g", "-k1b,1"]) + .pipe_in(" 1²---++3 1,234 Mi\n") + .succeeds() + .stdout_move_str(), + ); + locale_output.push_str( + &ts.ucmd() + .env("LC_ALL", &locale_fr_utf8) + .args(&["--debug", "-k2g", "-k1b,1"]) + .pipe_in(" 1²---++3 1,234 Mi\n") + .succeeds() + .stdout_move_str(), + ); + locale_output.push_str( + &ts.ucmd() + .env("LC_ALL", &locale_fr_utf8) + .args(&[ + "--debug", "-k1,1n", "-k1,1g", "-k1,1h", "-k2,2n", "-k2,2g", "-k2,2h", + "-k3,3n", "-k3,3g", "-k3,3h", + ]) + .pipe_in("+1234 1234Gi 1,234M\n") + .succeeds() + .stdout_move_str(), + ); + + let normalized = locale_output + .lines() + .map(|line| { + if line.starts_with("^^ ") { + "^ no match for key".to_string() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") + + "\n"; + + assert_eq!(normalized, EXPECTED_DEBUG_KEY_ANNOTATION_LOCALE); } } } @@ -3022,6 +3617,18 @@ e f 5436 down data path1 path2 path3 path4 path5\n"; .stdout_is(input); } +#[test] +fn test_empty_input_empty_output() { + // check for inconsistency #11958 + let input = "test test test"; + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("file", input); + + ucmd.args(&["-o", "file"]).pipe_in("").succeeds(); + assert_eq!(at.read("file"), ""); +} + #[test] fn test_consistent_sorting_with_i18n_collate() { // Regression test for issue #11980 @@ -3086,4 +3693,247 @@ fn test_sort_locale_punctuation() { } } -/* spell-checker: enable */ +#[test] +fn test_locale_empty_env_vars_hungarian_lc_collate() { + // Regression test for issue #11136 + let input = "gx\ngy\ngz\n"; + let output = "gx\ngz\ngy\n"; + + let hungarian = "hu_HU.UTF-8"; + let env_vars_combos = [ + vec![("LC_ALL", ""), ("LC_COLLATE", hungarian)], + vec![("LC_ALL", ""), ("LANG", hungarian)], + vec![("LC_ALL", ""), ("LC_COLLATE", ""), ("LANG", hungarian)], + ]; + + for vars in env_vars_combos { + new_ucmd!() + .envs(vars) + .pipe_in(input) + .succeeds() + .stdout_is(output); + } + + // check that LC_ALL, LC_COLLATE, LANG being all empty implies + // that locale falls back to C locale + new_ucmd!() + .env("LC_ALL", "") + .env("LC_COLLATE", "") + .env("LANG", "") + .pipe_in(input) + .succeeds() + .stdout_is(input); +} + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_stray_character() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-k2.3q", "/dev/null"]) + .fails_with_code(2); + + // The whole report: the `sort: ` prefix of the plain form, the key + // echoed back inside the option it was glued to, a caret on the `q` + // rather than on the whole `-k2.3q`, and the key syntax advice. + assert_eq!( + result.stderr_as_displayed(), + "\ +sort: stray character in field spec: invalid field specification '2.3q' + ╭─[ sort:1:11 ] + │ + 1 │ sort -k2.3q /dev/null + │ ─ + │ + │ Help: a key is FIELD[.CHAR][OPTS][,FIELD[.CHAR][OPTS]], as in -k2.3,4nr +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_inside_a_detached_key() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-k", "1.4,2w", "/dev/null"]) + .fails_with_code(2); + + // `-k 1.4,2w` puts the key in its own argument; the caret still lands + // on the offending character. + assert_eq!( + result.stderr_as_displayed(), + "\ +sort: stray character in field spec: invalid field specification '1.4,2w' + ╭─[ sort:1:14 ] + │ + 1 │ sort -k 1.4,2w /dev/null + │ ─ + │ + │ Help: a key is FIELD[.CHAR][OPTS][,FIELD[.CHAR][OPTS]], as in -k2.3,4nr +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_a_zero_character_offset() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-k3.0", "/dev/null"]) + .fails_with_code(2); + + // No syntax advice here: the label already says where counting starts. + assert_eq!( + result.stderr_as_displayed(), + "\ +sort: character offset is zero: invalid field specification '3.0' + ╭─[ sort:1:10 ] + │ + 1 │ sort -k3.0 /dev/null + │ ┬ + │ ╰── counting starts at 1 +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_a_missing_number() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-kw", "/dev/null"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +sort: invalid number at field start: invalid count at start of 'w' + ╭─[ sort:1:8 ] + │ + 1 │ sort -kw /dev/null + │ ┬ + │ ╰── expected a number here + │ + │ Help: a key is FIELD[.CHAR][OPTS][,FIELD[.CHAR][OPTS]], as in -k2.3,4nr +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_reports_contradicting_ordering_options() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-k1hV", "/dev/null"]) + .fails_with_code(2); + + // The caret covers the ordering options, not the field number. + assert_eq!( + result.stderr_as_displayed(), + "\ +sort: options '-hV' are incompatible + ╭─[ sort:1:9 ] + │ + 1 │ sort -k1hV /dev/null + │ ── +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_ignores_a_file_that_ends_like_the_key() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("data.2.3q"); + + // The input file's name ends with the key's text; the caret must stay + // on the `-k` operand, not drift onto the file. + let result = ucmd + .terminal_sim_stderr() + .args(&["data.2.3q", "-k2.3q"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +sort: stray character in field spec: invalid field specification '2.3q' + ╭─[ sort:1:21 ] + │ + 1 │ sort data.2.3q -k2.3q + │ ─ + │ + │ Help: a key is FIELD[.CHAR][OPTS][,FIELD[.CHAR][OPTS]], as in -k2.3,4nr +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_ignores_the_program_name_as_a_key() { + // A key spelled exactly like the program name must not pull the caret + // onto `sort` itself at the start of the line. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-k", "sort", "/dev/null"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +sort: invalid number at field start: invalid count at start of 'sort' + ╭─[ sort:1:9 ] + │ + 1 │ sort -k sort /dev/null + │ ──┬─ + │ ╰─── expected a number here + │ + │ Help: a key is FIELD[.CHAR][OPTS][,FIELD[.CHAR][OPTS]], as in -k2.3,4nr +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_unknown_unit_of_a_buffer_size() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-S", "8zz", "/dev/null"]) + .fails_with_code(2); + + // The number parsed; only the unit did not. + assert_eq!( + result.stderr_as_displayed(), + "\ +sort: invalid suffix in --buffer-size argument '8zz' + ╭─[ sort:1:10 ] + │ + 1 │ sort -S 8zz /dev/null + │ ─┬ + │ ╰── not a known unit + │ + │ Help: a size is a number and an optional unit: K, M, G and so on for 1024, KB, MB, GB for 1000 +───╯" + ); + } + + #[test] + fn test_plain_message_when_stderr_is_not_a_terminal() { + // The test harness pipes stderr, so the report must not appear. + new_ucmd!() + .args(&["-k2.3q", "/dev/null"]) + .fails_with_code(2) + .stderr_only( + "sort: stray character in field spec: invalid field specification '2.3q'\n", + ); + new_ucmd!() + .args(&["-S", "8zz", "/dev/null"]) + .fails_with_code(2) + .stderr_only("sort: invalid suffix in --buffer-size argument '8zz'\n"); + } +} + +// spell-checker:enable diff --git a/tests/by-util/test_split.rs b/tests/by-util/test_split.rs index a9e36f2c5a5..247b807de6e 100644 --- a/tests/by-util/test_split.rs +++ b/tests/by-util/test_split.rs @@ -2,14 +2,13 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore xzaaa sixhundredfiftyonebytes ninetyonebytes threebytes asciilowercase ghijkl mnopq rstuv wxyz fivelines twohundredfortyonebytes onehundredlines nbbbb dxen ncccc rlimit NOFILE + +// spell-checker:ignore xzaaa sixhundredfiftyonebytes ninetyonebytes threebytes asciilowercase ghijkl mnopq rstuv wxyz fivelines twohundredfortyonebytes onehundredlines nbbbb dxen ncccc rlimit Nofile use rand::{RngExt as _, SeedableRng, rng}; use regex::Regex; #[cfg(any(target_os = "linux", target_os = "android"))] -use rlimit::Resource; -#[cfg(not(windows))] -use std::env; +use rustix::process::Resource; #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStringExt; use std::path::Path; @@ -133,7 +132,7 @@ fn test_invalid_arg() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(all(unix, not(target_os = "android")))] fn test_split_to_non_seekable() { let (at, mut ucmd) = at_and_ucmd!(); at.symlink_file("/dev/stdout", "xaa"); @@ -336,27 +335,20 @@ fn test_filter() { #[test] #[cfg(unix)] fn test_filter_with_env_var_set() { - // This test will ensure that if $FILE env var was set before running --filter, it'll stay that - // way + // This test will ensure that if $FILE is already set in split's environment, --filter still + // sets it to each output file name // implemented like `test_split_default()` but run a command before writing let (at, mut ucmd) = at_and_ucmd!(); let name = "filtered"; let n_lines = 3; RandomFile::new(&at, name).add_lines(n_lines); - let env_var_value = "some-value"; - unsafe { - env::set_var("FILE", env_var_value); - } - ucmd.args(&[format!("--filter={}", "cat > $FILE").as_str(), name]) + ucmd.env("FILE", "some-value") + .args(&[format!("--filter={}", "cat > $FILE").as_str(), name]) .succeeds(); let glob = Glob::new(&at, ".", r"x[[:alpha:]][[:alpha:]]$"); assert_eq!(glob.collate(), at.read_bytes(name)); - assert_eq!( - env::var("FILE").unwrap_or_else(|_| "var was unset".to_owned()), - env_var_value - ); } #[test] @@ -1275,6 +1267,35 @@ fn test_number_by_lines_kth() { .stdout_only("20\n21\n22\n23\n24\n25\n26\n27\n28\n29\n"); } +/// Chunks smaller than one byte must not loop forever. +/// +/// When the input is shorter than the requested number of chunks, the trailing +/// chunks are zero-sized. The chunk-advancing loop used to make no progress in +/// that case and spun forever at 100% CPU. +#[test] +fn test_number_by_lines_fewer_bytes_than_chunks_elide() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("in", "a"); + ucmd.args(&["-e", "-n", "l/3", "in"]).succeeds().no_output(); + assert_eq!(at.read("xaa"), "a"); + assert!(!at.plus("xab").exists()); + assert!(!at.plus("xac").exists()); +} + +/// A huge chunk count must not overflow the skipped-chunk counter. +/// +/// With far more chunks than input bytes, all of the trailing chunks are +/// zero-sized and get skipped in one go. Counting them used to overflow and +/// panic in a build with overflow checks enabled. +#[test] +fn test_number_by_lines_kth_huge_number_of_chunks() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("in", "a"); + ucmd.args(&["-n", "l/1/9999999999", "in"]) + .succeeds() + .stdout_only("a"); +} + #[test] #[cfg(unix)] fn test_number_by_lines_kth_dev_null() { @@ -1671,7 +1692,7 @@ fn test_round_robin() { fn test_round_robin_limited_file_descriptors() { new_ucmd!() .args(&["-n", "r/40", "onehundredlines.txt"]) - .limit(Resource::NOFILE, 9, 9) + .limit(Resource::Nofile, 9, 9) .succeeds(); } @@ -1750,7 +1771,7 @@ fn test_split_non_utf8_argument_windows() { ucmd.args(&[opt, opt_value, name]).succeeds(); } -// Test '--separator' / '-t' option following GNU tests example +// Test '--separator' / '-t' option // test separators: '\n' , '\0' , ';' // test with '--lines=2' , '--line-bytes=4' , '--number=l/3' , '--number=r/3' , '--number=l/1/3' , '--number=r/1/3' #[test] @@ -2093,16 +2114,70 @@ fn test_split_non_utf8_additional_suffix_is_byte_preserving() { } #[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!(); +fn test_empty_input_does_not_create_output() { + for args in [&[][..], &["-b", "1"], &["-l", "1"], &["-C", "1"]] { + let scenario = TestScenario::new(util_name!()); + let at = &scenario.fixtures; + + scenario + .ucmd() + .args(args) + .arg("--verbose") + .pipe_in("") + .succeeds() + .no_output(); + assert!(!at.plus("xaa").exists()); + + at.touch("empty"); + scenario + .ucmd() + .args(args) + .arg("empty") + .succeeds() + .no_output(); + assert!(!at.plus("xaa").exists()); + } +} - 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"); +#[test] +fn test_empty_input_preserves_existing_output() { + for args in [&[][..], &["-b", "1"], &["-l", "1"], &["-C", "1"]] { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("xaa", "keep this output\n"); + + ucmd.args(args).pipe_in("").succeeds().no_output(); + assert_eq!(at.read("xaa"), "keep this output\n"); + assert!(!at.plus("xab").exists()); + } +} + +#[test] +fn test_empty_input_with_output_directory() { + for args in [&[][..], &["-b", "1"], &["-l", "1"], &["-C", "1"]] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("xaa"); + + ucmd.args(args).pipe_in("").succeeds().no_output(); + assert!(at.plus("xaa").is_dir()); + assert!(!at.plus("xab").exists()); + } +} + +#[test] +#[cfg(unix)] // To re-enable on Windows once I work out what goes wrong with it. +fn test_split_directory_already_exists() { + for args in [&[][..], &["-b", "1"], &["-l", "1"], &["-C", "1"]] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("xaa"); + + ucmd.args(args) + .pipe_in("data\n") + .fails_with_code(1) + .no_stdout() + .stderr_is("split: 'xaa': Is a directory\n"); + assert!(at.plus("xaa").is_dir()); + assert!(!at.plus("xab").exists()); + } } #[test] @@ -2115,3 +2190,105 @@ fn test_io_error() { //todo: add file path with proper distinction of input/output .stderr_contains("Input/output error\n"); } + +/// Writing a chunk to a full device must be reported and must stop the split. +#[test] +#[cfg(target_os = "linux")] +fn test_write_error_on_full_device() { + if !Path::new("/dev/full").exists() { + return; + } + + let (at, mut ucmd) = at_and_ucmd!(); + + // The first chunk lands on /dev/full, so its write can never succeed. + at.symlink_file("/dev/full", "xaa"); + ucmd.args(&["-b", "1"]) + .pipe_in("uv") + .fails_with_code(1) + .no_stdout() + .stderr_is("split: xaa: No space left on device\n"); + + // split must not have moved on to the next chunk. + assert!(at.plus("xaa").is_symlink()); + assert_eq!( + fs::read_link(at.plus("xaa")).unwrap(), + Path::new("/dev/full") + ); + assert!(!at.file_exists("xab")); +} + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_unknown_unit() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-b", "7zq", "/dev/null"]) + .fails_with_code(1); + + // The number parsed; only the unit did not. + assert_eq!( + result.stderr_as_displayed(), + "\ +split: invalid number of bytes: '7zq' + ╭─[ split:1:11 ] + │ + 1 │ split -b 7zq /dev/null + │ ─┬ + │ ╰── not a known unit + │ + │ Help: a size is a number and an optional unit: K, M, G and so on for 1024, KB, MB, GB for 1000 +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_inside_a_line_bytes_value() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--line-bytes=3qq", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("split:1:21"), "{stderr}"); + assert!(stderr.contains("not a known unit"), "{stderr}"); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_a_count_with_no_number() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-l", "qq", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + // Nothing usable was read, so the whole value is underlined and the + // message says the rest. + assert!(stderr.contains("invalid number of lines"), "{stderr}"); + assert!(!stderr.contains("not a known unit"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["-b", "7zq", "/dev/null"]) + .fails_with_code(1) + .stderr_is("split: invalid number of bytes: '7zq'\n"); + } +} + +#[test] +fn test_obsolete_lines_not_read_after_double_dash() { + // After `--` there are no more options, so `-1` names a file rather than + // being taken as the obsolete `split -1` line-count spelling. + new_ucmd!() + .args(&["--", "-1"]) + .fails() + .stderr_contains("cannot open '-1' for reading"); +} diff --git a/tests/by-util/test_stat.rs b/tests/by-util/test_stat.rs index 153ef39c88d..2f7c3157723 100644 --- a/tests/by-util/test_stat.rs +++ b/tests/by-util/test_stat.rs @@ -11,8 +11,9 @@ use uutests::unwrap_or_return; use uutests::util::{TestScenario, expected_result}; use uutests::util_name; -use std::fs::metadata; +use std::fs::{File, FileTimes, metadata}; use std::os::unix::fs::MetadataExt; +use std::time::{Duration, UNIX_EPOCH}; #[test] fn test_invalid_arg() { @@ -24,6 +25,25 @@ fn test_invalid_option() { new_ucmd!().arg("-w").arg("-q").arg("/").fails(); } +#[test] +fn test_format_hyphen_leading_as_separate_arg() { + // A hyphen-leading format string passed as its own argument (not + // attached with `=`) must not be mistaken for a new, unrecognized + // flag. + new_ucmd!() + .args(&["--format", "-%n", "/"]) + .succeeds() + .stdout_is("-/\n"); + new_ucmd!() + .args(&["--printf", "-%n", "/"]) + .succeeds() + .stdout_is("-/"); + new_ucmd!() + .args(&["-c", "-%n", "/"]) + .succeeds() + .stdout_is("-/\n"); +} + #[cfg(unix)] const NORMAL_FORMAT_STR: &str = "%a %A %b %B %d %D %f %F %g %G %h %i %m %n %o %s %u %U %x %X %y %Y %z %Z"; // avoid "%w %W" (birth/creation) due to `stat` limitations and linux kernel & rust version capability variations @@ -51,6 +71,35 @@ fn test_fs_format() { ts.ucmd().args(&args).succeeds().stdout_is(expected_stdout); } +#[test] +// `stat -f` is only implemented for these targets; elsewhere `fs_type` is +// still `unimplemented!()`. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_fs_default_format_block_size_label() { + // GNU prints "Block size:", not "Block Size:". + new_ucmd!() + .args(&["-f", "/"]) + .succeeds() + .stdout_contains("Block size:"); +} + +#[test] +// `stat -f` is only implemented for these targets; elsewhere `fs_type` is +// still `unimplemented!()`. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_fs_default_format_quotes_name() { + let ts = TestScenario::new(util_name!()); + ts.fixtures.touch("a b"); + ts.ucmd() + .args(&["-f", "a b"]) + .succeeds() + .stdout_contains(" File: 'a b'\n"); + ts.ucmd() + .args(&["-f", "-t", "a b"]) + .succeeds() + .stdout_str_check(|s| s.starts_with("'a b' ")); +} + #[cfg(unix)] #[test] fn test_terse_normal_format() { @@ -173,7 +222,7 @@ fn test_symlinks() { assert!(tested, "No symlink found to test in this environment"); } -#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] #[test] fn test_char() { // TODO: "(%t) (%x) (%w)" deviate from GNU stat for `character special file` on macOS @@ -188,7 +237,7 @@ fn test_char() { "/dev/pts/ptmx", #[cfg(target_vendor = "apple")] "%a %A %b %B %d %D %f %F %g %G %h %i %m %n %o %s (/%T) %u %U %W %X %y %Y %z %Z", - #[cfg(any(target_os = "android", target_vendor = "apple"))] + #[cfg(any(target_vendor = "apple", target_os = "android"))] "/dev/ptmx", ]; let ts = TestScenario::new(util_name!()); @@ -200,12 +249,6 @@ fn test_char() { #[cfg(target_os = "linux")] #[test] fn test_printf_atime_ctime_mtime_precision() { - // TODO Higher precision numbers (`%.3Y`, `%.4Y`, etc.) are - // formatted correctly, but we are not precise enough when we do - // some `mtime` computations, so we get `.7640` instead of - // `.7639`. This can be fixed by being more careful when - // transforming the number from `Metadata::mtime_nsec()` to the form - // used in rendering. let args = ["-c", "%.0Y %.1Y %.2X %.2Y %.2Z", "/dev/pts/ptmx"]; let ts = TestScenario::new(util_name!()); let expected_stdout = unwrap_or_return!(expected_result(&ts, &args)).stdout_move_str(); @@ -258,7 +301,77 @@ fn test_timestamp_format() { } } -#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +#[test] +fn test_timestamp_format_preserves_nanoseconds() { + let ts = TestScenario::new(util_name!()); + let path = ts.fixtures.plus("timestamp"); + let file = File::create(&path).unwrap(); + + let timestamp = UNIX_EPOCH + Duration::new(1_755_300_000, 123_456_789); + file.set_times( + FileTimes::new() + .set_accessed(timestamp) + .set_modified(timestamp), + ) + .unwrap(); + + let metadata = metadata(&path).unwrap(); + let expected = format!( + "1755300000.123456789 1755300000.123456789 {}.{:09}\n", + metadata.ctime(), + metadata.ctime_nsec() + ); + + ts.ucmd() + .args(&["-c", "%.9X %.9Y %.9Z", "timestamp"]) + .succeeds() + .stdout_is(expected); +} + +#[test] +fn test_timestamp_format_before_epoch() { + let ts = TestScenario::new(util_name!()); + let path = ts.fixtures.plus("timestamp"); + let file = File::create(&path).unwrap(); + + let timestamp = UNIX_EPOCH - Duration::new(0, 876_543_211); + file.set_times( + FileTimes::new() + .set_accessed(timestamp) + .set_modified(timestamp), + ) + .unwrap(); + + ts.ucmd() + .args(&["-c", "%.1X %.3X %.9X %.1Y %.3Y %.9Y", "timestamp"]) + .succeeds() + .stdout_is("-0.8 -0.876 -0.876543211 -0.8 -0.876 -0.876543211\n"); +} + +#[test] +fn test_timestamp_format_before_epoch_truncation() { + let ts = TestScenario::new(util_name!()); + let file = File::create(ts.fixtures.plus("fractional-time")).unwrap(); + let timestamp = UNIX_EPOCH - Duration::new(2, 234_567_891); + file.set_times( + FileTimes::new() + .set_accessed(timestamp) + .set_modified(timestamp), + ) + .unwrap(); + + for directive in ['X', 'Y'] { + let format = format!( + "%{directive}|%.0{directive}|%.{directive}|%.2{directive}|%.6{directive}|%.12{directive}" + ); + ts.ucmd() + .args(&["-c", &format, "fractional-time"]) + .succeeds() + .stdout_is("-3|-3|-2.234567891|-2.23|-2.234567|-2.234567891000\n"); + } +} + +#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] #[test] fn test_date() { // Just test the date for the time 0.3 change @@ -270,7 +383,7 @@ fn test_date() { "/bin/sh", #[cfg(target_vendor = "apple")] "%z", - #[cfg(any(target_os = "android", target_vendor = "apple"))] + #[cfg(any(target_vendor = "apple", target_os = "android"))] "/bin/sh", ]; let ts = TestScenario::new(util_name!()); @@ -285,7 +398,7 @@ fn test_date() { "/dev/ptmx", #[cfg(target_vendor = "apple")] "%z", - #[cfg(any(target_os = "android", target_vendor = "apple"))] + #[cfg(any(target_vendor = "apple", target_os = "android"))] "/dev/ptmx", ]; let ts = TestScenario::new(util_name!()); @@ -338,10 +451,10 @@ fn test_pipe_fifo() { #[cfg(all( unix, not(any( + target_vendor = "apple", target_os = "android", target_os = "freebsd", - target_os = "openbsd", - target_os = "macos" + target_os = "openbsd" )) ))] fn test_stdin_pipe_fifo1() { @@ -366,7 +479,7 @@ fn test_stdin_pipe_fifo1() { // TODO(#7583): Re-enable on Mac OS X (and maybe Android) #[test] -#[cfg(all(unix, not(any(target_os = "android", target_os = "macos"))))] +#[cfg(all(unix, not(any(target_vendor = "apple", target_os = "android"))))] fn test_stdin_pipe_fifo2() { // $ stat - // File: - @@ -396,8 +509,8 @@ fn test_stdin_with_fs_option() { #[cfg(all( unix, not(any( + target_vendor = "apple", target_os = "android", - target_os = "macos", target_os = "freebsd", target_os = "openbsd" )) @@ -411,7 +524,7 @@ fn test_stdin_redirect() { at.touch("f"); ts.ucmd() .arg("-") - .set_stdin(std::fs::File::open(at.plus("f")).unwrap()) + .set_stdin(File::open(at.plus("f")).unwrap()) .succeeds() .no_stderr() .stdout_contains("regular empty file") @@ -422,7 +535,75 @@ fn test_stdin_redirect() { fn test_without_argument() { new_ucmd!() .fails() - .stderr_contains("missing operand\nTry 'stat --help' for more information."); + .stderr_contains("the following required arguments were not provided"); // clap provided message +} + +#[test] +fn test_quoting_style_default() { + // By default, a name is only quoted when needed (shell-escape). + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.touch("plain"); + at.touch("a b"); + at.relative_symlink_file("plain", "link"); + ts.ucmd() + .args(&["-c", "%N", "plain", "a b", "link"]) + .succeeds() + .stdout_only("plain\n'a b'\nlink -> plain\n"); + + ts.ucmd() + .arg("a b") + .succeeds() + .stdout_contains(" File: 'a b'\n"); +} + +#[test] +fn test_quoted_name_directive() { + // %Qn quotes the name like %N, without dereferencing a symbolic link. + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.touch("a b"); + at.relative_symlink_file("a b", "link"); + ts.ucmd() + .args(&["-c", "%Qn|%-6Qn|", "a b", "link"]) + .succeeds() + .stdout_only("'a b'|'a b' |\nlink|link |\n"); + + ts.ucmd() + .env("QUOTING_STYLE", "c") + .args(&["-f", "-c", "%Qn", "a b"]) + .succeeds() + .stdout_only("\"a b\"\n"); + + ts.ucmd() + .args(&["-t", "a b"]) + .succeeds() + .stdout_str_check(|s| s.starts_with("'a b' ")); +} + +#[test] +fn test_quoting_style_env() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.touch("it's"); + at.touch("tab\there"); + for (style, expected) in [ + ("literal", "it's\ntab\there\n"), + ("shell", "\"it's\"\n'tab\there'\n"), + ("shell-always", "\"it's\"\n'tab\there'\n"), + ("shell-escape", "\"it's\"\n'tab'$'\\t''here'\n"), + ("shell-escape-always", "\"it's\"\n'tab'$'\\t''here'\n"), + ("c", "\"it's\"\n\"tab\\there\"\n"), + ("escape", "it's\ntab\\there\n"), + ("locale", "'it\\'s'\n'tab\\there'\n"), + ("clocale", "\"it's\"\n\"tab\\there\"\n"), + ] { + ts.ucmd() + .env("QUOTING_STYLE", style) + .args(&["-c", "%N", "it's", "tab\there"]) + .succeeds() + .stdout_only(expected); + } } #[test] @@ -499,7 +680,7 @@ fn test_quoting_style_invalid_env() { .env("QUOTING_STYLE", "fromage") .args(&["-c", "nom=[%N]", "baguette", "Croissant", "Escargot"]) .succeeds(); - res.stdout_is("nom=['baguette']\nnom=['Croissant']\nnom=['Escargot']\n"); + res.stdout_is("nom=[baguette]\nnom=[Croissant]\nnom=[Escargot]\n"); assert_eq!(res.stderr_str().matches(needle).count(), 1); // An empty value is also invalid and must be reported with empty quotes. @@ -507,7 +688,7 @@ fn test_quoting_style_invalid_env() { .env("QUOTING_STYLE", "") .args(&["-c", "%N", "baguette"]) .succeeds() - .stdout_is("'baguette'\n") + .stdout_is("baguette\n") .stderr_is("stat: ignoring invalid value of environment variable QUOTING_STYLE: ''\n"); // %%%N: a literal '%' followed by the quoted name, fallback style applies. @@ -515,7 +696,7 @@ fn test_quoting_style_invalid_env() { .env("QUOTING_STYLE", "soufflé") .args(&["-c", "%%%N", "baguette"]) .succeeds() - .stdout_is("%'baguette'\n") + .stdout_is("%baguette\n") .stderr_is( "stat: ignoring invalid value of environment variable QUOTING_STYLE: 'soufflé'\n", ); @@ -548,6 +729,17 @@ fn test_printf_octal_2() { .stdout_is_bytes(expected_stdout); } +#[test] +fn test_printf_octal_out_of_range() { + // Octal escapes whose value exceeds 255 wrap around, as they do in GNU stat. + let ts = TestScenario::new(util_name!()); + let expected_stdout = vec![0x00, 0xFF]; // \400 -> 256 & 0xFF, \777 -> 511 & 0xFF + ts.ucmd() + .args(&["--printf=\\400\\777", "."]) + .succeeds() + .stdout_is_bytes(expected_stdout); +} + #[test] fn test_printf_incomplete_hex() { let ts = TestScenario::new(util_name!()); @@ -585,19 +777,31 @@ fn test_printf_invalid_directive() { #[test] fn test_invalid_directive_after_multibyte_char() { let ts = TestScenario::new(util_name!()); - for (fmt, directive) in [("€%-", "%-"), ("ä%0", "%0"), ("€%.", "%.")] { + // The text before the directive is printed, as GNU does. What is checked + // here is the directive named: a multibyte char must not shift its offset. + for (fmt, before, directive) in [("€%-", "€", "%-"), ("ä%0", "ä", "%0"), ("€%.", "€", "%.")] + { ts.ucmd() .args(&["-c", fmt, "."]) .fails_with_code(1) - .stderr_only(format!("stat: '{directive}': invalid directive\n")); + .stdout_is(before) + .stderr_is(format!("stat: '{directive}': invalid directive\n")); } } #[test] -#[cfg(all( - feature = "feat_selinux", - any(target_os = "linux", target_os = "android") -))] +fn test_precision_splits_multibyte_char_in_value() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.touch("é"); + ts.ucmd() + .args(&["-c", "%.1n", "é"]) + .succeeds() + .stdout_only_bytes([0xc3, b'\n']); +} + +#[test] +#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_stat_selinux() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -736,3 +940,74 @@ fn test_no_such_directory_message() { .fails_with_code(1) .stderr_is("stat: cannot statx 'a': No such file or directory\n"); } + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_failing_directive() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-c", "%d%.3", "/dev/null"]) + .fails_with_code(1); + + // The first directive is fine; the caret takes the second one alone. + assert_eq!( + result.stderr_as_displayed(), + "\ +stat: '%.3': invalid directive + ╭─[ stat:1:11 ] + │ + 1 │ stat -c %d%.3 /dev/null + │ ─── + │ + │ Help: a directive is %[FLAGS][WIDTH][.PRECISION]LETTER, as in %-10.2s; a literal % is written %% +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_inside_a_printf_format() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--printf=%12", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("stat:1:15"), "{stderr}"); + assert!(stderr.contains("'%12': invalid directive"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["-c", "%d%.3", "/dev/null"]) + .fails_with_code(1) + .stderr_is("stat: '%.3': invalid directive\n"); + } +} + +#[test] +#[cfg(unix)] +fn test_error_message_preserves_non_utf8_filename() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + for (bytes, quoted) in [ + (b"missing-\xff".as_slice(), "'missing-'$'\\377'"), + (b"missing-\xc3\xa9".as_slice(), "'missing-'$'\\303\\251'"), + ] { + let name = OsStr::from_bytes(bytes); + for args in [vec![], vec!["-L"], vec!["-f"]] { + new_ucmd!() + .env("LC_ALL", "C") + .args(&args) + .arg(name) + .fails_with_code(1) + .stderr_contains(quoted); + } + } +} diff --git a/tests/by-util/test_stdbuf.rs b/tests/by-util/test_stdbuf.rs index 56cfae9ab10..04f4ad899a4 100644 --- a/tests/by-util/test_stdbuf.rs +++ b/tests/by-util/test_stdbuf.rs @@ -2,7 +2,8 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore cmdline dyld dylib PDEATHSIG setvbuf + +// spell-checker:ignore cmdline dyld dylib PDEATHSIG setvbuf ppid #[cfg(target_os = "linux")] use uutests::at_and_ucmd; @@ -15,6 +16,25 @@ fn invalid_input() { new_ucmd!().arg("-/").fails_with_code(125); } +// linux-gated to match the `at_and_ucmd` import above; the check itself is not +// platform-specific. +#[cfg(all(target_os = "linux", not(feature = "feat_external_libstdbuf")))] +#[test] +fn test_tmpdir_with_colon_is_rejected() { + // The preload variable is a colon-separated list with no escaping, so a + // libstdbuf path containing ':' would be split by the loader and its leading + // component loaded as a library of its own. With $TMPDIR under an attacker's + // control that component is attacker-chosen, so refuse instead. + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("evil.so:x"); + + ucmd.env("TMPDIR", at.plus("evil.so:x")) + .arg("-o0") + .arg("true") + .fails_with_code(125) + .stderr_contains("contains ':'"); +} + #[cfg(all(unix, not(feature = "feat_external_libstdbuf")))] #[test] fn test_permission() { @@ -29,8 +49,8 @@ fn test_permission() { #[cfg(all( feature = "feat_external_libstdbuf", unix, + not(target_vendor = "apple"), not(target_os = "openbsd"), - not(target_os = "macos"), not(target_os = "android"), not(target_env = "musl") ))] @@ -255,8 +275,8 @@ fn test_stdbuf_invalid_mode_fails() { // musl libc dynamic loader does not support LD_DEBUG, so disable on musl targets as well. #[cfg(all( unix, + not(target_vendor = "apple"), not(target_os = "openbsd"), - not(target_os = "macos"), not(target_os = "android"), not(target_env = "musl") ))] @@ -291,7 +311,7 @@ fn test_libstdbuf_preload() { // With GLIBC, the log should contain something like: // "symbol=setvbuf; lookup in file=/tmp/.tmp0mfmCg/libstdbuf.so [0]" // With FreeBSD dynamic loader, the log should contain something like: - // cspell:disable-next-line + // spell-checker:disable-next-line // "calling init function for /tmp/.tmpu11rhP/libstdbuf.so at ..." let libstdbuf_in_path = if cfg!(target_os = "freebsd") { uutils_debug @@ -304,7 +324,7 @@ fn test_libstdbuf_preload() { }; // Check for lack of architecture mismatch error. The potential error message with GLIBC is: - // cspell:disable-next-line + // spell-checker:disable-next-line // "ERROR: ld.so: object '/tmp/.tmpCLq8jl/libstdbuf.so' from LD_PRELOAD cannot be preloaded (cannot open shared object file): ignored." let arch_mismatch_line = uutils_debug .lines() @@ -346,15 +366,14 @@ fn test_stdbuf_non_utf8_paths() { .stdout_is("test content for stdbuf\n"); } +// stdbuf uses spawn()+wait() (not exec()) so that the TempDir holding +// libstdbuf.so is cleaned up after the child exits. The stdbuf process +// itself therefore stays in the process table as a thin waiter; the child +// immediately execs the requested command. +// See: https://github.com/uutils/coreutils/issues/13939 #[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) - +fn test_stdbuf_child_execs_command() { use std::process::{Command, Stdio}; use std::thread; use std::time::Duration; @@ -362,55 +381,237 @@ fn test_stdbuf_no_fork_regression() { 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"]) + let mut parent = Command::new(&scene.bin_path) + .args(["stdbuf", "-o0", "sleep", "5"]) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("Failed to start stdbuf"); - let child_pid = child.id(); + let parent_pid = parent.id(); - // Poll until exec happens or timeout - let cmdline_path = format!("/proc/{child_pid}/cmdline"); - let timeout = Duration::from_secs(2); + // Poll until the child process appears or timeout + let timeout = Duration::from_secs(3); let poll_interval = Duration::from_millis(10); let start_time = std::time::Instant::now(); - let command_name = loop { + let child_comm = loop { if start_time.elapsed() > timeout { - child.kill().ok(); - panic!("TIMEOUT: Process {child_pid} did not respond within {timeout:?}"); + parent.kill().ok(); + panic!("TIMEOUT: child of {parent_pid} did not appear 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(); - } + // Find children of our stdbuf process + let found = std::fs::read_dir("/proc").ok().and_then(|entries| { + entries.flatten().find_map(|entry| { + let pid: u32 = entry.file_name().to_string_lossy().parse().ok()?; + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let mut parts = stat.splitn(5, ' '); + let _pid = parts.next(); + let comm = parts + .next() + .unwrap_or("") + .trim_matches(|c| c == '(' || c == ')') + .to_string(); + let _state = parts.next(); + let ppid: u32 = parts.next().unwrap_or("0").parse().unwrap_or(0); + if ppid == parent_pid && comm.contains("sleep") { + Some(comm) + } else { + None + } + }) + }); + if found.is_some() { + break found; } - thread::sleep(poll_interval); + + if start_time.elapsed() > timeout { + break None; + } }; - // 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 + parent.kill().ok(); + parent.wait().ok(); + 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()" + child_comm.is_some(), + "stdbuf should have spawned a child running 'sleep' (pid={parent_pid})" ); + assert!( + child_comm.as_deref().unwrap_or("").contains("sleep"), + "Expected child to be 'sleep', got: {child_comm:?}" + ); +} + +/// Verify that stdbuf does not leak temporary directories. +/// Each invocation should clean up its own tmpdir. +/// Regression test for https://github.com/uutils/coreutils/issues/13939 +#[test] +#[cfg(all(target_os = "linux", not(feature = "feat_external_libstdbuf")))] +fn test_stdbuf_no_tmpdir_leak() { + use std::process::Command; + + // Use a dedicated TMPDIR so we only count stdbuf-created dirs, + // not dirs created by the test harness itself. + let dedicated_tmpdir = tempfile::tempdir().unwrap(); + let scene = TestScenario::new(util_name!()); + + for _ in 0..5 { + Command::new(&scene.bin_path) + .args(["stdbuf", "-oL", "true"]) + .env("TMPDIR", dedicated_tmpdir.path()) + .status() + .expect("failed to run stdbuf"); + } + + let leaked: Vec<_> = std::fs::read_dir(dedicated_tmpdir.path()) + .unwrap() + .flatten() + .filter(|e| e.metadata().is_ok_and(|m| m.is_dir())) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); - // Ensure we're running the expected target command assert!( - command_name.contains("sleep"), - "Expected 'sleep' command at PID {child_pid}, got: {command_name}" + leaked.is_empty(), + "stdbuf leaked {n} temporary director{pl}: {leaked:?}", + n = leaked.len(), + pl = if leaked.len() == 1 { "y" } else { "ies" }, ); +} + +/// Verify that the temporary directory created for libstdbuf.so is private (0700) +/// and the embedded library file is owner read/write only (0600). +/// Regression test for https://github.com/uutils/coreutils/issues/13939 +#[test] +#[cfg(all(target_os = "linux", not(feature = "feat_external_libstdbuf")))] +fn test_stdbuf_tmpdir_is_private() { + use std::os::unix::fs::PermissionsExt; + + // Use a dedicated TMPDIR so we only observe stdbuf's directory. + let dedicated_tmpdir = tempfile::tempdir().unwrap(); + let scene = TestScenario::new(util_name!()); + + // Use a long-running command so the tmpdir exists while we inspect it. + let mut child = std::process::Command::new(&scene.bin_path) + .args(["stdbuf", "-o0", "sleep", "5"]) + .env("TMPDIR", dedicated_tmpdir.path()) + .spawn() + .expect("failed to spawn stdbuf"); + + // Give it time to create the tmpdir + std::thread::sleep(std::time::Duration::from_millis(300)); + + let stdbuf_dirs: Vec<_> = std::fs::read_dir(dedicated_tmpdir.path()) + .unwrap() + .flatten() + .filter(|e| e.metadata().is_ok_and(|m| m.is_dir())) + .map(|e| e.path()) + .collect(); - // Cleanup child.kill().ok(); child.wait().ok(); + + assert!( + !stdbuf_dirs.is_empty(), + "expected stdbuf to create a temporary directory in {dedicated_tmpdir:?}" + ); + + for dir in &stdbuf_dirs { + let mode = std::fs::metadata(dir) + .expect("metadata") + .permissions() + .mode(); + assert_eq!( + mode & 0o777, + 0o700, + "tmpdir {dir:?} has unsafe permissions {mode:#o}, expected 0o700" + ); + + for entry in std::fs::read_dir(dir).expect("read_dir").flatten() { + if !entry.file_type().is_ok_and(|t| t.is_file()) { + continue; + } + let file_mode = entry.metadata().expect("metadata").permissions().mode() & 0o777; + assert_eq!( + file_mode, + 0o600, + "libstdbuf at {} has unsafe permissions {file_mode:#o}, expected 0o600", + entry.path().display(), + ); + } + } +} + +/// stdbuf waits on the command instead of exec'ing it, so it has to translate +/// the child's fate back into its own exit status: a command killed by a +/// signal must still be reported as `128 + signal`, not as a clean 0. +#[test] +#[cfg(unix)] +fn test_stdbuf_reports_signalled_command() { + // SIGQUIT (3) rather than the usual SIGTERM, so the expected 131 cannot be + // confused with a status the shell would produce on its own. + new_ucmd!() + .args(&["-o0", "sh", "-c", "kill -QUIT $$"]) + .fails_with_code(131); +} + +#[cfg(unix)] +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[test] + fn test_snippet_points_at_the_unknown_unit() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-o", "6pq", "head"]) + .fails_with_code(125); + + // The number parsed; only the unit did not. + let stderr = result.stderr_as_displayed(); + assert!( + stderr.starts_with( + "\ +stdbuf: invalid mode '6pq' + ╭─[ stdbuf:1:12 ] + │ + 1 │ stdbuf -o 6pq head + │ ─┬ + │ ╰── not a known unit + │ + │ Help: a size is a number and an optional unit: K, M, G and so on for 1024, KB, MB, GB for 1000 +───╯" + ), + "{stderr}" + ); + // The caret replaces the message, not the usage hint: a pipe and a + // terminal must not disagree on whether one was printed. + assert!( + stderr.ends_with("stdbuf --help' for more information."), + "{stderr}" + ); + } + + #[test] + fn test_snippet_points_inside_a_long_option_value() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--error=pq", "head"]) + .fails_with_code(125); + let stderr = result.stderr_as_displayed(); + + // Nothing usable was read, so the whole value is underlined. + assert!(stderr.contains("stdbuf:1:16"), "{stderr}"); + assert!(!stderr.contains("not a known unit"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["-o", "6pq", "head"]) + .fails_with_code(125) + .usage_error("invalid mode '6pq'"); + } } diff --git a/tests/by-util/test_stty.rs b/tests/by-util/test_stty.rs index f8a2095a89d..157758db903 100644 --- a/tests/by-util/test_stty.rs +++ b/tests/by-util/test_stty.rs @@ -2,8 +2,11 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + // spell-checker:ignore parenb parmrk ixany iuclc onlcr ofdel icanon noflsh econl igpar ispeed ospeed NCCS nonhex gstty notachar cbreak evenp oddp CSIZE +#![cfg(unix)] + use uutests::util::{expected_result, pty_path}; use uutests::{at_and_ts, new_ucmd, unwrap_or_return}; @@ -25,7 +28,6 @@ fn test_invalid_arg() { } #[test] -#[cfg(unix)] fn test_basic() { let (path, _controller, _replica) = pty_path(); new_ucmd!() @@ -35,7 +37,6 @@ fn test_basic() { } #[test] -#[cfg(unix)] fn test_all_flag() { let (path, _controller, _replica) = pty_path(); let result = new_ucmd!().args(&["--all", "--file", &path]).succeeds(); @@ -46,7 +47,6 @@ fn test_all_flag() { } #[test] -#[cfg(unix)] fn test_sane() { let (path, _controller, _replica) = pty_path(); @@ -154,11 +154,11 @@ fn invalid_setting() { #[test] fn invalid_baud_setting() { #[cfg(not(any( + target_vendor = "apple", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", - target_os = "openbsd", - target_vendor = "apple" + target_os = "openbsd" )))] new_ucmd!() .args(&["100"]) @@ -181,11 +181,11 @@ fn invalid_baud_setting() { .stderr_contains("missing argument to 'ospeed'"); #[cfg(not(any( + target_vendor = "apple", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", - target_os = "openbsd", - target_vendor = "apple" + target_os = "openbsd" )))] new_ucmd!() .args(&["ispeed", "995"]) @@ -193,11 +193,11 @@ fn invalid_baud_setting() { .stderr_contains("invalid ispeed '995'"); #[cfg(not(any( + target_vendor = "apple", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", - target_os = "openbsd", - target_vendor = "apple" + target_os = "openbsd" )))] new_ucmd!() .args(&["ospeed", "995"]) @@ -213,7 +213,6 @@ fn invalid_baud_setting() { } #[test] -#[cfg(unix)] fn valid_baud_formats() { let (path, _controller, _replica) = pty_path(); for speed in &[" +9600", "9600.49", "9600.50", "9599.51", " 9600."] { @@ -292,7 +291,6 @@ fn row_column_sizes() { } #[test] -#[cfg(unix)] fn test_row_column_hex_octal() { let (path, _controller, _replica) = pty_path(); let (_at, ts) = at_and_ts!(); @@ -474,11 +472,11 @@ fn grouped_flag_removal() { fn baud_rate_validation() { // Test various baud rate formats #[cfg(any( + target_vendor = "apple", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", - target_os = "openbsd", - target_vendor = "apple" + target_os = "openbsd" ))] { // BSD accepts numeric baud rates @@ -544,7 +542,6 @@ fn conflicting_print_modes() { // Additional integration tests to increase coverage #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_save_format() { // Test --save flag outputs settings in save format @@ -563,7 +560,6 @@ fn test_save_format() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_set_control_flags() { // Test setting parenb flag and verify it's set @@ -613,7 +609,6 @@ fn test_set_control_flags() { // Tests for saved state parsing and restoration #[test] -#[cfg(unix)] fn test_save_and_restore() { let (path, _controller, _replica) = pty_path(); let saved = new_ucmd!() @@ -628,7 +623,6 @@ fn test_save_and_restore() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_set_input_flags() { // Test setting ignbrk flag and verify @@ -666,7 +660,6 @@ fn test_set_input_flags() { } #[test] -#[cfg(unix)] fn test_save_with_g_flag() { let (path, _controller, _replica) = pty_path(); let saved = new_ucmd!() @@ -681,7 +674,6 @@ fn test_save_with_g_flag() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_set_output_flags() { // Test setting opost flag and verify @@ -719,7 +711,6 @@ fn test_set_output_flags() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_set_local_flags() { // Test setting isig flag and verify @@ -757,7 +748,6 @@ fn test_set_local_flags() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_combo_cbreak() { // Test cbreak combination setting - should disable icanon @@ -784,7 +774,6 @@ fn test_combo_cbreak() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_combo_nl() { // Test nl combination setting - should disable icrnl and onlcr @@ -801,7 +790,6 @@ fn test_combo_nl() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_combo_ek() { // Test ek combination setting (erase and kill) - should set erase and kill to defaults @@ -816,7 +804,6 @@ fn test_combo_ek() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_combo_litout() { // Test litout combination setting - should disable parenb, istrip, opost @@ -834,7 +821,6 @@ fn test_combo_litout() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_combo_pass8() { // Test pass8 combination setting - should disable parenb, istrip, set cs8 @@ -852,7 +838,6 @@ fn test_combo_pass8() { } #[test] -#[cfg(unix)] fn test_save_restore_after_change() { let (path, _controller, _replica) = pty_path(); let saved = new_ucmd!() @@ -877,7 +862,6 @@ fn test_save_restore_after_change() { // These tests both validate what we expect each input to return and their error codes // and also use the GNU coreutils results to validate our results match expectations #[test] -#[cfg(unix)] fn test_saved_state_valid_formats() { let (path, _controller, _replica) = pty_path(); let (_at, ts) = at_and_ts!(); @@ -899,7 +883,6 @@ fn test_saved_state_valid_formats() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_combo_decctlq() { // Test decctlq combination setting - should enable ixany @@ -915,7 +898,6 @@ fn test_combo_decctlq() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_combo_dec() { // Test dec combination setting - should set multiple flags @@ -932,7 +914,6 @@ fn test_combo_dec() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_combo_crt() { // Test crt combination setting - should set echoe @@ -965,7 +946,6 @@ fn test_combo_tabs() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_multiple_settings() { // Test setting multiple flags at once and verify all are set @@ -983,7 +963,6 @@ fn test_multiple_settings() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_set_all_control_chars() { // Test setting intr control character and verify @@ -1028,7 +1007,6 @@ fn test_set_all_control_chars() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_print_size() { // Test size print setting - should output "rows ; columns ;" @@ -1041,7 +1019,6 @@ fn test_print_size() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_print_speed() { // Test speed print setting - should output a numeric speed @@ -1058,7 +1035,6 @@ fn test_print_speed() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_set_rows_cols() { // Test setting rows and verify @@ -1097,7 +1073,6 @@ fn test_set_rows_cols() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_character_size_settings() { // Test cs5 setting and verify @@ -1135,7 +1110,6 @@ fn test_character_size_settings() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_baud_rate_settings() { // Test setting ispeed and verify @@ -1162,7 +1136,6 @@ fn test_baud_rate_settings() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_min_time_settings() { // Test min setting and verify @@ -1189,7 +1162,6 @@ fn test_min_time_settings() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_complex_scenario() { // Test a complex scenario with multiple settings and verify all are applied @@ -1211,7 +1183,6 @@ fn test_complex_scenario() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_raw_mode() { // Test raw mode setting @@ -1228,7 +1199,6 @@ fn test_raw_mode() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_cooked_mode() { // Test cooked mode setting (opposite of raw) @@ -1245,7 +1215,6 @@ fn test_cooked_mode() { } #[test] -#[cfg(unix)] fn test_saved_state_invalid_formats() { let (path, _controller, _replica) = pty_path(); let (_at, ts) = at_and_ts!(); @@ -1311,7 +1280,6 @@ fn test_saved_state_invalid_formats() { } #[test] -#[cfg(unix)] #[ignore = "Fails because cargo test does not run in a tty"] fn test_parity_settings() { // Test evenp setting and verify (should set parenb and cs7) @@ -1506,11 +1474,11 @@ fn invalid_baud_rate() { // On non-BSD systems, test invalid numeric baud rate // On BSD systems, any u32 is accepted, so we skip this test #[cfg(not(any( + target_vendor = "apple", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", - target_os = "openbsd", - target_vendor = "apple" + target_os = "openbsd" )))] { new_ucmd!() @@ -1545,7 +1513,6 @@ fn control_char_decimal_overflow() { } #[test] -#[cfg(unix)] #[ignore = "Fails because the implementation of print state is not correctly printing flags on certain platforms"] fn test_saved_state_with_control_chars() { let (path, _controller, _replica) = pty_path(); @@ -1571,7 +1538,6 @@ fn test_saved_state_with_control_chars() { // 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" @@ -1593,7 +1559,6 @@ fn test_stdin_not_tty_fails() { // 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; @@ -1640,7 +1605,6 @@ fn test_stty_uses_stdin() { } #[test] -#[cfg(unix)] fn test_ispeed_ospeed_valid_speeds() { let (path, _controller, _replica) = pty_path(); let (_at, ts) = at_and_ts!(); @@ -1668,16 +1632,13 @@ fn test_ispeed_ospeed_valid_speeds() { } #[test] -#[cfg(all( - unix, - not(any( - target_os = "freebsd", - target_os = "dragonfly", - target_os = "netbsd", - target_os = "openbsd", - target_vendor = "apple" - )) -))] +#[cfg(not(any( + target_vendor = "apple", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd" +)))] #[ignore = "Issue: #9547"] fn test_ispeed_ospeed_invalid_speeds() { let (path, _controller, _replica) = pty_path(); @@ -1704,7 +1665,6 @@ fn test_ispeed_ospeed_invalid_speeds() { } #[test] -#[cfg(unix)] fn test_columns_env_wrapping() { use std::process::Stdio; let (path, _controller, _replica) = pty_path(); diff --git a/tests/by-util/test_sum.rs b/tests/by-util/test_sum.rs index 8baaebe0d1a..c75af526c68 100644 --- a/tests/by-util/test_sum.rs +++ b/tests/by-util/test_sum.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStringExt; use uutests::at_and_ucmd; @@ -74,6 +75,20 @@ fn test_invalid_file() { ucmd.arg("a").fails().stderr_is("sum: a: Is a directory\n"); } +#[test] +fn test_invalid_file_does_not_stop_other_files() { + // https://github.com/uutils/coreutils/issues/13131 + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("d"); + at.write("f", "hello\n"); + + ucmd.args(&["d", "f"]) + .fails_with_code(1) + .stdout_is("36979 1 f\n") + .stderr_is("sum: d: Is a directory\n"); +} + #[test] fn test_invalid_metadata() { let (_, mut ucmd) = at_and_ucmd!(); @@ -106,7 +121,7 @@ fn test_filename_ends_with_slash() { .stderr_is("sum: a/: Not a directory\n"); } -#[cfg(all(unix, not(target_os = "macos"), not(target_os = "openbsd")))] +#[cfg(all(unix, not(target_vendor = "apple"), not(target_os = "openbsd")))] #[cfg_attr(wasi_runner, ignore)] #[test] fn test_filename_proc_self_mem() { diff --git a/tests/by-util/test_sync.rs b/tests/by-util/test_sync.rs index fdb93b72fd4..8516aac9d77 100644 --- a/tests/by-util/test_sync.rs +++ b/tests/by-util/test_sync.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use std::fs; use tempfile::tempdir; use uutests::new_ucmd; @@ -80,7 +81,7 @@ fn test_sync_no_permission_dir() { result.stderr_contains("sync: error opening 'foo': Permission denied"); } -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] #[cfg(feature = "chmod")] #[test] fn test_sync_no_permission_file() { @@ -142,7 +143,7 @@ fn test_sync_fdatasync_error_handling() { .stderr_contains("error opening"); } -#[cfg(target_os = "macos")] +#[cfg(target_vendor = "apple")] #[test] fn test_sync_syncfs_error_handling_macos() { // Test that syncfs properly handles invalid paths on macOS @@ -177,8 +178,7 @@ fn test_sync_multiple_files() { fn test_sync_multiple_nonexistent_files() { let result = new_ucmd!().arg("--data").arg("bad1").arg("bad2").fails(); - result.stderr_contains("sync: error opening 'bad1': No such file or directory"); - result.stderr_contains("sync: error opening 'bad2': No such file or directory"); + result.stderr_is("sync: error opening 'bad1': No such file or directory\nsync: error opening 'bad2': No such file or directory\n"); } #[cfg(any(target_os = "linux", target_os = "android"))] diff --git a/tests/by-util/test_tac.rs b/tests/by-util/test_tac.rs index e839311ac24..a33ba10bd08 100644 --- a/tests/by-util/test_tac.rs +++ b/tests/by-util/test_tac.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 axxbxx bxxaxx axxx axxxx xxaxx xxax xxxxa axyz zyax zyxa bbaaa aaabc bcdddd cddddaaabc xyzabc abcxyzabc nbbaaa EISDIR -#[cfg(target_os = "linux")] + +// spell-checker:ignore axxbxx bxxaxx axxx axxxx xxaxx xxax xxxxa axyz zyax zyxa bbaaa aaabc bcdddd cddddaaabc xyzabc abcxyzabc nbbaaa EISDIR SIGBUS mmap + +#[cfg(unix)] use uutests::at_and_ucmd; use uutests::new_ucmd; use uutests::util::TestScenario; @@ -145,7 +147,7 @@ fn test_before_no_separator() { #[test] fn test_before_empty_file() { - new_ucmd!().arg("-b").pipe_in("").succeeds().stdout_is(""); + new_ucmd!().arg("-b").pipe_in("").succeeds().no_output(); } #[test] @@ -395,7 +397,7 @@ fn test_failed_write_is_reported() { .stderr_is("tac: failed to write to stdout: No space left on device\n"); } -#[cfg(target_os = "linux")] +#[cfg(unix)] #[test] fn test_stdin_bad_tmpdir_fallback() { // When TMPDIR is invalid, tac falls back to reading stdin directly into memory @@ -475,3 +477,80 @@ fn test_regular_end_anchor() { .succeeds() .stdout_is("\nccc\nbbaaa\nb"); } + +/// Regression test for . +/// +/// `tac` used to mmap regular files, so truncating a file mid-read raised +/// SIGBUS and killed the process. It now reads files into memory up front, so a +/// concurrent truncation can no longer crash it. The assertion only checks that +/// no signal killed `tac`, so it is stable regardless of how the race lands. +#[test] +#[cfg(unix)] +fn test_tac_file_truncated_during_read_does_not_crash() { + use std::fs::OpenOptions; + use std::time::Duration; + + let (at, mut ucmd) = at_and_ucmd!(); + + // A large sparse file, so the read overlaps with the truncation below. + // `set_len` keeps it sparse, so creation stays cheap. + let name = "input"; + at.make_file(name).set_len(64 * 1024 * 1024).unwrap(); + + let child = ucmd.arg(name).run_no_wait(); + + // Give tac a moment to start reading, then truncate the file out from + // under it. + std::thread::sleep(Duration::from_millis(2)); + OpenOptions::new() + .write(true) + .open(at.plus(name)) + .unwrap() + .set_len(0) + .unwrap(); + + let result = child.wait().unwrap(); + assert!( + result.signal().is_none(), + "tac was killed by signal {:?} (SIGBUS regression, see #9748)", + result.signal() + ); +} + +/// Companion to the test above for the `tac < file` path. `tac` used to mmap the +/// raw stdin fd, exposing a redirected file to the same SIGBUS-on-truncation +/// race; it now copies stdin to an unlinked temp file before mapping. +#[test] +#[cfg(unix)] +fn test_tac_stdin_redirected_file_truncated_during_read_does_not_crash() { + use std::fs::{File, OpenOptions}; + use std::time::Duration; + + let (at, mut ucmd) = at_and_ucmd!(); + + // A large sparse file, so the read overlaps with the truncation below. + let name = "input"; + at.make_file(name).set_len(64 * 1024 * 1024).unwrap(); + + // Redirect the regular file in as stdin (`tac < input`). + let child = ucmd + .set_stdin(File::open(at.plus(name)).unwrap()) + .run_no_wait(); + + // Give tac a moment to start reading, then truncate the file out from + // under it. + std::thread::sleep(Duration::from_millis(2)); + OpenOptions::new() + .write(true) + .open(at.plus(name)) + .unwrap() + .set_len(0) + .unwrap(); + + let result = child.wait().unwrap(); + assert!( + result.signal().is_none(), + "tac was killed by signal {:?} (SIGBUS regression, see #9748)", + result.signal() + ); +} diff --git a/tests/by-util/test_tail.rs b/tests/by-util/test_tail.rs index 5330d0df55a..9758f5cf7ca 100644 --- a/tests/by-util/test_tail.rs +++ b/tests/by-util/test_tail.rs @@ -3,10 +3,11 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) abcdefghijklmnopqrstuvwxyz efghijklmnopqrstuvwxyz vwxyz emptyfile file siette ocho nueve diez MULT -// spell-checker:ignore (libs) kqueue +// spell-checker:ignore (ToDO) abcdefghijklmnopqrstuvwxyz efghijklmnopqrstuvwxyz vwxyz emptyfile file siette ocho nueve diez MULT watchme nofile wxyz +// spell-checker:ignore (libs) kqueue ELOOP EISDIR // spell-checker:ignore (jargon) tailable untailable datasame runneradmin tmpi // spell-checker:ignore (cmd) taskkill + #![allow( clippy::unicode_not_nfc, clippy::cast_lossless, @@ -16,24 +17,18 @@ use pretty_assertions::assert_eq; use rand::distr::Alphanumeric; use rstest::rstest; -#[cfg(all( - not(target_vendor = "apple"), - not(target_os = "windows"), - not(target_os = "android"), - not(target_os = "freebsd") -))] +#[cfg(all(not(target_os = "android"), not(target_os = "freebsd"), not(windows)))] use rustix::process::{Pid, Signal, kill_process}; use std::char::from_digit; use std::fs::File; use std::io::Write; -#[cfg(not(target_vendor = "apple"))] use std::io::{Seek, SeekFrom}; #[cfg(all( not(target_vendor = "apple"), not(target_os = "android"), - not(target_os = "windows"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] use std::path::Path; use std::process::Stdio; @@ -41,9 +36,9 @@ use tail::chunks::BUFFER_SIZE as CHUNK_BUFFER_SIZE; #[cfg(all( not(target_vendor = "apple"), not(target_os = "android"), - not(target_os = "windows"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] use tail::text; use uutests::at_and_ucmd; @@ -106,7 +101,6 @@ fn test_stdin_explicit() { } #[test] -#[cfg(not(target_vendor = "apple"))] // FIXME: for currently not working platforms fn test_stdin_redirect_file() { // $ echo foo > f @@ -129,7 +123,7 @@ fn test_stdin_redirect_file() { .set_stdin(File::open(at.plus("f")).unwrap()) .arg("-v") .succeeds() - .stdout_only("==> standard input <==\nfoo"); + .stdout_only("==> 'standard input' <==\nfoo"); } #[test] @@ -160,9 +154,8 @@ fn test_stdin_redirect_file_follow() { } #[test] -#[cfg(not(target_vendor = "apple"))] // FIXME: for currently not working platforms fn test_stdin_redirect_offset() { - // inspired by: "gnu/tests/tail-2/start-middle.sh" + // Test following a file from the middle let (at, mut ucmd) = at_and_ucmd!(); @@ -174,7 +167,6 @@ fn test_stdin_redirect_offset() { } #[test] -#[cfg(not(target_vendor = "apple"))] // FIXME: for currently not working platforms fn test_stdin_redirect_offset2() { // like test_stdin_redirect_offset but with multiple files @@ -190,7 +182,7 @@ fn test_stdin_redirect_offset2() { .args(&["k", "-", "l", "m"]) .succeeds() .stdout_only( - "==> k <==\n1\n2\n\n==> standard input <==\n2\n\n==> l <==\n3\n4\n\n==> m <==\n5\n6\n", + "==> k <==\n1\n2\n\n==> 'standard input' <==\n2\n\n==> l <==\n3\n4\n\n==> m <==\n5\n6\n", ); } @@ -203,6 +195,29 @@ fn test_nc_0_wo_follow() { ts.ucmd().args(&["-c0", "missing"]).succeeds().no_output(); } +#[test] +fn test_zero_bytes_with_suffix() { + // "0K" must be 0 bytes, not 1KiB (bare suffix parses as 1) + let ts = TestScenario::new(util_name!()); + ts.ucmd() + .args(&["-c0K"]) + .pipe_in("qwerty") + .ignore_stdin_write_error() + .succeeds() + .no_output(); + ts.ucmd() + .args(&["-c00K"]) + .pipe_in("qwerty") + .ignore_stdin_write_error() + .succeeds() + .no_output(); + ts.ucmd() + .args(&["-c+0K"]) + .pipe_in("qwerty") + .succeeds() + .stdout_is("qwerty"); +} + #[test] #[cfg(all(unix, not(target_os = "freebsd")))] fn test_nc_0_wo_follow2() { @@ -227,7 +242,7 @@ fn test_nc_0_wo_follow2() { } #[test] -#[cfg(not(target_os = "windows"))] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_n0_with_follow() { let (at, mut ucmd) = at_and_ucmd!(); let test_file = "test.txt"; @@ -261,11 +276,14 @@ fn test_permission_denied() { .set_permissions(PermissionsExt::from_mode(0o000)) .unwrap(); - ts.ucmd() - .arg("unreadable") - .fails_with_code(1) - .stderr_is("tail: cannot open 'unreadable' for reading: Permission denied\n") - .no_stdout(); + let cmd = ts.ucmd().arg("unreadable").fails_with_code(1); + cmd.no_stdout(); + + if std::env::var("UUTESTS_WASM_RUNNER").is_ok() { + cmd.stderr_contains("tail: cannot open 'unreadable' for reading:"); + } else { + cmd.stderr_is("tail: cannot open 'unreadable' for reading: Permission denied\n"); + } } // TODO: Add similar test for windows @@ -283,10 +301,16 @@ fn test_permission_denied_multiple() { .set_permissions(PermissionsExt::from_mode(0o000)) .unwrap(); - ucmd.args(&["file1", "unreadable", "file2"]) - .fails_with_code(1) - .stderr_is("tail: cannot open 'unreadable' for reading: Permission denied\n") - .stdout_is("==> file1 <==\n\n==> file2 <==\n"); + let cmd = ucmd + .args(&["file1", "unreadable", "file2"]) + .fails_with_code(1); + cmd.stdout_is("==> file1 <==\n\n==> file2 <==\n"); + + if std::env::var("UUTESTS_WASM_RUNNER").is_ok() { + cmd.stderr_contains("tail: cannot open 'unreadable' for reading:"); + } else { + cmd.stderr_is("tail: cannot open 'unreadable' for reading: Permission denied\n"); + } } #[test] @@ -314,10 +338,10 @@ fn test_follow_redirect_stdin_name_retry() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms fn test_stdin_redirect_dir() { // $ mkdir dir @@ -328,17 +352,23 @@ fn test_stdin_redirect_dir() { let at = &ts.fixtures; at.mkdir("dir"); + let expected = if std::env::var("UUTESTS_WASM_RUNNER").is_ok() { + "tail: Is a directory\n" + } else { + "tail: error reading 'standard input': Is a directory\n" + }; + ts.ucmd() .set_stdin(File::open(at.plus("dir")).unwrap()) .fails_with_code(1) .no_stdout() - .stderr_is("tail: error reading 'standard input': Is a directory\n"); + .stderr_is(expected); ts.ucmd() .set_stdin(File::open(at.plus("dir")).unwrap()) .arg("-") .fails_with_code(1) .no_stdout() - .stderr_is("tail: error reading 'standard input': Is a directory\n"); + .stderr_is(expected); } // On macOS path.is_dir() can be false for directories if it was a redirect, @@ -457,7 +487,7 @@ fn test_follow_stdin_name_retry() { #[test] fn test_follow_bad_fd() { // Provoke a "bad file descriptor" error by closing the fd - // inspired by: "gnu/tests/tail-2/follow-stdin.sh" + // Test following stdin // `$ tail -f <&-` OR `$ tail -f - <&-` // tail: cannot fstat 'standard input': Bad file descriptor @@ -501,7 +531,7 @@ fn test_null_default() { } #[test] -#[cfg(not(target_os = "windows"))] // FIXME: test times out +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_single() { let (at, mut ucmd) = at_and_ucmd!(); @@ -529,9 +559,21 @@ fn test_follow_single() { .stdout_only(expected); } +#[test] +#[cfg(wasi_runner)] +fn test_follow_file_unsupported() { + new_ucmd!() + .arg("-f") + .arg(FOOBAR_TXT) + .fails_with_code(1) + .stdout_is_fixture("foobar_single_default.expected") + .stderr_is("tail: follow mode is not supported on this platform\n"); +} + /// Test for following when bytes are written that are not valid UTF-8. #[test] -#[cfg(not(target_os = "windows"))] // FIXME: test times out +#[cfg(not(windows))] // FIXME: test times out +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_non_utf8_bytes() { // Tail the test file and start following it. let (at, mut ucmd) = at_and_ucmd!(); @@ -589,7 +631,7 @@ fn test_permission_denied_is_not_reported_as_not_found() { } #[test] -#[cfg(not(target_os = "windows"))] // FIXME: test times out +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_multiple() { let (at, mut ucmd) = at_and_ucmd!(); let mut child = ucmd @@ -625,7 +667,7 @@ fn test_follow_multiple() { } #[test] -#[cfg(not(target_os = "windows"))] // FIXME: test times out +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_name_multiple() { // spell-checker:disable-next-line for argument in ["--follow=name", "--follo=nam", "--f=n"] { @@ -638,9 +680,9 @@ fn test_follow_name_multiple() { #[cfg(target_os = "linux")] let delay = 100; - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] let delay = 2000; - #[cfg(not(any(target_os = "linux", target_os = "macos")))] + #[cfg(not(any(target_vendor = "apple", target_os = "linux")))] let delay = 1000; child @@ -671,6 +713,7 @@ fn test_follow_name_multiple() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_multiple_untailable() { // $ tail -f DIR1 DIR2 // ==> DIR1 <== @@ -711,19 +754,31 @@ fn test_follow_stdin_pipe() { } #[test] -#[cfg(not(target_os = "windows"))] // FIXME: for currently not working platforms +#[cfg(not(windows))] // FIXME: for currently not working platforms fn test_follow_invalid_pid() { + let wasm = std::env::var("UUTESTS_WASM_RUNNER").is_ok(); + let negative_pid_error = if wasm { + "tail: invalid PID: '-1234': invalid digit found in string\n" + } else { + "tail: invalid PID: '-1234'\n" + }; + new_ucmd!() .args(&["-f", "--pid=-1234"]) .fails_with_code(1) .no_stdout() - .stderr_is("tail: invalid PID: '-1234'\n"); + .stderr_is(negative_pid_error); new_ucmd!() .args(&["-f", "--pid=abc"]) .fails() .no_stdout() .stderr_is("tail: invalid PID: 'abc': invalid digit found in string\n"); - let max_pid = (i32::MAX as i64 + 1).to_string(); + + let max_pid = if wasm { + (u128::from(u64::MAX) + 1).to_string() + } else { + (i64::from(i32::MAX) + 1).to_string() + }; new_ucmd!() .args(&["-f", "--pid", &max_pid]) .fails() @@ -739,10 +794,11 @@ fn test_follow_invalid_pid() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), - not(target_os = "freebsd") + not(target_os = "freebsd"), + not(windows) ))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_with_pid() { use std::process::Command; @@ -914,6 +970,32 @@ fn test_lines_with_size_suffix() { .stdout_is_fixture(EXPECTED_FILE); } +#[test] +fn test_lines_file_size_multiple_of_block_size() { + // Files of exactly one and two 64 KiB blocks. + for size in [65_536, 131_072] { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("f", &"y\n".repeat(size / 2)); + ucmd.args(&["-n", "1", "f"]).succeeds().stdout_only("y\n"); + } +} + +#[test] +fn test_lines_reach_into_first_block() { + // 8192 lines of 16 bytes each: a file of exactly two 64 KiB blocks. + const LINES: usize = 8192; + let lines: Vec = (0..LINES).map(|i| format!("{i:015}\n")).collect(); + let scene = TestScenario::new(util_name!()); + scene.fixtures.write("f", &lines.concat()); + for n in [5000, LINES, LINES + 1] { + scene + .ucmd() + .args(&["-n", &n.to_string(), "f"]) + .succeeds() + .stdout_only(lines[LINES.saturating_sub(n)..].concat()); + } +} + #[test] fn test_multiple_input_files() { new_ucmd!() @@ -940,6 +1022,7 @@ fn test_multiple_input_files_missing() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_missing() { // Ensure that --follow=name does not imply --retry. // Ensure that --follow={descriptor,name} (without --retry) does *not wait* for the @@ -1008,6 +1091,7 @@ fn test_dir() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_dir_follow() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1027,6 +1111,7 @@ fn test_dir_follow() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_dir_follow_retry() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1179,6 +1264,30 @@ fn test_obsolete_syntax_zero_lines_file() { .no_output(); } +/// Test for obsolete syntax `tail +0`: like `+1`, print the whole input. +#[test] +fn test_obsolete_syntax_positive_zero_lines() { + for arg in ["+0", "+00", "+0l"] { + new_ucmd!() + .args(&[arg]) + .pipe_in("a\nb\nc\nd\ne\n") + .succeeds() + .no_stderr() + .stdout_is("a\nb\nc\nd\ne\n"); + } +} + +/// Test for obsolete syntax `tail +0c`: like `+1c`, print the whole input. +#[test] +fn test_obsolete_syntax_positive_zero_bytes() { + new_ucmd!() + .args(&["+0c"]) + .pipe_in("a\nb\nc\nd\ne\n") + .succeeds() + .no_stderr() + .stdout_is("a\nb\nc\nd\ne\n"); +} + /// Test for reading all lines, specified by `tail -n +0`. #[test] fn test_positive_zero_lines() { @@ -1226,6 +1335,38 @@ fn test_invalid_num() { .starts_with("tail: invalid number of bytes: '³'"); } +#[test] +fn test_lowercase_multiplier_suffixes_rejected() { + // GNU accepts a lowercase suffix only for "k" and "m"; every other + // multiplier must be uppercase. "b" is bare-only (no B/iB/D form). + for suffix in ["g", "t", "p", "e", "z", "y", "r", "q"] { + new_ucmd!() + .args(&["-c", &format!("2{suffix}")]) + .fails() + .stderr_str() + .starts_with(&format!("tail: invalid number of bytes: '2{suffix}'")); + new_ucmd!() + .args(&["-n", &format!("2{suffix}")]) + .fails() + .stderr_str() + .starts_with(&format!("tail: invalid number of lines: '2{suffix}'")); + } +} + +#[test] +fn test_accepted_multiplier_suffixes() { + for suffix in [ + "b", "k", "m", "K", "M", "G", "T", "P", "E", "Z", "Y", "R", "Q", "kB", "KiB", "kD", "MiB", + "GB", + ] { + new_ucmd!() + .args(&["-c", &format!("1{suffix}")]) + .pipe_in("x") + .ignore_stdin_write_error() + .succeeds(); + } +} + #[test] fn test_oversized_num() { const BIG: &str = "99999999999999999999999999999"; @@ -1265,6 +1406,31 @@ fn test_oversized_num() { .stdout_is(DATA); } +// `tail -c +N` on a seekable file larger than the block size seeks to byte +// `N-1`. An offset past the largest seekable position must not abort the +// process; it should behave like a start beyond the end of the file. +#[test] +fn test_positive_bytes_file_offset_past_seek_limit() { + let (at, mut ucmd) = at_and_ucmd!(); + // Larger than tail's `sane_blksize` (~4 KiB) so the seek path is taken. + at.write("big", &"a".repeat(8192)); + ucmd.args(&["-c", "+18446744073709551615", "big"]) + .succeeds() + .no_stdout(); +} + +// A `-c -N` count past `i64::MAX` asks for the whole file. +#[test] +fn test_negative_bytes_file_count_past_seek_limit() { + for count in ["-9223372036854775808", "-18446744073709551615"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("big", &"a".repeat(8192)); + ucmd.args(&["-c", count, "big"]) + .succeeds() + .stdout_only("a".repeat(8192)); + } +} + #[test] fn test_num_with_undocumented_sign_bytes() { // tail: '-' is not documented (8.32 man pages) @@ -1289,8 +1455,9 @@ fn test_num_with_undocumented_sign_bytes() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_bytes_for_funny_unix_files() { - // inspired by: gnu/tests/tail-2/tail-c.sh + // Test tail with byte count let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; for file in ["/proc/version", "/sys/kernel/profiling"] { @@ -1307,8 +1474,8 @@ fn test_bytes_for_funny_unix_files() { } #[test] -fn test_retry1() { - // inspired by: gnu/tests/tail-2/retry.sh +fn test_retry_warn_without_follow() { + // Test tail --retry behavior // Ensure --retry without --follow results in a warning. let ts = TestScenario::new(util_name!()); @@ -1325,12 +1492,12 @@ fn test_retry1() { } #[test] -fn test_retry2() { - // inspired by: gnu/tests/tail-2/retry.sh +fn test_retry_missing_file_error() { + // Test tail --retry behavior // The same as test_retry2 with a missing file: expect error message and exit 1. let ts = TestScenario::new(util_name!()); - let missing = "missing"; + let missing = "absent"; ts.ucmd() .arg(missing) @@ -1338,29 +1505,30 @@ fn test_retry2() { .fails_with_code(1) .stderr_is( "tail: warning: --retry ignored; --retry is useful only when following\n\ - tail: cannot open 'missing' for reading: No such file or directory\n", + tail: cannot open 'absent' for reading: No such file or directory\n", ); } #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms -fn test_retry3() { - // inspired by: gnu/tests/tail-2/retry.sh +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] +fn test_retry_follow_name_waits_for_creation() { + // Test tail --retry behavior // Ensure that `tail --retry --follow=name` waits for the file to appear. let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; - let missing = "missing"; + let missing = "watchme"; - let expected_stderr = "tail: cannot open 'missing' for reading: No such file or directory\n\ - tail: 'missing' has appeared; following new file\n"; - let expected_stdout = "X\n"; + let expected_stderr = "tail: cannot open 'watchme' for reading: No such file or directory\n\ + tail: 'watchme' has appeared; following new file\n"; + let expected_stdout = "hello\n"; let mut delay = 1500; let mut args = vec!["--follow=name", "--retry", missing, "--use-polling"]; @@ -1372,7 +1540,7 @@ fn test_retry3() { at.touch(missing); p.delay(delay); - at.truncate(missing, "X\n"); + at.truncate(missing, "hello\n"); p.delay(delay); p.kill() @@ -1390,25 +1558,26 @@ fn test_retry3() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms -fn test_retry4() { - // inspired by: gnu/tests/tail-2/retry.sh +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] +fn test_retry_descriptor_detects_truncation() { + // Test tail --retry behavior // Ensure that `tail --retry --follow=descriptor` waits for the file to appear. // Ensure truncation is detected. let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; - let missing = "missing"; + let missing = "watchme"; let expected_stderr = "tail: warning: --retry only effective for the initial open\n\ - tail: cannot open 'missing' for reading: No such file or directory\n\ - tail: 'missing' has appeared; following new file\n\ - tail: missing: file truncated\n"; - let expected_stdout = "X1\nX\n"; + tail: cannot open 'watchme' for reading: No such file or directory\n\ + tail: 'watchme' has appeared; following new file\n\ + tail: watchme: file truncated\n"; + let expected_stdout = "greetings\nhi\n"; let mut args = vec![ "-s.1", "--max-unchanged-stats=1", @@ -1426,10 +1595,11 @@ fn test_retry4() { at.touch(missing); p.delay(delay); - at.truncate(missing, "X1\n"); + at.truncate(missing, "greetings\n"); p.delay(delay); - at.truncate(missing, "X\n"); + // shorter than the previous content, so tail sees the shrink as a truncation + at.truncate(missing, "hi\n"); p.delay(delay); p.make_assertion().is_alive(); @@ -1448,22 +1618,23 @@ fn test_retry4() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms -fn test_retry5() { - // inspired by: gnu/tests/tail-2/retry.sh +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] +fn test_retry_descriptor_gives_up_on_untailable() { + // Test tail --retry behavior // Ensure that `tail --follow=descriptor --retry` exits when the file appears untailable. let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; - let missing = "missing"; + let missing = "watchme"; let expected_stderr = "tail: warning: --retry only effective for the initial open\n\ - tail: cannot open 'missing' for reading: No such file or directory\n\ - tail: 'missing' has been replaced with an untailable file; giving up on this name\n\ + tail: cannot open 'watchme' for reading: No such file or directory\n\ + tail: 'watchme' has been replaced with an untailable file; giving up on this name\n\ tail: no files remaining\n"; let mut delay = 1500; @@ -1493,26 +1664,27 @@ fn test_retry5() { // ==> existing <== // >X #[test] -#[cfg(all(not(target_os = "windows"), not(target_os = "android")))] // FIXME: for currently not working platforms -fn test_retry6() { - // inspired by: gnu/tests/tail-2/retry.sh +#[cfg(all(not(windows), not(target_os = "android")))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] +fn test_descriptor_no_retry_skips_late_file() { + // Test tail --retry behavior // Ensure that --follow=descriptor (without --retry) does *not* try // to open a file after an initial fail, even when there are other tailable files. let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; - let missing = "missing"; - let existing = "existing"; + let missing = "nofile"; + let existing = "active"; at.touch(existing); - let expected_stderr = "tail: cannot open 'missing' for reading: No such file or directory\n"; - let expected_stdout = "==> existing <==\nX\n"; + let expected_stderr = "tail: cannot open 'nofile' for reading: No such file or directory\n"; + let expected_stdout = "==> active <==\nhere\n"; let mut p = ts .ucmd() .arg("--follow=descriptor") - .arg("missing") - .arg("existing") + .arg("nofile") + .arg("active") .run_no_wait(); #[cfg(target_vendor = "apple")] @@ -1521,10 +1693,10 @@ fn test_retry6() { let delay = 1000; p.make_assertion_with_delay(delay).is_alive(); - at.truncate(missing, "Y\n"); + at.truncate(missing, "gone\n"); p.delay(delay); - at.truncate(existing, "X\n"); + at.truncate(existing, "here\n"); p.delay(delay); p.make_assertion().is_alive(); @@ -1538,26 +1710,27 @@ fn test_retry6() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms -fn test_retry7() { - // inspired by: gnu/tests/tail-2/retry.sh +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] +fn test_capital_f_recovers_after_dir_swap() { + // Test tail --retry behavior // Ensure that `tail -F` retries when the file is initially untailable. let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; - let untailable = "untailable"; - - let expected_stderr = "tail: error reading 'untailable': Is a directory\n\ - tail: untailable: cannot follow end of this type of file\n\ - tail: 'untailable' has become accessible\n\ - tail: 'untailable' has become inaccessible: No such file or directory\n\ - tail: 'untailable' has been replaced with an untailable file\n\ - tail: 'untailable' has become accessible\n"; - let expected_stdout = "foo\nbar\n"; + let untailable = "dir_node"; + + let expected_stderr = "tail: error reading 'dir_node': Is a directory\n\ + tail: dir_node: cannot follow end of this type of file\n\ + tail: 'dir_node' has become accessible\n\ + tail: 'dir_node' has become inaccessible: No such file or directory\n\ + tail: 'dir_node' has been replaced with an untailable file\n\ + tail: 'dir_node' has become accessible\n"; + let expected_stdout = "alpha\nbeta\n"; let mut args = vec![ "-s.1", @@ -1579,22 +1752,22 @@ fn test_retry7() { // or (The first is the common case, "has appeared" arises with slow rmdir): // tail: 'untailable' has appeared; following new file at.rmdir(untailable); - at.truncate(untailable, "foo\n"); + at.truncate(untailable, "alpha\n"); p.delay(delay); // NOTE: GNU's `tail` only shows "become inaccessible" // if there's a delay between rm and mkdir. - // tail: 'untailable' has become inaccessible: No such file or directory + // tail: 'dir_node' has become inaccessible: No such file or directory at.remove(untailable); p.delay(delay); - // tail: 'untailable' has been replaced with an untailable file\n"; + // tail: 'dir_node' has been replaced with an untailable file\n"; at.mkdir(untailable); p.delay(delay); // full circle, back to the beginning at.rmdir(untailable); - at.truncate(untailable, "bar\n"); + at.truncate(untailable, "beta\n"); p.delay(delay); p.make_assertion().is_alive(); @@ -1616,6 +1789,7 @@ fn test_retry7() { #[test] #[cfg(unix)] #[cfg(not(target_os = "android"))] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_name_replaced_by_symlink_is_untailable() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -1635,9 +1809,28 @@ fn test_follow_name_replaced_by_symlink_is_untailable() { .run_no_wait(); p.make_assertion_with_delay(500).is_alive(); + // Wait until tail's *initial* read of "watched" has actually completed + // (i.e. the pre-existing content has been printed) before swapping it + // for a symlink. Otherwise, under scheduler contention, tail's initial + // open (which legitimately follows symlinks, like any other file open) + // could race with the swap and open the symlink's target on its very + // first read -- before the untailable-symlink guard (which only applies + // to the follow loop) is even active. That would be a test timing bug, + // not the vulnerability this test is meant to catch. + for _ in 0..50 { + if p.stdout_all().contains("visible") { + break; + } + p.delay(20); + } + // Swap the watched file for a symlink pointing at a secret file. - at.remove("watched"); - at.symlink_file("secret", "watched"); + // Create the symlink under a temporary name and rename it into place + // atomically, so the poller never observes an intermediate state where + // "watched" is missing (which would race with the poll interval and be + // reported as "has become inaccessible" instead). + at.symlink_file("secret", "watched.tmp"); + at.rename("watched.tmp", "watched"); p.delay(1000); p.kill() @@ -1650,11 +1843,12 @@ fn test_follow_name_replaced_by_symlink_is_untailable() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_retry8() { // Ensure that inotify will switch to polling mode if directory // of the watched file was initially missing and later created. @@ -1720,12 +1914,13 @@ fn test_retry8() { #[cfg(all( not(target_vendor = "apple"), not(target_os = "android"), - not(target_os = "windows"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_retry9() { - // inspired by: gnu/tests/tail-2/inotify-dir-recreate.sh + // Test inotify behavior when directory is recreated // Ensure that inotify will switch to polling mode if directory // of the watched file was removed and recreated. @@ -1802,12 +1997,13 @@ fn test_retry9() { #[cfg(all( not(target_vendor = "apple"), not(target_os = "android"), - not(target_os = "windows"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_descriptor_vs_rename1() { - // inspired by: gnu/tests/tail-2/descriptor-vs-rename.sh + // Test file descriptor behavior vs rename // $ ((rm -f A && touch A && sleep 1 && echo -n "A\n" >> A && sleep 1 && \ // mv A B && sleep 1 && echo -n "B\n" >> B &)>/dev/null 2>&1 &) ; \ // sleep 1 && target/debug/tail --follow=descriptor A ---disable-inotify @@ -1866,10 +2062,11 @@ fn test_follow_descriptor_vs_rename1() { #[cfg(all( not(target_vendor = "apple"), not(target_os = "android"), - not(target_os = "windows"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_descriptor_vs_rename2() { // Ensure the headers are correct for --verbose. // NOTE: GNU's tail does not update the header from FILE_A to FILE_C after `mv FILE_A FILE_C` @@ -1918,33 +2115,34 @@ fn test_follow_descriptor_vs_rename2() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms -fn test_follow_name_retry_headers() { - // inspired by: "gnu/tests/tail-2/F-headers.sh" +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] +fn test_follow_name_shows_headers_on_creation() { + // Test -F flag with file headers // Ensure tail -F distinguishes output with the // correct headers for created/renamed files /* - $ tail --follow=descriptor -s.1 --max-unchanged-stats=1 -F a b - tail: cannot open 'a' for reading: No such file or directory - tail: cannot open 'b' for reading: No such file or directory - tail: 'a' has appeared; following new file - ==> a <== - x - tail: 'b' has appeared; following new file - - ==> b <== - y + $ tail --follow=descriptor -s.1 --max-unchanged-stats=1 -F log1 log2 + tail: cannot open 'log1' for reading: No such file or directory + tail: cannot open 'log2' for reading: No such file or directory + tail: 'log1' has appeared; following new file + ==> log1 <== + ping + tail: 'log2' has appeared; following new file + + ==> log2 <== + pong */ let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; - let file_a = "a"; - let file_b = "b"; + let file_a = "log1"; + let file_b = "log2"; let mut args = vec![ "-F", @@ -1961,17 +2159,17 @@ fn test_follow_name_retry_headers() { p.make_assertion_with_delay(delay).is_alive(); - at.truncate(file_a, "x\n"); + at.truncate(file_a, "ping\n"); p.delay(delay); - at.truncate(file_b, "y\n"); + at.truncate(file_b, "pong\n"); p.delay(delay); - let expected_stderr = "tail: cannot open 'a' for reading: No such file or directory\n\ - tail: cannot open 'b' for reading: No such file or directory\n\ - tail: 'a' has appeared; following new file\n\ - tail: 'b' has appeared; following new file\n"; - let expected_stdout = "\n==> a <==\nx\n\n==> b <==\ny\n"; + let expected_stderr = "tail: cannot open 'log1' for reading: No such file or directory\n\ + tail: cannot open 'log2' for reading: No such file or directory\n\ + tail: 'log1' has appeared; following new file\n\ + tail: 'log2' has appeared; following new file\n"; + let expected_stdout = "\n==> log1 <==\nping\n\n==> log2 <==\npong\n"; p.make_assertion().is_alive(); p.kill() @@ -1988,7 +2186,8 @@ fn test_follow_name_retry_headers() { } #[test] -#[cfg(all(not(target_os = "windows"), not(target_os = "android")))] // FIXME: for currently not working platforms +#[cfg(all(not(windows), not(target_os = "android")))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_name_remove() { // This test triggers a remove event while `tail --follow=name file` is running. // ((sleep 2 && rm file &)>/dev/null 2>&1 &) ; tail --follow=name file @@ -2048,11 +2247,8 @@ fn test_follow_name_remove() { } #[test] -#[cfg(all( - not(target_os = "windows"), - not(target_os = "android"), - not(target_os = "freebsd") -))] // FIXME: for currently not working platforms +#[cfg(all(not(target_os = "android"), not(target_os = "freebsd")))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_name_truncate1() { // This test triggers a truncate event while `tail --follow=name file` is running. // $ cp file backup && head file > file && sleep 1 && cp backup file @@ -2064,6 +2260,9 @@ fn test_follow_name_truncate1() { let backup = "backup"; let expected_stdout = at.read(FOLLOW_NAME_EXP); + // `expected_stdout` starts with tail's initial static output (the last 10 lines); + // everything after that only appears once the truncate/restore below are observed. + let initial_stdout = &expected_stdout[..expected_stdout.find("END(25)\n").unwrap() + 8]; let expected_stderr = format!("{}: {source}: file truncated\n", ts.util_name); let args = ["--follow=name", source]; @@ -2071,6 +2270,18 @@ fn test_follow_name_truncate1() { let delay = 1000; p.make_assertion().is_alive(); + // Wait for tail's initial read of `source` to finish before mutating it below. + // Watch registration happens before this initial output is printed, so once it + // appears the truncate/restore below are guaranteed to be observed as events. + // A fixed delay isn't enough here: a slow-starting child process (observed on + // macOS CI, see #12335) can otherwise race ahead of tail's own startup. + for _ in 0..500 { + if p.stdout_all() == initial_stdout { + break; + } + p.delay(10); + } + at.copy(source, backup); p.delay(delay); @@ -2089,12 +2300,27 @@ fn test_follow_name_truncate1() { } #[test] -#[cfg(all( - not(target_os = "windows"), - not(target_os = "android"), - not(target_os = "freebsd") -))] // FIXME: for currently not working platforms +#[cfg(all(not(target_os = "android"), not(target_os = "freebsd")))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_name_truncate2() { + fn wait_for_output( + child: &mut uutests::util::UChild, + expected_stdout: &str, + expected_stderr: &str, + ) { + for _ in 0..500 { + if child.stdout_all() == expected_stdout && child.stderr_all() == expected_stderr { + return; + } + child.delay(10); + } + child + .make_assertion() + .with_all_output() + .stdout_is(expected_stdout) + .stderr_is(expected_stderr); + } + // This test triggers a truncate event while `tail --follow=name file` is running. // $ ((sleep 1 && echo -n "x\nx\nx\n" >> file && sleep 1 && \ // echo -n "x\n" > file &)>/dev/null 2>&1 &) ; tail --follow=name file @@ -2111,20 +2337,19 @@ fn test_follow_name_truncate2() { let args = ["--follow=name", source]; let mut p = ts.ucmd().args(&args).run_no_wait(); - let delay = 1000; p.make_assertion().is_alive(); at.append(source, "x\n"); - p.delay(delay); + wait_for_output(&mut p, "x\n", ""); at.append(source, "x\n"); - p.delay(delay); + wait_for_output(&mut p, "x\nx\n", ""); at.append(source, "x\n"); - p.delay(delay); + wait_for_output(&mut p, "x\nx\nx\n", ""); at.truncate(source, "x\n"); - p.delay(delay); + wait_for_output(&mut p, expected_stdout, &expected_stderr); p.make_assertion().is_alive(); @@ -2136,7 +2361,7 @@ fn test_follow_name_truncate2() { } #[test] -#[cfg(not(target_os = "windows"))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_name_truncate3() { // Opening an empty file in truncate mode should not trigger a truncate event while // `tail --follow=name file` is running. @@ -2170,9 +2395,10 @@ fn test_follow_name_truncate3() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), - not(feature = "feat_selinux") // flaky + not(windows), + not(feature = "selinux") // flaky ))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_name_truncate4() { // Truncating a file with the same content it already has should not trigger a truncate event @@ -2207,9 +2433,10 @@ fn test_follow_name_truncate4() { } #[test] -#[cfg(not(target_os = "windows"))] // FIXME: for currently not working platforms -fn test_follow_truncate_fast() { - // inspired by: "gnu/tests/tail-2/truncate.sh" +#[cfg(not(windows))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] +fn test_follow_detects_file_truncation() { + // Test tail behavior on file truncation // Ensure all logs are output upon file truncation // This is similar to `test_follow_name_truncate1-3` but uses very short delays @@ -2224,7 +2451,12 @@ fn test_follow_truncate_fast() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; - let mut args = vec!["-s.1", "--max-unchanged-stats=1", "f", "---disable-inotify"]; + let mut args = vec![ + "-s.1", + "--max-unchanged-stats=1", + "data", + "---disable-inotify", + ]; let follow = vec!["-f", "-F"]; let mut delay = 1000; @@ -2232,19 +2464,19 @@ fn test_follow_truncate_fast() { for mode in &follow { args.push(mode); - at.truncate("f", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n"); + at.truncate("data", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n"); let mut p = ts.ucmd().args(&args).run_no_wait(); p.make_assertion_with_delay(delay).is_alive(); - at.truncate("f", "11\n12\n13\n14\n15\n"); + at.truncate("data", "11\n12\n13\n14\n15\n"); p.delay(delay); p.make_assertion().is_alive(); p.kill() .make_assertion() .with_all_output() - .stderr_is("tail: f: file truncated\n") + .stderr_is("tail: data: file truncated\n") .stdout_is("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n"); args.pop(); @@ -2257,11 +2489,12 @@ fn test_follow_truncate_fast() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_name_move_create1() { // This test triggers a move/create event while `tail --follow=name file` is running. // ((sleep 2 && mv file backup && sleep 2 && cp backup file &)>/dev/null 2>&1 &) ; tail --follow=name file @@ -2314,13 +2547,14 @@ fn test_follow_name_move_create1() { #[cfg(all( not(target_vendor = "apple"), not(target_os = "android"), - not(target_os = "windows"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms -fn test_follow_name_move_create2() { - // inspired by: "gnu/tests/tail-2/inotify-hash-abuse.sh" - // Exercise an abort-inducing flaw in inotify-enabled tail -F +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] +fn test_follow_name_hash_table_stress() { + // Test inotify hash table under heavy file churn by watching 9 files simultaneously. + // Exercises an abort-inducing flaw in inotify-enabled tail -F let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -2354,13 +2588,13 @@ fn test_follow_name_move_create2() { at.truncate("9", "x\n"); p.delay(delay); - at.rename("1", "f"); + at.rename("1", "moved"); p.delay(delay); at.truncate("1", "a\n"); p.delay(delay); - // NOTE: Because "gnu/tests/tail-2/inotify-hash-abuse.sh" 'forgets' to clear the files used + // NOTE: Files used in the previous loop iteration are reused intentionally // during the first loop iteration, we also don't clear them to get the same side-effects. // Side-effects are truncating a file with the same content, see: test_follow_name_truncate4 // at.remove("1"); @@ -2382,7 +2616,7 @@ fn test_follow_name_move_create2() { .stderr_is(expected_stderr) .stdout_is(expected_stdout); - at.remove("f"); + at.remove("moved"); if i == 0 { args.push("---disable-inotify"); } @@ -2393,11 +2627,12 @@ fn test_follow_name_move_create2() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_name_move1() { // This test triggers a move event while `tail --follow=name file` is running. // ((sleep 2 && mv file backup &)>/dev/null 2>&1 &) ; tail --follow=name file @@ -2455,11 +2690,12 @@ fn test_follow_name_move1() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_name_move2() { // Like test_follow_name_move1, but move to a name that's already monitored. @@ -2543,11 +2779,12 @@ fn test_follow_name_move2() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_name_move_retry1() { // Similar to test_follow_name_move1 but with `--retry` (`-F`) // This test triggers two move/rename events while `tail --follow=name --retry file` is running. @@ -2603,13 +2840,14 @@ fn test_follow_name_move_retry1() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), not(target_os = "android"), not(target_os = "freebsd"), - not(target_os = "openbsd") + not(target_os = "openbsd"), + not(windows) ))] // FIXME: for currently not working platforms -fn test_follow_name_move_retry2() { - // inspired by: "gnu/tests/tail-2/F-vs-rename.sh" +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] +fn test_follow_name_rename_chain() { + // Test -F flag behavior across file renames // Similar to test_follow_name_move2 (move to a name that's already monitored) // but with `--retry` (`-F`) @@ -2707,7 +2945,8 @@ fn test_follow_name_move_retry2() { } #[test] -#[cfg(not(target_os = "windows"))] // FIXME: for currently not working platforms +#[cfg(not(windows))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_inotify_only_regular() { // The GNU test inotify-only-regular.sh uses strace to ensure that `tail -f` // doesn't make inotify syscalls and only uses inotify for regular files or fifos. @@ -2781,12 +3020,12 @@ fn test_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") + not(target_os = "openbsd"), + not(windows) ))] +#[cfg_attr(wasi_runner, ignore = "WASI: no FIFO/mkfifo support")] fn test_fifo_with_pid() { use std::process::{Command, Stdio}; @@ -3160,7 +3399,7 @@ fn test_pipe_when_lines_option_given_input_size_is_one_byte_greater_than_buffer_ // FIXME: windows: this test failed with timeout in the CI. Running this test in // a Windows VirtualBox image produces no errors. #[test] -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] fn test_pipe_when_lines_option_given_input_size_has_multiple_size_of_buffer_size() { let total_lines = 100; let random_string = RandomizedString::generate_with_delimiter( @@ -3460,7 +3699,7 @@ fn test_pipe_when_bytes_option_given_input_size_is_one_byte_greater_than_buffer_ // FIXME: windows: this test failed with timeout in the CI. Running this test in // a Windows VirtualBox image produces no errors. #[test] -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] fn test_pipe_when_bytes_option_given_input_size_has_multiple_size_of_buffer_size() { let random_string = RandomizedString::generate(AlphanumericNewline, CHUNK_BUFFER_SIZE * 3); let random_string = random_string.as_str(); @@ -3568,13 +3807,13 @@ fn test_seek_bytes_forward_outside_file() { .arg("+100") .arg(FOOBAR_TXT) .succeeds() - .stdout_is(""); + .no_output(); } // Some basic tests for ---presume-input-pipe. These tests build upon the // debug_assert in bounded tail to detect that we're using the bounded_tail in // case the option is given on command line. -#[cfg(all(not(target_os = "android"), not(target_os = "windows")))] // FIXME: +#[cfg(all(not(target_os = "android"), not(windows)))] // FIXME: #[test] fn test_args_when_presume_input_pipe_given_input_is_pipe() { let random_string = RandomizedString::generate(AlphanumericNewline, 1000); @@ -3680,7 +3919,7 @@ fn test_when_argument_file_is_a_symlink() { let mut file = at.make_file("target"); - at.symlink_file("target", "link"); + at.relative_symlink_file("target", "link"); ts.ucmd().args(&["-c", "+0", "link"]).succeeds().no_output(); @@ -3692,16 +3931,6 @@ fn test_when_argument_file_is_a_symlink() { .args(&["-c", "+0", "link"]) .succeeds() .stdout_only(random_string); - - at.mkdir("dir"); - - at.symlink_file("dir", "dir_link"); - - let expected = "tail: error reading 'dir_link': Is a directory\n"; - ts.ucmd() - .arg("dir_link") - .fails_with_code(1) - .stderr_only(expected); } // TODO: make this work on windows @@ -3712,13 +3941,12 @@ fn test_when_argument_file_is_a_symlink_to_directory_then_error() { let at = &ts.fixtures; at.mkdir("dir"); - at.symlink_file("dir", "dir_link"); + at.relative_symlink_file("dir", "dir_link"); - let expected = "tail: error reading 'dir_link': Is a directory\n"; ts.ucmd() .arg("dir_link") .fails_with_code(1) - .stderr_only(expected); + .stderr_only("tail: error reading 'dir_link': Is a directory\n"); } // TODO: make this work on windows @@ -3767,13 +3995,13 @@ fn test_when_argument_file_is_non_existent_unix_socket_address_then_error() { let result = net::UnixListener::bind(at.plus(socket)); assert!(result.is_ok()); - #[cfg(all(not(target_os = "freebsd"), not(target_os = "macos")))] + #[cfg(not(any(target_vendor = "apple", target_os = "freebsd")))] let expected_stderr = format!("tail: cannot open '{socket}' for reading: No such device or address\n"); #[cfg(target_os = "freebsd")] let expected_stderr = format!("tail: cannot open '{socket}' for reading: Operation not supported\n"); - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] let expected_stderr = format!("tail: cannot open '{socket}' for reading: Operation not supported on socket\n"); @@ -3815,7 +4043,7 @@ fn test_when_argument_files_are_simple_combinations_of_stdin_and_regular_file() at.write("data", "file data"); at.write("fifo", "fifo data"); - let expected = "==> standard input <==\n\ + let expected = "==> 'standard input' <==\n\ fifo data\n\ ==> empty <==\n"; scene @@ -3825,7 +4053,7 @@ fn test_when_argument_files_are_simple_combinations_of_stdin_and_regular_file() .succeeds() .stdout_only(expected); - let expected = "==> standard input <==\n\ + let expected = "==> 'standard input' <==\n\ \n\ ==> empty <==\n"; scene @@ -3837,7 +4065,7 @@ fn test_when_argument_files_are_simple_combinations_of_stdin_and_regular_file() let expected = "==> empty <==\n\ \n\ - ==> standard input <==\n"; + ==> 'standard input' <==\n"; scene .ucmd() .args(&["-c", "+0", "empty", "-"]) @@ -3847,7 +4075,7 @@ fn test_when_argument_files_are_simple_combinations_of_stdin_and_regular_file() let expected = "==> empty <==\n\ \n\ - ==> standard input <==\n\ + ==> 'standard input' <==\n\ fifo data"; scene .ucmd() @@ -3856,7 +4084,7 @@ fn test_when_argument_files_are_simple_combinations_of_stdin_and_regular_file() .succeeds() .stdout_only(expected); - let expected = "==> standard input <==\n\ + let expected = "==> 'standard input' <==\n\ pipe data\n\ ==> data <==\n\ file data"; @@ -3869,7 +4097,7 @@ fn test_when_argument_files_are_simple_combinations_of_stdin_and_regular_file() let expected = "==> data <==\n\ file data\n\ - ==> standard input <==\n\ + ==> 'standard input' <==\n\ pipe data"; scene .ucmd() @@ -3878,9 +4106,9 @@ fn test_when_argument_files_are_simple_combinations_of_stdin_and_regular_file() .succeeds() .stdout_only(expected); - let expected = "==> standard input <==\n\ + let expected = "==> 'standard input' <==\n\ pipe data\n\ - ==> standard input <==\n"; + ==> 'standard input' <==\n"; scene .ucmd() .args(&["-c", "+0", "-", "-"]) @@ -3888,9 +4116,9 @@ fn test_when_argument_files_are_simple_combinations_of_stdin_and_regular_file() .succeeds() .stdout_only(expected); - let expected = "==> standard input <==\n\ + let expected = "==> 'standard input' <==\n\ fifo data\n\ - ==> standard input <==\n"; + ==> 'standard input' <==\n"; scene .ucmd() .args(&["-c", "+0", "-", "-"]) @@ -3909,11 +4137,11 @@ fn test_when_argument_files_are_triple_combinations_of_fifo_pipe_and_regular_fil at.write("data", "file data"); at.write("fifo", "fifo data"); - let expected = "==> standard input <==\n\ + let expected = "==> 'standard input' <==\n\ \n\ ==> empty <==\n\ \n\ - ==> standard input <==\n"; + ==> 'standard input' <==\n"; scene .ucmd() @@ -3922,11 +4150,11 @@ fn test_when_argument_files_are_triple_combinations_of_fifo_pipe_and_regular_fil .succeeds() .stdout_only(expected); - let expected = "==> standard input <==\n\ + let expected = "==> 'standard input' <==\n\ \n\ ==> empty <==\n\ \n\ - ==> standard input <==\n"; + ==> 'standard input' <==\n"; scene .ucmd() .args(&["-c", "+0", "-", "empty", "-"]) @@ -3935,11 +4163,11 @@ fn test_when_argument_files_are_triple_combinations_of_fifo_pipe_and_regular_fil .succeeds() .stdout_only(expected); - let expected = "==> standard input <==\n\ + let expected = "==> 'standard input' <==\n\ pipe data\n\ ==> data <==\n\ file data\n\ - ==> standard input <==\n"; + ==> 'standard input' <==\n"; scene .ucmd() .args(&["-c", "+0", "-", "data", "-"]) @@ -3958,18 +4186,18 @@ fn test_when_argument_files_are_triple_combinations_of_fifo_pipe_and_regular_fil // the pipe. Seems that windows `cmd` (like posix shells) ignores pipes when a fifo is present. // This is actually the wished behavior and the test therefore succeeds. #[cfg(windows)] - let expected = "==> standard input <==\n\ + let expected = "==> 'standard input' <==\n\ fifo data\n\ ==> data <==\n\ file data\n\ - ==> standard input <==\n\ + ==> 'standard input' <==\n\ (The process tried to write to a nonexistent pipe.\r\n)?"; #[cfg(unix)] - let expected = "==> standard input <==\n\ + let expected = "==> 'standard input' <==\n\ fifo data\n\ ==> data <==\n\ file data\n\ - ==> standard input <==\n"; + ==> 'standard input' <==\n"; #[cfg(windows)] let cmd = ["cmd", "/C"]; @@ -3986,11 +4214,11 @@ fn test_when_argument_files_are_triple_combinations_of_fifo_pipe_and_regular_fil .succeeds() .stdout_only(expected); - let expected = "==> standard input <==\n\ + let expected = "==> 'standard input' <==\n\ fifo data\n\ ==> data <==\n\ file data\n\ - ==> standard input <==\n"; + ==> 'standard input' <==\n"; scene .ucmd() .args(&["-c", "+0", "-", "data", "-"]) @@ -4047,30 +4275,13 @@ fn test_args_when_settings_check_warnings_then_shows_warnings() { .succeeds() .stdout_only(expected_stdout); - let expected_stdout = format!( - "tail: warning: --retry only effective for the initial open\n\ - {file_data}" - ); - let mut child = scene - .ucmd() - .args(&["--follow=descriptor", "--retry", "data"]) - .stderr_to_stdout() - .run_no_wait(); - - child - .delay(500) - .kill() - .make_assertion() - .with_current_output() - .stdout_only(expected_stdout); - let expected_stdout = format!( "tail: warning: PID ignored; --pid=PID is useful only when following\n\ {file_data}" ); scene .ucmd() - .args(&["--pid=1000", "data"]) + .args(&["--pid=0", "data"]) .stderr_to_stdout() .succeeds() .stdout_only(expected_stdout); @@ -4094,16 +4305,55 @@ fn test_args_when_settings_check_warnings_then_shows_warnings() { .stdout_only(expected_stdout); } +#[test] +fn test_args_when_settings_check_warnings_follow_retry() { + let scene = TestScenario::new(util_name!()); + let file_data = "file data\n"; + scene.fixtures.write("data", file_data); + + if std::env::var("UUTESTS_WASM_RUNNER").is_ok() { + scene + .ucmd() + .args(&["--follow=descriptor", "--retry", "data"]) + .stderr_to_stdout() + .fails_with_code(1) + .stdout_only(format!( + "tail: warning: --retry only effective for the initial open\n\ + {file_data}\ + tail: follow mode is not supported on this platform\n" + )); + return; + } + + let expected_stdout = format!( + "tail: warning: --retry only effective for the initial open\n\ + {file_data}" + ); + let mut child = scene + .ucmd() + .args(&["--follow=descriptor", "--retry", "data"]) + .stderr_to_stdout() + .run_no_wait(); + + child + .delay(500) + .kill() + .make_assertion() + .with_current_output() + .stdout_only(expected_stdout); +} + /// TODO: Write similar tests for windows #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_args_when_settings_check_warnings_follow_indefinitely_then_warning() { let scene = TestScenario::new(util_name!()); let file_data = "file data\n"; scene.fixtures.write("data", file_data); - let expected_stdout = "==> standard input <==\n"; + let expected_stdout = "==> 'standard input' <==\n"; let expected_stderr = "tail: warning: following standard input indefinitely is ineffective\n"; // `tail -f - data` (without any redirect) would also print this warning in a terminal but we're @@ -4128,7 +4378,7 @@ fn test_args_when_settings_check_warnings_follow_indefinitely_then_warning() { .stdout_is(expected_stdout); let expected_stdout = "tail: warning: following standard input indefinitely is ineffective\n\ - ==> standard input <==\n"; + ==> 'standard input' <==\n"; // same like above but this time the order of the output matters and we're redirecting stderr to // stdout // tail -f - data < /dev/ptmx @@ -4150,7 +4400,7 @@ fn test_args_when_settings_check_warnings_follow_indefinitely_then_warning() { "tail: warning: following standard input indefinitely is ineffective\n\ ==> data <==\n\ {file_data}\n\ - ==> standard input <==\n" + ==> 'standard input' <==\n" ); // tail -f data - < /dev/ptmx let mut child = scene @@ -4168,7 +4418,7 @@ fn test_args_when_settings_check_warnings_follow_indefinitely_then_warning() { .stdout_only(expected_stdout); let expected_stdout = "tail: warning: following standard input indefinitely is ineffective\n\ - ==> standard input <==\n"; + ==> 'standard input' <==\n"; // tail -f - - < /dev/ptmx let mut child = scene .ucmd() @@ -4185,7 +4435,7 @@ fn test_args_when_settings_check_warnings_follow_indefinitely_then_warning() { .stdout_only(expected_stdout); let expected_stdout = "tail: warning: following standard input indefinitely is ineffective\n\ - ==> standard input <==\n"; + ==> 'standard input' <==\n"; // tail -f - - data < /dev/ptmx let mut child = scene .ucmd() @@ -4202,7 +4452,7 @@ fn test_args_when_settings_check_warnings_follow_indefinitely_then_warning() { .stdout_only(expected_stdout); let expected_stdout = "tail: warning: following standard input indefinitely is ineffective\n\ - ==> standard input <==\n"; + ==> 'standard input' <==\n"; // tail --pid=100000 -f - data < /dev/ptmx let mut child = scene .ucmd() @@ -4221,6 +4471,7 @@ fn test_args_when_settings_check_warnings_follow_indefinitely_then_warning() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_args_when_settings_check_warnings_follow_indefinitely_then_no_warning() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -4239,7 +4490,7 @@ fn test_args_when_settings_check_warnings_follow_indefinitely_then_no_warning() let pipe_data = "pipe data"; let expected_stdout = format!( - "==> standard input <==\n\ + "==> 'standard input' <==\n\ {pipe_data}\n\ ==> {file_name} <==\n\ {file_data}" @@ -4262,7 +4513,7 @@ fn test_args_when_settings_check_warnings_follow_indefinitely_then_no_warning() // Fails currently on macos with // Diff < left / right > : // ==> standard input <== + // >==> 'standard input' <== // >fifo data // > // ==> data <== @@ -4270,7 +4521,7 @@ fn test_args_when_settings_check_warnings_follow_indefinitely_then_no_warning() #[cfg(not(target_vendor = "apple"))] { let expected_stdout = format!( - "==> standard input <==\n\ + "==> 'standard input' <==\n\ {fifo_data}\n\ ==> {file_name} <==\n\ {file_data}" @@ -4290,7 +4541,7 @@ fn test_args_when_settings_check_warnings_follow_indefinitely_then_no_warning() .stdout_only(expected_stdout); let expected_stdout = format!( - "==> standard input <==\n\ + "==> 'standard input' <==\n\ {fifo_data}\n\ ==> {file_name} <==\n\ {file_data}" @@ -4535,6 +4786,7 @@ fn test_follow_when_file_and_symlink_are_pointing_to_same_file_and_append_data() } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_args_when_directory_given_shorthand_big_f_together_with_retry() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -4590,11 +4842,12 @@ fn test_args_when_directory_given_shorthand_big_f_together_with_retry() { #[test] #[cfg(all( not(target_vendor = "apple"), - not(target_os = "windows"), + not(windows), not(target_os = "freebsd"), not(target_os = "openbsd"), - not(feature = "feat_selinux") // flaky + not(feature = "selinux") // flaky ))] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_when_files_are_pointing_to_same_relative_file_and_file_stays_same_size() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -4659,24 +4912,20 @@ fn test_args_sleep_interval_when_illegal_argument_then_usage_error(#[case] sleep } #[test] -fn test_gnu_args_plus_c() { +fn test_tail_obsolete_plus_bytes() { let scene = TestScenario::new(util_name!()); - - // obs-plus-c1 scene .ucmd() .arg("+2c") - .pipe_in("abcd") + .pipe_in("wxyz") .succeeds() - .stdout_only("bcd"); - // obs-plus-c2 + .stdout_only("xyz"); scene .ucmd() .arg("+8c") - .pipe_in("abcd") + .pipe_in("wxyz") .succeeds() .stdout_only(""); - // obs-plus-x1: same as +10c scene .ucmd() .arg("+c") @@ -4686,24 +4935,20 @@ fn test_gnu_args_plus_c() { } #[test] -fn test_gnu_args_c() { +fn test_tail_obsolete_bytes() { let scene = TestScenario::new(util_name!()); - - // obs-c3 scene .ucmd() .arg("-1c") - .pipe_in("abcd") + .pipe_in("wxyz") .succeeds() - .stdout_only("d"); - // obs-c4 + .stdout_only("z"); scene .ucmd() .arg("-9c") - .pipe_in("abcd") + .pipe_in("wxyz") .succeeds() - .stdout_only("abcd"); - // obs-c5 + .stdout_only("wxyz"); scene .ucmd() .arg("-12c") @@ -4713,31 +4958,26 @@ fn test_gnu_args_c() { } #[test] -fn test_gnu_args_l() { +fn test_tail_obsolete_lines() { let scene = TestScenario::new(util_name!()); - - // obs-l1 scene .ucmd() .arg("-1l") .pipe_in("x") .succeeds() .stdout_only("x"); - // obs-l2 scene .ucmd() .arg("-1l") .pipe_in("x\ny\n") .succeeds() .stdout_only("y\n"); - // obs-l3 scene .ucmd() .arg("-1l") .pipe_in("x\ny") .succeeds() .stdout_only("y"); - // obs-l: same as -10l scene .ucmd() .arg("-l") @@ -4747,24 +4987,20 @@ fn test_gnu_args_l() { } #[test] -fn test_gnu_args_plus_l() { +fn test_tail_obsolete_plus_lines() { let scene = TestScenario::new(util_name!()); - - // obs-plus-l4 scene .ucmd() .arg("+1l") .pipe_in("x\ny\n") .succeeds() .stdout_only("x\ny\n"); - // ops-plus-l5 scene .ucmd() .arg("+2l") .pipe_in("x\ny\n") .succeeds() .stdout_only("y\n"); - // obs-plus-x2: same as +10l scene .ucmd() .arg("+l") @@ -4774,24 +5010,20 @@ fn test_gnu_args_plus_l() { } #[test] -fn test_gnu_args_number() { +fn test_tail_obsolete_number() { let scene = TestScenario::new(util_name!()); - - // obs-1 scene .ucmd() .arg("-1") .pipe_in("x") .succeeds() .stdout_only("x"); - // obs-2 scene .ucmd() .arg("-1") .pipe_in("x\ny\n") .succeeds() .stdout_only("y\n"); - // obs-3 scene .ucmd() .arg("-1") @@ -4801,17 +5033,14 @@ fn test_gnu_args_number() { } #[test] -fn test_gnu_args_plus_number() { +fn test_tail_obsolete_plus_number() { let scene = TestScenario::new(util_name!()); - - // obs-plus-4 scene .ucmd() .arg("+1") .pipe_in("x\ny\n") .succeeds() .stdout_only("x\ny\n"); - // ops-plus-5 scene .ucmd() .arg("+2") @@ -4821,10 +5050,8 @@ fn test_gnu_args_plus_number() { } #[test] -fn test_gnu_args_b() { +fn test_tail_obsolete_blocks() { let scene = TestScenario::new(util_name!()); - - // obs-b scene .ucmd() .arg("-b") @@ -4834,45 +5061,38 @@ fn test_gnu_args_b() { } #[test] -fn test_gnu_args_err() { +fn test_tail_obsolete_error_cases() { let scene = TestScenario::new(util_name!()); - - // err-1 scene .ucmd() .arg("+cl") .fails_with_code(1) .no_stdout() .stderr_is("tail: cannot open '+cl' for reading: No such file or directory\n"); - // err-2 scene .ucmd() .arg("-cl") .fails_with_code(1) .no_stdout() .stderr_is("tail: invalid number of bytes: 'l'\n"); - // err-3 scene .ucmd() .arg("+2cz") .fails_with_code(1) .no_stdout() .stderr_is("tail: cannot open '+2cz' for reading: No such file or directory\n"); - // err-4 scene .ucmd() .arg("-2cX") .fails_with_code(1) .no_stdout() .stderr_is("tail: option used in invalid context -- 2\n"); - // err-5: large numbers now clamp to u64::MAX scene .ucmd() .arg("-c99999999999999999999") .pipe_in("x") .succeeds() .stdout_is("x"); - // err-6 scene .ucmd() .arg("-c --") @@ -4901,7 +5121,8 @@ fn test_gnu_args_err() { } #[test] -fn test_gnu_args_f() { +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] +fn test_tail_obsolete_f_flag() { let scene = TestScenario::new(util_name!()); let at = &scene.fixtures; @@ -4922,6 +5143,7 @@ fn test_gnu_args_f() { #[test] #[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_obsolete_encoding_unix() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; @@ -4955,7 +5177,7 @@ fn test_obsolete_encoding_windows() { } #[test] -#[cfg(not(target_vendor = "apple"))] // FIXME: for currently not working platforms +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_following_with_pid() { use std::process::Command; @@ -5032,10 +5254,16 @@ fn test_when_piped_input_then_no_broken_pipe() { #[test] #[cfg(unix)] -fn test_when_output_closed_then_no_broken_pie() { - let mut cmd = new_ucmd!(); +#[cfg_attr( + wasi_runner, + ignore = "WASI: closed stdout reports a generic I/O error rather than BrokenPipe" +)] +fn test_when_output_closed_then_no_broken_pipe() { + let (at, mut cmd) = at_and_ucmd!(); + at.make_file("input").set_len(10_000_000).unwrap(); + let mut child = cmd - .args(&["-c", "100000", "/dev/zero"]) + .args(&["-c", "10000000", "input"]) .set_stdout(Stdio::piped()) .run_no_wait(); // Dropping the stdout should not lead to an error. @@ -5071,6 +5299,15 @@ fn test_failed_write_is_reported() { .stderr_is("tail: No space left on device\n"); } +#[cfg(target_os = "linux")] +#[test] +fn test_failed_warning_write_is_reported() { + new_ucmd!() + .args(&["--pid=0", "/dev/null"]) + .set_stderr(File::create("/dev/full").unwrap()) + .fails_with_code(1); +} + #[cfg(target_os = "linux")] #[test] fn test_failed_write_is_reported_on_seekable_input() { @@ -5087,7 +5324,8 @@ fn test_failed_write_is_reported_on_seekable_input() { } #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_dev_zero() { new_ucmd!() .args(&["-c", "1", "/dev/zero"]) @@ -5132,6 +5370,7 @@ fn test_follow_pipe_f() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_stdout_pipe_close() { let (at, mut ucmd) = at_and_ucmd!(); at.write("f", "line1\nline2\n"); @@ -5147,6 +5386,7 @@ fn test_follow_stdout_pipe_close() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_debug_flag_with_polling() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -5167,6 +5407,7 @@ fn test_debug_flag_with_polling() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_debug_flag_with_inotify() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures; @@ -5184,6 +5425,7 @@ fn test_debug_flag_with_inotify() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_dangling_symlink() { let (at, mut ucmd) = at_and_ucmd!(); at.symlink_file("target", "link"); @@ -5198,6 +5440,7 @@ fn test_follow_dangling_symlink() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: tail follow mode disabled")] fn test_follow_symlink_target_change() { let (at, mut ucmd) = at_and_ucmd!(); at.write("t1", "A\n"); @@ -5227,3 +5470,72 @@ fn test_no_skip_after_error() { .fails() .stdout_contains("hello"); } + +#[cfg(unix)] +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[test] + fn test_snippet_points_at_the_unknown_unit() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-c", "1fb", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("tail:1:10"), "{stderr}"); + assert!(stderr.contains("not a known unit"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["-n", "5QQ", "/dev/null"]) + .fails_with_code(1) + .stderr_is("tail: invalid number of lines: '5QQ'\n"); + } +} + +#[test] +fn test_invalid_count_keeps_its_leading_zeros() { + // Leading zeros are stripped only so the count is read as decimal rather + // than octal. That is internal, so GNU still names the argument as typed. + new_ucmd!() + .args(&["-c", "0fb", "/dev/null"]) + .fails_with_code(1) + .stderr_is("tail: invalid number of bytes: '0fb'\n"); + new_ucmd!() + .args(&["-n", "000ff", "/dev/null"]) + .fails_with_code(1) + .stderr_is("tail: invalid number of lines: '000ff'\n"); + // The sign is not put back with them: GNU reports `-c-0fb` as '0fb'. + new_ucmd!() + .args(&["-c-0fb", "/dev/null"]) + .fails_with_code(1) + .stderr_is("tail: invalid number of bytes: '0fb'\n"); +} + +#[test] +fn test_header_quotes_names_needing_it() { + // Same quoting rules as head: only names that need it are quoted. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("plain", "p\n"); + at.write("two words", "w\n"); + + ucmd.args(&["-n1", "plain", "two words"]) + .succeeds() + .stdout_only("==> plain <==\np\n\n==> 'two words' <==\nw\n"); +} + +// Windows rejects control characters in file names, so this one is unix-only. +#[test] +#[cfg(unix)] +fn test_header_quotes_name_with_control_char() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("tab\there", "t\n"); + + ucmd.args(&["-v", "-n1", "tab\there"]) + .succeeds() + .stdout_only("==> 'tab'$'\\t''here' <==\nt\n"); +} diff --git a/tests/by-util/test_tee.rs b/tests/by-util/test_tee.rs index 2948eaa045b..00e30eada05 100644 --- a/tests/by-util/test_tee.rs +++ b/tests/by-util/test_tee.rs @@ -2,6 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + +// spell-checker:ignore nopipe Fsize Nofile sigxfsz + #![allow(clippy::borrow_as_ptr)] use uutests::{at_and_ucmd, new_ucmd}; @@ -14,8 +17,6 @@ use std::time::Duration; // inspired by: // https://github.com/coreutils/coreutils/tests/misc/tee.sh -// spell-checker:ignore nopipe - #[test] #[cfg(unix)] fn test_error_stdin_directory() { @@ -669,7 +670,7 @@ mod linux_only { .pipe_in(&content[..]) .fails() .stdout_contains(&content) - .stderr_contains("No space left on device"); + .stderr_is("tee: /dev/full: No space left on device\n"); assert_eq!(at.read(file_out), content); } @@ -697,6 +698,63 @@ mod linux_only { assert_eq!(at.read(file_out_b), content); assert!(result.stderr_str().contains("No space left on device")); } + + /// Runs tee with 2 outputs that fail part-way, and checks that the bytes they did + /// not take do not end up in the other outputs. + fn check_failed_outputs_do_not_spill(open_file_limit: Option) { + use rustix::process::Resource; + + const CAP: u64 = 100 * 1024; + let (at, mut ucmd) = at_and_ucmd!(); + let content: Vec = (0..3 * 1024 * 1024).map(|i| (i % 251) as u8).collect(); + at.write_bytes("in", &content); + + // stdout and /dev/null are not limited by the file size. "capped" is written + // through tee's 2nd pipe, which stdout and /dev/null keep using after it fails; + // "capped2", the last output, through its 1st. + ucmd.args(&["capped", "/dev/null", "capped2"]) + .limit(Resource::Fsize, CAP, CAP) + .ignore_sigxfsz(); + if let Some(limit) = open_file_limit { + ucmd.limit(Resource::Nofile, limit, limit); + } + let mut child = ucmd + .set_stdin(std::fs::File::open(at.plus("in")).unwrap()) + .set_stdout(Stdio::piped()) + .run_no_wait(); + let stdout = child.stdout_exact_bytes(content.len()); + // Not `stderr_is`: a coverage build fails to write its profile under the + // same file size limit, and says so on stderr. + child + .wait() + .unwrap() + .code_is(1) + .stderr_contains("tee: capped: File too large\ntee: capped2: File too large\n"); + + assert!(stdout == content, "stdout differs from the input"); + assert_eq!(at.metadata("capped").len(), CAP); + assert_eq!(at.metadata("capped2").len(), CAP); + } + + #[test] + fn test_tee_failed_output_does_not_spill_into_others() { + check_failed_outputs_do_not_spill(None); + } + + // Just enough file descriptors for stdio, the 3 outputs and tee's 2 pipes: a + // failed output's pipe has to be freed before it is replaced. + #[test] + fn test_tee_failed_output_at_open_file_limit() { + check_failed_outputs_do_not_spill(Some(10)); + } + + #[test] + fn test_permission_denied_clean() { + new_ucmd!() + .arg("/dev/mem") + .fails_with_code(1) + .stderr_is("tee: /dev/mem: Permission denied\n"); + } } // Additional cross-platform tee tests to cover GNU compatibility around --output-error diff --git a/tests/by-util/test_test.rs b/tests/by-util/test_test.rs index 7ed296328c4..d12eaadd40c 100644 --- a/tests/by-util/test_test.rs +++ b/tests/by-util/test_test.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) egid euid pseudofloat +// spell-checker:ignore (words) egid euid icacls pseudofloat boolop BOOLOP use uutests::util::TestScenario; use uutests::{at_and_ucmd, new_ucmd, util_name}; @@ -77,8 +77,11 @@ fn test_not_and_is_false() { } #[test] -fn test_not_and_not_succeeds() { - new_ucmd!().args(&["!", "-a", "!"]).succeeds(); +fn test_not_and_not_is_a_syntax_error() { + new_ucmd!() + .args(&["!", "-a", "!"]) + .fails_with_code(2) + .stderr_contains("'-a': unary operator expected"); } #[test] @@ -98,6 +101,18 @@ fn test_errors_miss_and_or() { .stderr_contains("'-a': unary operator expected"); } +#[test] +fn test_unknown_two_byte_operator_errors() { + new_ucmd!() + .args(&["-Q", "x"]) + .fails_with_code(2) + .stderr_contains("'-Q': unary operator expected"); + new_ucmd!() + .args(&["x", "-Q", "y"]) + .fails_with_code(2) + .stderr_contains("'-Q': binary operator expected"); +} + #[test] fn test_negated_or() { new_ucmd!() @@ -175,12 +190,11 @@ fn test_string_comparison() { } #[test] -#[ignore = "fixme: error reporting"] fn test_dangling_string_comparison_is_error() { new_ucmd!() .args(&["missing_something", "="]) .fails_with_code(2) - .stderr_is("test: missing argument after '='"); + .stderr_is("test: missing argument after '='\n"); } #[test] @@ -255,13 +269,153 @@ fn test_some_int_compares() { } #[test] -#[ignore = "fixme: evaluation error (code 1); GNU returns 0"] +fn test_integer_length_operands() { + new_ucmd!().args(&["-l", "abc", "-eq", "3"]).succeeds(); + new_ucmd!().args(&["3", "-eq", "-l", "abc"]).succeeds(); + new_ucmd!().args(&["-l", "abc", "-ne", "4"]).succeeds(); +} + +#[test] +fn test_file_operator_rejects_length_operand() { + new_ucmd!() + .args(&["-l", "a", "-nt", "b"]) + .fails_with_code(2) + .stderr_contains("-nt does not accept -l"); +} + +#[test] +fn test_tty_out_of_range_is_false() { + new_ucmd!() + .args(&["-t", "999999999999999999999999999999999999"]) + .fails_with_code(1); +} + +#[test] +fn test_and_or_do_not_short_circuit() { + new_ucmd!() + .args(&["", "-a", "1", "-eq", "bad"]) + .fails_with_code(2) + .stderr_contains("invalid integer 'bad'"); + new_ucmd!() + .args(&["x", "-o", "1", "-eq", "bad"]) + .fails_with_code(2) + .stderr_contains("invalid integer 'bad'"); +} + +#[test] fn test_values_greater_than_i64_allowed() { new_ucmd!() .args(&["9223372036854775808", "-gt", "0"]) .succeeds(); } +/// The 71-digit operand reported in GNU compatibility issue #12874. +const BIG: &str = "16267277278126277227728782172782882627278282882172762677623672762783782"; +/// `BIG` with its final digit incremented, so the two operands have the same +/// width and differ only in the least significant digit. +const BIG_PLUS_ONE: &str = + "16267277278126277227728782172782882627278282882172762677623672762783783"; +/// One digit shorter than `BIG`, so the two operands differ in width. +const SMALLER: &str = "1626727727812627722772878217278288262727828288217276267762367276278378"; + +#[test] +fn test_values_greater_than_i128_allowed() { + // i128::MAX + 1 + new_ucmd!() + .args(&["170141183460469231731687303715884105728", "-gt", "0"]) + .succeeds(); + // i128::MIN - 1 + new_ucmd!() + .args(&["-170141183460469231731687303715884105729", "-lt", "0"]) + .succeeds(); +} + +#[test] +fn test_large_int_compares() { + let scenario = TestScenario::new(util_name!()); + + let tests = [ + [BIG, "-eq", BIG], + [BIG, "-ge", BIG], + [BIG, "-le", BIG], + [BIG, "-ne", "1"], + ["1", "-lt", BIG], + [BIG, "-gt", "1"], + // Same width, differing only in the least significant digit. + [BIG_PLUS_ONE, "-gt", BIG], + [BIG, "-lt", BIG_PLUS_ONE], + // Differing widths. + [BIG, "-gt", SMALLER], + [SMALLER, "-lt", BIG], + ]; + + for test in &tests { + scenario.ucmd().args(&test[..]).succeeds(); + } + + // run the inverse of all these tests + for test in &tests { + scenario.ucmd().arg("!").args(&test[..]).fails_with_code(1); + } +} + +#[test] +fn test_large_negative_int_compares() { + let scenario = TestScenario::new(util_name!()); + let neg_big = format!("-{BIG}"); + let neg_smaller = format!("-{SMALLER}"); + + let tests = [ + [neg_big.as_str(), "-eq", neg_big.as_str()], + [neg_big.as_str(), "-lt", "0"], + [neg_big.as_str(), "-lt", BIG], + [BIG, "-gt", neg_big.as_str()], + // A wider negative number is the smaller of the two. + [neg_big.as_str(), "-lt", neg_smaller.as_str()], + [neg_smaller.as_str(), "-gt", neg_big.as_str()], + ]; + + for test in &tests { + scenario.ucmd().args(&test[..]).succeeds(); + } + + // run the inverse of all these tests + for test in &tests { + scenario.ucmd().arg("!").args(&test[..]).fails_with_code(1); + } +} + +#[test] +fn test_int_compares_ignore_redundant_sign_and_leading_zeros() { + let scenario = TestScenario::new(util_name!()); + let padded_big = format!("+00{BIG}"); + + let tests = [ + // Zero carries no sign. + ["-0", "-eq", "0"], + ["+0", "-eq", "-0"], + ["0", "-eq", "0000000000"], + ["007", "-eq", "7"], + ["-007", "-eq", "-7"], + [padded_big.as_str(), "-eq", BIG], + ]; + + for test in &tests { + scenario.ucmd().args(&test[..]).succeeds(); + } +} + +#[test] +fn test_malformed_integers_are_still_errors() { + // Accepting wider operands must not make any of these parse. + for operand in ["1_0", "0x10", "1e3", "++5", "5-", "-", "+", "", "4 2"] { + new_ucmd!() + .args(&[operand, "-eq", "0"]) + .fails_with_code(2) + .stderr_is(format!("test: invalid integer '{operand}'\n")); + } +} + #[test] fn test_negative_int_compare() { let scenario = TestScenario::new(util_name!()); @@ -296,6 +450,7 @@ fn test_float_inequality_is_error() { #[test] #[cfg(not(windows))] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_invalid_utf8_integer_compare() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; @@ -335,13 +490,36 @@ fn test_isatty_whitespace_stripping() { } #[test] -#[cfg(unix)] +fn test_isatty_invalid_fd_is_false() { + // Asking the CRT about an unopened descriptor aborted the process. + new_ucmd!().args(&["-t", "99"]).fails_with_code(1); + new_ucmd!().args(&["-t", "-1"]).fails_with_code(1); +} + +#[test] +#[cfg(windows)] +fn test_isatty_unknown_fd_windows() { + // Only the standard streams are known on Windows. + new_ucmd!().args(&["-t", "3"]).fails_with_code(1); +} + +#[test] fn test_file_is_itself() { new_ucmd!() .args(&["regular_file", "-ef", "regular_file"]) .succeeds(); } +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] +// Disabled for android, since the temp dir doesn't allow creating hard links +#[cfg(not(target_os = "android"))] +fn test_hard_link_is_same_file() { + let (at, mut ucmd) = at_and_ucmd!(); + at.hard_link("regular_file", "hard_link"); + ucmd.args(&["regular_file", "-ef", "hard_link"]).succeeds(); +} + #[test] #[cfg(not(target_os = "android"))] fn test_file_is_newer_than_and_older_than_itself() { @@ -378,14 +556,12 @@ fn test_file_is_newer_than_non_existing_file() { } #[test] -#[cfg(unix)] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_same_device_inode() { let scenario = TestScenario::new(util_name!()); let at = &scenario.fixtures; - scenario.cmd("touch").arg("regular_file").succeeds(); - scenario.cmd("touch").arg("regular_file_second").succeeds(); - + at.touch("regular_file_second"); at.symlink_file("regular_file", "symlink"); scenario @@ -459,7 +635,8 @@ fn test_file_is_readable() { } #[test] -#[cfg(not(windows))] // FIXME: implement on Windows +#[cfg(not(windows))] +#[cfg_attr(wasi_runner, ignore = "WASI: no permission bits")] fn test_file_is_not_readable() { let scenario = TestScenario::new(util_name!()); let mut ucmd = scenario.ucmd(); @@ -472,12 +649,13 @@ fn test_file_is_not_readable() { } #[test] +#[cfg_attr(wasi_runner, ignore = "WASI: no permission bits")] fn test_file_is_writable() { new_ucmd!().args(&["-w", "regular_file"]).succeeds(); } #[test] -#[cfg(not(windows))] // FIXME: implement on Windows +#[cfg(not(windows))] fn test_file_is_not_writable() { let scenario = TestScenario::new(util_name!()); let mut ucmd = scenario.ucmd(); @@ -504,11 +682,8 @@ fn test_file_is_not_executable() { use std::os::unix::fs::PermissionsExt; let metadata = std::fs::metadata(at.plus("regular_file")).unwrap(); let mut permissions = metadata.permissions(); - - // The conversion is useless on some platforms and casts from u16 to - // u32 on others - #[allow(clippy::useless_conversion)] - permissions.set_mode(permissions.mode() & !u32::from(libc::S_IXUSR)); + #[allow(clippy::unnecessary_cast)] + permissions.set_mode(permissions.mode() & !(libc::S_IXUSR as u32)); std::fs::set_permissions(at.plus("regular_file"), permissions).unwrap(); } ucmd.args(&["!", "-x", "regular_file"]).succeeds(); @@ -516,6 +691,7 @@ fn test_file_is_not_executable() { #[test] #[cfg(not(windows))] +#[cfg_attr(wasi_runner, ignore = "WASI: no permission bits")] fn test_file_is_executable() { let scenario = TestScenario::new(util_name!()); let mut chmod = scenario.cmd("chmod"); @@ -538,12 +714,111 @@ fn test_file_is_not_writable_windows() { ucmd.args(&["!", "-w", "readonly_file"]).succeeds(); } +#[test] +#[cfg(windows)] +fn test_readonly_directory_is_writable_windows() { + // NTFS ignores the read-only attribute on directories. + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("readonly_dir"); + at.set_readonly("readonly_dir"); + ucmd.args(&["-w", "readonly_dir"]).succeeds(); +} + #[test] #[cfg(windows)] fn test_file_is_executable_windows() { let (at, mut ucmd) = at_and_ucmd!(); - at.touch("program.exe"); - ucmd.args(&["-x", "program.exe"]).succeeds(); + at.touch("PROGRAM.EXE"); + ucmd.args(&["-x", "PROGRAM.EXE"]).succeeds(); +} + +#[test] +#[cfg(windows)] +fn test_file_is_executable_from_pathext_windows() { + let scenario = TestScenario::new(util_name!()); + scenario.fixtures.touch("script.Py"); + scenario.fixtures.touch("program.exe"); + + scenario + .ucmd() + .env("PATHEXT", ".PY") + .args(&["-x", "script.Py"]) + .succeeds(); + + scenario + .ucmd() + .env("PATHEXT", ".PY") + .args(&["!", "-x", "program.exe"]) + .succeeds(); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI: no permission bits")] +fn test_directory_is_executable() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("dir"); + ucmd.args(&["-x", "dir"]).succeeds(); +} + +// Denies a single right so cleanup still works: it needs DELETE and directory +// listing. +#[cfg(windows)] +fn deny_everyone(scenario: &TestScenario, name: &str, rights: &str) { + let ace = format!("*S-1-1-0:({rights})"); + scenario + .cmd("icacls") + .args(&[name, "/deny", ace.as_str()]) + .succeeds(); +} + +#[test] +#[cfg(windows)] +fn test_file_is_not_readable_windows() { + let scenario = TestScenario::new(util_name!()); + scenario.fixtures.touch("crypto_file"); + deny_everyone(&scenario, "crypto_file", "RD"); + + scenario.ucmd().args(&["!", "-r", "crypto_file"]).succeeds(); +} + +#[test] +#[cfg(windows)] +fn test_file_is_not_writable_by_acl_windows() { + let scenario = TestScenario::new(util_name!()); + let at = &scenario.fixtures; + at.touch("immutable_file"); + at.mkdir("immutable_dir"); + deny_everyone(&scenario, "immutable_file", "WD"); + deny_everyone(&scenario, "immutable_dir", "WD"); + + scenario + .ucmd() + .args(&["!", "-w", "immutable_file"]) + .succeeds(); + scenario + .ucmd() + .args(&["!", "-w", "immutable_dir"]) + .succeeds(); +} + +#[test] +#[cfg(windows)] +fn test_file_is_not_executable_by_acl_windows() { + let scenario = TestScenario::new(util_name!()); + scenario.fixtures.touch("program.exe"); + deny_everyone(&scenario, "program.exe", "X"); + + scenario.ucmd().args(&["!", "-x", "program.exe"]).succeeds(); +} + +#[test] +#[cfg(windows)] +fn test_directory_is_not_executable_by_acl_windows() { + let scenario = TestScenario::new(util_name!()); + scenario.fixtures.mkdir("locked_dir"); + deny_everyone(&scenario, "locked_dir", "X"); + + scenario.ucmd().args(&["!", "-x", "locked_dir"]).succeeds(); } #[test] @@ -564,14 +839,12 @@ fn test_not_is_not_empty() { } #[test] -#[cfg(not(windows))] fn test_symlink_is_symlink() { let scenario = TestScenario::new(util_name!()); let at = &scenario.fixtures; at.symlink_file("regular_file", "symlink"); - // FIXME: implement on Windows scenario.ucmd().args(&["-h", "symlink"]).succeeds(); scenario.ucmd().args(&["-L", "symlink"]).succeeds(); } @@ -608,6 +881,7 @@ fn test_nonexistent_file_is_not_symlink() { // Only the superuser is allowed to set the sticky bit on files on FreeBSD/OpenBSD. // Windows has no concept of sticky bit #[cfg(not(any(windows, target_os = "freebsd", target_os = "openbsd")))] +#[cfg_attr(wasi_runner, ignore = "WASI: no permission bits")] fn test_file_is_sticky() { let scenario = TestScenario::new(util_name!()); let mut ucmd = scenario.ucmd(); @@ -703,13 +977,12 @@ fn test_parenthesized_right_parenthesis_as_literal() { } #[test] -#[cfg(not(windows))] +#[cfg_attr(wasi_runner, ignore = "WASI: no uid/gid")] fn test_file_owned_by_euid() { new_ucmd!().args(&["-O", "regular_file"]).succeeds(); } #[test] -#[cfg(not(windows))] fn test_nonexistent_file_not_owned_by_euid() { new_ucmd!() .args(&["-O", "nonexistent_file"]) @@ -718,6 +991,7 @@ fn test_nonexistent_file_not_owned_by_euid() { #[test] #[cfg(not(windows))] +#[cfg_attr(wasi_runner, ignore = "WASI: no uid/gid")] fn test_file_not_owned_by_euid() { new_ucmd!() .args(&["-f", "/bin/sh", "-a", "!", "-O", "/bin/sh"]) @@ -726,6 +1000,7 @@ fn test_file_not_owned_by_euid() { #[test] #[cfg(not(windows))] +#[cfg_attr(wasi_runner, ignore = "WASI: no uid/gid")] fn test_file_owned_by_egid() { // On some platforms (mostly the BSDs) the test fixture files copied to the // /tmp directory will have a different gid than the current egid (due to @@ -749,7 +1024,6 @@ fn test_file_owned_by_egid() { } #[test] -#[cfg(not(windows))] fn test_nonexistent_file_not_owned_by_egid() { new_ucmd!() .args(&["-G", "nonexistent_file"]) @@ -758,6 +1032,7 @@ fn test_nonexistent_file_not_owned_by_egid() { #[test] #[cfg(not(windows))] +#[cfg_attr(wasi_runner, ignore = "WASI: no uid/gid")] fn test_file_not_owned_by_egid() { let target_file = if cfg!(target_os = "freebsd") { // The coreutils test runner user has a primary group id of "wheel", @@ -773,6 +1048,24 @@ fn test_file_not_owned_by_egid() { .succeeds(); } +#[test] +#[cfg(windows)] +fn test_file_owned_by_current_group_windows() { + new_ucmd!().args(&["-G", "regular_file"]).succeeds(); +} + +#[test] +#[cfg(windows)] +fn test_file_not_owned_by_current_token_windows() { + // The system directory belongs to TrustedInstaller, so it is owned neither + // by the user nor by the Administrators group an elevated shell runs as. + let system_root = std::env::var("SystemRoot").expect("SystemRoot is not set"); + + new_ucmd!() + .args(&["-d", &system_root, "-a", "!", "-O", &system_root]) + .succeeds(); +} + #[test] fn test_op_precedence_and_or_1() { new_ucmd!().args(&[" ", "-o", "", "-a", ""]).succeeds(); @@ -870,7 +1163,6 @@ fn test_inverted_parenthetical_bool_op_precedence() { } #[test] -#[ignore = "fixme: error reporting"] fn test_dangling_parenthesis() { new_ucmd!() .args(&["(", "(", "a", "!=", "b", ")", "-o", "-n", "c"]) @@ -906,17 +1198,45 @@ fn test_or_as_filename() { } #[test] -#[ignore = "TODO: Busybox has this working"] fn test_filename_or_with_equal() { new_ucmd!().args(&["-f", "=", "a", "-o", "b"]).succeeds(); } #[test] -#[ignore = "GNU considers this an error"] fn test_string_length_and_nothing() { new_ucmd!().args(&["-n", "a", "-a"]).fails_with_code(2); } +#[test] +fn test_boolop_without_right_operand() { + for op in ["-a", "-o"] { + new_ucmd!() + .args(&["x", op]) + .fails_with_code(2) + .stderr_is(format!("test: missing argument after '{op}'\n")); + + // An empty left operand is still an operand. + new_ucmd!().args(&["", op]).fails_with_code(2); + + // Whatever precedes it, the trailing BOOLOP has nothing to join to. + new_ucmd!().args(&["x", "-a", "y", op]).fails_with_code(2); + new_ucmd!().args(&["(", "x", ")", op]).fails_with_code(2); + new_ucmd!().args(&["!", "x", op]).fails_with_code(2); + } +} + +#[test] +fn test_lone_boolop_is_a_string() { + // With no operand on either side, -a and -o are ordinary strings. + for op in ["-a", "-o"] { + new_ucmd!().arg(op).succeeds(); + new_ucmd!().args(&["!", op]).fails_with_code(1); + } + + // A unary operator still takes the BOOLOP as its operand. + new_ucmd!().args(&["-n", "-a"]).succeeds(); +} + #[test] fn test_bracket_syntax_success() { let scenario = TestScenario::new("["); @@ -964,7 +1284,6 @@ fn test_bracket_syntax_version() { #[test] #[allow(non_snake_case)] -#[cfg(unix)] fn test_file_N() { use std::{fs::FileTimes, time::Duration}; @@ -980,6 +1299,7 @@ fn test_file_N() { .set_modified(std::time::UNIX_EPOCH); f.set_times(times).unwrap(); // TODO: stat call for debugging #7570, remove? + #[cfg(unix)] println!("{}", scene.cmd_shell("stat file").succeeds().stdout_str()); scene.ucmd().args(&["-N", "file"]).fails(); @@ -990,6 +1310,7 @@ fn test_file_N() { .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(123)); f.set_times(times).unwrap(); // TODO: stat call for debugging #7570, remove? + #[cfg(unix)] println!("{}", scene.cmd_shell("stat file").succeeds().stdout_str()); scene.ucmd().args(&["-N", "file"]).succeeds(); } @@ -1069,3 +1390,209 @@ fn test_unary_op_as_literal_in_three_arg_form() { new_ucmd!().args(&["-f", "=", "a"]).fails_with_code(1); new_ucmd!().args(&["-f", "=", "a", "-o", "b"]).succeeds(); } + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_offending_argument() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["7", "-eq", "zap"]) + .fails_with_code(2); + + // The whole report: the `test: ` prefix of the plain form, the + // expression echoed back, a caret on `zap`, and the operator advice. + assert_eq!( + result.stderr_as_displayed(), + "\ +test: invalid integer 'zap' + ╭─[ test:1:7 ] + │ + 1 │ 7 -eq zap + │ ─── + │ + │ Help: -eq, -ne, -lt, -le, -gt and -ge compare integers; use =, !=, < or > to compare strings + │ -eq equal, -ne not equal, -lt less than, -le less than or equal, -gt greater than, -ge greater than or equal +───╯" + ); + } + + #[test] + fn test_plain_message_is_the_default() { + // The test harness pipes stderr, so the report must not appear. + new_ucmd!() + .args(&["7", "-eq", "zap"]) + .fails_with_code(2) + .stderr_is("test: invalid integer 'zap'\n"); + } + + #[cfg(unix)] + #[test] + fn test_extra_argument_points_past_the_expression() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["k", "!=", "m", "spare"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +test: extra argument 'spare' + ╭─[ test:1:8 ] + │ + 1 │ k != m spare + │ ──┬── + │ ╰──── the expression was already complete here + │ + │ Help: an unquoted variable expanding to several words is the usual cause; quote it as \"$var\" to keep it a single operand +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_missing_operand_suggests_quoting_the_variable() { + // `test "$empty" -gt 1` with an unset variable ends up here: the + // operator is left without a right-hand operand. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["31", "-gt"]) + .fails_with_code(2); + + // The caret sits on the operator left dangling, and the advice names + // the cause rather than restating the error. + assert_eq!( + result.stderr_as_displayed(), + "\ +test: missing argument after '-gt' + ╭─[ test:1:4 ] + │ + 1 │ 31 -gt + │ ─── + │ + │ Help: an unset or empty variable expands to nothing, leaving the operator without an operand; quote it as \"$var\" +───╯" + ); + } + + #[test] + fn test_missing_operand_plain_message_is_unchanged() { + // Piped stderr keeps the one-line form scripts match on. + new_ucmd!() + .args(&["31", "-gt"]) + .fails_with_code(2) + .stderr_is("test: missing argument after '-gt'\n"); + } + + #[cfg(unix)] + #[test] + fn test_extra_operand_suggests_quoting_the_variable() { + // What `test x = $fruit` looks like when fruit="ripe pear". + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["x", "=", "ripe", "pear"]) + .fails_with_code(2); + + // The caret lands on the word past the end of the comparison, and the + // integer advice — which belongs to another error — stays out of it. + assert_eq!( + result.stderr_as_displayed(), + "\ +test: extra argument 'pear' + ╭─[ test:1:10 ] + │ + 1 │ x = ripe pear + │ ──┬─ + │ ╰─── the expression was already complete here + │ + │ Help: an unquoted variable expanding to several words is the usual cause; quote it as \"$var\" to keep it a single operand +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_dash_t_advises_about_descriptors_not_comparisons() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-t", "stdout"]) + .fails_with_code(2); + + // The message stays the one GNU prints; only the label and the advice + // are specific to `-t`, which has nothing to do with `-eq` and friends. + assert_eq!( + result.stderr_as_displayed(), + "\ +test: invalid integer 'stdout' + ╭─[ test:1:4 ] + │ + 1 │ -t stdout + │ ────── + │ + │ Help: -t takes a file descriptor number: 0 is standard input, 1 standard output, 2 standard error +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_integer_comparison_keeps_its_own_advice() { + // The counterpart of the test above: the same operand under a real + // comparison still gets the operator advice, not the descriptor one. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["1", "-eq", "stdout"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +test: invalid integer 'stdout' + ╭─[ test:1:7 ] + │ + 1 │ 1 -eq stdout + │ ────── + │ + │ Help: -eq, -ne, -lt, -le, -gt and -ge compare integers; use =, !=, < or > to compare strings + │ -eq equal, -ne not equal, -lt less than, -le less than or equal, -gt greater than, -ge greater than or equal +───╯" + ); + } + + #[test] + fn test_dash_t_plain_message_is_unchanged() { + new_ucmd!() + .args(&["-t", "stdout"]) + .fails_with_code(2) + .stderr_is("test: invalid integer 'stdout'\n"); + } + + #[cfg(unix)] + #[test] + fn test_bracket_form_reports_under_its_own_name() { + // Both the header and the snippet name `[`, and the trailing `]` is + // dropped before the expression is echoed back. + let result = TestScenario::new("[") + .ucmd() + .terminal_sim_stderr() + .args(&["7", "-eq", "zap", "]"]) + .fails_with_code(2); + + assert_eq!( + result.stderr_as_displayed(), + "\ +[: invalid integer 'zap' + ╭─[ [:1:7 ] + │ + 1 │ 7 -eq zap + │ ─── + │ + │ Help: -eq, -ne, -lt, -le, -gt and -ge compare integers; use =, !=, < or > to compare strings + │ -eq equal, -ne not equal, -lt less than, -le less than or equal, -gt greater than, -ge greater than or equal +───╯" + ); + } +} diff --git a/tests/by-util/test_timeout.rs b/tests/by-util/test_timeout.rs index 5e24fdb1095..3bc087d9e01 100644 --- a/tests/by-util/test_timeout.rs +++ b/tests/by-util/test_timeout.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 dont +// spell-checker:ignore dont SIGBREAK use rstest::rstest; use std::time::Duration; @@ -11,6 +11,15 @@ use uucore::display::Quotable; use uutests::util::TestScenario; use uutests::{new_ucmd, util_name}; +/// A scenario plus the path to the multicall test binary, used to run child +/// commands (`sleep`, `true`, ...) portably: it exists on every test platform, +/// unlike `sh`/`sleep` from `PATH`. +fn scenario_with_bin() -> (TestScenario, String) { + let ts = TestScenario::new(util_name!()); + let bin = ts.bin_path.to_string_lossy().into_owned(); + (ts, bin) +} + #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(125); @@ -18,9 +27,10 @@ fn test_invalid_arg() { #[test] fn test_subcommand_return_code() { - new_ucmd!().arg("1").arg("true").succeeds(); + let (ts, bin) = scenario_with_bin(); + ts.ucmd().args(&["1", &bin, "true"]).succeeds(); - new_ucmd!().arg("1").arg("false").fails_with_code(1); + ts.ucmd().args(&["1", &bin, "false"]).fails_with_code(1); } #[rstest] @@ -43,34 +53,54 @@ fn test_invalid_kill_after() { #[test] fn test_command_with_args() { - new_ucmd!() - .args(&["1700", "echo", "-n", "abcd"]) + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&["1700", &bin, "echo", "-n", "abcd"]) .succeeds() .stdout_only("abcd"); } +#[test] +#[cfg(unix)] +fn test_command_with_non_utf8_args() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&["1700", &bin, "echo"]) + .arg(OsStr::from_bytes(b"a\xffb")) + .succeeds() + .stdout_only_bytes(b"a\xffb\n"); +} + #[test] fn test_verbose() { + let (ts, bin) = scenario_with_bin(); for verbose_flag in ["-v", "--verbose"] { - new_ucmd!() - .args(&[verbose_flag, ".1", "sleep", "1"]) + ts.ucmd() + .args(&[verbose_flag, ".1", &bin, "sleep", "1"]) .fails() - .stderr_only("timeout: sending signal TERM to command 'sleep'\n"); - new_ucmd!() - .args(&[verbose_flag, "-s0", "-k.1", ".1", "sleep", "1"]) + .no_stdout() + .stderr_contains("timeout: sending signal TERM to command"); + ts.ucmd() + .args(&[verbose_flag, "-s0", "-k.1", ".1", &bin, "sleep", "1"]) .fails() - .stderr_only("timeout: sending signal 0 to command 'sleep'\ntimeout: sending signal KILL to command 'sleep'\n"); + .no_stdout() + .stderr_contains("timeout: sending signal 0 to command") + .stderr_contains("timeout: sending signal KILL to command"); } } #[test] fn test_zero_timeout() { - new_ucmd!() - .args(&["-v", "0", "sleep", ".1"]) + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&["-v", "0", &bin, "sleep", ".1"]) .succeeds() .no_output(); - new_ucmd!() - .args(&["-v", "0", "-s0", "-k0", "sleep", ".1"]) + ts.ucmd() + .args(&["-v", "0", "-s0", "-k0", &bin, "sleep", ".1"]) .succeeds() .no_output(); } @@ -85,9 +115,10 @@ fn test_command_empty_args() { #[test] fn test_foreground() { + let (ts, bin) = scenario_with_bin(); for arg in ["-f", "--foreground"] { - new_ucmd!() - .args(&[arg, ".1", "sleep", "10"]) + ts.ucmd() + .args(&[arg, ".1", &bin, "sleep", "10"]) .fails_with_code(124) .no_output(); } @@ -95,9 +126,10 @@ fn test_foreground() { #[test] fn test_preserve_status() { + let (ts, bin) = scenario_with_bin(); for arg in ["-p", "--preserve-status"] { - new_ucmd!() - .args(&[arg, ".1", "sleep", "10"]) + ts.ucmd() + .args(&[arg, ".1", &bin, "sleep", "10"]) // 128 + SIGTERM = 128 + 15 .fails_with_code(128 + 15) .no_output(); @@ -106,18 +138,53 @@ fn test_preserve_status() { #[test] fn test_kill_after_preserves_timeout_exit_without_preserve_status() { - new_ucmd!() - .args(&["-k", "1", "1", "sleep", "10"]) + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&["-k", "1", "1", &bin, "sleep", "10"]) + .fails_with_code(124) + .no_output(); +} + +/// A timeout signal of KILL reports 128 + 9 rather than 124, with or +/// without `--kill-after`; other signals keep reporting 124. +#[test] +fn test_kill_signal_reports_signal_exit_code() { + let (ts, bin) = scenario_with_bin(); + for args in [ + &["-s", "KILL", ".05"][..], + &["--signal=9", ".05"], + &["-s", "SIGKILL", "--kill-after=3", ".05"], + // Only the child is signaled here, but the status is still 128+9. + &["--foreground", "-s", "KILL", ".05"], + ] { + ts.ucmd() + .args(args) + .args(&[bin.as_str(), "sleep", "20"]) + .fails_with_code(128 + 9) + .no_output(); + } + ts.ucmd() + .args(&["-s", "HUP", ".05", &bin, "sleep", "20"]) .fails_with_code(124) .no_output(); } + #[test] fn test_preserve_status_even_when_send_signal() { + let (ts, bin) = scenario_with_bin(); // When sending CONT signal, process doesn't get killed or stopped. // So, expected result is success and code 0. for cont_spelling in ["CONT", "cOnT", "SIGcont"] { - new_ucmd!() - .args(&["-s", cont_spelling, "--preserve-status", ".1", "sleep", "1"]) + ts.ucmd() + .args(&[ + "-s", + cont_spelling, + "--preserve-status", + ".1", + &bin, + "sleep", + "1", + ]) .succeeds() .no_output(); } @@ -125,30 +192,32 @@ fn test_preserve_status_even_when_send_signal() { #[test] fn test_dont_overflow() { - new_ucmd!() - .args(&["9223372036854775808d", "sleep", "0"]) + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&["9223372036854775808d", &bin, "sleep", "0"]) .succeeds() .no_output(); - new_ucmd!() - .args(&["-k", "9223372036854775808d", "10", "sleep", "0"]) + ts.ucmd() + .args(&["-k", "9223372036854775808d", "10", &bin, "sleep", "0"]) .succeeds() .no_output(); } #[test] fn test_dont_underflow() { - new_ucmd!() - .args(&[".0000000001", "sleep", "1"]) + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&[".0000000001", &bin, "sleep", "1"]) .fails_with_code(124) .no_output(); - new_ucmd!() - .args(&["1e-100", "sleep", "1"]) + ts.ucmd() + .args(&["1e-100", &bin, "sleep", "1"]) .fails_with_code(124) .no_output(); // Unlike GNU coreutils, we underflow to 1ns for very short timeouts. // https://debbugs.gnu.org/cgi/bugreport.cgi?bug=77535 - new_ucmd!() - .args(&["1e-18172487393827593258", "sleep", "1"]) + ts.ucmd() + .args(&["1e-18172487393827593258", &bin, "sleep", "1"]) .fails_with_code(124) .no_output(); } @@ -180,13 +249,15 @@ fn test_invalid_multi_byte_characters() { /// Test that the long form of the `--kill-after` argument is recognized. #[test] fn test_kill_after_long() { - new_ucmd!() - .args(&["--kill-after=1", "1", "sleep", "0"]) + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&["--kill-after=1", "1", &bin, "sleep", "0"]) .succeeds() .no_output(); } #[test] +#[cfg(unix)] fn test_kill_subprocess() { new_ucmd!() .args(&[ @@ -202,14 +273,20 @@ fn test_kill_subprocess() { #[test] fn test_hex_timeout_ending_with_d() { - new_ucmd!() - .args(&["0x0.1d", "sleep", "10"]) + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&["0x0.1d", &bin, "sleep", "10"]) .timeout(Duration::from_secs(1)) .fails_with_code(124) .no_output(); } #[test] +#[cfg_attr( + all(target_vendor = "apple", target_arch = "x86_64"), + ignore = "wontfix: intermittent failure in obsolete macOS x86_64 (macOS bug)." +)] +#[cfg(unix)] fn test_terminate_child_on_receiving_terminate() { let mut timeout_cmd = new_ucmd!() .args(&[ @@ -237,6 +314,39 @@ fn test_command_not_found() { .fails_with_code(127); } +#[test] +#[cfg(unix)] +fn test_non_utf8_command_not_found() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let ts = TestScenario::new(util_name!()); + ts.ucmd() + .arg("1") + .arg(ts.fixtures.plus(OsStr::from_bytes(b"absent-\xff"))) + .fails_with_code(127) + .no_stdout(); +} + +#[test] +#[cfg(all(unix, not(target_vendor = "apple")))] +fn test_non_utf8_command_path() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let ts = TestScenario::new(util_name!()); + let dir = ts.fixtures.plus(OsStr::from_bytes(b"dir-\xff")); + std::fs::create_dir(&dir).unwrap(); + let cmd = dir.join("coreutils"); + std::os::unix::fs::symlink(&ts.bin_path, &cmd).unwrap(); + ts.ucmd() + .arg("10") + .arg(&cmd) + .args(&["echo", "ran"]) + .succeeds() + .stdout_only("ran\n"); +} + #[test] fn test_command_cannot_invoke() { // Test exit code 126 when command exists but cannot be invoked @@ -279,9 +389,9 @@ fn test_forward_sigint_to_child() { "trap 'echo got_int; exit 42' INT; sleep 5", ]) .run_no_wait(); - #[cfg(target_os = "macos")] + #[cfg(target_vendor = "apple")] cmd.delay(1000); - #[cfg(not(target_os = "macos"))] + #[cfg(not(target_vendor = "apple"))] cmd.delay(100); cmd.kill_with_custom_signal(nix::sys::signal::Signal::SIGINT); cmd.make_assertion() @@ -292,8 +402,9 @@ fn test_forward_sigint_to_child() { #[test] fn test_foreground_signal0_kill_after() { - new_ucmd!() - .args(&["--foreground", "-s0", "-k.1", ".1", "sleep", "10"]) + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&["--foreground", "-s0", "-k.1", ".1", &bin, "sleep", "10"]) .fails_with_code(137); } @@ -314,3 +425,93 @@ fn test_realtime_signal_names() { .fails() .stderr_contains("sending signal RTMAX to command"); } + +/// The whole process tree must be timed out in non-foreground mode, not just +/// the direct child: a grandchild sequencer (an inner `cmd.exe`) would +/// otherwise survive the timeout and create the marker file. +#[test] +#[cfg(windows)] +fn test_windows_kills_process_tree() { + let (ts, bin) = scenario_with_bin(); + let at = &ts.fixtures; + at.write( + "tree_grandchild.bat", + &format!("@echo off\r\n\"{bin}\" sleep 1\r\n\"{bin}\" touch tree_marker\r\n"), + ); + // Outer cmd is timeout's direct child; inner cmd (running the batch file) + // is the grandchild doing the work. + ts.ucmd() + .args(&[".3", "cmd", "/c", "cmd", "/c", "tree_grandchild.bat"]) + .fails_with_code(124); + // Give a surviving grandchild ample time to reach the `touch`. + std::thread::sleep(Duration::from_millis(2500)); + assert!( + !at.file_exists("tree_marker"), + "grandchild survived the process-tree kill" + ); +} + +/// `-s INT` is delivered as a CTRL_BREAK to the child's group (or termination +/// without a console); either way the child dies and timeout reports 124. +#[test] +#[cfg(windows)] +fn test_windows_int_signal_kills_child() { + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&["-v", "-s", "INT", ".1", &bin, "sleep", "10"]) + .fails_with_code(124) + .stderr_contains("sending signal INT to command"); +} + +#[test] +#[cfg(windows)] +fn test_windows_kill_after() { + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&["-s0", "-k", ".2", ".2", &bin, "sleep", "10"]) + .fails_with_code(137); +} + +/// Terminations use exit code 128 + N, so `--preserve-status` reports the +/// same codes as on unix. +#[test] +#[cfg(windows)] +fn test_windows_preserve_status_signal_codes() { + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&["-p", "-s", "KILL", ".1", &bin, "sleep", "10"]) + .fails_with_code(128 + 9); + ts.ucmd() + .args(&["-p", "-s", "HUP", ".1", &bin, "sleep", "10"]) + .fails_with_code(128 + 1); +} + +/// POSIX signal names/numbers are accepted; names unix would reject (the +/// CRT-only SIGBREAK) are rejected here too. +#[test] +#[cfg(windows)] +fn test_windows_signal_name_parsing() { + let (ts, bin) = scenario_with_bin(); + ts.ucmd() + .args(&["-s", "SIGBREAK", "1", &bin, "true"]) + .fails_with_code(125) + .usage_error("'SIGBREAK': invalid signal"); + ts.ucmd() + .args(&["-s", "USR1", "1", &bin, "true"]) + .succeeds(); + ts.ucmd().args(&["-s", "9", "1", &bin, "true"]).succeeds(); +} + +/// A file that exists but is not executable yields 126, like unix. +#[test] +#[cfg(windows)] +fn test_windows_command_cannot_invoke() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.touch("not_executable.txt"); + // The explicit path is needed: a bare filename is searched in PATH (not + // the current directory) and would fail with 127 instead. + ts.ucmd() + .args(&["1", ".\\not_executable.txt"]) + .fails_with_code(126); +} diff --git a/tests/by-util/test_touch.rs b/tests/by-util/test_touch.rs index ea94bfc1310..1a585bc82b4 100644 --- a/tests/by-util/test_touch.rs +++ b/tests/by-util/test_touch.rs @@ -2,7 +2,8 @@ // // 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 datetime mdhm mdhms mktime strtime ymdhm ymdhms + +// spell-checker:ignore (formats) cymdhm cymdhms datetime filestat mdhm mdhms mktime preopen strtime tzdb ymdhm ymdhms use filetime::FileTime; #[cfg(not(target_os = "freebsd"))] @@ -166,6 +167,10 @@ fn test_touch_2_digit_years_2038() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: pre-epoch timestamps not representable by path_filestat_set_times" +)] fn test_touch_2_digit_years_69() { // 69 and after are 19xx let (at, mut ucmd) = at_and_ucmd!(); @@ -623,6 +628,10 @@ fn test_touch_set_date7() { } #[test] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no tzdb; TZ env var is not honoured so timezone-dependent timestamps differ" +)] fn test_touch_set_date_without_leading_zeroes() { let (at, mut ucmd) = at_and_ucmd!(); let file = "test_touch_set_date_without_leading_zeroes"; @@ -645,7 +654,11 @@ fn test_touch_set_date_without_leading_zeroes() { /// expected by the old nix-based implementation. After switching to rustix /// (which uses i64 `tv_sec` natively), this should succeed on all targets. #[test] -#[cfg(target_os = "linux")] +#[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: pre-epoch timestamps not representable by path_filestat_set_times" +)] fn test_touch_set_date_year_zero() { let (at, mut ucmd) = at_and_ucmd!(); let file = "test_touch_year_zero"; @@ -780,6 +793,10 @@ fn test_touch_mtime_dst_succeeds() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: no tzdb; TZ env var is not honoured so DST validation is skipped" +)] fn test_touch_mtime_dst_fails() { let file = "test_touch_set_mtime_dst_fails"; @@ -797,6 +814,10 @@ fn test_touch_mtime_dst_fails() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: guest root is a writable preopen, not the protected system root" +)] fn test_touch_system_fails() { let file = "/"; new_ucmd!() @@ -805,6 +826,25 @@ fn test_touch_system_fails() { .stderr_contains("setting times of '/'"); } +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: guest root is a writable preopen, not the protected system root" +)] +fn test_touch_explicit_time_on_root_owned_file_eperm() { + // Setting explicit timestamps on a file we do not own fails with EPERM + // (not EACCES), which GNU reports as "Operation not permitted" (#15020). + if rustix::process::geteuid().is_root() { + println!("Skipping test when running as root"); + return; + } + new_ucmd!() + .args(&["-d", "2000-01-01", "/"]) + .fails() + .stderr_only("touch: setting times of '/': Operation not permitted\n"); +} + #[test] #[cfg(unix)] #[cfg_attr(wasi_runner, ignore = "WASI: no FIFO support")] @@ -842,7 +882,7 @@ fn test_touch_dash_updates_stdout_file() { } #[test] -#[cfg(not(target_os = "windows"))] +#[cfg(not(windows))] fn test_touch_trailing_slash() { let file = "no-file/"; new_ucmd!().args(&[file]).fails().stderr_only(format!( @@ -851,7 +891,7 @@ fn test_touch_trailing_slash() { } #[test] -#[cfg(target_os = "windows")] +#[cfg(windows)] fn test_touch_trailing_slash_windows() { let file = "no-file/"; new_ucmd!() @@ -874,6 +914,7 @@ fn test_touch_no_such_file_error_msg() { #[test] #[cfg(not(any(target_os = "freebsd", target_os = "openbsd")))] +#[cfg_attr(wasi_runner, ignore = "WASI: touch - (stdout) is unsupported")] fn test_touch_changes_time_of_file_in_stdout() { // command like: `touch - 1< ./c` // should change the timestamp of c @@ -896,6 +937,10 @@ fn test_touch_changes_time_of_file_in_stdout() { #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: filesystem permission errors surface as ENOENT rather than EACCES" +)] fn test_touch_permission_denied_error_msg() { let (at, mut ucmd) = at_and_ucmd!(); @@ -976,7 +1021,7 @@ fn test_touch_trailing_slash_no_create() { ucmd.args(&["-c", "loop/"]).fails_with_code(1); assert!(!at.file_exists("loop")); - #[cfg(not(target_os = "macos"))] + #[cfg(not(target_vendor = "apple"))] // MacOS supports trailing slash for symlinks to files { let (at, mut ucmd) = at_and_ucmd!(); @@ -1016,10 +1061,20 @@ fn test_touch_no_dereference_dangling() { #[test] #[cfg(not(target_os = "openbsd"))] +#[cfg_attr(wasi_runner, ignore = "WASI: touch - (stdout) is unsupported")] fn test_touch_dash() { new_ucmd!().args(&["-h", "-"]).succeeds().no_output(); } +#[test] +#[cfg(wasi_runner)] +fn test_touch_dash_unsupported() { + new_ucmd!() + .arg("-") + .fails_with_code(1) + .stderr_only("touch: touch - (stdout) is not supported on WASI\n"); +} + #[test] fn test_touch_invalid_date_format() { let file = "test_touch_invalid_date_format"; @@ -1056,15 +1111,22 @@ fn test_touch_symlink_with_no_deref() { let (at, mut ucmd) = at_and_ucmd!(); let target = "foo.txt"; let symlink = "bar.txt"; - let time = FileTime::from_unix_time(123, 0); + let initial_time = FileTime::from_unix_time(123, 0); + let updated_atime = FileTime::from_unix_time(456, 0); at.touch(target); + let target_times = get_file_times(&at, target); at.relative_symlink_file(target, symlink); - set_symlink_file_times(at.plus(symlink), time, time).unwrap(); + set_symlink_file_times(at.plus(symlink), initial_time, initial_time).unwrap(); - ucmd.args(&["-a", "--no-dereference", symlink]).succeeds(); - // Modification time shouldn't be set to the destination's modification time - assert_eq!(time, get_symlink_times(&at, symlink).1); + ucmd.args(&["-a", "--no-dereference", "-d", "@456", symlink]) + .succeeds(); + + assert_eq!( + (updated_atime, initial_time), + get_symlink_times(&at, symlink) + ); + assert_eq!(target_times, get_file_times(&at, target)); } #[test] @@ -1124,6 +1186,7 @@ fn test_touch_f_option() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_touch_non_utf8_paths() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; @@ -1140,6 +1203,7 @@ fn test_touch_non_utf8_paths() { #[test] #[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] fn test_touch_device_files() { let (_, mut ucmd) = at_and_ucmd!(); ucmd.args(&["/dev/null", "/dev/zero", "/dev/full", "/dev/random"]) @@ -1155,6 +1219,10 @@ fn test_touch_device_files() { // check in util/check-safe-traversal.sh. #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: absolute symlink targets cannot be followed" +)] fn test_touch_does_not_truncate_symlink_target() { use std::os::unix::fs::symlink; @@ -1170,6 +1238,10 @@ fn test_touch_does_not_truncate_symlink_target() { // Touching a dangling symlink creates its target as an empty file, like GNU. #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: absolute symlink targets cannot be followed" +)] fn test_touch_through_dangling_symlink_creates_target() { use std::os::unix::fs::symlink; @@ -1181,3 +1253,29 @@ fn test_touch_through_dangling_symlink_creates_target() { assert!(at.file_exists("missing")); assert_eq!(at.read("missing"), ""); } + +// touch must be able to update the times of an owned file that is neither +// readable nor writable (mode 0). Setting explicit times via utimensat-by-path +// only requires ownership, so this succeeds even though the file cannot be +// opened. Regression test: touch should fail gracefully on unwritable directory. +#[test] +#[cfg(unix)] +fn test_touch_set_time_on_unreadable_unwritable_file() { + use std::os::unix::fs::PermissionsExt; + + let (at, mut ucmd) = at_and_ucmd!(); + let file = "no_rights"; + at.touch(file); + std::fs::set_permissions(at.plus(file), std::fs::Permissions::from_mode(0o000)).unwrap(); + + ucmd.args(&["-d", "2000-01-03 00:00", "-c", file]) + .succeeds() + .no_output(); + + // Restore permissions so the test harness can read back the metadata. + std::fs::set_permissions(at.plus(file), std::fs::Permissions::from_mode(0o644)).unwrap(); + let expected = str_to_filetime("%Y-%m-%d %H:%M", "2000-01-03 00:00"); + let (atime, mtime) = get_file_times(&at, file); + assert_eq!(atime, expected); + assert_eq!(mtime, expected); +} diff --git a/tests/by-util/test_tr.rs b/tests/by-util/test_tr.rs index 066474fc985..b2655de2e05 100644 --- a/tests/by-util/test_tr.rs +++ b/tests/by-util/test_tr.rs @@ -2,7 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore aabbaa aabbcc aabc abbb abbbcddd abcc abcdefabcdef abcdefghijk abcdefghijklmn abcdefghijklmnop ABCDEFGHIJKLMNOPQRS abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ ABCDEFZZ abcxyz ABCXYZ abcxyzabcxyz ABCXYZABCXYZ acbdef alnum amzamz AMZXAMZ bbbd cclass cefgm cntrl compl dabcdef dncase fooclass Gzabcdefg PQRST upcase wxyzz xdigit XXXYYY xycde xyyye xyyz xyzzzzxyzzzz ZABCDEF Zamz Cdefghijkl Cdefghijklmn asdfqqwweerr qwerr asdfqwer qwer aassddffqwer asdfqwer + +// spell-checker:ignore lowre punct aabbaa aabbcc aabc abbb abbbcddd abcc abcdefabcdef abcdefghijk abcdefghijklmn abcdefghijklmnop ABCDEFGHIJKLMNOPQRS abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ ABCDEFZZ abcxyz ABCXYZ abcxyzabcxyz ABCXYZABCXYZ acbdef alnum amzamz AMZXAMZ bbbd cclass cefgm cntrl compl dabcdef dncase fooclass Gzabcdefg PQRST upcase wxyzz xdigit XXXYYY xycde xyyye xyyz xyzzzzxyzzzz ZABCDEF Zamz Cdefghijkl Cdefghijklmn asdfqqwweerr qwerr asdfqwer qwer aassddffqwer asdfqwer mnop mnopq pqopq pqqqq ppqqrr pqrr ppqr ppqqpp + use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -154,6 +156,87 @@ fn test_delete_complement_2() { .stdout_is("01234567890"); } +#[test] +fn test_delete_one_char_large_input() { + // Longer than one read, with no, few, many and only matches. `few` is + // sparse enough for the path that copies the runs between matches. + let none = vec![b'x'; 40_000]; + let few = [[b'x'; 999].as_slice(), b","].concat().repeat(40); + let many = b"a,".repeat(20_000); + let only = vec![b','; 40_000]; + for input in [none, few, many, only] { + let expected: Vec = input.iter().copied().filter(|&b| b != b',').collect(); + new_ucmd!() + .args(&["-d", ","]) + .pipe_in(input) + .succeeds() + .stdout_is_bytes(expected); + } +} + +#[test] +fn test_delete_set_large_input() { + let input: Vec = (0..=u8::MAX).cycle().take(40_000).collect(); + + let expected: Vec = input + .iter() + .copied() + .filter(|&b| !matches!(b, 0 | b'a'..=b'f' | u8::MAX)) + .collect(); + new_ucmd!() + .args(&["-d", "\\000a-f\\377"]) + .pipe_in(input.clone()) + .succeeds() + .stdout_is_bytes(expected); + + let expected: Vec = input + .iter() + .copied() + .filter(u8::is_ascii_lowercase) + .collect(); + new_ucmd!() + .args(&["-cd", "a-z"]) + .pipe_in(input) + .succeeds() + .stdout_is_bytes(expected); +} + +#[test] +fn test_delete_set_mostly_deleted() { + // Almost everything deleted, then half, then almost everything again. + let few_kept = [[b'a'; 99].as_slice(), b"\n"].concat().repeat(100); + let half_kept = b"a\n".repeat(5_000); + let input = [few_kept.as_slice(), &half_kept, &few_kept].concat(); + let expected: Vec = input + .iter() + .copied() + .filter(|b| !b.is_ascii_lowercase()) + .collect(); + new_ucmd!() + .args(&["-d", "a-z"]) + .pipe_in(input) + .succeeds() + .stdout_is_bytes(expected); +} + +#[test] +fn test_translate_one_char_large_input() { + // Longer than one read, with no and some matches. + let none = vec![b'x'; 40_000]; + let some = b"a,".repeat(20_000); + for input in [none, some] { + let expected: Vec = input + .iter() + .map(|&b| if b == b',' { b';' } else { b }) + .collect(); + new_ucmd!() + .args(&[",", ";"]) + .pipe_in(input) + .succeeds() + .stdout_is_bytes(expected); + } +} + #[test] fn test_complement1() { new_ucmd!() @@ -305,7 +388,7 @@ fn test_delete_and_squeeze_one_set() { .args(&["-ds", "a-z"]) .fails() .stderr_contains("missing operand after 'a-z'") - .stderr_contains("Two strings must be given when deleting and squeezing."); + .stderr_contains("Two strings must be given when both deleting and squeezing repeats."); } #[test] @@ -385,6 +468,7 @@ fn test_truncate_with_set1_shorter_than_set2() { fn test_truncate_applies_before_complement_with_class() { new_ucmd!() .args(&["-ct", "[:digit:]", "X"]) + .ignore_stdin_write_error() .pipe_in("A") .fails() .stderr_contains("when translating with complemented character classes,\nstring2 must map all characters in the domain to one"); @@ -573,8 +657,7 @@ fn alnum_expands_number_uppercase_lowercase() { } #[test] -fn check_against_gnu_tr_tests() { - // ['1', qw(abcd '[]*]'), {IN=>'abcd'}, {OUT=>']]]]'}], +fn tr_translate_range_to_repeat_class_zero() { new_ucmd!() .args(&["abcd", "[]*]"]) .pipe_in("abcd") @@ -583,61 +666,55 @@ fn check_against_gnu_tr_tests() { } #[test] -fn check_against_gnu_tr_tests_2() { - // ['2', qw(abc '[%*]xyz'), {IN=>'abc'}, {OUT=>'xyz'}], +fn tr_translate_range_to_repeat_class() { new_ucmd!() - .args(&["abc", "[%*]xyz"]) - .pipe_in("abc") + .args(&["rst", "[%*]uvw"]) + .pipe_in("rst") .succeeds() - .stdout_is("xyz"); + .stdout_is("uvw"); } #[test] -fn check_against_gnu_tr_tests_3() { - // ['3', qw('' '[.*]'), {IN=>'abc'}, {OUT=>'abc'}], +fn tr_translate_empty_set1_noop() { new_ucmd!() .args(&["", "[.*]"]) - .pipe_in("abc") + .pipe_in("rst") .succeeds() - .stdout_is("abc"); + .stdout_is("rst"); } #[test] -fn check_against_gnu_tr_tests_4() { +fn tr_truncate_set1_longer_than_set2() { // # Test --truncate-set1 behavior when string1 is longer than string2 - // ['4', qw(-t abcd xy), {IN=>'abcde'}, {OUT=>'xycde'}], new_ucmd!() - .args(&["-t", "abcd", "xy"]) - .pipe_in("abcde") + .args(&["-t", "mnop", "pq"]) + .pipe_in("mnopq") .succeeds() - .stdout_is("xycde"); + .stdout_is("pqopq"); } #[test] -fn check_against_gnu_tr_tests_5() { +fn tr_bsd_set1_longer_extends_last_char() { // # Test bsd behavior (the default) when string1 is longer than string2 - // ['5', qw(abcd xy), {IN=>'abcde'}, {OUT=>'xyyye'}], new_ucmd!() - .args(&["abcd", "xy"]) - .pipe_in("abcde") + .args(&["mnop", "pq"]) + .pipe_in("mnopq") .succeeds() - .stdout_is("xyyye"); + .stdout_is("pqqqq"); } #[test] -fn check_against_gnu_tr_tests_6() { +fn tr_posix_repeat_class_extension() { // # Do it the posix way - // ['6', qw(abcd 'x[y*]'), {IN=>'abcde'}, {OUT=>'xyyye'}], new_ucmd!() - .args(&["abcd", "x[y*]"]) - .pipe_in("abcde") + .args(&["mnop", "p[q*]"]) + .pipe_in("mnopq") .succeeds() - .stdout_is("xyyye"); + .stdout_is("pqqqq"); } #[test] -fn check_against_gnu_tr_tests_7() { - // ['7', qw(-s a-p '%[.*]$'), {IN=>'abcdefghijklmnop'}, {OUT=>'%.$'}], +fn tr_squeeze_range_to_special_chars() { new_ucmd!() .args(&["-s", "a-p", "%[.*]$"]) .pipe_in("abcdefghijklmnop") @@ -646,8 +723,7 @@ fn check_against_gnu_tr_tests_7() { } #[test] -fn check_against_gnu_tr_tests_8() { - // ['8', qw(-s a-p '[.*]$'), {IN=>'abcdefghijklmnop'}, {OUT=>'.$'}], +fn tr_squeeze_range_to_repeat_class() { new_ucmd!() .args(&["-s", "a-p", "[.*]$"]) .pipe_in("abcdefghijklmnop") @@ -656,8 +732,7 @@ fn check_against_gnu_tr_tests_8() { } #[test] -fn check_against_gnu_tr_tests_9() { - // ['9', qw(-s a-p '%[.*]'), {IN=>'abcdefghijklmnop'}, {OUT=>'%.'}], +fn tr_squeeze_range_to_class_prefix() { new_ucmd!() .args(&["-s", "a-p", "%[.*]"]) .pipe_in("abcdefghijklmnop") @@ -666,48 +741,43 @@ fn check_against_gnu_tr_tests_9() { } #[test] -fn check_against_gnu_tr_tests_a() { - // ['a', qw(-s '[a-z]'), {IN=>'aabbcc'}, {OUT=>'abc'}], +fn tr_squeeze_char_class_alnum() { new_ucmd!() - .args(&["-s", "[a-z]"]) - .pipe_in("aabbcc") + .args(&["-s", "[p-z]"]) + .pipe_in("ppqqrr") .succeeds() - .stdout_is("abc"); + .stdout_is("pqr"); } #[test] -fn check_against_gnu_tr_tests_b() { - // ['b', qw(-s '[a-c]'), {IN=>'aabbcc'}, {OUT=>'abc'}], +fn tr_squeeze_explicit_range() { new_ucmd!() - .args(&["-s", "[a-c]"]) - .pipe_in("aabbcc") + .args(&["-s", "[p-r]"]) + .pipe_in("ppqqrr") .succeeds() - .stdout_is("abc"); + .stdout_is("pqr"); } #[test] -fn check_against_gnu_tr_tests_c() { - // ['c', qw(-s '[a-b]'), {IN=>'aabbcc'}, {OUT=>'abcc'}], +fn tr_squeeze_partial_range() { new_ucmd!() - .args(&["-s", "[a-b]"]) - .pipe_in("aabbcc") + .args(&["-s", "[p-q]"]) + .pipe_in("ppqqrr") .succeeds() - .stdout_is("abcc"); + .stdout_is("pqrr"); } #[test] -fn check_against_gnu_tr_tests_d() { - // ['d', qw(-s '[b-c]'), {IN=>'aabbcc'}, {OUT=>'aabc'}], +fn tr_squeeze_tail_range() { new_ucmd!() - .args(&["-s", "[b-c]"]) - .pipe_in("aabbcc") + .args(&["-s", "[q-r]"]) + .pipe_in("ppqqrr") .succeeds() - .stdout_is("aabc"); + .stdout_is("ppqr"); } #[test] -fn check_against_gnu_tr_tests_e() { - // ['e', qw(-s '[\0-\5]'), {IN=>"\0\0a\1\1b\2\2\2c\3\3\3d\4\4\4\4e\5\5"}, {OUT=>"\0a\1b\2c\3d\4e\5"}], +fn tr_squeeze_nul_range() { new_ucmd!() .args(&["-s", r"[\0-\5]"]) .pipe_in( @@ -718,9 +788,8 @@ fn check_against_gnu_tr_tests_e() { } #[test] -fn check_against_gnu_tr_tests_f() { +fn tr_delete_equivalence_class_open() { // # tests of delete - // ['f', qw(-d '[=[=]'), {IN=>'[[[[[[[]]]]]]]]'}, {OUT=>']]]]]]]]'}], new_ucmd!() .args(&["-d", "[=[=]"]) .pipe_in("[[[[[[[]]]]]]]]") @@ -729,8 +798,7 @@ fn check_against_gnu_tr_tests_f() { } #[test] -fn check_against_gnu_tr_tests_g() { - // ['g', qw(-d '[=]=]'), {IN=>'[[[[[[[]]]]]]]]'}, {OUT=>'[[[[[[['}], +fn tr_delete_equivalence_class_close() { new_ucmd!() .args(&["-d", "[=]=]"]) .pipe_in("[[[[[[[]]]]]]]]") @@ -739,18 +807,16 @@ fn check_against_gnu_tr_tests_g() { } #[test] -fn check_against_gnu_tr_tests_h() { - // ['h', qw(-d '[:xdigit:]'), {IN=>'0123456789acbdefABCDEF'}, {OUT=>''}], +fn tr_delete_xdigit_all() { new_ucmd!() .args(&["-d", "[:xdigit:]"]) .pipe_in("0123456789acbdefABCDEF") .succeeds() - .stdout_is(""); + .no_output(); } #[test] -fn check_against_gnu_tr_tests_i() { - // ['i', qw(-d '[:xdigit:]'), {IN=>'w0x1y2z3456789acbdefABCDEFz'}, {OUT=>'wxyzz'}], +fn tr_delete_xdigit_leaves_non_hex() { new_ucmd!() .args(&["-d", "[:xdigit:]"]) .pipe_in("w0x1y2z3456789acbdefABCDEFz") @@ -759,18 +825,16 @@ fn check_against_gnu_tr_tests_i() { } #[test] -fn check_against_gnu_tr_tests_j() { - // ['j', qw(-d '[:digit:]'), {IN=>'0123456789'}, {OUT=>''}], +fn tr_delete_digit_all() { new_ucmd!() .args(&["-d", "[:digit:]"]) .pipe_in("0123456789") .succeeds() - .stdout_is(""); + .no_output(); } #[test] -fn check_against_gnu_tr_tests_k() { - // ['k', qw(-d '[:digit:]'), {IN=>'a0b1c2d3e4f5g6h7i8j9k'}, {OUT=>'abcdefghijk'}], +fn tr_delete_digit_leaves_alpha() { new_ucmd!() .args(&["-d", "[:digit:]"]) .pipe_in("a0b1c2d3e4f5g6h7i8j9k") @@ -779,58 +843,52 @@ fn check_against_gnu_tr_tests_k() { } #[test] -fn check_against_gnu_tr_tests_l() { - // ['l', qw(-d '[:lower:]'), {IN=>'abcdefghijklmnopqrstuvwxyz'}, {OUT=>''}], +fn tr_delete_lower_all() { new_ucmd!() .args(&["-d", "[:lower:]"]) .pipe_in("abcdefghijklmnopqrstuvwxyz") .succeeds() - .stdout_is(""); + .no_output(); } #[test] -fn check_against_gnu_tr_tests_m() { - // ['m', qw(-d '[:upper:]'), {IN=>'ABCDEFGHIJKLMNOPQRSTUVWXYZ'}, {OUT=>''}], +fn tr_delete_upper_all() { new_ucmd!() .args(&["-d", "[:upper:]"]) .pipe_in("ABCDEFGHIJKLMNOPQRSTUVWXYZ") .succeeds() - .stdout_is(""); + .no_output(); } #[test] -fn check_against_gnu_tr_tests_n() { - // ['n', qw(-d '[:lower:][:upper:]'), {IN=>'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'}, {OUT=>''}], +fn tr_delete_alpha_all() { new_ucmd!() .args(&["-d", "[:lower:][:upper:]"]) .pipe_in("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") .succeeds() - .stdout_is(""); + .no_output(); } #[test] -fn check_against_gnu_tr_tests_o() { - // ['o', qw(-d '[:alpha:]'), {IN=>'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'}, {OUT=>''}], +fn tr_delete_alnum_all() { new_ucmd!() .args(&["-d", "[:alpha:]"]) .pipe_in("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") .succeeds() - .stdout_is(""); + .no_output(); } #[test] -fn check_against_gnu_tr_tests_p() { - // ['p', qw(-d '[:alnum:]'), {IN=>'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'}, {OUT=>''}], +fn tr_delete_space_class() { new_ucmd!() .args(&["-d", "[:alnum:]"]) .pipe_in("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") .succeeds() - .stdout_is(""); + .no_output(); } #[test] -fn check_against_gnu_tr_tests_q() { - // ['q', qw(-d '[:alnum:]'), {IN=>'.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.'}, {OUT=>'..'}], +fn tr_delete_complement_alnum() { new_ucmd!() .args(&["-d", "[:alnum:]"]) .pipe_in(".abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.") @@ -839,10 +897,7 @@ fn check_against_gnu_tr_tests_q() { } #[test] -fn check_against_gnu_tr_tests_r() { - // ['r', qw(-ds '[:alnum:]' .), - // {IN=>'.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.'}, - // {OUT=>'.'}], +fn tr_delete_complement_alpha() { new_ucmd!() .args(&["-ds", "[:alnum:]", "."]) .pipe_in(".abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.") @@ -851,24 +906,18 @@ fn check_against_gnu_tr_tests_r() { } #[test] -fn check_against_gnu_tr_tests_s() { +fn tr_squeeze_complement_alnum_to_newline_bsd() { // # The classic example, with string2 BSD-style - // ['s', qw(-cs '[:alnum:]' '\n'), - // {IN=>'The big black fox jumped over the fence.'}, - // {OUT=>"The\nbig\nblack\nfox\njumped\nover\nthe\nfence\n"}], new_ucmd!() .args(&["-cs", "[:alnum:]", "\n"]) - .pipe_in("The big black fox jumped over the fence.") + .pipe_in("The quick brown fox jumps over the lazy dog.") .succeeds() - .stdout_is("The\nbig\nblack\nfox\njumped\nover\nthe\nfence\n"); + .stdout_is("The\nquick\nbrown\nfox\njumps\nover\nthe\nlazy\ndog\n"); } #[test] -fn check_against_gnu_tr_tests_t() { +fn tr_squeeze_complement_alnum_to_newline_posix() { // # The classic example, POSIX-style - // ['t', qw(-cs '[:alnum:]' '[\n*]'), - // {IN=>'The big black fox jumped over the fence.'}, - // {OUT=>"The\nbig\nblack\nfox\njumped\nover\nthe\nfence\n"}], new_ucmd!() .args(&["-cs", "[:alnum:]", "[\n*]"]) .pipe_in("The big black fox jumped over the fence.") @@ -877,18 +926,16 @@ fn check_against_gnu_tr_tests_t() { } #[test] -fn check_against_gnu_tr_tests_u() { - // ['u', qw(-ds b a), {IN=>'aabbaa'}, {OUT=>'a'}], +fn tr_delete_squeeze_combined() { new_ucmd!() - .args(&["-ds", "b", "a"]) - .pipe_in("aabbaa") + .args(&["-ds", "q", "p"]) + .pipe_in("ppqqpp") .succeeds() - .stdout_is("a"); + .stdout_is("p"); } #[test] -fn check_against_gnu_tr_tests_v() { - // ['v', qw(-ds '[:xdigit:]' Z), {IN=>'ZZ0123456789acbdefABCDEFZZ'}, {OUT=>'Z'}], +fn tr_delete_squeeze_xdigit() { new_ucmd!() .args(&["-ds", "[:xdigit:]", "Z"]) .pipe_in("ZZ0123456789acbdefABCDEFZZ") @@ -897,12 +944,9 @@ fn check_against_gnu_tr_tests_v() { } #[test] -fn check_against_gnu_tr_tests_w() { +fn tr_translate_alnum_to_rot13_plus_space() { // # Try some data with 8th bit set in case something is mistakenly // # sign-extended. - // ['w', qw(-ds '\350' '\345'), - // {IN=>"\300\301\377\345\345\350\345"}, - // {OUT=>"\300\301\377\345"}], new_ucmd!() .arg("-ds") .args(&["\\350", "\\345"]) @@ -912,9 +956,7 @@ fn check_against_gnu_tr_tests_w() { } #[test] -fn check_against_gnu_tr_tests_x() { - // ['x', qw(-s abcdefghijklmn '[:*016]'), - // {IN=>'abcdefghijklmnop'}, {OUT=>':op'}], +fn tr_squeeze_alnum_remove_spaces() { new_ucmd!() .args(&["-s", "abcdefghijklmn", "[:*016]"]) .pipe_in("abcdefghijklmnop") @@ -923,8 +965,7 @@ fn check_against_gnu_tr_tests_x() { } #[test] -fn check_against_gnu_tr_tests_y() { - // ['y', qw(-d a-z), {IN=>'abc $code'}, {OUT=>' $'}], +fn tr_translate_digits_to_letters() { new_ucmd!() .args(&["-d", "a-z"]) .pipe_in("abc $code") @@ -933,8 +974,7 @@ fn check_against_gnu_tr_tests_y() { } #[test] -fn check_against_gnu_tr_tests_z() { - // ['z', qw(-ds a-z '$.'), {IN=>'a.b.c $$$$code\\'}, {OUT=>'. $\\'}], +fn tr_translate_control_chars() { new_ucmd!() .args(&["-ds", "a-z", "$."]) .pipe_in("a.b.c $$$$code\\") @@ -943,9 +983,8 @@ fn check_against_gnu_tr_tests_z() { } #[test] -fn check_against_gnu_tr_tests_range_a_a() { +fn tr_translate_single_char_range() { // # Make sure that a-a is accepted. - // ['range-a-a', qw(a-a z), {IN=>'abc'}, {OUT=>'zbc'}], new_ucmd!() .args(&["a-a", "z"]) .pipe_in("abc") @@ -954,8 +993,7 @@ fn check_against_gnu_tr_tests_range_a_a() { } #[test] -fn check_against_gnu_tr_tests_null() { - // ['null', qw(a ''), {IN=>''}, {OUT=>''}, {EXIT=>1}, +fn tr_translate_with_null_byte() { // {ERR=>"$prog: when not truncating set1, string2 must be non-empty\n"}], new_ucmd!() .args(&["a", ""]) @@ -965,10 +1003,7 @@ fn check_against_gnu_tr_tests_null() { } #[test] -fn check_against_gnu_tr_tests_upcase() { - // ['upcase', qw('[:lower:]' '[:upper:]'), - // {IN=>'abcxyzABCXYZ'}, - // {OUT=>'ABCXYZABCXYZ'}], +fn tr_translate_lower_to_upper() { new_ucmd!() .args(&["[:lower:]", "[:upper:]"]) .pipe_in("abcxyzABCXYZ") @@ -977,10 +1012,7 @@ fn check_against_gnu_tr_tests_upcase() { } #[test] -fn check_against_gnu_tr_tests_dncase() { - // ['dncase', qw('[:upper:]' '[:lower:]'), - // {IN=>'abcxyzABCXYZ'}, - // {OUT=>'abcxyzabcxyz'}], +fn tr_translate_upper_to_lower() { new_ucmd!() .args(&["[:upper:]", "[:lower:]"]) .pipe_in("abcxyzABCXYZ") @@ -989,8 +1021,7 @@ fn check_against_gnu_tr_tests_dncase() { } #[test] -fn check_against_gnu_tr_tests_rep_cclass() { - // ['rep-cclass', qw('a[=*2][=c=]' xyyz), {IN=>'a=c'}, {OUT=>'xyz'}], +fn tr_repeat_complement_char_class() { new_ucmd!() .args(&["a[=*2][=c=]", "xyyz"]) .pipe_in("a=c") @@ -999,8 +1030,7 @@ fn check_against_gnu_tr_tests_rep_cclass() { } #[test] -fn check_against_gnu_tr_tests_rep_1() { - // ['rep-1', qw('[:*3][:digit:]' a-m), {IN=>':1239'}, {OUT=>'cefgm'}], +fn tr_repeat_class_in_set2_basic() { new_ucmd!() .args(&["[:*3][:digit:]", "a-m"]) .pipe_in(":1239") @@ -1009,8 +1039,7 @@ fn check_against_gnu_tr_tests_rep_1() { } #[test] -fn check_against_gnu_tr_tests_rep_2() { - // ['rep-2', qw('a[b*512]c' '1[x*]2'), {IN=>'abc'}, {OUT=>'1x2'}], +fn tr_repeat_class_extends_set2() { new_ucmd!() .args(&["a[b*512]c", "1[x*]2"]) .pipe_in("abc") @@ -1019,8 +1048,7 @@ fn check_against_gnu_tr_tests_rep_2() { } #[test] -fn check_against_gnu_tr_tests_rep_3() { - // ['rep-3', qw('a[b*513]c' '1[x*]2'), {IN=>'abc'}, {OUT=>'1x2'}], +fn tr_repeat_class_with_squeeze() { new_ucmd!() .args(&["a[b*513]c", "1[x*]2"]) .pipe_in("abc") @@ -1029,9 +1057,8 @@ fn check_against_gnu_tr_tests_rep_3() { } #[test] -fn check_against_gnu_tr_tests_o_rep_1() { +fn tr_translate_overlap_repeat() { // # Another couple octal repeat count tests. - // ['o-rep-1', qw('[b*08]' '[x*]'), {IN=>''}, {OUT=>''}, {EXIT=>1}, // {ERR=>"$prog: invalid repeat count '08' in [c*n] construct\n"}], new_ucmd!() .args(&["[b*08]", "[x*]"]) @@ -1041,8 +1068,7 @@ fn check_against_gnu_tr_tests_o_rep_1() { } #[test] -fn check_against_gnu_tr_tests_o_rep_2() { - // ['o-rep-2', qw('[b*010]cd' '[a*7]BC[x*]'), {IN=>'bcd'}, {OUT=>'BCx'}], +fn tr_translate_overlap_repeat_squeeze() { new_ucmd!() .args(&["[b*010]cd", "[a*7]BC[x*]"]) .pipe_in("bcd") @@ -1071,8 +1097,7 @@ fn non_octal_repeat_count_test() { } #[test] -fn check_against_gnu_tr_tests_esc() { - // ['esc', qw('a\-z' A-Z), {IN=>'abc-z'}, {OUT=>'AbcBC'}], +fn tr_translate_with_escape_sequence() { new_ucmd!() .args(&[r"a\-z", "A-Z"]) .pipe_in("abc-z") @@ -1081,8 +1106,7 @@ fn check_against_gnu_tr_tests_esc() { } #[test] -fn check_against_gnu_tr_tests_bs_055() { - // ['bs-055', qw('a\055b' def), {IN=>"a\055b"}, {OUT=>'def'}], +fn tr_translate_octal_backslash() { new_ucmd!() .args(&["a\u{055}b", "def"]) .pipe_in("a\u{055}b") @@ -1093,8 +1117,7 @@ fn check_against_gnu_tr_tests_bs_055() { #[test] // Fails on Windows because it will not separate '\' and 'x' as separate arguments #[cfg(unix)] -fn check_against_gnu_tr_tests_bs_at_end() { - // ['bs-at-end', qw('\\' x), {IN=>"\\"}, {OUT=>'x'}, +fn tr_translate_backslash_at_end() { // {ERR=>"$prog: warning: an unescaped backslash at end of " // . "string is not portable\n"}], new_ucmd!() @@ -1106,9 +1129,8 @@ fn check_against_gnu_tr_tests_bs_at_end() { } #[test] -fn check_against_gnu_tr_tests_ross_0a() { +fn tr_ross_delete_no_squeeze() { // # From Ross - // ['ross-0a', qw(-cs '[:upper:]' 'X[Y*]'), {IN=>''}, {OUT=>''}, {EXIT=>1}, // {ERR=>$map_all_to_1}], new_ucmd!() .args(&["-cs", "[:upper:]", "X[Y*]"]) @@ -1118,8 +1140,7 @@ fn check_against_gnu_tr_tests_ross_0a() { } #[test] -fn check_against_gnu_tr_tests_ross_0b() { - // ['ross-0b', qw(-cs '[:cntrl:]' 'X[Y*]'), {IN=>''}, {OUT=>''}, {EXIT=>1}, +fn tr_ross_delete_with_squeeze() { // {ERR=>$map_all_to_1}], new_ucmd!() .args(&["-cs", "[:cntrl:]", "X[Y*]"]) @@ -1129,9 +1150,7 @@ fn check_against_gnu_tr_tests_ross_0b() { } #[test] -fn check_against_gnu_tr_tests_ross_1a() { - // ['ross-1a', qw(-cs '[:upper:]' '[X*]'), - // {IN=>'AMZamz123.-+AMZ'}, {OUT=>'AMZXAMZ'}], +fn tr_ross_translate_complement() { new_ucmd!() .args(&["-cs", "[:upper:]", "[X*]"]) .pipe_in("AMZamz123.-+AMZ") @@ -1140,19 +1159,16 @@ fn check_against_gnu_tr_tests_ross_1a() { } #[test] -fn check_against_gnu_tr_tests_ross_1b() { - // ['ross-1b', qw(-cs '[:upper:][:digit:]' '[Z*]'), {IN=>''}, {OUT=>''}], +fn tr_ross_translate_complement_squeeze() { new_ucmd!() .args(&["-cs", "[:upper:][:digit:]", "[Z*]"]) .pipe_in("") .succeeds() - .stdout_is(""); + .no_output(); } #[test] -fn check_against_gnu_tr_tests_ross_2() { - // ['ross-2', qw(-dcs '[:lower:]' n-rs-z), - // {IN=>'amzAMZ123.-+amz'}, {OUT=>'amzamz'}], +fn tr_ross_delete_complement() { new_ucmd!() .args(&["-dcs", "[:lower:]", "n-rs-z"]) .pipe_in("amzAMZ123.-+amz") @@ -1161,9 +1177,7 @@ fn check_against_gnu_tr_tests_ross_2() { } #[test] -fn check_against_gnu_tr_tests_ross_3() { - // ['ross-3', qw(-ds '[:xdigit:]' '[:alnum:]'), - // {IN=>'.ZABCDEFGzabcdefg.0123456788899.GG'}, {OUT=>'.ZGzg..G'}], +fn tr_ross_delete_complement_squeeze() { new_ucmd!() .args(&["-ds", "[:xdigit:]", "[:alnum:]"]) .pipe_in(".ZABCDEFGzabcdefg.0123456788899.GG") @@ -1172,40 +1186,36 @@ fn check_against_gnu_tr_tests_ross_3() { } #[test] -fn check_against_gnu_tr_tests_ross_4() { - // ['ross-4', qw(-dcs '[:alnum:]' '[:digit:]'), {IN=>''}, {OUT=>''}], +fn tr_ross_translate_overlong() { new_ucmd!() .args(&["-dcs", "[:alnum:]", "[:digit:]"]) .pipe_in("") .succeeds() - .stdout_is(""); + .no_output(); } #[test] -fn check_against_gnu_tr_tests_ross_5() { - // ['ross-5', qw(-dc '[:lower:]'), {IN=>''}, {OUT=>''}], +fn tr_ross_translate_squeeze_overlong() { new_ucmd!() .args(&["-dc", "[:lower:]"]) .pipe_in("") .succeeds() - .stdout_is(""); + .no_output(); } #[test] -fn check_against_gnu_tr_tests_ross_6() { - // ['ross-6', qw(-dc '[:upper:]'), {IN=>''}, {OUT=>''}], +fn tr_ross_translate_squeeze_delete() { new_ucmd!() .args(&["-dc", "[:upper:]"]) .pipe_in("") .succeeds() - .stdout_is(""); + .no_output(); } #[test] -fn check_against_gnu_tr_tests_empty_eq() { +fn tr_error_empty_equivalence_class() { // # Ensure that these fail. // # Prior to 2.0.20, each would evoke a failed assertion. - // ['empty-eq', qw('[==]' x), {IN=>''}, {OUT=>''}, {EXIT=>1}, // {ERR=>"$prog: missing equivalence class character '[==]'\n"}], new_ucmd!() .args(&["[==]", "x"]) @@ -1224,8 +1234,7 @@ fn check_too_many_chars_in_eq() { } #[test] -fn check_against_gnu_tr_tests_empty_cc() { - // ['empty-cc', qw('[::]' x), {IN=>''}, {OUT=>''}, {EXIT=>1}, +fn tr_error_empty_char_class() { // {ERR=>"$prog: missing character class name '[::]'\n"}], new_ucmd!() .args(&["[::]", "x"]) @@ -1235,7 +1244,7 @@ fn check_against_gnu_tr_tests_empty_cc() { } #[test] -fn check_against_gnu_tr_tests_invalid_cc() { +fn tr_error_invalid_char_class() { new_ucmd!() .args(&["[:fooclass:]", "x"]) .pipe_in("") @@ -1244,7 +1253,7 @@ fn check_against_gnu_tr_tests_invalid_cc() { } #[test] -fn check_against_gnu_tr_tests_repeat_set1() { +fn tr_error_repeat_in_set1() { new_ucmd!() .args(&["[a*]", "a"]) .pipe_in("") @@ -1253,7 +1262,7 @@ fn check_against_gnu_tr_tests_repeat_set1() { } #[test] -fn check_against_gnu_tr_tests_repeat_set2() { +fn tr_translate_repeat_in_set2() { new_ucmd!() .args(&["a", "[a*][a*]"]) .pipe_in("") @@ -1262,9 +1271,8 @@ fn check_against_gnu_tr_tests_repeat_set2() { } #[test] -fn check_against_gnu_tr_tests_repeat_bs_9() { +fn tr_translate_repeat_octal() { // # Weird repeat counts. - // ['repeat-bs-9', qw(abc '[b*\9]'), {IN=>'abcd'}, {OUT=>'[b*d'}], new_ucmd!() .args(&["abc", r"[b*\9]"]) .pipe_in("abcd") @@ -1273,8 +1281,7 @@ fn check_against_gnu_tr_tests_repeat_bs_9() { } #[test] -fn check_against_gnu_tr_tests_repeat_0() { - // ['repeat-0', qw(abc '[b*0]'), {IN=>'abcd'}, {OUT=>'bbbd'}], +fn tr_translate_repeat_zero_count() { new_ucmd!() .args(&["abc", "[b*0]"]) .pipe_in("abcd") @@ -1283,9 +1290,7 @@ fn check_against_gnu_tr_tests_repeat_0() { } #[test] -fn check_against_gnu_tr_tests_repeat_zeros() { - // ['repeat-zeros', qw(abc '[b*00000000000000000000]'), - // {IN=>'abcd'}, {OUT=>'bbbd'}], +fn tr_translate_repeat_multiple_zeros() { new_ucmd!() .args(&["abc", "[b*00000000000000000000]"]) .pipe_in("abcd") @@ -1294,8 +1299,7 @@ fn check_against_gnu_tr_tests_repeat_zeros() { } #[test] -fn check_against_gnu_tr_tests_repeat_compl() { - // ['repeat-compl', qw(-c '[a*65536]\n' '[b*]'), {IN=>'abcd'}, {OUT=>'abbb'}], +fn tr_translate_repeat_complement() { new_ucmd!() .args(&["-c", "[a*65536]\n", "[b*]"]) .pipe_in("abcd") @@ -1304,8 +1308,7 @@ fn check_against_gnu_tr_tests_repeat_compl() { } #[test] -fn check_against_gnu_tr_tests_repeat_x_c() { - // ['repeat-xC', qw(-C '[a*65536]\n' '[b*]'), {IN=>'abcd'}, {OUT=>'abbb'}], +fn tr_translate_repeat_x_complement() { new_ucmd!() .args(&["-C", "[a*65536]\n", "[b*]"]) .pipe_in("abcd") @@ -1314,9 +1317,8 @@ fn check_against_gnu_tr_tests_repeat_x_c() { } #[test] -fn check_against_gnu_tr_tests_fowler_1() { +fn tr_fowler_translate_basic() { // # From Glenn Fowler. - // ['fowler-1', qw(ah -H), {IN=>'aha'}, {OUT=>'-H-'}], new_ucmd!() .args(&["ah", "-H"]) .pipe_in("aha") @@ -1325,9 +1327,8 @@ fn check_against_gnu_tr_tests_fowler_1() { } #[test] -fn check_against_gnu_tr_tests_no_abort_1() { +fn tr_translate_no_abort_on_long_input() { // # Up to coreutils-6.9, this would provoke a failed assertion. - // ['no-abort-1', qw(-c a '[b*256]'), {IN=>'abc'}, {OUT=>'abb'}], new_ucmd!() .args(&["-c", "a", "[b*256]"]) .pipe_in("abc") @@ -1582,6 +1583,22 @@ fn test_multibyte_octal_sequence() { .stdout_is("Ł)"); } +#[test] +fn test_octal_warning_still_fires_after_a_bad_sequence() { + // The set fails on the character class, but the ambiguous octal escape + // after it must still be warned about: the whole set is parsed even when + // an earlier sequence is bad. + // No `pipe_in`: the sets are rejected before stdin is ever read, so + // handing the child input only races its exit. + new_ucmd!() + .args(&[r"[:foo:]\400", "y"]) + .fails() + .stderr_is( + "tr: warning: the ambiguous octal escape \\400 is being interpreted as the 2-byte sequence \\040, 0\n\ + tr: invalid character class 'foo'\n", + ); +} + #[test] fn test_backwards_range() { new_ucmd!() @@ -1594,6 +1611,116 @@ fn test_backwards_range() { ); } +#[cfg(target_pointer_width = "64")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: usize is 32-bit, so these repeat counts do not parse" +)] +#[test] +fn test_huge_repeat_count_in_set1() { + // A repeat count this large used to be expanded character by character, + // which aborted the process before it read any input. + new_ucmd!() + .args(&["[a*9223372036854775808]", "b"]) + .pipe_in("abc") + .succeeds() + .stdout_only("bbc"); + new_ucmd!() + .args(&["[a*99999999999999]b", "xy"]) + .pipe_in("abc") + .succeeds() + .stdout_only("yyc"); + new_ucmd!() + .args(&["-t", "[a*99999999999999]", "x"]) + .pipe_in("abc") + .succeeds() + .stdout_only("xbc"); + new_ucmd!() + .args(&["-d", "[a*99999999999999]"]) + .pipe_in("abc") + .succeeds() + .stdout_only("bc"); +} + +#[cfg(target_pointer_width = "64")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: usize is 32-bit, so these repeat counts do not parse" +)] +#[test] +fn test_huge_repeat_count_in_set2() { + new_ucmd!() + .args(&["abc", "[x*99999999999999]"]) + .pipe_in("abc") + .succeeds() + .stdout_only("xxx"); + new_ucmd!() + .args(&["abcd", "[x*99999999999999]yz"]) + .pipe_in("abcd") + .succeeds() + .stdout_only("xxxx"); + new_ucmd!() + .args(&["-c", "a", "[x*99999999999999]"]) + .pipe_in("abc") + .succeeds() + .stdout_only("axx"); +} + +#[cfg(target_pointer_width = "64")] +#[cfg_attr( + wasi_runner, + ignore = "WASI: usize is 32-bit, so these repeat counts do not parse" +)] +#[test] +fn test_repeat_lengths_beyond_usize() { + // Set lengths are kept exact when repeat counts add up past usize::MAX, + // so positions past that point still line up the way they should. + new_ucmd!() + .args(&["-c", "[a*18446744073709551614]bc", "x"]) + .pipe_in("abcd") + .succeeds() + .stdout_only("abcx"); + new_ucmd!() + .args(&["[a*18446744073709551615]b", "[x*18446744073709551614][y*]z"]) + .pipe_in("ab") + .succeeds() + .stdout_only("yz"); + new_ucmd!() + .args(&[ + "[a*18446744073709551615]b[:upper:]", + "[x*18446744073709551615][:upper:]", + ]) + .fails() + .stderr_contains("must be matched by"); +} + +#[test] +fn test_repeat_keeps_every_set2_character_for_squeeze() { + // The mappings a->x and a->y both come from the one run of `a`, and the + // last one wins, but x is still part of set2 and so still squeezed. + new_ucmd!() + .args(&["-s", "[a*2]", "xy"]) + .pipe_in("xxaa") + .succeeds() + .stdout_only("xy"); + new_ucmd!() + .args(&["-s", "a", "xyz"]) + .pipe_in("aazz") + .succeeds() + .stdout_only("xz"); +} + +#[test] +fn test_repeat_in_set1_padded_by_star_in_set2() { + // The star in set2 is padded to the length of the repeat in set1, and + // the last of the mappings for `a` wins. + new_ucmd!() + .args(&["[a*3]bc", "x[y*]z"]) + .pipe_in("abc") + .succeeds() + .stdout_only("yyz"); +} + #[test] fn test_non_digit_repeat() { new_ucmd!() @@ -1642,9 +1769,11 @@ fn test_failed_write_is_reported() { #[test] #[cfg_attr(wasi_runner, ignore = "WASI: no pipe/signal support")] fn test_broken_pipe_no_error() { + // More than a pipe buffer, so that the output blocks until the reader is gone. new_ucmd!() .args(&["e", "a"]) - .pipe_in("hello".repeat(100)) + .pipe_in("hello".repeat(100_000)) + .ignore_stdin_write_error() .run_stdout_starts_with(b"") .fails_silently(); } @@ -1652,19 +1781,201 @@ fn test_broken_pipe_no_error() { #[cfg(unix)] #[test] fn test_stdin_is_socket() { + use std::fs::File; use std::io::Write as _; - let (fd1, fd2) = rustix::net::socketpair( - rustix::net::AddressFamily::UNIX, - rustix::net::SocketType::STREAM, - rustix::net::SocketFlags::empty(), - None, - ) + let (mut writer, reader): (File, File) = { + rustix::net::socketpair( + rustix::net::AddressFamily::UNIX, + rustix::net::SocketType::STREAM, + rustix::net::SocketFlags::empty(), + None, + ) + .map(|(fd0, fd1)| (fd0.into(), fd1.into())) + } .unwrap(); - std::fs::File::from(fd1).write_all(b"::").unwrap(); + + writer.write_all(b"::").unwrap(); + drop(writer); + new_ucmd!() .args(&[":", ";"]) - .set_stdin(fd2) + .set_stdin(reader) .succeeds() .stdout_is(";;"); } + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_misspelled_class() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["w[:lowre:]w", "x"]) + .fails_with_code(1); + + // The whole report: the `tr: ` prefix of the plain form, the argument + // list echoed back, a caret covering `[:lowre:]` only — not the whole + // set — and the list of valid class names. + assert_eq!( + result.stderr_as_displayed(), + "\ +tr: invalid character class 'lowre' + ╭─[ tr:1:5 ] + │ + 1 │ tr w[:lowre:]w x + │ ───────── + │ + │ Help: classes are alnum, alpha, blank, cntrl, digit, graph, lower, print, punct, space, upper and xdigit +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_backwards_range() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["qw[y-b]", "x"]) + .fails_with_code(1); + + // The caret covers `y-b` alone, not the letters around the range. + assert_eq!( + result.stderr_as_displayed(), + "\ +tr: range-endpoints of 'y-b' are in reverse collating sequence order + ╭─[ tr:1:7 ] + │ + 1 │ tr qw[y-b] x + │ ─┬─ + │ ╰─── did you mean 'b-y'? + │ + │ Help: a range goes from the lower character to the higher one, as in a-z +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_repeat_count() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["wxy", "[z*4k]"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_as_displayed(), + "\ +tr: invalid repeat count '4k' in [c*n] construct + ╭─[ tr:1:8 ] + │ + 1 │ tr wxy [z*4k] + │ ────── + │ + │ Help: [c*N] repeats c N times, [c*] pads SET2 to the length of SET1 +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_underlines_the_whole_set_when_the_set_is_at_fault() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["w[q*]", "xyz"]) + .fails_with_code(1); + + // Nothing inside the set is wrong on its own, so all of it is marked. + assert_eq!( + result.stderr_as_displayed(), + "\ +tr: the [c*] repeat construct may not appear in string1 + ╭─[ tr:1:4 ] + │ + 1 │ tr w[q*] xyz + │ ───── + │ + │ Help: [c*N] repeats c N times, [c*] pads SET2 to the length of SET1 +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_into_the_second_set() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["[:lower:]", "q[=we=]"]) + .fails_with_code(1); + + // The set at fault is the second operand, not the first. + assert_eq!( + result.stderr_as_displayed(), + "\ +tr: we: equivalence class operand must be a single character + ╭─[ tr:1:15 ] + │ + 1 │ tr [:lower:] q[=we=] + │ ────── + │ + │ Help: [=c=] stands for every character equivalent to c +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_ignores_the_program_name_as_a_set() { + // The second set reads exactly like the program name; the caret must + // stay on the operand, not drift onto `tr` at the start of the line. + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-c", "[:alpha:]", "tr"]) + .fails_with_code(1); + + assert_eq!( + result.stderr_as_displayed(), + "\ +tr: when translating with complemented character classes, +string2 must map all characters in the domain to one + ╭─[ tr:1:17 ] + │ + 1 │ tr -c [:alpha:] tr + │ ─┬ + │ ╰── only one character may be complemented to +───╯" + ); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + // The test harness pipes stderr, so the report must not appear. + new_ucmd!() + .args(&["w[:lowre:]w", "x"]) + .fails_with_code(1) + .stderr_only("tr: invalid character class 'lowre'\n"); + } + + #[test] + fn test_repeat_hyphen() { + new_ucmd!() + .args(&["-s", "[:blank:]", "[-*]"]) + .pipe_in("Base Z\n") + .succeeds() + .stdout_only("Base-Z\n"); + + new_ucmd!() + .args(&["-s", "[:blank:]", "[-*5]"]) + .pipe_in("Base Z\n") + .succeeds() + .stdout_only("Base-Z\n"); + + new_ucmd!() + .args(&["[-*]", "a"]) + .fails_with_code(1) + .stderr_only("tr: the [c*] repeat construct may not appear in string1\n"); + } +} diff --git a/tests/by-util/test_true.rs b/tests/by-util/test_true.rs index 23c947fb29c..4e11449baee 100644 --- a/tests/by-util/test_true.rs +++ b/tests/by-util/test_true.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use regex::Regex; #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] use std::fs::OpenOptions; @@ -62,6 +63,6 @@ fn test_full() { .arg(option) .set_stdout(dev_full) .fails() - .stderr_contains("No space left on device"); + .stderr_is("true: No space left on device\n"); } } diff --git a/tests/by-util/test_truncate.rs b/tests/by-util/test_truncate.rs index d44723c36b6..841fd53a9fe 100644 --- a/tests/by-util/test_truncate.rs +++ b/tests/by-util/test_truncate.rs @@ -6,10 +6,10 @@ // spell-checker:ignore (words) RFILE use std::io::{Seek, SeekFrom, Write}; -use uutests::at_and_ucmd; -use uutests::new_ucmd; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; use uutests::util::TestScenario; -use uutests::util_name; +use uutests::{at_and_ucmd, new_ucmd, util_name}; static FILE1: &str = "truncate_test_1"; static FILE2: &str = "truncate_test_2"; @@ -38,6 +38,16 @@ fn test_increase_file_size_kb() { assert_eq!(expected, actual, "expected '{expected}' got '{actual}'"); } +#[test] +fn test_size_above_i64_max_is_rejected_without_creating_file() { + // A size above i64::MAX is rejected up front and the file is not created. + let (at, mut ucmd) = at_and_ucmd!(); + ucmd.args(&["-s", "9223372036854775808", "new-file"]) + .fails_with_code(1) + .stderr_contains("Value too large for defined data type"); + assert!(!at.file_exists("new-file")); +} + #[test] fn test_reference() { let expected = 5 * 1000; @@ -60,6 +70,32 @@ fn test_reference() { assert_eq!(expected, actual, "expected '{expected}' got '{actual}'"); } +#[test] +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] +fn test_reference_non_utf8_path() { + use std::os::unix::ffi::OsStrExt; + + let expected = 5 * 1000; + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + let mut file = at.make_file(FILE2); + + let reference = std::ffi::OsStr::from_bytes(b"test_\xFF\xFE.txt"); + scene.ucmd().arg("-s").arg("+5KB").arg(reference).succeeds(); + + scene + .ucmd() + .arg("--reference") + .arg(reference) + .arg(FILE2) + .succeeds(); + + file.seek(SeekFrom::End(0)).unwrap(); + let actual = file.stream_position().unwrap(); + assert_eq!(expected, actual, "expected '{expected}' got '{actual}'"); +} + #[test] fn test_decrease_file_size() { let expected = 6; @@ -297,6 +333,35 @@ fn test_relative_size_overflow_preserves_file() { assert_eq!(at.read(FILE1), "x"); } +#[cfg(unix)] +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI: no block size support")] +fn test_io_blocks_uses_file_block_size() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write(FILE1, "x"); + let block_size = at.metadata(FILE1).blksize(); + + ucmd.args(&["--io-blocks", "--size=1", FILE1]) + .succeeds() + .no_output(); + + assert_eq!(at.metadata(FILE1).len(), block_size); +} + +#[cfg(unix)] +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI: no block size support")] +fn test_io_blocks_uses_parent_block_size_for_new_file() { + let (at, mut ucmd) = at_and_ucmd!(); + let block_size = at.metadata(".").blksize(); + + ucmd.args(&["--io-blocks", "--size=2", FILE1]) + .succeeds() + .no_output(); + + assert_eq!(at.metadata(FILE1).len(), 2 * block_size); +} + /// Test that truncating a non-existent file creates that file. #[test] fn test_new_file() { @@ -440,54 +505,112 @@ fn test_negative_size_with_space() { assert!(at.read_bytes(FILE1).is_empty()); } -#[cfg(not(windows))] #[test] -#[cfg_attr(wasi_runner, ignore = "WASI: no FIFO/mkfifo support")] -fn test_fifo_error_size_only() { - let (at, mut ucmd) = at_and_ucmd!(); - at.mkfifo("fifo"); - ucmd.args(&["-s", "0", "fifo"]) - .fails() - .no_stdout() - .stderr_contains("cannot open 'fifo' for writing: No such device or address"); +#[cfg(target_os = "linux")] +#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] +fn test_truncate_non_utf8_paths() { + use std::os::unix::ffi::OsStrExt; + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + let file_name = std::ffi::OsStr::from_bytes(b"test_\xFF\xFE.txt"); + at.write(&file_name.to_string_lossy(), "test content"); + + // Test that truncate can handle non-UTF-8 filenames + ts.ucmd().arg("-s").arg("10").arg(file_name).succeeds(); } -#[cfg(not(windows))] #[test] -#[cfg_attr(wasi_runner, ignore = "WASI: no FIFO/mkfifo support")] -fn test_fifo_error_reference_file_only() { - let (at, mut ucmd) = at_and_ucmd!(); - at.mkfifo("fifo"); - at.make_file("reference_file"); - ucmd.args(&["-r", "reference_file", "fifo"]) +fn test_empty_size() { + new_ucmd!() + .args(&["-s", "", "asd"]) .fails() - .no_stdout() - .stderr_contains("cannot open 'fifo' for writing: No such device or address"); + .stderr_is("truncate: Invalid number: ''\n"); } -#[cfg(not(windows))] #[test] -#[cfg_attr(wasi_runner, ignore = "WASI: no FIFO/mkfifo support")] -fn test_fifo_error_reference_and_size() { - let (at, mut ucmd) = at_and_ucmd!(); - at.mkfifo("fifo"); - at.make_file("reference_file"); - ucmd.args(&["-r", "reference_file", "-s", "+0", "fifo"]) +fn test_sign_as_a_size() { + new_ucmd!() + .args(&["-s", "+", "asd"]) .fails() - .no_stdout() - .stderr_contains("cannot open 'fifo' for writing: No such device or address"); + .stderr_is("truncate: Invalid number: '+'\n"); } #[test] -#[cfg(target_os = "linux")] -#[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] -fn test_truncate_non_utf8_paths() { - use std::os::unix::ffi::OsStrExt; - let ts = TestScenario::new(util_name!()); - let at = &ts.fixtures; - let file_name = std::ffi::OsStr::from_bytes(b"test_\xFF\xFE.txt"); - at.write(&file_name.to_string_lossy(), "test content"); +fn test_repeated_size_takes_the_last() { + // GNU lets a later -s override an earlier one rather than erroring. + let (at, mut ucmd) = at_and_ucmd!(); + at.make_file("repeated"); + ucmd.args(&["-s", "1", "-s", "2", "repeated"]).succeeds(); + assert_eq!(at.metadata("repeated").len(), 2); +} - // Test that truncate can handle non-UTF-8 filenames - ts.ucmd().arg("-s").arg("10").arg(file_name).succeeds(); +#[cfg(unix)] +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[test] + fn test_snippet_counts_the_mode_character_before_the_size() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("probe"); + + let result = ucmd + .terminal_sim_stderr() + .args(&["--size=+2Zx", "probe"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + // The `+` is part of the operand but not of the size, so the caret has + // to count it back in to land on the unit. + assert!(stderr.contains("truncate:1:19"), "{stderr}"); + assert!(stderr.contains("not a known unit"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("probe"); + + ucmd.args(&["-s", "10fb", "probe"]) + .fails_with_code(1) + .stderr_contains("Invalid number: '10fb'"); + } +} + +#[cfg(not(windows))] +#[cfg(not(wasi_runner))] // WASI: no FIFO/mkfifo support +mod fifo { + use super::*; + + #[test] + fn test_fifo_error_size_only() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkfifo("fifo"); + ucmd.args(&["-s", "0", "fifo"]) + .fails() + .no_stdout() + .stderr_contains("cannot open 'fifo' for writing: No such device or address"); + } + + #[test] + fn test_fifo_error_reference_file_only() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkfifo("fifo"); + at.make_file("reference_file"); + ucmd.args(&["-r", "reference_file", "fifo"]) + .fails() + .no_stdout() + .stderr_contains("cannot open 'fifo' for writing: No such device or address"); + } + + #[test] + fn test_fifo_error_reference_and_size() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkfifo("fifo"); + at.make_file("reference_file"); + ucmd.args(&["-r", "reference_file", "-s", "+0", "fifo"]) + .fails() + .no_stdout() + .stderr_contains("cannot open 'fifo' for writing: No such device or address"); + } } diff --git a/tests/by-util/test_tsort.rs b/tests/by-util/test_tsort.rs index 64fe97385df..fbd1c0b8644 100644 --- a/tests/by-util/test_tsort.rs +++ b/tests/by-util/test_tsort.rs @@ -2,6 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + +// spell-checker:ignore nosuchfile hrllo awuyues nawuyues nhrllo + #![allow(clippy::cast_possible_wrap)] use uutests::at_and_ucmd; @@ -229,8 +232,7 @@ fn test_odd_number_of_tokens() { new_ucmd!() .pipe_in("a\n") .fails_with_code(1) - .stdout_is("") - .stderr_is(TSORT_ODD_ERROR); + .stderr_only(TSORT_ODD_ERROR); } #[test] @@ -242,6 +244,57 @@ fn test_only_one_input_file() { ucmd.arg("f") .arg("g") .fails_with_code(1) - .stdout_is("") - .stderr_is(TSORT_EXTRA_OPERAND_ERROR); + .stderr_only(TSORT_EXTRA_OPERAND_ERROR); +} + +#[test] +fn test_nonexistent_file_error_includes_filename() { + new_ucmd!() + .arg("nosuchfile.txt") + .fails_with_code(1) + .stderr_only("tsort: nosuchfile.txt: No such file or directory\n"); +} + +#[test] +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] +fn test_write_error() { + use std::fs::OpenOptions; + + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("input", "a d\n"); + + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + + ucmd.arg("input") + .set_stdout(dev_full) + .fails() + .stderr_contains("write error") + .stderr_contains("No space left on device"); +} + +#[test] +fn test_invalid_utf8_input() { + let (at, mut ucmd) = at_and_ucmd!(); + + // Equivalent to: + // + // hrllo + // awuyues + // apple + // iphone + // iphone + // a + // \xff + // \xff + + std::fs::write( + at.plus("a"), + b"hrllo\nawuyues\napple\niphone\niphone\na\n\xff\n\xff\n", + ) + .unwrap(); + + ucmd.arg("a") + .succeeds() + .stdout_only_bytes(b"apple\nhrllo\n\xff\niphone\nawuyues\na\n"); } diff --git a/tests/by-util/test_tty.rs b/tests/by-util/test_tty.rs index be0ea00afa9..5103a60efac 100644 --- a/tests/by-util/test_tty.rs +++ b/tests/by-util/test_tty.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + #[cfg(unix)] use std::fs::File; @@ -23,7 +24,7 @@ fn test_dev_null_silent() { .args(&["-s"]) .set_stdin(File::open("/dev/null").unwrap()) .fails_with_code(1) - .stdout_is(""); + .no_output(); } #[test] @@ -100,3 +101,17 @@ fn test_version_pipe_no_stderr() { child.close_stdout(); child.wait().unwrap().no_stderr(); } + +#[test] +#[cfg(target_os = "linux")] +fn test_write_error() { + let dev_full = std::fs::OpenOptions::new() + .write(true) + .open("/dev/full") + .unwrap(); + + new_ucmd!() + .set_stdout(dev_full) + .fails() + .stderr_is("tty: No space left on device\n"); +} diff --git a/tests/by-util/test_uname.rs b/tests/by-util/test_uname.rs index 25485f5c120..3d45e79d365 100644 --- a/tests/by-util/test_uname.rs +++ b/tests/by-util/test_uname.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -111,7 +112,7 @@ fn test_uname_operating_system() { .arg("--operating-system") .succeeds() .stdout_is("Redox\n"); - #[cfg(target_os = "windows")] + #[cfg(windows)] { let result = new_ucmd!().arg("--operating-system").succeeds(); println!("{:?}", result.stdout_str()); @@ -134,3 +135,33 @@ fn test_uname_output_for_invisible_chars() { let result = new_ucmd!().arg("--all").succeeds(); assert_eq!(re.find(result.stdout_str().trim_end()), None); } + +#[test] +fn test_uname_all_labeled() { + let result = new_ucmd!().arg("-A").succeeds(); + let stdout = result.stdout_str(); + // One labeled "Label: value" line per item. Like GNU, an unknown processor or + // hardware platform is omitted, and we never determine either one. + assert_eq!(stdout.lines().count(), 6); + for label in [ + "Kernel name: ", + "Node name: ", + "Kernel release: ", + "Kernel version: ", + "Machine: ", + "Operating system: ", + ] { + assert!(stdout.contains(label), "missing {label:?}"); + } + assert!(!stdout.contains("Processor:")); + assert!(!stdout.contains("Hardware platform:")); +} + +#[test] +fn test_uname_all_labeled_long_flag() { + let short = new_ucmd!().arg("-A").succeeds(); + new_ucmd!() + .arg("--all-labeled") + .succeeds() + .stdout_is(short.stdout_str()); +} diff --git a/tests/by-util/test_unexpand.rs b/tests/by-util/test_unexpand.rs index b29b2446a69..675b83f3556 100644 --- a/tests/by-util/test_unexpand.rs +++ b/tests/by-util/test_unexpand.rs @@ -2,8 +2,8 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// -// spell-checker:ignore contenta edgecase behaviour + +// spell-checker:ignore contenta edgecase behaviour tcaf use uutests::{at_and_ucmd, new_ucmd}; @@ -167,6 +167,60 @@ fn unexpand_spaces_after_fields() { .stdout_is("\t\tA B C D\t\t A\t\n"); } +#[test] +fn unexpand_leading_spaces_before_multibyte() { + // Leading whitespace is converted even in the default (UTF-8) mode, and the + // multibyte tail is copied verbatim. + new_ucmd!() + .pipe_in(" caf\u{e9}\n") + .succeeds() + .stdout_is("\tcaf\u{e9}\n"); +} + +#[test] +fn unexpand_deep_indent_stops_at_last_tabstop() { + // With several tab stops, conversion stops past the last one: the extra + // leading spaces beyond column 4 stay as spaces. + new_ucmd!() + .args(&["-t", "2,4"]) + .pipe_in(" x\n") + .succeeds() + .stdout_is("\t\t x\n"); +} + +#[test] +fn unexpand_wide_char_width_before_tab() { + // A 3-byte, double-width character advances the column by two, so the + // following tab lands on the next tab stop. + new_ucmd!() + .args(&["-a", "-t", "4"]) + .pipe_in(" \u{3000}X\ty\n") + .succeeds() + .stdout_is(" \u{3000}X\ty\n"); +} + +#[test] +fn unexpand_ideographic_space_absorbed_into_tab() { + // U+3000 is a width-2 blank: a run of them that reaches a tab stop is + // converted to a tab, just like a run of ASCII spaces. + new_ucmd!() + .arg("-a") + .pipe_in("\u{3000}\u{3000}\u{3000}\u{3000}Z\n") + .succeeds() + .stdout_is("\tZ\n"); +} + +#[test] +fn unexpand_ideographic_space_preserved_when_not_converted() { + // A blank run that does not reach a tab stop is emitted verbatim, so the + // multibyte blank keeps its original bytes rather than becoming spaces. + new_ucmd!() + .arg("-a") + .pipe_in("a\u{3000}b\n") + .succeeds() + .stdout_is("a\u{3000}b\n"); +} + #[test] fn unexpand_read_from_file() { new_ucmd!().arg("with_spaces.txt").arg("-t4").succeeds(); @@ -216,6 +270,18 @@ fn test_comma_separated_tabs_shortcut() { .stdout_is("a\tb\tc"); } +#[test] +fn test_blank_separated_tabs() { + // GNU accepts a space or a tab as a tab-list separator, just like a comma. + for sep in [" ", "\t"] { + new_ucmd!() + .args(&["-a", "-t", &format!("3{sep}9")]) + .pipe_in("a b c") + .succeeds() + .stdout_is("a\tb\tc"); + } +} + #[test] fn test_tabs_cannot_be_zero() { new_ucmd!() @@ -247,7 +313,7 @@ fn test_tabs_with_invalid_chars() { #[test] fn test_tabs_shortcut_with_too_large_size() { let arg = format!("-{}", u128::MAX); - let expected_error = "tab stop value is too large"; + let expected_error = "tab stop is too large"; new_ucmd!().arg(arg).fails().stderr_contains(expected_error); } @@ -259,7 +325,7 @@ fn test_extended_tabstop_increment_overflow() { new_ucmd!() .arg(arg) .fails() - .stderr_contains("tab stop value is too large"); + .stderr_contains("tab stop is too large"); } #[test] @@ -319,19 +385,32 @@ fn test_non_utf8_filename() { #[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) + // Column position uses display width, not byte count (matches GNU 9.11). + // "1ΔΔΔ5" is 5 columns wide (each Δ is a 2-byte char of display width 1), + // so the run of 3 spaces reaches tab stop 8 and collapses to a single tab. new_ucmd!() .args(&["-a"]) .pipe_in("1ΔΔΔ5 99999\n") .succeeds() - .stdout_is("1ΔΔΔ5 99999\n"); + .stdout_is("1ΔΔΔ5\t99999\n"); +} + +#[test] +fn unexpand_wide_multibyte_char_width() { + // A 3-byte character of display width 2 must advance the column by 2, not by + // its byte length. U+FF0D (full-width hyphen, 3 bytes, width 2) at column 0 + // reaches column 2; the following 6 spaces then collapse to one tab at + // column 8. Regression for the byte-length-as-width bug. + new_ucmd!() + .args(&["-a"]) + .pipe_in("\u{FF0D} X\n") + .succeeds() + .stdout_is("\u{FF0D}\tX\n"); } #[test] fn test_blanks_ext1() { - // Test case from GNU test suite: blanks-ext1 + // Test unexpand with extended blank handling (blanks-ext1) // ['blanks-ext1', '-t', '3,+6', {IN=> "\t "}, {OUT=> "\t\t"}], new_ucmd!() .args(&["-t", "3,+6"]) @@ -342,7 +421,7 @@ fn test_blanks_ext1() { #[test] fn test_blanks_ext2() { - // Test case from GNU test suite: blanks-ext2 + // Test unexpand with extended blank handling (blanks-ext2) // ['blanks-ext2', '-t', '3,/9', {IN=> "\t "}, {OUT=> "\t\t"}], new_ucmd!() .args(&["-t", "3,/9"]) diff --git a/tests/by-util/test_uniq.rs b/tests/by-util/test_uniq.rs index 8e940a743cd..02ea5525421 100644 --- a/tests/by-util/test_uniq.rs +++ b/tests/by-util/test_uniq.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 nabcd badoption schar +// spell-checker:ignore nabcd badoption schar nosuchfile + use uucore::posix::OBSOLETE; use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -162,6 +163,23 @@ fn test_stdin_all_repeated() { .stdout_is_fixture("sorted-all-repeated.expected"); } +#[test] +fn test_repeated_all_repeated_uses_final_delimiter() { + const DUPLICATE_RUNS: &str = "copper\ncopper\nsilver\ngold\ngold\nplatinum\n"; + + new_ucmd!() + .args(&["--all-repeated=prepend", "-D"]) + .pipe_in(DUPLICATE_RUNS) + .succeeds() + .stdout_is("copper\ncopper\ngold\ngold\n"); + + new_ucmd!() + .args(&["-D", "--all-repeated=separate"]) + .pipe_in(DUPLICATE_RUNS) + .succeeds() + .stdout_is("copper\ncopper\n\ngold\ngold\n"); +} + #[test] fn test_all_repeated_followed_by_filename() { let filename = "test.txt"; @@ -355,10 +373,10 @@ struct TestCase { #[test] #[allow(clippy::too_many_lines)] -fn gnu_tests() { +fn uniq_basic_dedup_cases() { let cases = [ TestCase { - name: "1", + name: "tc_01", args: &[], input: "", stdout: Some(""), @@ -366,58 +384,58 @@ fn gnu_tests() { exit: None, }, TestCase { - name: "2", + name: "tc_02", args: &[], - input: "a\na\n", - stdout: Some("a\n"), + input: "x\nx\n", + stdout: Some("x\n"), stderr: None, exit: None, }, TestCase { - name: "3", + name: "tc_03", args: &[], - input: "a\na", - stdout: Some("a\n"), + input: "x\nx", + stdout: Some("x\n"), stderr: None, exit: None, }, TestCase { - name: "4", + name: "tc_04", args: &[], - input: "a\nb", - stdout: Some("a\nb\n"), + input: "p\nq", + stdout: Some("p\nq\n"), stderr: None, exit: None, }, TestCase { - name: "5", + name: "tc_05", args: &[], - input: "a\na\nb", - stdout: Some("a\nb\n"), + input: "x\nx\ny", + stdout: Some("x\ny\n"), stderr: None, exit: None, }, TestCase { - name: "6", + name: "tc_06", args: &[], - input: "b\na\na\n", - stdout: Some("b\na\n"), + input: "q\np\np\n", + stdout: Some("q\np\n"), stderr: None, exit: None, }, TestCase { - name: "7", + name: "tc_07", args: &[], - input: "a\nb\nc\n", - stdout: Some("a\nb\nc\n"), + input: "r\ns\nt\n", + stdout: Some("r\ns\nt\n"), stderr: None, exit: None, }, TestCase { name: "2z", args: &["-z"], - input: "a\na\n", - stdout: Some("a\na\n\0"), + input: "x\nx\n", + stdout: Some("x\nx\n\0"), stderr: None, exit: None, }, @@ -1203,3 +1221,31 @@ fn test_failed_write_is_reported() { .fails() .stderr_is("uniq: write error: No space left on device\n"); } + +#[test] +fn test_repeated_skip_fields_takes_the_last() { + // GNU lets a later -f override an earlier one rather than erroring. + new_ucmd!() + .args(&["-f", "1", "-f", "2"]) + .pipe_in("x y a\nz w a\n") + .succeeds() + .stdout_is("x y a\n"); +} + +#[test] +fn test_nonexistent_input_file_error_matches_gnu() { + new_ucmd!() + .arg("nosuchfile.txt") + .fails_with_code(1) + .stderr_only("uniq: nosuchfile.txt: No such file or directory\n"); +} + +#[test] +fn test_obsolete_skip_fields_not_read_after_double_dash() { + // After `--` there are no more options, so `-1` names a file rather than + // being taken as the obsolete "skip 1 field" spelling. + new_ucmd!() + .args(&["--", "-1"]) + .fails() + .stderr_contains("-1"); +} diff --git a/tests/by-util/test_unlink.rs b/tests/by-util/test_unlink.rs index 310b1ee970c..5b12d7205a2 100644 --- a/tests/by-util/test_unlink.rs +++ b/tests/by-util/test_unlink.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -47,10 +48,14 @@ fn test_unlink_directory() { let res = ucmd.arg(dir).fails(); let stderr = res.stderr_str(); - assert!( - stderr == "unlink: cannot unlink 'dir': Is a directory\n" - || stderr == "unlink: cannot unlink 'dir': Permission denied\n" - ); + // Linux returns EISDIR; macOS and the BSDs return EPERM, which GNU + // reports as "Operation not permitted"; Windows reports access denied. + assert!(matches!( + stderr, + "unlink: cannot unlink 'dir': Is a directory\n" + | "unlink: cannot unlink 'dir': Operation not permitted\n" + | "unlink: cannot unlink 'dir': Permission denied\n" + )); } #[test] diff --git a/tests/by-util/test_uptime.rs b/tests/by-util/test_uptime.rs index faf354d50fd..1f356ae4ced 100644 --- a/tests/by-util/test_uptime.rs +++ b/tests/by-util/test_uptime.rs @@ -2,11 +2,14 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// + // spell-checker:ignore utmp runlevel testusr testx boottime + #![allow(clippy::cast_possible_wrap, clippy::unreadable_literal)] -use uutests::{at_and_ucmd, new_ucmd}; +#[cfg(unix)] +use uutests::at_and_ucmd; +use uutests::new_ucmd; use regex::Regex; @@ -16,13 +19,21 @@ fn test_invalid_arg() { } #[test] +#[cfg(not(target_os = "android"))] fn test_uptime() { - new_ucmd!() - .succeeds() + let result = new_ucmd!().succeeds(); + result.stdout_contains(" up "); + // Don't check for users as it doesn't show in some CI + #[cfg(unix)] + result .stdout_contains("load average:") - .stdout_contains(" up "); + .stdout_does_not_contain(", ,"); - // Don't check for users as it doesn't show in some CI + // Windows has no load average; the line ends after the user count. + #[cfg(windows)] + result + .stdout_does_not_contain("load average") + .stdout_matches(&Regex::new(r" up .*, \d+ users?\n$").unwrap()); } #[test] @@ -42,7 +53,8 @@ fn test_write_error_handling() { /// Checks for files without utmpx records for which boot time cannot be calculated #[test] -#[cfg(not(any(target_os = "openbsd", target_os = "freebsd")))] +#[cfg(unix)] +#[cfg(not(any(target_os = "openbsd", target_os = "freebsd", target_os = "android")))] // Disabled for freebsd, since it doesn't use the utmpxname() sys call to change the default utmpx // file that is accessed using getutxent() fn test_uptime_for_file_without_utmpx_records() { @@ -59,6 +71,7 @@ fn test_uptime_for_file_without_utmpx_records() { /// Checks whether uptime displays the correct stderr msg when its called with a fifo #[test] #[cfg(all(unix, feature = "cp"))] +#[cfg(not(target_os = "android"))] fn test_uptime_with_fifo() { use uutests::{util::TestScenario, util_name}; @@ -88,6 +101,7 @@ fn test_uptime_with_fifo() { } #[test] +#[cfg(unix)] #[cfg(not(target_os = "freebsd"))] fn test_uptime_with_non_existent_file() { // Disabled for freebsd, since it doesn't use the utmpxname() sys call to change the default utmpx @@ -102,7 +116,8 @@ fn test_uptime_with_non_existent_file() { // TODO create a similar test for macos // This will pass #[test] -#[cfg(not(any(target_os = "openbsd", target_os = "macos")))] +#[cfg(unix)] +#[cfg(not(any(target_vendor = "apple", target_os = "openbsd", target_os = "android")))] #[cfg(not(target_env = "musl"))] #[cfg_attr( all(target_arch = "aarch64", target_os = "linux"), @@ -267,6 +282,7 @@ fn test_uptime_with_file_containing_valid_boot_time_utmpx_record() { } #[test] +#[cfg(unix)] fn test_uptime_with_extra_argument() { new_ucmd!() .arg("a") @@ -274,8 +290,20 @@ fn test_uptime_with_extra_argument() { .fails() .stderr_contains("unexpected value 'b'"); } + +/// The utmp file operand is unix-only; any operand is rejected on Windows. +#[test] +#[cfg(windows)] +fn test_uptime_with_file_windows() { + new_ucmd!() + .arg("file1") + .fails_with_code(1) + .stderr_contains("unexpected argument"); +} + /// Checks whether uptime displays the correct stderr msg when its called with a directory #[test] +#[cfg(unix)] fn test_uptime_with_dir() { let (at, mut ucmd) = at_and_ucmd!(); @@ -309,14 +337,14 @@ fn test_uptime_pretty_print() { .arg("-p") .succeeds() .stdout_contains("up") - .stdout_contains("minute"); + .stdout_matches(&Regex::new(r"hour|minute").unwrap()); } /// 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. #[test] -#[cfg(target_os = "macos")] +#[cfg(target_vendor = "apple")] fn test_uptime_macos_reliability() { // Run uptime multiple times to ensure consistent success // (Previously would fail intermittently when utmpx had no BOOT_TIME) @@ -340,7 +368,7 @@ fn test_uptime_macos_reliability() { /// Test uptime --since reliability on macOS. /// Verifies the sysctl fallback works for the --since flag. #[test] -#[cfg(target_os = "macos")] +#[cfg(target_vendor = "apple")] fn test_uptime_since_macos() { let re = Regex::new(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}").unwrap(); @@ -362,7 +390,7 @@ fn test_uptime_since_macos() { /// Test that uptime output format is consistent on macOS. /// Ensures the sysctl fallback produces properly formatted output. #[test] -#[cfg(target_os = "macos")] +#[cfg(target_vendor = "apple")] fn test_uptime_macos_output_format() { let result = new_ucmd!().succeeds(); let stdout = result.stdout_str(); diff --git a/tests/by-util/test_users.rs b/tests/by-util/test_users.rs index dd1e043dacf..2a6f933836a 100644 --- a/tests/by-util/test_users.rs +++ b/tests/by-util/test_users.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + use uutests::new_ucmd; #[cfg(any(target_vendor = "apple", target_os = "linux"))] use uutests::{util::TestScenario, util_name}; diff --git a/tests/by-util/test_vdir.rs b/tests/by-util/test_vdir.rs index bb9f314e9b3..89bb0f984be 100644 --- a/tests/by-util/test_vdir.rs +++ b/tests/by-util/test_vdir.rs @@ -2,6 +2,9 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + +// spell-checker:ignore iproduct + use regex::Regex; use uutests::new_ucmd; use uutests::util::TestScenario; @@ -23,13 +26,118 @@ fn test_default_output() { let at = &scene.fixtures; at.mkdir("some-dir1"); at.touch("some-file1"); + filetime::set_file_mtime( + at.plus("some-file1"), + filetime::FileTime::from_unix_time(978_307_200, 0), + ) + .unwrap(); scene.ucmd().succeeds().stdout_contains("some-file1"); scene .ucmd() .succeeds() - .stdout_matches(&Regex::new("[rwx-]{10}.*some-file1\n$").unwrap()); + .stdout_contains("Jan 1 2001 some-file1\n"); + scene + .ucmd() + .arg("--time-style=long-iso") + .succeeds() + .stdout_contains("2001-01-01 00:00 some-file1\n"); + scene + .ucmd() + .env("TIME_STYLE", "long-iso") + .succeeds() + .stdout_contains("2001-01-01 00:00 some-file1\n"); +} + +#[test] +fn test_default_format_overrides() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.touch("file"); + for (args, expected) in [ + (vec!["-1"], "file\n"), + (vec!["-C"], "file\n"), + (vec!["-x"], "file\n"), + (vec!["-m"], "file\n"), + (vec!["--format=single-column"], "file\n"), + (vec!["--zero"], "file\0"), + (vec!["-g", "-C"], "file\n"), + ] { + scene + .ucmd() + .env("TIME_STYLE", "invalid") + .args(&args) + .succeeds() + .stdout_only(expected); + } + for args in [["-l", "-1"], ["-g", "--zero"], ["-C", "-g"]] { + scene + .ucmd() + .args(&args) + .succeeds() + .stdout_contains("total 0"); + } +} + +#[test] +fn test_quoting_defaults() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.touch("a b"); + scene.ucmd().succeeds().stdout_contains(" a\\ b\n"); + scene.ucmd().arg("--zero").succeeds().stdout_only("a b\0"); + scene + .ucmd() + .env("QUOTING_STYLE", "literal") + .succeeds() + .stdout_contains(" a b\n"); + scene + .ucmd() + .env("QUOTING_STYLE", "literal") + .arg("-b") + .succeeds() + .stdout_contains(" a\\ b\n"); + scene + .ucmd() + .arg("--dired") + .succeeds() + .stdout_contains(" a\\ b\n"); +} + +#[test] +#[cfg(unix)] +fn test_literal_quoting_on_terminal() { + let scene = TestScenario::new(util_name!()); + scene.fixtures.touch("a\nb"); + scene + .ucmd() + .env("QUOTING_STYLE", "literal") + .terminal_simulation(true) + .succeeds() + .stdout_contains(" a\r\nb\r\n"); +} + +#[test] +fn test_time_selection_preserves_name_sort() { + let scene = TestScenario::new(util_name!()); + for (name, seconds) in [("a", 978_307_200), ("b", 1_009_843_200)] { + scene.fixtures.touch(name); + filetime::set_file_atime( + scene.fixtures.plus(name), + filetime::FileTime::from_unix_time(seconds, 0), + ) + .unwrap(); + } + let name_order = Regex::new(r"(?s)2001 a\n.*2002 b\n").unwrap(); + let zero_output = Regex::new(r"\Ab\x00a\x00\z").unwrap(); + for (time, zero) in itertools::iproduct!(["-u", "--time=atime"], [false, true]) { + scene + .ucmd() + .args(&[time, "--time-style=+%Y", "a", "b"]) + .args(if zero { &["--zero"] } else { &[] }) + .succeeds() + .no_stderr() + .stdout_matches(if zero { &zero_output } else { &name_order }); + } } #[test] @@ -83,3 +191,30 @@ fn test_version() { .no_stderr() .stdout_is(format!("vdir {}\n", uucore::crate_version!())); } + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_unknown_unit_of_block_size() { + let result = new_ucmd!() + .terminal_sim_stderr() + .arg("--block-size=1fb") + .fails_with_code(2); + let stderr = result.stderr_as_displayed(); + + // The report names the utility as it was called, not `ls`. + assert!(stderr.contains("vdir:1:"), "{stderr}"); + assert!(stderr.contains("not a known unit"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .arg("--block-size=1fb") + .fails_with_code(2) + .stderr_is("vdir: invalid --block-size argument '1fb'\n"); + } +} diff --git a/tests/by-util/test_wc.rs b/tests/by-util/test_wc.rs index 62f100b4293..16fd79293f5 100644 --- a/tests/by-util/test_wc.rs +++ b/tests/by-util/test_wc.rs @@ -3,12 +3,13 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore (flags) lwmcL clmwL ; (path) bogusfile emptyfile manyemptylines moby notrailingnewline onelongemptyline onelongword weirdchars ioerrdir + #[cfg(unix)] 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 ioerrdir #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); @@ -296,8 +297,8 @@ fn test_non_unicode_names() { .expect("Only unix platforms can test non-unicode names"); at.mkdir("some-dir1"); - at.touch(&target1); - at.touch(&target2); + at.touch(target1); + at.touch(target2); ucmd.args(&[target1, target2]).succeeds().stdout_is_bytes( [ @@ -437,7 +438,7 @@ fn test_read_from_directory_error() { // wasi-libc may report a different error string than the host libc cmd.stderr_contains("wc: .:"); } else if cfg!(windows) { - cmd.stderr_contains(".: Permission denied").stdout_is(""); + cmd.stderr_contains(".: Permission denied").no_stdout(); } else { cmd.stderr_contains(".: Is a directory") .stdout_is(" 0 0 0 .\n"); @@ -505,7 +506,7 @@ fn test_files0_disabled_files_argument() { .arg("lorem_ipsum.txt") .fails() .stderr_contains(MSG) - .stdout_is(""); + .no_stdout(); } #[test] @@ -565,7 +566,7 @@ fn test_files0_from_with_stdin_try_read_from_stdin() { .pipe_in("-") .fails() .stderr_contains(MSG) - .stdout_is(""); + .no_stdout(); } #[test] diff --git a/tests/by-util/test_who.rs b/tests/by-util/test_who.rs index fddb2268174..2456ad0fbb2 100644 --- a/tests/by-util/test_who.rs +++ b/tests/by-util/test_who.rs @@ -111,7 +111,7 @@ fn test_runlevel() { ts.ucmd().arg(opt).succeeds().stdout_is(expected_stdout); #[cfg(not(target_os = "linux"))] - ts.ucmd().arg(opt).succeeds().stdout_is(""); + ts.ucmd().arg(opt).succeeds().no_output(); } } @@ -187,7 +187,7 @@ fn test_users() { // TODO: `--users` sometimes differs from GNU's output on macOS (race condition?) // actual: "runner console Jun 23 06:37 00:34 196\n" // expect: "runner console Jun 23 06:37 old 196\n" - if cfg!(target_os = "macos") { + if cfg!(target_vendor = "apple") { v_actual.remove(5); v_expect.remove(5); } @@ -221,7 +221,7 @@ fn test_dead() { #[test] #[ignore = "issue #3219"] fn test_all_separately() { - if cfg!(target_os = "macos") { + if cfg!(target_vendor = "apple") { // TODO: fix `-u`, see: test_users return; } @@ -239,7 +239,7 @@ fn test_all_separately() { #[test] #[ignore = "issue #3219"] fn test_all() { - if cfg!(target_os = "macos") { + if cfg!(target_vendor = "apple") { // TODO: fix `-u`, see: test_users return; } @@ -292,3 +292,228 @@ fn test_piped_to_dev_full() { .fails() .stderr_is("who: No space left on device\n"); } + +// `-q` took a separate branch that printed with `println!`, which aborts the +// process on a write error instead of reporting it (#13388). +#[cfg(target_os = "linux")] +#[test] +fn test_short_list_piped_to_dev_full() { + let ts = TestScenario::new(util_name!()); + + let dev_full = std::fs::OpenOptions::new() + .write(true) + .open("/dev/full") + .unwrap(); + + ts.ucmd() + .arg("-q") + .set_stdout(dev_full) + .fails() + .stderr_is("who: No space left on device\n"); +} + +/// Builds a glibc `utmp` file holding one record of each type that `who` +/// reports, so the per-record code paths can be tested without relying on the +/// contents of the host's `/var/run/utmp`. +/// +/// The layout is the one glibc uses on `x86_64`; other targets use different +/// field sizes. +#[cfg(all(target_os = "linux", target_env = "gnu", target_arch = "x86_64"))] +mod utmp_file { + use uutests::at_and_ucmd; + use uutests::util::CmdResult; + + const RUN_LVL: i16 = 1; + const BOOT_TIME: i16 = 2; + const NEW_TIME: i16 = 3; + const INIT_PROCESS: i16 = 5; + const LOGIN_PROCESS: i16 = 6; + const USER_PROCESS: i16 = 7; + const DEAD_PROCESS: i16 = 8; + + /// Tue Nov 14 22:13:20 UTC 2023 + const TIMESTAMP: i32 = 1_700_000_000; + + #[derive(Default)] + struct Record<'a> { + kind: i16, + pid: i32, + line: &'a str, + id: &'a str, + user: &'a str, + host: &'a str, + exit: (i16, i16), + } + + impl Record<'_> { + fn to_bytes(&self) -> Vec { + fn field(buf: &mut Vec, value: &str, len: usize) { + assert!(value.len() <= len); + buf.extend_from_slice(value.as_bytes()); + buf.resize(buf.len() + len - value.len(), 0); + } + + let mut buf = Vec::with_capacity(384); + buf.extend_from_slice(&self.kind.to_ne_bytes()); + buf.extend_from_slice(&[0; 2]); // padding + buf.extend_from_slice(&self.pid.to_ne_bytes()); + field(&mut buf, self.line, 32); + field(&mut buf, self.id, 4); + field(&mut buf, self.user, 32); + field(&mut buf, self.host, 256); + buf.extend_from_slice(&self.exit.0.to_ne_bytes()); + buf.extend_from_slice(&self.exit.1.to_ne_bytes()); + buf.extend_from_slice(&0_i32.to_ne_bytes()); // ut_session + buf.extend_from_slice(&TIMESTAMP.to_ne_bytes()); // ut_tv.tv_sec + buf.extend_from_slice(&0_i32.to_ne_bytes()); // ut_tv.tv_usec + buf.extend_from_slice(&[0; 16]); // ut_addr_v6 + buf.extend_from_slice(&[0; 20]); // reserved + assert_eq!(buf.len(), 384); + buf + } + } + + /// The run level record stores the previous level in the upper byte of + /// the pid and the current one in the lower byte. + fn run_level(previous: u8, current: u8) -> Record<'static> { + Record { + kind: RUN_LVL, + pid: i32::from(previous) * 256 + i32::from(current), + line: "~", + id: "~~", + user: "runlevel", + ..Record::default() + } + } + + fn records() -> Vec> { + vec![ + Record { + kind: BOOT_TIME, + line: "~", + id: "~~", + user: "reboot", + ..Record::default() + }, + run_level(b'N', b'5'), + Record { + kind: NEW_TIME, + line: "{", + user: "date", + ..Record::default() + }, + Record { + kind: INIT_PROCESS, + pid: 1234, + line: "tty9", + id: "si", + ..Record::default() + }, + Record { + kind: LOGIN_PROCESS, + pid: 2345, + line: "tty1", + id: "1", + user: "LOGIN", + ..Record::default() + }, + // The terminal does not exist, so its write state and idle time + // are reported as unknown. + Record { + kind: USER_PROCESS, + pid: 3456, + line: "ttyNotThere", + id: "ts/9", + user: "alice", + host: "example.org", + ..Record::default() + }, + Record { + kind: DEAD_PROCESS, + pid: 4567, + line: "pts/98", + id: "ts/8", + exit: (1, 2), + ..Record::default() + }, + ] + } + + fn run_who(records: &[Record], args: &[&str]) -> CmdResult { + let (at, mut ucmd) = at_and_ucmd!(); + let bytes: Vec = records.iter().flat_map(Record::to_bytes).collect(); + at.write_bytes("utmp", &bytes); + ucmd.env("LC_ALL", "C") + .env("TZ", "UTC") + .args(args) + .arg("utmp") + .succeeds() + } + + #[test] + fn test_default_lists_user_sessions() { + run_who(&records(), &[]).stdout_is("alice ttyNotThere Nov 14 22:13 (example.org)\n"); + } + + #[test] + fn test_each_selector() { + let cases = [ + ("-b", " system boot Nov 14 22:13\n"), + ( + "-r", + " run-level 5 Nov 14 22:13 last=S\n", + ), + ("-t", " clock change Nov 14 22:13\n"), + ( + "-p", + " tty9 Nov 14 22:13 1234 id=si\n", + ), + ( + "-l", + "LOGIN tty1 Nov 14 22:13 2345 id=1\n", + ), + ( + "-d", + " pts/98 Nov 14 22:13 4567 id=ts/8 term=1 exit=2\n", + ), + ( + "-u", + "alice ttyNotThere Nov 14 22:13 ? 3456 (example.org)\n", + ), + ("-T", "alice ? ttyNotThere Nov 14 22:13 (example.org)\n"), + ]; + for (arg, expected) in cases { + run_who(&records(), &[arg]).stdout_is(expected); + } + } + + #[test] + fn test_all() { + run_who(&records(), &["-a"]).stdout_is( + " system boot Nov 14 22:13 + run-level 5 Nov 14 22:13 last=S + clock change Nov 14 22:13 + tty9 Nov 14 22:13 1234 id=si +LOGIN tty1 Nov 14 22:13 2345 id=1 +alice ? ttyNotThere Nov 14 22:13 ? 3456 (example.org) + pts/98 Nov 14 22:13 4567 id=ts/8 term=1 exit=2 +", + ); + } + + #[test] + fn test_count_lists_only_user_sessions() { + run_who(&records(), &["-q"]).stdout_contains("alice\n"); + } + + #[test] + fn test_run_level_reports_previous_level() { + run_who(&[run_level(b'3', b'5')], &["-r"]) + .stdout_is(" run-level 5 Nov 14 22:13 last=3\n"); + } + + #[test] + fn test_run_level_without_previous_level() { + run_who(&[run_level(0, b'5')], &["-r"]).stdout_is(" run-level 5 Nov 14 22:13\n"); + } +} diff --git a/tests/by-util/test_yes.rs b/tests/by-util/test_yes.rs index c638915be94..ee6f41a2c00 100644 --- a/tests/by-util/test_yes.rs +++ b/tests/by-util/test_yes.rs @@ -2,30 +2,24 @@ // // 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::process::ExitStatus; -#[cfg(unix)] -use std::os::unix::process::ExitStatusExt; +use std::ffi::OsStr; use uutests::new_ucmd; -#[cfg(unix)] -fn check_termination(result: ExitStatus) { - assert_eq!(result.signal(), Some(libc::SIGPIPE)); -} - -#[cfg(not(unix))] -fn check_termination(result: ExitStatus) { - assert!(result.success(), "yes did not exit successfully"); -} - const NO_ARGS: &[&str] = &[]; /// Run `yes`, capture some of the output, then check exit status. fn run(args: &[impl AsRef], expected: &[u8]) { let result = new_ucmd!().args(args).run_stdout_starts_with(expected); - check_termination(result.exit_status()); + + // On Unix systems (not WASI), yes should be terminated by SIGPIPE when the pipe closes. + // On WASI and Windows, there are no signals, so just check the process succeeded. + #[cfg(all(unix, not(wasi_runner)))] + result.signal_name_is("PIPE"); + + #[cfg(any(not(unix), wasi_runner))] + result.success(); } #[test] @@ -39,28 +33,33 @@ fn test_version() { } #[test] +#[cfg_attr(wasi_runner, ignore)] fn test_simple() { run(NO_ARGS, b"y\ny\ny\ny\n"); } #[test] +#[cfg_attr(wasi_runner, ignore)] fn test_args() { run(&["a", "bar", "c"], b"a bar c\na bar c\na ba"); } #[test] +#[cfg_attr(wasi_runner, ignore)] fn test_long_output() { run(NO_ARGS, "y\n".repeat(512 * 1024).as_bytes()); } /// Test with an output that seems likely to get mangled in case of incomplete writes. #[test] +#[cfg_attr(wasi_runner, ignore)] fn test_long_odd_output() { run(&["abcdef"], "abcdef\n".repeat(1024 * 1024).as_bytes()); } /// Test with an input that doesn't fit in the standard buffer. #[test] +#[cfg_attr(wasi_runner, ignore)] fn test_long_input() { #[cfg(not(windows))] const TIMES: usize = 14000; @@ -74,8 +73,23 @@ fn test_long_input() { run(&[&arg[..arg.len() - 1]], expected_out.as_bytes()); } +/// A joined line larger than the internal broker pipe's capacity (1 MiB) +/// can cause a deadlock +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg_attr(wasi_runner, ignore)] +fn test_long_line_exceeds_pipe_capacity() { + // A single argv string is capped at ~128 KiB by the kernel, so use many + // args joined by spaces to build a >1 MiB line instead of one huge arg. + let word = "a".repeat(60_000); + let args: Vec<&str> = std::iter::repeat_n(word.as_str(), 20).collect(); + let line = args.join(" "); + run(&args, format!("{line}\n{line}\n").as_bytes()); +} + #[test] #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] +#[cfg_attr(wasi_runner, ignore)] fn test_piped_to_dev_full() { use std::fs::OpenOptions; @@ -96,12 +110,11 @@ fn test_piped_to_dev_full() { } #[test] -#[cfg(any(unix, target_os = "wasi"))] +#[cfg(unix)] +// WASI runners (wasmtime) require UTF-8 arguments, so skip this test when testing WASI binaries +#[cfg_attr(wasi_runner, ignore = "WASI: argv must be valid UTF-8")] fn test_non_utf8() { - #[cfg(unix)] use std::os::unix::ffi::OsStrExt; - #[cfg(target_os = "wasi")] - use std::os::wasi::ffi::OsStrExt; run( &[ diff --git a/tests/fixtures/env/vars.conf.txt b/tests/fixtures/env/vars.conf.txt deleted file mode 100644 index 9364d6d122a..00000000000 --- a/tests/fixtures/env/vars.conf.txt +++ /dev/null @@ -1,4 +0,0 @@ -# comment -FOO=bar - -BAR="bamf this" diff --git a/tests/fixtures/pr/0Fnt b/tests/fixtures/pr/0Fnt deleted file mode 100644 index 9ba3a906c7b..00000000000 --- a/tests/fixtures/pr/0Fnt +++ /dev/null @@ -1,36 +0,0 @@ - -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 - -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 - -29 xyzxyzxyz XYZXYZXYZ abcabcab -30 456789 123456789 xyzxyzxyz XYZXYZXYZ -1 12345678 -2 3456789 abcdefghi -3 12345678 diff --git a/tests/fixtures/pr/0Ft b/tests/fixtures/pr/0Ft deleted file mode 100644 index bdd599d4752..00000000000 --- a/tests/fixtures/pr/0Ft +++ /dev/null @@ -1,35 +0,0 @@ - 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 - -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 - -29 xyzxyzxyz XYZXYZXYZ abcabcab -30 456789 123456789 xyzxyzxyz XYZXYZXYZ -1 12345678 -2 3456789 abcdefghi -3 12345678 diff --git a/tests/fixtures/pr/FnFn b/tests/fixtures/pr/FnFn deleted file mode 100644 index fa91abafc1e..00000000000 --- a/tests/fixtures/pr/FnFn +++ /dev/null @@ -1,68 +0,0 @@ -1 FF-Test: FF's in Text 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: One Empty Page -7 text\f\f\n; text\f\n\ftext; \f\ftext; -8 \f\f\n; \f\n\f\n; -9 -10 zzzzzzzzzzzzzzzzzzzzzzzzzz123456789 -1 12345678 -2 12345678 -3 line truncation before FF; r_r_o_l-test: -14 456789 123456789 123456789 123456789 - - -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 - - -29 xyzxyzxyz XYZXYZXYZ abcabcab -30 456789 123456789 xyzxyzxyz XYZXYZXYZ -1 12345678 -2 3456789 abcdefghi -3 12345678 -4 12345678 -5 12345678 -6 12345678 -7 12345678 -8 12345678 -9 3456789 abcdefghi -40 DEFGHI 123456789 -41 yzxyzxyz XYZXYZXYZ abcabcab -42 456789 123456789 abcdefghi ABCDEDFHI - - - -43 xyzxyzxyz XYZXYZXYZ abcabcab -44 456789 123456789 xyzxyzxyz XYZXYZXYZ -5 12345678 -6 12345678 -7 12345678 -8 12345678 -9 12345678 -50 12345678 -1 12345678 -2 12345678 -3 12345678 -4 12345678 -55 yzxyzxyz XYZXYZXYZ abcabcab -56 456789 123456789 abcdefghi ABCDEDFHI - -57 xyzxyzxyz XYZXYZXYZ abcabcab -58 456789 123456789 xyzxyzxyz XYZXYZXYZ -9 12345678 -60 DEFGHI 123456789 diff --git a/tests/fixtures/pr/tFFt-ll b/tests/fixtures/pr/tFFt-ll deleted file mode 100644 index 39eca655f37..00000000000 --- a/tests/fixtures/pr/tFFt-ll +++ /dev/null @@ -1,56 +0,0 @@ -1<<< -Test: FF's in Text >>> -2<<< -b -3 / -a -3 / ... >>> -3<<< >>> -4<<< 123456789 123456789 123456789 123456789 123456789 123456789 123456789 >>> - -6<<< -Arangements: One Empty Page >>> -7<<< \f\f\n; text\f\n\ftext; \f\ftext; >>> -8<<< f\f\n; \f\n\f\n; >>> -9<<< >>> -10<<< >>> -1<<< >>> -2<<< >>> -3<<< truncation before FF; r_r_o_l-test: >>> -14<<< 123456789 123456789 123456789 >>> 15<<< xyzxyzxyz XYZXYZXYZ abcabcab >>> -16<<< 123456789 xyzxyzxyz XYZXYZXYZ >>> -7<<< >>> -8<<< >>> -9<<< >>> -20<<< >>> -1<<< >>> - - -4<<< >>> -5<<< >>> -6<<< >>> -27<<< truncation before FF; (r_l-test): >>> -28<<< trunc 29<<>> -30<<< 123456789 xyzxyzxyz XYZXYZXYZ >>> -1<<< >>> -2<<< abcdefghi >>> -3<<< >>> -4<<< >>> -5<<< >>> -6<<< >>> -7<<< >>> -8<<< >>> -9<<< abcdefghi >>> -40<<< 123456789 >>> -41<<< XYZXYZXYZ abcabcab >>> -42<<< 123456789 abcdefghi ABCDEDFHI >>> 43<<< xyzxyzxyz XYZXYZXYZ abcabcab >>> -44<<< 123456789 xyzxyzxyz XYZXYZXYZ >>> -5<<< >>> -6<<< >>> -7<<< >>> -8<<< >>> -9<<< >>> -50<<< >>> -1<<< >>> -2<<< >>> -3<<< >>> -4<<< >>> -55<<< XYZXYZXYZ abcabcab >>> -56<<< 123456789 abcdefghi ABCDEDFHI >>> 57<<< xyzxyzxyz XYZXYZXYZ abcabcab >>> -58<<< 123456789 xyzxyzxyz XYZXYZXYZ >>> -9<<< >>> -60<<< 123456789 >>> diff --git a/tests/fixtures/pr/test_one_page_no_ht.log.expected b/tests/fixtures/pr/test_one_page_no_ht.log.expected index 2abea9890ee..4de6f5bf306 100644 --- a/tests/fixtures/pr/test_one_page_no_ht.log.expected +++ b/tests/fixtures/pr/test_one_page_no_ht.log.expected @@ -54,4 +54,3 @@ Mon Dec 10 11:42:59.155 Info: 802.1X changed Mon Dec 10 11:42:59.157 Info: -[AirPortExtraImplementation processAirPortStateChanges]: pppConnectionState 0 Mon Dec 10 11:42:59.159 Info: -[AirPortExtraImplementation processAirPortStateChanges]: old state=4 bars, new state=4 bars Mon Dec 10 11:42:59.352 Info: 802.1X changed - diff --git a/tests/fixtures/pr/test_page_length1.log.expected b/tests/fixtures/pr/test_page_length1.log.expected index a09a2a98630..44b8e7f034e 100644 --- a/tests/fixtures/pr/test_page_length1.log.expected +++ b/tests/fixtures/pr/test_page_length1.log.expected @@ -3,10 +3,8 @@ 8 Mon Dec 10 11:42:56.856 Info: -[AirPortExtraImplementation processAirPortStateChanges]: old state=4 bars, new state=4 bars 9 Mon Dec 10 11:42:57.002 Info: 802.1X changed 10 Mon Dec 10 11:42:57.003 Info: -[AirPortExtraImplementation processAirPortStateChanges]: pppConnectionState 0 - 11 Mon Dec 10 11:42:57.003 Info: -[AirPortExtraImplementation processAirPortStateChanges]: old state=4 bars, new state=4 bars 12 Mon Dec 10 11:42:57.152 Info: 802.1X changed 13 Mon Dec 10 11:42:57.154 Info: -[AirPortExtraImplementation processAirPortStateChanges]: pppConnectionState 0 14 Mon Dec 10 11:42:57.154 Info: -[AirPortExtraImplementation processAirPortStateChanges]: old state=4 bars, new state=4 bars 15 Mon Dec 10 11:42:57.302 Info: 802.1X changed - diff --git a/tests/test_localization_and_colors.rs b/tests/test_localization_and_colors.rs index f2a1ff08485..814d03e94ff 100644 --- a/tests/test_localization_and_colors.rs +++ b/tests/test_localization_and_colors.rs @@ -2,7 +2,6 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// use std::collections::HashSet; use std::env; diff --git a/tests/test_util_name.rs b/tests/test_util_name.rs index bb47694337e..cb565072eb1 100644 --- a/tests/test_util_name.rs +++ b/tests/test_util_name.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 readelf +// spell-checker:ignore readelf memfd Memfd fexecve use uutests::util::TestScenario; @@ -43,6 +43,36 @@ fn binary_name_protection() { .stdout_contains("coreutils"); } +// missing /proc/self/fd on Android? +#[test] +#[cfg(all(feature = "true", target_os = "linux", target_env = "gnu"))] +fn binary_name_execfn_fallback() { + use std::os::{fd::AsRawFd, unix::process::CommandExt}; + use std::process::Command; + let ts = TestScenario::new("true"); + let fd = rustix::fs::memfd_create("a", rustix::fs::MemfdFlags::empty()).unwrap(); + let mut file = std::fs::File::from(fd); + let mut source = std::fs::File::open(&ts.bin_path).unwrap(); + std::io::copy(&mut source, &mut file).unwrap(); + let raw_fd = file.as_raw_fd(); + + // memfd has AT_EXECFN as below. Fallback to argv[0] + let res1 = Command::new(format!("/proc/self/fd/{raw_fd}")) + .arg0("true") + .output() + .unwrap() + .status + .success(); + // fexecve uses /dev/fd which is symlink to /proc/self/fd/N + let res2 = Command::new(format!("/dev/fd/{raw_fd}")) + .arg0("true") + .output() + .unwrap() + .status + .success(); + assert!(res1 && res2); +} + #[test] fn test_coreutils_help_ignore_args() { let scenario = TestScenario::new("help_ignoring_args"); diff --git a/tests/uudoc/mod.rs b/tests/uudoc/mod.rs index 4a96a532137..cf852c65794 100644 --- a/tests/uudoc/mod.rs +++ b/tests/uudoc/mod.rs @@ -174,3 +174,31 @@ fn test_markdown_header_format() { } } } + +#[test] +fn test_source_date_epoch() { + let output = get_uudoc_command() + .arg("manpage") + .arg("ls") + .env("SOURCE_DATE_EPOCH", "123456") + .output() + .expect("Failed to execute command"); + + assert!( + output.status.success(), + "Command failed with status: {}", + output.status + ); + assert!( + 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); + let first_line = output_str + .lines() + .find(|line| line.starts_with(".TH")) + .unwrap(); + assert_eq!(first_line, ".TH LS 1 \"1970-01-02\""); +} diff --git a/tests/uutests/Cargo.toml b/tests/uutests/Cargo.toml index 7cac6ab71d2..affc98cf28a 100644 --- a/tests/uutests/Cargo.toml +++ b/tests/uutests/Cargo.toml @@ -15,9 +15,6 @@ version.workspace = true [package.metadata.docs.rs] all-features = true -[lints] -workspace = true - [lib] path = "src/lib/lib.rs" @@ -39,8 +36,11 @@ uucore = { workspace = true, features = [ [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] [target.'cfg(unix)'.dependencies] -nix = { workspace = true, features = ["process", "signal", "term", "user"] } -rlimit = { workspace = true } +nix = { workspace = true, features = ["fs", "term"] } +rustix = { workspace = true, features = ["process"] } -[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "openbsd"))))'.dependencies] +[target.'cfg(any(target_os = "freebsd", target_os = "hurd", target_os = "linux", target_os = "android", target_os = "netbsd"))'.dependencies] xattr = { workspace = true } + +[lints] +workspace = true diff --git a/tests/uutests/src/lib/lib.rs b/tests/uutests/src/lib/lib.rs index 05e2b13824c..afd8f1a3939 100644 --- a/tests/uutests/src/lib/lib.rs +++ b/tests/uutests/src/lib/lib.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + #[macro_use] pub mod macros; pub mod random; diff --git a/tests/uutests/src/lib/mod.rs b/tests/uutests/src/lib/mod.rs index 05e2b13824c..afd8f1a3939 100644 --- a/tests/uutests/src/lib/mod.rs +++ b/tests/uutests/src/lib/mod.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + #[macro_use] pub mod macros; pub mod random; diff --git a/tests/uutests/src/lib/random.rs b/tests/uutests/src/lib/random.rs index cdb64a1097c..d5c3f7c4c47 100644 --- a/tests/uutests/src/lib/random.rs +++ b/tests/uutests/src/lib/random.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. + #![allow(clippy::naive_bytecount)] use rand::{ diff --git a/tests/uutests/src/lib/util.rs b/tests/uutests/src/lib/util.rs index 8286ecfea75..585a53add65 100644 --- a/tests/uutests/src/lib/util.rs +++ b/tests/uutests/src/lib/util.rs @@ -2,9 +2,10 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -//spell-checker: ignore (linux) rlimit prlimit coreutil ggroups uchild uncaptured scmd SHLVL canonicalized openpty -//spell-checker: ignore (linux) winsize xpixel ypixel setrlimit FSIZE SIGBUS SIGSEGV sigbus tmpfs mksocket -//spell-checker: ignore (ToDO) ttyname + +//spell-checker:ignore (linux) rlimit prlimit coreutil ggroups uchild uncaptured scmd SHLVL canonicalized openpty +//spell-checker:ignore (linux) winsize xpixel ypixel setrlimit Fsize SIGBUS SIGSEGV SIGXFSZ EFBIG sigbus tmpfs mksocket +//spell-checker:ignore (ToDO) ttyname #![allow(dead_code)] #![allow( @@ -16,7 +17,7 @@ use core::str; #[cfg(unix)] use libc::mode_t; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "redox")))] use nix::pty::OpenptyResult; #[cfg(unix)] use nix::sys; @@ -24,7 +25,7 @@ use nix::sys; use nix::sys::stat::{self, SFlag}; use pretty_assertions::assert_eq; #[cfg(unix)] -use rlimit::setrlimit; +use rustix::process::{Resource, Rlimit, setrlimit}; use std::borrow::Cow; use std::collections::VecDeque; use std::ffi::{OsStr, OsString}; @@ -340,8 +341,8 @@ impl CmdResult { /// /// # Platform specific behavior /// - /// This assertion method is only available on unix systems. - #[cfg(unix)] + /// This assertion method is only available on unix systems, except for fuchsia. + #[cfg(all(unix, not(target_os = "fuchsia")))] #[track_caller] pub fn signal_name_is(&self, name: &str) -> &Self { use uucore::signals::signal_by_name_or_value; @@ -399,6 +400,42 @@ impl CmdResult { std::str::from_utf8(&self.stderr).unwrap() } + /// Returns the program's standard error with the carriage returns a + /// pseudo-terminal inserts and any trailing padding on each line removed. + /// + /// Diagnostics rendered under [`UCommand::terminal_sim_stderr`] pad their + /// lines out to the width of the report, which makes them awkward to + /// compare verbatim; this gives back the block as it reads on screen. + pub fn stderr_as_displayed(&self) -> String { + self.stderr_str() + .lines() + .map(str::trim_end) + .collect::>() + .join("\n") + } + + /// Returns the one-based column a caret diagnostic points at. + /// + /// A report opens with a header naming the utility and the position it + /// points at — `╭─[ chmod:1:5 ]` — which is the only place the column is + /// written out; the caret row itself is padded and drawn with box + /// characters. Asserting on the column keeps a test to the one thing it + /// cares about, where matching the block verbatim would break on every + /// wording change. + /// + /// # Returns + /// + /// `None` when stderr carries no such header: the plain one-line message + /// was printed, or the diagnostic pointed at another line. + pub fn caret_column(&self) -> Option { + let header = format!("{}:1:", self.util_name.as_ref()?); + let line = self + .stderr_str() + .lines() + .find(|line| line.contains(&header))?; + line.rsplit(':').next()?.trim_end_matches(" ]").parse().ok() + } + /// Returns the program's standard error as a string slice, automatically handling invalid utf8 pub fn stderr_str_lossy(&self) -> Cow<'_, str> { String::from_utf8_lossy(&self.stderr) @@ -961,7 +998,13 @@ pub fn get_root_path() -> &'static str { /// # Returns /// /// `true` if both paths have the same set of extended attributes, `false` otherwise. -#[cfg(all(unix, not(any(target_os = "macos", target_os = "openbsd"))))] +#[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "netbsd" +))] pub fn compare_xattrs>(path1: P, path2: P) -> bool { let get_sorted_xattrs = |path: P| { xattr::list(path) @@ -1383,10 +1426,8 @@ impl TestScenario { fixture_path_builder.push(TESTS_DIR); fixture_path_builder.push(FIXTURES_DIR); fixture_path_builder.push(util_name.as_ref()); - if let Ok(m) = fs::metadata(&fixture_path_builder) { - if m.is_dir() { - recursive_copy(&fixture_path_builder, &ts.fixtures.subdir).unwrap(); - } + if fs::metadata(&fixture_path_builder).is_ok_and(|m| m.is_dir()) { + recursive_copy(&fixture_path_builder, &ts.fixtures.subdir).unwrap(); } ts } @@ -1445,8 +1486,8 @@ impl TestScenario { Ok(()) } - #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] /// Unmounts the temporary filesystem if it is currently mounted. + #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] pub fn umount_temp_fs(&mut self) { if let Some(mount_point) = self.tmp_fs_mountpoint.as_ref() { self.cmd("umount").arg(mount_point).succeeds(); @@ -1502,7 +1543,9 @@ pub struct UCommand { stderr: Option, bytes_into_stdin: Option>, #[cfg(unix)] - limits: Vec<(rlimit::Resource, u64, u64)>, + limits: Vec<(Resource, u64, u64)>, + #[cfg(unix)] + ignore_sigxfsz: bool, stderr_to_stdout: bool, timeout: Option, #[cfg(unix)] @@ -1665,18 +1708,23 @@ impl UCommand { } #[cfg(unix)] - pub fn limit( - &mut self, - resource: rlimit::Resource, - soft_limit: u64, - hard_limit: u64, - ) -> &mut Self { + pub fn limit(&mut self, resource: Resource, soft_limit: u64, hard_limit: u64) -> &mut Self { self.limits.push((resource, soft_limit, hard_limit)); self } + /// Ignore SIGXFSZ in the child, so that exceeding an `Fsize` [`limit`](Self::limit) makes + /// the write fail with `EFBIG` instead of killing the process. + /// + /// Only the child's disposition changes; the test process is left alone. #[cfg(unix)] + pub fn ignore_sigxfsz(&mut self) -> &mut Self { + self.ignore_sigxfsz = true; + self + } + /// The umask is a value that restricts the permissions of newly created files and directories. + #[cfg(unix)] pub fn umask(&mut self, umask: mode_t) -> &mut Self { self.umask = Some(umask); self @@ -1725,6 +1773,21 @@ impl UCommand { self } + /// Attach stderr (and only stderr) to a simulated terminal, with colors + /// disabled through `NO_COLOR`. + /// + /// This is useful to test output that is only rendered when + /// `stderr.is_terminal()` is `true`, such as the rich error reports of + /// `chmod` or `test`, while letting assertions see plain text. + #[cfg(unix)] + pub fn terminal_sim_stderr(&mut self) -> &mut Self { + self.terminal_sim_stdio(TerminalSimulation { + stderr: true, + ..Default::default() + }) + .env("NO_COLOR", "1") + } + #[cfg(unix)] fn read_from_pty(pty_fd: OwnedFd, out: File) { let read_file = File::from(pty_fd); @@ -1907,9 +1970,9 @@ impl UCommand { let mut captured_stdout = None; let mut captured_stderr = None; - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "redox")))] let mut stdin_pty: Option = None; - #[cfg(not(unix))] + #[cfg(not(all(unix, not(target_os = "redox"))))] let stdin_pty: Option = None; if self.stderr_to_stdout { let mut output = CapturedOutput::default(); @@ -1944,7 +2007,7 @@ impl UCommand { .stderr(stderr); } - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "redox")))] if let Some(simulated_terminal) = &self.terminal_simulation { let terminal_size = simulated_terminal.size.unwrap_or(libc::winsize { ws_col: 80, @@ -1996,7 +2059,13 @@ impl UCommand { let limits_copy = self.limits.clone(); let closure = move || -> Result<()> { for &(resource, soft_limit, hard_limit) in &limits_copy { - setrlimit(resource, soft_limit, hard_limit)?; + setrlimit( + resource, + Rlimit { + current: Some(soft_limit), + maximum: Some(hard_limit), + }, + )?; } Ok(()) }; @@ -2008,6 +2077,18 @@ impl UCommand { } } + #[cfg(unix)] + if self.ignore_sigxfsz { + // SAFETY: signal() is async-signal-safe, and an ignored disposition + // survives exec. + unsafe { + command.pre_exec(|| { + libc::signal(libc::SIGXFSZ, libc::SIG_IGN); + Ok(()) + }); + } + } + #[cfg(unix)] if let Some(umask) = self.umask { unsafe { @@ -2945,7 +3026,7 @@ pub fn whoami() -> String { /// - path: The filesystem path to the PTY replica device /// - controller: The controller file /// - replica: The replica file -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "redox")))] pub fn pty_path() -> (String, File, File) { use nix::pty::openpty; use nix::unistd::ttyname; @@ -3564,7 +3645,13 @@ mod tests { } } - #[cfg(all(unix, not(any(target_os = "macos", target_os = "openbsd"))))] + #[cfg(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "netbsd" + ))] #[test] fn test_compare_xattrs() { use tempfile::tempdir; @@ -3600,16 +3687,16 @@ mod tests { #[cfg(unix)] #[test] fn test_application_of_process_resource_limits_limited_file_size() { - let unit_size_bytes = if cfg!(target_os = "macos") { 1024 } else { 512 }; + let unit_size_bytes = if cfg!(target_vendor = "apple") { + 1024 + } else { + 512 + }; let ts = TestScenario::new("util"); ts.cmd("sh") .args(&["-c", "ulimit -Sf; ulimit -Hf"]) - .limit( - rlimit::Resource::FSIZE, - 8 * unit_size_bytes, - 16 * unit_size_bytes, - ) + .limit(Resource::Fsize, 8 * unit_size_bytes, 16 * unit_size_bytes) .succeeds() .no_stderr() .stdout_is("8\n16\n"); diff --git a/util/GHA-delete-GNU-workflow-logs.sh b/util/GHA-delete-GNU-workflow-logs.sh index 95f5c240eb4..de21de9d184 100755 --- a/util/GHA-delete-GNU-workflow-logs.sh +++ b/util/GHA-delete-GNU-workflow-logs.sh @@ -1,7 +1,5 @@ #!/bin/sh -# spell-checker:ignore (utils) gitsome jq jaq ; (gh) repos - # ME="${0}" # ME_dir="$(dirname -- "${ME}")" # ME_parent_dir="$(dirname -- "${ME_dir}")" @@ -9,16 +7,10 @@ # ref: -# note: requires `gh` and `jq` - -## tools available? - -# * `gh` available? -GH=$(command -v gh) -"${GH}" --version || (echo "ERR!: missing \`gh\` (see install instructions at )"; exit 1) -# * `jq` or fallback available? -: ${JQ:=$(command -v jq || command -v jaq)} -"${JQ}" --version || (echo "ERR!: missing \`jq\` (install with \`sudo apt install jq\`)"; exit 1) +if ! command -v gh >/dev/null 2>&1; then + echo "ERR!: missing \`gh\` (see install instructions at )" 1>&2 + exit 1 +fi case "${dry_run}" in '0' | 'f' | 'false' | 'no' | 'never' | 'none') unset dry_run ;; @@ -30,7 +22,7 @@ REPO_NAME="${REPO_NAME:-coreutils}" WORK_NAME="${WORK_NAME:-GNU}" # * `--paginate` retrieves all pages -# gh api --paginate "repos/${USER_NAME}/${REPO_NAME}/actions/runs" | jq -r ".workflow_runs[] | select(.name == \"${WORK_NAME}\") | (.id)" | xargs -n1 sh -c "for arg do { echo gh api repos/${USER_NAME}/${REPO_NAME}/actions/runs/\${arg} -X DELETE ; if [ -z "$dry_run" ]; then gh api repos/${USER_NAME}/${REPO_NAME}/actions/runs/\${arg} -X DELETE ; fi ; } ; done ;" _ -"${GH}" api "repos/${USER_NAME}/${REPO_NAME}/actions/runs" | - "${JQ}" -r ".workflow_runs[] | select(.name == \"${WORK_NAME}\") | (.id)" | - xargs -n1 sh -c "for arg do { echo ${GH} api repos/${USER_NAME}/${REPO_NAME}/actions/runs/\${arg} -X DELETE ; if [ -z \"${dry_run}\" ]; then ${GH} api repos/${USER_NAME}/${REPO_NAME}/actions/runs/\${arg} -X DELETE ; fi ; } ; done ;" _ +# gh api --paginate "repos/${USER_NAME}/${REPO_NAME}/actions/runs" --jq ".workflow_runs[] | select(.name == \"${WORK_NAME}\") | (.id)" | xargs -n1 sh -c "for arg do { echo gh api repos/${USER_NAME}/${REPO_NAME}/actions/runs/\${arg} -X DELETE ; if [ -z "$dry_run" ]; then gh api repos/${USER_NAME}/${REPO_NAME}/actions/runs/\${arg} -X DELETE ; fi ; } ; done ;" _ +gh api "repos/${USER_NAME}/${REPO_NAME}/actions/runs" \ + --jq ".workflow_runs[] | select(.name == \"${WORK_NAME}\") | (.id)" | + xargs -n1 sh -c "for arg do { echo gh api repos/${USER_NAME}/${REPO_NAME}/actions/runs/\${arg} -X DELETE ; if [ -z \"${dry_run}\" ]; then gh api repos/${USER_NAME}/${REPO_NAME}/actions/runs/\${arg} -X DELETE ; fi ; } ; done ;" _ diff --git a/util/analyze-gnu-results.py b/util/analyze-gnu-results.py index b9e8534b30f..00b1122fb9e 100755 --- a/util/analyze-gnu-results.py +++ b/util/analyze-gnu-results.py @@ -100,8 +100,8 @@ def analyze_test_results(json_data): error_count = 0 # Not in JSON data but included for compatibility # Analyze each utility's tests - for utility, tests in json_data.items(): - for test_name, result in tests.items(): + for tests in json_data.values(): + for result in tests.values(): total_tests += 1 match result: diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 40218a8d63c..53d3e9694d1 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -4,7 +4,7 @@ # spell-checker:ignore (paths) abmon deref discrim eacces 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 gnproc +# spell-checker:ignore openat TOCTOU CFLAGS tmpfs gnproc autoreconf set -e @@ -114,11 +114,15 @@ done # 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 +# Use GNU nproc for *BSD and macOS +NPROC="$(command -v nproc||command -v gnproc)" + if test -f gnu-built; then echo "GNU build already found. Skip" echo "'rm -f $(pwd)/{gnu-built,src/getlimits}' to force the build" echo "Note: the customization of the tests will still happen" else + autoreconf # Disable useless checks sed -i 's|check-texinfo: $(syntax_checks)|check-texinfo:|' doc/local.mk # Stop manpage generation for cleaner log @@ -132,12 +136,11 @@ else # Use a better diff 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}")" - cp -f src/getlimits "${UU_BUILD_DIR}" + # Skip make if possible. The SELinux job does not build the GNU tree at all; + # the block after this "if" builds the one program its tests need. + if [ "${SELINUX_ENABLED}" != 1 ]; then + test -x src/getlimits || make -j "$("${NPROC}")" + fi # Handle generated factor tests t_first=00 @@ -161,15 +164,35 @@ else touch gnu-built fi -# Keep getlimits available on PATH for GNU shell and Perl tests even when -# reusing an existing GNU build directory. -test -f src/getlimits && cp -f src/getlimits "${UU_BUILD_DIR}" - # Keep Makefile.in newer than the local.mk files we just modified, # and Makefile newer than Makefile.in, so make won't re-run # automake or config.status and undo our edits. touch Makefile.in Makefile +# The GNU shell and Perl tests call getlimits_, so getlimits has to be a real +# program on PATH, also when reusing an existing GNU build directory. An earlier +# version of this script left an empty, non-executable stub behind under +# SELINUX_ENABLED, hence the test for an executable rather than for a file. +# Build only that program, plus the generated sources that the "all" target +# would otherwise pull in, so the SELinux job still skips the rest of the tree. +# This has to come after the touch above: stripping the factor tests leaves +# tests/local.mk with a trailing backslash that automake rejects, so a make that +# still sees it as newer than Makefile.in dies in the remake rule. +if ! test -x src/getlimits; then + rm -f src/getlimits + printf 'built-sources: $(BUILT_SOURCES)\n' | + make -f Makefile -f - -j "$("${NPROC}")" built-sources || true + make -j "$("${NPROC}")" src/getlimits || true +fi +if test -x src/getlimits; then + # Remove the destination first: cp keeps the permissions of an existing + # file, so a leftover stub there would stay non-executable. + rm -f "${UU_BUILD_DIR}/getlimits" + cp -f src/getlimits "${UU_BUILD_DIR}" +else + echo "WARNING: could not build getlimits; tests calling getlimits_ will fail" >&2 +fi + # Patch the Makefile PATH to point to uutils build dir instead of GNU src/ sed -i "s/^[[:blank:]]*PATH=.*/ PATH='${UU_BUILD_DIR//\//\\/}\$(PATH_SEPARATOR)'\"\$\$PATH\" \\\/" Makefile # Prevent make check from rebuilding the GNU binaries over the uutils ones @@ -203,6 +226,10 @@ 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 +# Our message is better: +# clap provides allowed range for --parallel for given invalid value +sed -i '/^# Invalid --parallel arguments\./,+4d' tests/sort/sort.pl + # 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' @@ -291,9 +318,6 @@ sed -i -Ez "s/\n([^\n#]*pad-3\.2[^\n]*)\n([^\n]*)\n([^\n]*)/\n# uutils\/numfmt s 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 - # GNU doesn't support width > INT_MAX # disable these test cases sed -i -E "s|^([^#]*2_31.*)$|#\1|g" tests/printf/printf-cov.pl diff --git a/util/build-pgo.sh b/util/build-pgo.sh new file mode 100755 index 00000000000..e5738f9cde1 --- /dev/null +++ b/util/build-pgo.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +# spell-checker:ignore (jargon) profdata profraw sysroot rustlib nullglob aeiou nocheck CGU Cprofile cygpath atexit +# +# Build uutils coreutils with Profile-Guided Optimization. +# +# 1. build an instrumented multicall binary (-Cprofile-generate) +# 2. run representative workloads to collect raw profiles +# 3. merge them with llvm-profdata +# 4. build the optimized binary (-Cprofile-use), unless --train-only +# +# Runs on Linux, macOS and Windows (git-bash). +# +# Usage: +# util/build-pgo.sh [--target TRIPLE] [--target-dir DIR] [--features LIST] +# [--train-only] [--llvm-profdata PATH] + +set -euo pipefail + +# On Windows the instrumented binary, rustc and the profiler runtime are all +# native programs that do not understand git-bash's `/d/a/...` paths, while bash +# itself does not understand `D:\a\...`. `cygpath -m` yields `D:/a/...`, which +# both sides accept, so every path the script builds goes through it. +norm_path() { + if command -v cygpath >/dev/null 2>&1; then cygpath -m "$1"; else printf '%s\n' "$1"; fi +} + +REPO_ROOT="$(norm_path "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)")" +TARGET_DIR="${REPO_ROOT}/target/coreutils-pgo" +FEATURES="unix" +TARGET="" +TRAIN_ONLY=0 +LLVM_PROFDATA="" + +while [ $# -gt 0 ]; do + case "$1" in + --target) TARGET="$2"; shift 2 ;; + --target-dir) TARGET_DIR="$2"; shift 2 ;; + --features) FEATURES="$2"; shift 2 ;; + --llvm-profdata) LLVM_PROFDATA="$2"; shift 2 ;; + --train-only) TRAIN_ONLY=1; shift ;; + -h|--help) sed -n '3,16p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +mkdir -p "$TARGET_DIR" +TARGET_DIR="$(norm_path "$(cd "$TARGET_DIR" && pwd)")" + +HOST="$(rustc --print host-tuple)" +[ -n "$TARGET" ] || TARGET="$HOST" +# Cargo puts the artifacts of an explicit --target under a per-target directory, +# and Windows binaries carry a suffix. +case "$TARGET" in *windows*) EXE=".exe" ;; *) EXE="" ;; esac +echo "target: ${TARGET} (host: ${HOST})" + +SCRIPT_START=$SECONDS + +fmt_duration() { printf '%dm%02ds' $(($1 / 60)) $(($1 % 60)); } +begin_step() { + STEP_NAME="$1" + STEP_START=$SECONDS + echo + echo "=== ${STEP_NAME} ===" +} +end_step() { echo "--- ${STEP_NAME}: $(fmt_duration $((SECONDS - STEP_START))) ---"; } + +PROFILE_DIR="${TARGET_DIR}/profiles" +CORPUS_DIR="${TARGET_DIR}/corpus" +MERGED="${TARGET_DIR}/coreutils.profdata" + +# llvm-profdata must come from the *active* toolchain: its version has to match +# the rustc that instrumented the binary. +if [ -z "$LLVM_PROFDATA" ]; then + SYSROOT="$(norm_path "$(rustc --print sysroot)")" + # llvm-profdata ships for the host, whatever we are cross-building for. + case "$HOST" in *windows*) PROFDATA_EXE=".exe" ;; *) PROFDATA_EXE="" ;; esac + LLVM_PROFDATA="${SYSROOT}/lib/rustlib/${HOST}/bin/llvm-profdata${PROFDATA_EXE}" +fi +if [ ! -x "$LLVM_PROFDATA" ]; then + echo "llvm-profdata not found at ${LLVM_PROFDATA}" >&2 + echo "Run: rustup component add llvm-tools" >&2 + exit 1 +fi +echo "llvm-profdata: ${LLVM_PROFDATA}" + +cargo_build() { + # $1: target dir, $2: extra rustflags + local feature_args=() + if [ -n "$FEATURES" ]; then feature_args=(--features="$FEATURES"); fi + ( + cd "$REPO_ROOT" + export CARGO_TARGET_DIR="$1" + export CARGO_INCREMENTAL=0 + export RUSTFLAGS="${RUSTFLAGS:+${RUSTFLAGS} }$2" + # bash 3.2 (macOS) errors on an empty array expansion under `set -u`, + # hence the `[@]+` guard on both expansions below. + echo "Running: cargo build --release --target=${TARGET} ${feature_args[*]+${feature_args[*]}}" + echo " RUSTFLAGS=${RUSTFLAGS}" + cargo build --release --target="$TARGET" ${feature_args[@]+"${feature_args[@]}"} + ) +} + +begin_step "Step 1: instrumented build" +INSTR_DIR="${TARGET_DIR}/instrumented" +rm -rf "$PROFILE_DIR" +mkdir -p "$PROFILE_DIR" +# The instrumented build must use the same LTO and codegen-unit settings as the +# final one. Inlining happens before instrumentation, so building it with LTO +# off yields counters for functions that no longer exist once the final build +# runs whole-program codegen, and the profile is then largely wasted on it. +# +# `--cfg pgo_training` makes the binary flush its own counters before exiting: +# it always leaves through `std::process::exit`, which on Windows is +# `ExitProcess` and skips the `atexit` handler the profiling runtime would +# otherwise write the profile from. +cargo_build "$INSTR_DIR" "-Cprofile-generate=${PROFILE_DIR} --cfg pgo_training" + +BIN="${INSTR_DIR}/${TARGET}/release/coreutils${EXE}" +[ -x "$BIN" ] || { echo "instrumented binary not found: ${BIN}" >&2; exit 1; } +# Training runs the binary we just built, so a foreign target only works where +# the host can execute it (x86_64 on arm64 macOS needs Rosetta, for instance). +if ! "$BIN" true >/dev/null 2>&1; then + echo "cannot run the instrumented ${TARGET} binary on this ${HOST} host" >&2 + exit 1 +fi +end_step + +begin_step "Step 2: corpus" +# Everything is generated from scratch: reading whatever /usr/share/dict/words +# or /etc/passwd happens to hold would make the profile machine-dependent. +export LLVM_PROFILE_FILE="${PROFILE_DIR}/coreutils-%p.profraw" +rm -rf "$CORPUS_DIR" +mkdir -p "$CORPUS_DIR" + +WORDS="${CORPUS_DIR}/words.txt" +NUMBERS="${CORPUS_DIR}/numbers.txt" +REPEATED="${CORPUS_DIR}/repeated.txt" +PAIRS="${CORPUS_DIR}/pairs.txt" +BLOB="${CORPUS_DIR}/blob.bin" +COLUMNS="${CORPUS_DIR}/columns.txt" + +# Built with bash's own printf rather than awk: git-bash has no awk, and the +# whole corpus is still generated in a couple of seconds. +# Shuffled by a stride so sort/uniq get unordered input rather than a no-op. +for ((i = 0; i < 200000; i++)); do printf 'w%06d\n' $(((i * 7919) % 200000)); done > "$WORDS" +for ((i = 0; i < 100000; i++)); do printf 'line%04d\n' $((i % 1000)); done > "$REPEATED" +for ((i = 0; i < 100000; i++)); do printf '%08d value%d\n' "$i" "$i"; done > "$PAIRS" +for ((i = 0; i < 2000; i++)); do printf 'user%d:x:%d:%d:User %d:/home/user%d:/bin/sh\n' "$i" $((1000 + i)) $((1000 + i)) "$i" "$i"; done > "$COLUMNS" +# seq/dd are part of what we want to profile, so use the instrumented binary. +"$BIN" seq 500000 > "$NUMBERS" +"$BIN" dd "if=${BIN}" "of=${BLOB}" bs=64K count=64 2>/dev/null +end_step + +begin_step "Step 3: training workloads" +# Kept inside the target dir rather than under TMPDIR: git-bash may hand back a +# Windows-style TMPDIR that neither mktemp nor the workloads would agree on. +WORK="${TARGET_DIR}/work" +rm -rf "$WORK" +mkdir -p "$WORK" +trap 'rm -rf "$WORK"' EXIT + +# Individual workloads are allowed to fail (a util may be absent from the +# selected feature set); a partial profile is still a usable profile. +run() { "$BIN" "$@" >/dev/null 2>&1 || true; } + +# sort: the single hottest util, in its main modes +run sort "$WORDS" -o "${WORK}/sorted.txt" +run sort -n "$NUMBERS" -o "${WORK}/sorted-n.txt" +run sort -u "$REPEATED" -o "${WORK}/sorted-u.txt" +run sort -r "$WORDS" -o "${WORK}/sorted-r.txt" +run sort -k2,2 -t: "$COLUMNS" -o "${WORK}/sorted-k.txt" + +# wc / cat / head / tail, to a regular file so the write path is not /dev/null +run wc -l "$WORDS" +run wc -w "$WORDS" +run wc -c "$BLOB" +run wc "$WORDS" "$NUMBERS" +"$BIN" cat "$WORDS" > "${WORK}/cat.txt" 2>/dev/null || true +"$BIN" cat -n "$WORDS" > "${WORK}/cat-n.txt" 2>/dev/null || true +"$BIN" head -n 50000 "$WORDS" > "${WORK}/head.txt" 2>/dev/null || true +"$BIN" tail -n 50000 "$WORDS" > "${WORK}/tail.txt" 2>/dev/null || true +run head -c 1M "$BLOB" + +"$BIN" cat "$WORDS" | "$BIN" sort | "$BIN" uniq -c > "${WORK}/pipe1.txt" 2>/dev/null || true +"$BIN" seq 200000 | "$BIN" wc -l > /dev/null 2>&1 || true +"$BIN" cat "$BLOB" | "$BIN" sha256sum > /dev/null 2>&1 || true +"$BIN" sort -n "$NUMBERS" | "$BIN" tail -n 1000 | "$BIN" cut -c1-4 > "${WORK}/pipe2.txt" 2>/dev/null || true + +# text utils +run uniq "$REPEATED" +run uniq -c "$REPEATED" +run uniq -u "$REPEATED" +run cut -d: -f1,3 "$COLUMNS" +run cut -c1-10 "$WORDS" +run nl "$WORDS" +run fold -w 40 "$WORDS" +run expand "$WORDS" +run unexpand "$WORDS" +run paste "$WORDS" "$WORDS" +run join "$PAIRS" "$PAIRS" +run join --nocheck-order "$PAIRS" "$PAIRS" +run split -l 20000 "$WORDS" "${WORK}/split-" +"$BIN" tr a-z A-Z < "$WORDS" > "${WORK}/tr.txt" 2>/dev/null || true +"$BIN" tr -d aeiou < "$WORDS" > "${WORK}/tr-d.txt" 2>/dev/null || true +"$BIN" tee "${WORK}/tee.txt" < "$NUMBERS" > /dev/null 2>&1 || true + +# numbers / encoding / hashing +run seq 1000000 +run seq 0 0.1 10000 +"$BIN" base64 "$BLOB" > "${WORK}/encoded.b64" 2>/dev/null || true +run base64 -d "${WORK}/encoded.b64" +run cksum "$BLOB" +run md5sum "$BLOB" +run sha1sum "$BLOB" +run sha256sum "$BLOB" + +# file management: cp/mv/rm/ls are as hot in practice as anything above +run cp "$WORDS" "${WORK}/copy.txt" +run cp -r "${REPO_ROOT}/src/uu/sort" "${WORK}/tree" +run mv "${WORK}/copy.txt" "${WORK}/moved.txt" +run mv "${WORK}/tree" "${WORK}/tree2" +run ls -la "${REPO_ROOT}/src/uu" +run ls -lR "${REPO_ROOT}/src/uu" +run ls --color=always -la "${REPO_ROOT}/src" +run du -sh "${REPO_ROOT}/src" +run df -h +run rm -rf "${WORK}/tree2" + +echo "Workloads complete." +end_step + +begin_step "Step 4: merging profiles" +shopt -s nullglob +RAW=("${PROFILE_DIR}"/coreutils-*.profraw) +shopt -u nullglob +if [ ${#RAW[@]} -eq 0 ]; then + echo "no .profraw files found in ${PROFILE_DIR}" >&2 + exit 1 +fi +echo "Merging ${#RAW[@]} profile(s)..." +"$LLVM_PROFDATA" merge -sparse "${RAW[@]}" -o "$MERGED" +echo "Merged profile: ${MERGED}" + +# A profile that covers almost nothing still builds fine and silently produces a +# barely-optimized binary, so fail loudly instead: every workload in step 3 is +# allowed to fail individually, and without this a broken corpus would ship. +"$LLVM_PROFDATA" show "$MERGED" | head -6 +COVERED="$("$LLVM_PROFDATA" show "$MERGED" | sed -n 's/^Total functions: *//p')" +MIN_FUNCTIONS=500 +if [ -z "$COVERED" ] || [ "$COVERED" -lt "$MIN_FUNCTIONS" ]; then + echo "profile covers only ${COVERED:-0} functions (expected >= ${MIN_FUNCTIONS})" >&2 + echo "the training workloads probably did not run; refusing to ship this profile" >&2 + # Distinguish "the workloads never ran" from "they ran but the counters were + # never written": the first leaves no/short raw files, the second leaves + # plenty of same-sized ones that merge down to zero functions. + echo "--- raw profiles in ${PROFILE_DIR}:" >&2 + ls -l "$PROFILE_DIR" | head -12 >&2 + echo "--- first raw profile (${RAW[0]}):" >&2 + "$LLVM_PROFDATA" show "${RAW[0]}" >&2 || true + echo "--- instrumented binary: ${BIN}" >&2 + ls -l "$BIN" >&2 + exit 1 +fi +echo "Profile covers ${COVERED} functions." +end_step + +# CI needs the absolute, natively-spelled path to put in RUSTFLAGS; it cannot +# reconstruct it portably because of the Windows path rewriting above. +printf '%s\n' "$MERGED" > "${TARGET_DIR}/profdata-path.txt" + +if [ "$TRAIN_ONLY" -eq 1 ]; then + echo + echo "To use the profile in a release build, add to RUSTFLAGS:" + echo " -Cprofile-use=${MERGED}" + echo "Total: $(fmt_duration $((SECONDS - SCRIPT_START)))" + exit 0 +fi + +begin_step "Step 5: optimized build" +cargo_build "$TARGET_DIR" "-Cprofile-use=${MERGED}" +end_step +echo +echo "Optimized binary: ${TARGET_DIR}/${TARGET}/release/coreutils${EXE}" +echo "Total: $(fmt_duration $((SECONDS - SCRIPT_START)))" diff --git a/util/build-run-test-coverage-linux.sh b/util/build-run-test-coverage-linux.sh index 83faf107280..4dd1d7e94d9 100755 --- a/util/build-run-test-coverage-linux.sh +++ b/util/build-run-test-coverage-linux.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # spell-checker:ignore (env/flags) Ccodegen Cinstrument Coverflow Cpanic Zpanic -# spell-checker:ignore PROFDATA PROFRAW coreutil librairies nextest profdata profraw rustlib +# spell-checker:ignore PROFDATA PROFRAW coreutil librairies nextest profdata profraw rustlib setfacl # This script will build, run and generate coverage reports for the whole # testsuite. @@ -53,7 +53,37 @@ PROFDATA_DIR="${COVERAGE_DIR}/data" REPORT_DIR="${COVERAGE_DIR}/report" REPORT_PATH="${REPORT_DIR}/total.lcov.info" -rm -rf "${PROFRAW_DIR}" && mkdir -p "${PROFRAW_DIR}" +# Some tests set umask(0777) via uutests pre_exec. LLVM profile files created +# under that umask can be mode 000; later writes fail with EACCES and the error +# lands on utility stderr (breaks exact asserts, e.g. mkfifo). +# Default ACL keeps new .profraw files owner-writable. Keep LLVM's bounded +# online-merge pool (%4m) instead of one file per process (%p-%m). +prepare_profraw_dir() { + rm -rf "${PROFRAW_DIR}" + mkdir -p "${PROFRAW_DIR}" + chmod 700 "${PROFRAW_DIR}" + if ! command -v setfacl >/dev/null 2>&1; then + if [ -n "${CI:-}" ]; then + echo "error: setfacl required for coverage profraw dir (install acl)" >&2 + exit 1 + fi + return 0 + fi + if ! setfacl -d -m u::rw,g::---,o::---,m::rw "${PROFRAW_DIR}"; then + if [ -n "${CI:-}" ]; then + echo "error: setfacl default ACL failed on ${PROFRAW_DIR}" >&2 + exit 1 + fi + fi + if ! setfacl -m u::rwx,g::---,o::---,m::rwx "${PROFRAW_DIR}"; then + if [ -n "${CI:-}" ]; then + echo "error: setfacl access ACL failed on ${PROFRAW_DIR}" >&2 + exit 1 + fi + fi +} + +prepare_profraw_dir rm -rf "${PROFDATA_DIR}" && mkdir -p "${PROFDATA_DIR}" rm -rf "${REPORT_DIR}" && mkdir -p "${REPORT_DIR}" @@ -108,7 +138,7 @@ for UTIL in ${UTIL_LIST}; do fi echo "## Clear the trace directory to free up space" - rm -rf "${PROFRAW_DIR}" && mkdir -p "${PROFRAW_DIR}" + prepare_profraw_dir done; echo "Running coverage tests over uucore" diff --git a/util/check-common.sh b/util/check-common.sh new file mode 100755 index 00000000000..78b9ea4661a --- /dev/null +++ b/util/check-common.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# +# Shared setup for the util/check-*.sh syscall verification scripts +# (check-safe-traversal.sh, check-toctou.sh, check-libc-interposition.sh). +# +# Source it after setting CHECK_UTILS to the utilities the caller exercises: +# +# CHECK_UTILS="mkfifo touch head" +# . "$(dirname "${BASH_SOURCE[0]}")/check-common.sh" +# +# It provides $PROJECT_ROOT, a $TEMP_DIR cleaned up on exit, fail_immediately(), +# require_command(), and util_cmd()/have_util() to resolve a utility to a +# runnable command whether the tree was built as individual binaries or as the +# multicall binary. + +: "${PROFILE:=release-small}" +export PROFILE + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN_DIR="$PROJECT_ROOT/target/$PROFILE" +TEMP_DIR=$(mktemp -d) + +cleanup() { + rm -rf "$TEMP_DIR" +} +trap cleanup EXIT + +fail_immediately() { + echo "❌ FAILED: $1" + # Only the strace-based checks leave logs behind; mention them if they exist. + if compgen -G "$TEMP_DIR/strace_*.log" >/dev/null; then + echo "" + echo "Debug information available in: $TEMP_DIR/strace_*.log" + fi + exit 1 +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Error: $1 is required to run these checks" + exit 1 + fi +} + +# Prefer individual binaries: they are what CI builds, and tracing them avoids +# the multicall dispatch noise. Fall back to the multicall binary otherwise. +detect_binaries() { + local util + for util in $CHECK_UTILS; do + if [ -f "$BIN_DIR/$util" ]; then + echo "Using individual binaries" + USE_MULTICALL=0 + return + fi + done + + if [ -f "$BIN_DIR/coreutils" ]; then + echo "Using multicall binary" + USE_MULTICALL=1 + COREUTILS_BIN="$BIN_DIR/coreutils" + MULTICALL_UTILS=$("$COREUTILS_BIN" --list) + return + fi + + echo "Error: No binaries found. Please build first with 'cargo build --profile=$PROFILE'" + exit 1 +} + +# Resolve a utility to a runnable command, or return 1 when it was not built. +# A multicall binary built without the unix feature set has no chmod or chown +# in it, so ask it what it actually dispatches. +util_cmd() { + local util="$1" + if [ "$USE_MULTICALL" -eq 1 ]; then + grep -qx "$util" <<<"$MULTICALL_UTILS" || return 1 + echo "$COREUTILS_BIN $util" + else + [ -f "$BIN_DIR/$util" ] || return 1 + echo "$BIN_DIR/$util" + fi +} + +have_util() { + util_cmd "$1" >/dev/null +} + +detect_binaries diff --git a/util/check-libc-interposition.sh b/util/check-libc-interposition.sh new file mode 100755 index 00000000000..b09191ee600 --- /dev/null +++ b/util/check-libc-interposition.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# +# Check that utilities reach the kernel through libc symbols rather than raw +# syscalls. +# +# LD_PRELOAD wrappers (fakeroot, fakechroot, pseudo) are an essential part of +# distribution build tooling, and they interpose libc symbols. A hand-written +# syscall(2) goes around them: the change lands on disk, but their bookkeeping +# never sees it, so every later stat() reports the old state -- silently, with +# an empty stderr and exit status 0 (issue #14028). +# +# spell-checker:ignore fakeroot fakechroot + +set -e + +echo "=== libc Interposition Verification ===" + +# shellcheck disable=SC2034 # read by check-common.sh once sourced +CHECK_UTILS="chmod chown" +. "$(dirname "${BASH_SOURCE[0]}")/check-common.sh" + +require_command fakeroot + +# Run a utility under fakeroot and compare what fakeroot's database reports +# afterwards against what the utility asked for. A raw syscall leaves the +# database stale, so the observed value is the one from before the run. +# +# $1 test name, $2 command to run (relative to the tree), $3 stat format, +# $4 expected value +assert_visible_under_fakeroot() { + local name="$1" cmd="$2" format="$3" expected="$4" observed tree + + tree="$TEMP_DIR/$name" + mkdir -p "$tree/vendor" + + # The chown makes fakeroot track both inodes, so what it reports back comes + # from its database rather than straight from the filesystem. + observed=$(cd "$tree" && fakeroot sh -c \ + "$CHOWN_CMD -R 7:11 . && $cmd && /usr/bin/stat -c $format vendor") + + if [ "$observed" != "$expected" ]; then + fail_immediately "$name: fakeroot reports '$observed', expected '$expected' -- the change must go through a libc symbol, not a raw syscall (issue #14028)" + fi + echo "✓ $name is visible to LD_PRELOAD wrappers" +} + +# chown itself is the vehicle for every other check, so it has to be present. +CHOWN_CMD=$(util_cmd chown) || { + echo "Error: chown was not built, cannot set up the checks." + echo "Build it with 'cargo build --profile=${PROFILE} -p uu_chmod -p uu_chown'" + exit 1 +} +assert_visible_under_fakeroot "chown -R" "true" "%u:%g" "7:11" + +if CHMOD_CMD=$(util_cmd chmod); then + assert_visible_under_fakeroot "chmod -R" "$CHMOD_CMD -R 2751 ." "%a" "2751" +else + echo "⚠ chmod not built, skipping" +fi + +echo "" +echo "=== Summary ===" +echo "All checked utilities go through libc!" diff --git a/util/check-open-once.sh b/util/check-open-once.sh new file mode 100755 index 00000000000..f9b19086bda --- /dev/null +++ b/util/check-open-once.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# +# spell-checker:ignore strace reflink FDCWD EXDEV tmpfs xdev dstdir RDONLY +# +# Redundant-open verification. +# +# These strace-based checks assert that a utility resolves an operand path +# once and works off the resulting descriptor, rather than re-opening the +# same path for each stage of its work. Repeated opens cost syscalls and +# let two stages disagree about which file they are acting on. +# +# This is a correctness and efficiency check, not a security one: the +# companion scripts check-toctou.sh and check-safe-traversal.sh cover the +# cases where a split across two path-based syscalls is actually +# attacker-exploitable. +# + +set -e + +echo "=== Redundant Open Verification ===" + +# shellcheck disable=SC2034 # read by check-common.sh once sourced +CHECK_UTILS="cp mv" +. "$(dirname "${BASH_SOURCE[0]}")/check-common.sh" + +# check-common.sh installs `trap cleanup EXIT`; extend it so the scratch +# directory on the second filesystem goes away with it. +SHM_DIR="" +cleanup() { + rm -rf "$TEMP_DIR" + if [ -n "$SHM_DIR" ]; then + rm -rf "$SHM_DIR" + fi +} + +cd "$TEMP_DIR" + +# cp used to open the source three times per copy: once in check_for_data, +# once in check_sparse_detection, then again in the copy itself. The +# sparseness probe and the copy now share one descriptor, so the strategy +# and the bytes come from the same file (#13185). +if cp_cmd=$(util_cmd cp); then + echo "" + echo "Testing cp (single source open)..." + # rustix issues open(2) directly on Linux, so match both spellings -- + # an openat-only filter would silently match nothing and pass. + for mode in "" "--reflink=never --sparse=always" "--reflink=never --sparse=never"; do + rm -f cp_probe_dst + printf 'source bytes for the probe\n' > cp_probe_src.txt + # shellcheck disable=SC2086 # $mode is a deliberate word-split flag list + strace -f -e trace=open,openat -o strace_cp_open.log \ + $cp_cmd $mode cp_probe_src.txt cp_probe_dst 2>/dev/null || true + + if [ ! -s strace_cp_open.log ]; then + fail_immediately "strace produced no output for cp" + fi + opens=$(grep -cE '(open|openat)\(.*"cp_probe_src\.txt"' strace_cp_open.log || true) + if [ "$opens" -ne 1 ]; then + cat strace_cp_open.log + fail_immediately "cp ${mode:-(default)} opened the source $opens times, expected exactly 1 - the sparseness probe and the copy must share one descriptor (#13185)" + fi + echo "✓ cp ${mode:-(default)} opens the source exactly once" + done + rm -f cp_probe_src.txt cp_probe_dst +fi + +# mv must not open an operand it is only going to rename, and its +# cross-device fallback -- a copy, since rename(2) cannot cross filesystems -- +# must open the source once. mv has its own fallback rather than cp's +# (mv.rs, open_source + create_dest_restrictive + copy_fast), so this pins +# an invariant that a future move onto shared copy machinery could break. +if mv_cmd=$(util_cmd mv); then + echo "" + echo "Testing mv (single source open)..." + + printf 'moved by rename\n' > mv_probe_src.txt + strace -f -e trace=open,openat -o strace_mv_rename.log \ + $mv_cmd mv_probe_src.txt mv_probe_dst 2>/dev/null || true + if [ ! -s strace_mv_rename.log ]; then + fail_immediately "strace produced no output for mv" + fi + opens=$(grep -cE '(open|openat)\(.*"mv_probe_src\.txt"' strace_mv_rename.log || true) + if [ "$opens" -ne 0 ]; then + cat strace_mv_rename.log + fail_immediately "mv opened the source $opens times for a same-filesystem move, expected 0 - it must rename, not copy" + fi + echo "✓ mv (same filesystem) never opens the source" + rm -f mv_probe_dst + + # /dev/shm is tmpfs on Linux, so it is a different filesystem from + # $TEMP_DIR and rename(2) across the two returns EXDEV. Skip rather than + # fail where that does not hold, so the check stays honest about what it + # actually exercised. + if [ -d /dev/shm ] && [ "$(stat -c %d /dev/shm)" != "$(stat -c %d "$TEMP_DIR")" ]; then + SHM_DIR=$(mktemp -d /dev/shm/uutils-open-once.XXXXXX) + + printf 'moved across filesystems\n' > mv_probe_xdev.txt + strace -f -e trace=open,openat -o strace_mv_xdev.log \ + $mv_cmd mv_probe_xdev.txt "$SHM_DIR/dst" 2>/dev/null || true + opens=$(grep -cE '(open|openat)\(.*"mv_probe_xdev\.txt"' strace_mv_xdev.log || true) + if [ "$opens" -ne 1 ]; then + cat strace_mv_xdev.log + fail_immediately "mv opened the source $opens times for a cross-device move, expected exactly 1" + fi + echo "✓ mv (cross-device file) opens the source exactly once" + + mkdir -p mv_probe_dir + printf 'inner payload\n' > mv_probe_dir/inner.txt + strace -f -e trace=open,openat -o strace_mv_xdev_dir.log \ + $mv_cmd mv_probe_dir "$SHM_DIR/dstdir" 2>/dev/null || true + # Match any path ending in inner.txt opened O_RDONLY: that is the + # source read, whether it is spelled relative to the cwd or, once the + # traversal is fd-anchored, as a bare name under a directory fd. The + # O_RDONLY rules out the destination, which is opened for writing. + opens=$(grep -cE '(open|openat)\([^)]*"[^"]*inner\.txt"[^)]*O_RDONLY' strace_mv_xdev_dir.log || true) + if [ "$opens" -ne 1 ]; then + cat strace_mv_xdev_dir.log + fail_immediately "mv opened a file inside a cross-device directory move $opens times, expected exactly 1" + fi + echo "✓ mv (cross-device directory) opens each file exactly once" + else + echo "- skipped mv cross-device checks: no second filesystem available" + fi + rm -rf mv_probe_src.txt mv_probe_xdev.txt mv_probe_dir +fi + +echo "" +echo "✓ Redundant open verification completed" diff --git a/util/check-safe-traversal.sh b/util/check-safe-traversal.sh index b49a4ed4a0c..c52deafb148 100755 --- a/util/check-safe-traversal.sh +++ b/util/check-safe-traversal.sh @@ -6,41 +6,13 @@ 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) - -# Function to exit immediately on error -fail_immediately() { - echo "❌ FAILED: $1" - echo "" - echo "Debug information available in: $TEMP_DIR/strace_*.log" - exit 1 -} - -cleanup() { - rm -rf "$TEMP_DIR" -} -trap cleanup EXIT - echo "=== Safe Traversal Verification ===" -# Assume binaries are already built (for CI usage) -# Prefer individual binaries for more accurate testing -if [ -f "$PROJECT_ROOT/target/${PROFILE}/rm" ]; then - echo "Using individual binaries" - USE_MULTICALL=0 -elif [ -f "$PROJECT_ROOT/target/${PROFILE}/coreutils" ]; then - echo "Using multicall binary" - USE_MULTICALL=1 - COREUTILS_BIN="$PROJECT_ROOT/target/${PROFILE}/coreutils" -else - echo "Error: No binaries found. Please build first with 'cargo build --profile=${PROFILE}'" - exit 1 -fi +# shellcheck disable=SC2034 # read by check-common.sh once sourced +CHECK_UTILS="rm chmod chown chgrp du mv cp chcon split" +. "$(dirname "${BASH_SOURCE[0]}")/check-common.sh" + +require_command strace cd "$TEMP_DIR" @@ -63,20 +35,12 @@ check_utility() { local strace_log="strace_${util}_${test_name}.log" - # Choose binary to use - if [ "$USE_MULTICALL" -eq 1 ]; then - local util_cmd="$COREUTILS_BIN $util" - else - local util_path="$PROJECT_ROOT/target/${PROFILE}/$util" - if [ ! -f "$util_path" ]; then - fail_immediately "$util binary not found at $util_path" - fi - local util_cmd="$util_path" - fi + local cmd + cmd=$(util_cmd "$util") || fail_immediately "$util binary not found in $BIN_DIR" # Run utility under strace strace -f -e trace="$trace_syscalls" -o "$strace_log" \ - $util_cmd $test_args 2>/dev/null || true + $cmd $test_args 2>/dev/null || true cat $strace_log # Check for expected safe syscalls local found_safe=0 @@ -186,20 +150,8 @@ assert_descent_nofollow() { echo "✓ $util descent opens use O_NOFOLLOW" } -# Get list of available utilities -if [ "$USE_MULTICALL" -eq 1 ]; then - AVAILABLE_UTILS=$($COREUTILS_BIN --list) -else - AVAILABLE_UTILS="" - for util in rm chmod chown chgrp du mv cp chcon split; do - if [ -f "$PROJECT_ROOT/target/${PROFILE}/$util" ]; then - AVAILABLE_UTILS="$AVAILABLE_UTILS $util" - fi - done -fi - # Test rm - should use openat, unlinkat, newfstatat -if echo "$AVAILABLE_UTILS" | grep -q "rm"; then +if have_util rm; then cp -r test_dir test_rm check_utility "rm" "openat,unlinkat,newfstatat,unlink,rmdir" "openat" "-rf test_rm" "recursive_remove" @@ -214,7 +166,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "rm"; then fi # Test chmod - should use openat, fchmodat, newfstatat -if echo "$AVAILABLE_UTILS" | grep -q "chmod"; then +if have_util chmod; then cp -r test_dir test_chmod check_utility "chmod" "openat,fchmodat,fchmodat2,newfstatat,chmod" "openat fchmodat" "-R 755 test_chmod" "recursive_chmod" @@ -226,7 +178,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "chmod"; then fi # Test chown - should use openat, fchownat, newfstatat -if echo "$AVAILABLE_UTILS" | grep -q "chown"; then +if have_util chown; then cp -r test_dir test_chown USER_ID=$(id -u) GROUP_ID=$(id -g) @@ -235,7 +187,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "chown"; then fi # Test chgrp - should use openat, fchownat, newfstatat -if echo "$AVAILABLE_UTILS" | grep -q "chgrp"; then +if have_util chgrp; then cp -r test_dir test_chgrp check_utility "chgrp" "openat,fchownat,newfstatat,chown,lchown" "openat fchownat" "-R $GROUP_ID test_chgrp" "recursive_chgrp" assert_descent_nofollow "chgrp" strace_chgrp_recursive_chgrp.log @@ -247,12 +199,8 @@ fi # rename/symlink race could redirect a privileged recursive relabel off-tree # (issue #11402). This holds even without SELinux: the fd-anchored open happens # before the SELinux get/set, so the syscalls are observable regardless. -if echo "$AVAILABLE_UTILS" | grep -q "chcon"; then - if [ "$USE_MULTICALL" -eq 1 ]; then - chcon_cmd="$COREUTILS_BIN chcon" - else - chcon_cmd="$PROJECT_ROOT/target/${PROFILE}/chcon" - fi +if have_util chcon; then + chcon_cmd=$(util_cmd chcon) mkdir -p chcon_tree/sub echo a > chcon_tree/file @@ -286,13 +234,13 @@ if echo "$AVAILABLE_UTILS" | grep -q "chcon"; then fi # Test du - should use openat, newfstatat -if echo "$AVAILABLE_UTILS" | grep -q "du"; then +if have_util du; then cp -r test_dir test_du check_utility "du" "openat,newfstatat,stat,lstat" "openat" "-a test_du" "directory_usage" fi # Test mv - should use openat, renameat for directory moves -if echo "$AVAILABLE_UTILS" | grep -q "mv"; then +if have_util mv; then mkdir -p test_mv_src/sub echo "test" > test_mv_src/file.txt echo "test" > test_mv_src/sub/file2.txt @@ -302,12 +250,8 @@ fi # cp invariant checks. Both #10011 (restrictive 0600 destination mode) and # #10017 (O_NOFOLLOW on the -P source) need to hold; verify each on its own # strace. -if echo "$AVAILABLE_UTILS" | grep -q "cp"; then - if [ "$USE_MULTICALL" -eq 1 ]; then - cp_cmd="$COREUTILS_BIN cp" - else - cp_cmd="$PROJECT_ROOT/target/${PROFILE}/cp" - fi +if have_util cp; then + cp_cmd=$(util_cmd cp) # #10011: destination created with mode 0600 so other users cannot open # the file through its umask-derived initial mode before cp narrows it. @@ -343,12 +287,8 @@ fi # O_CREAT|O_EXCL and only ever truncates via ftruncate after an fd-based check # that the opened output is not the input -- so a split that would overwrite its # own input is refused. -if echo "$AVAILABLE_UTILS" | grep -q "split"; then - if [ "$USE_MULTICALL" -eq 1 ]; then - split_cmd="$COREUTILS_BIN split" - else - split_cmd="$PROJECT_ROOT/target/${PROFILE}/split" - fi +if have_util split; then + split_cmd=$(util_cmd split) printf '0123456789abcdef' > split_input strace -f -e trace=openat -o strace_split_output_open.log \ @@ -380,7 +320,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "split"; then fi # mv cross-device (EXDEV) must use fd-based *xattr ops (issue #10014). -if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then +if have_util mv && [ -d /dev/shm ]; then # Need different filesystems for the EXDEV fallback to fire. temp_fs_id=$(stat -f -c %i "$TEMP_DIR" 2>/dev/null || echo "") shm_fs_id=$(stat -f -c %i /dev/shm 2>/dev/null || echo "") @@ -394,11 +334,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then cross_dst=$(mktemp -u -p /dev/shm cross_dst.XXXXXX) echo "cross-device payload" > "$cross_src" if setfattr -n user.tag -v pinned "$cross_src" 2>/dev/null; then - if [ "$USE_MULTICALL" -eq 1 ]; then - mv_cmd="$COREUTILS_BIN mv" - else - mv_cmd="$PROJECT_ROOT/target/${PROFILE}/mv" - fi + mv_cmd=$(util_cmd mv) strace -f -e trace='%file,fgetxattr,fsetxattr,flistxattr,getxattr,setxattr,listxattr' \ -o strace_mv_xattr.log \ $mv_cmd "$cross_src" "$cross_dst" 2>/dev/null || true @@ -431,7 +367,7 @@ fi # attacker racing in a planted symlink would otherwise let the copy write # through to the symlink's target. rustix may emit either open(2) or # openat(2) depending on the path, so the check accepts both. -if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then +if have_util mv && [ -d /dev/shm ]; then temp_fs_id=$(stat -f -c %i "$TEMP_DIR" 2>/dev/null || echo "") shm_fs_id=$(stat -f -c %i /dev/shm 2>/dev/null || echo "") if [ -z "$temp_fs_id" ] || [ -z "$shm_fs_id" ] || [ "$temp_fs_id" = "$shm_fs_id" ]; then @@ -442,11 +378,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then echo "payload" > "$nofollow_src" echo "existing" > "$nofollow_dst" - if [ "$USE_MULTICALL" -eq 1 ]; then - mv_cmd="$COREUTILS_BIN mv" - else - mv_cmd="$PROJECT_ROOT/target/${PROFILE}/mv" - fi + mv_cmd=$(util_cmd mv) strace -f -e trace=open,openat,openat2 -o strace_mv_nofollow.log \ $mv_cmd -f "$nofollow_src" "$nofollow_dst" 2>/dev/null || true @@ -473,7 +405,7 @@ fi # of the parent directory cannot redirect the temp-and-rename dance, and # the temp name must come from /dev/urandom rather than a guessable # pid+nanos pattern. -if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then +if have_util mv && [ -d /dev/shm ]; then temp_fs_id=$(stat -f -c %i "$TEMP_DIR" 2>/dev/null || echo "") shm_fs_id=$(stat -f -c %i /dev/shm 2>/dev/null || echo "") if [ -z "$temp_fs_id" ] || [ -z "$shm_fs_id" ] || [ "$temp_fs_id" = "$shm_fs_id" ]; then @@ -485,11 +417,7 @@ if echo "$AVAILABLE_UTILS" | grep -q "mv" && [ -d /dev/shm ]; then # Pre-existing dest forces the EEXIST branch into create_symlink_replace. ln -s /elsewhere "$sym_dst" - if [ "$USE_MULTICALL" -eq 1 ]; then - mv_cmd="$COREUTILS_BIN mv" - else - mv_cmd="$PROJECT_ROOT/target/${PROFILE}/mv" - fi + mv_cmd=$(util_cmd mv) strace -f -e trace=openat,symlink,symlinkat,rename,renameat,renameat2,unlink,unlinkat,read \ -o strace_mv_symlink_replace.log \ $mv_cmd "$sym_src" "$sym_dst" 2>/dev/null || true @@ -529,8 +457,10 @@ echo "Checking for dangerous path resolution patterns..." echo "Checking path resolution frequency..." for log in strace_*.log; do if [ -f "$log" ]; then - path_resolutions=$(grep -c "test_" "$log" 2>/dev/null || echo "0") - if [ "$path_resolutions" -gt 20 ]; then + # grep -c already prints 0 when nothing matches, so a `|| echo 0` + # here would make the variable "0\n0" and break the comparison. + path_resolutions=$(grep -c "test_" "$log" 2>/dev/null || true) + if [ "${path_resolutions:-0}" -gt 20 ]; then echo "⚠ $log: High path resolution count ($path_resolutions) - potential TOCTOU risk" fi fi diff --git a/util/check-toctou.sh b/util/check-toctou.sh index 975bdcf92a0..5ff3e980f54 100755 --- a/util/check-toctou.sh +++ b/util/check-toctou.sh @@ -14,67 +14,20 @@ 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) - -fail_immediately() { - echo "❌ FAILED: $1" - echo "" - echo "Debug information available in: $TEMP_DIR/strace_*.log" - exit 1 -} - -cleanup() { - rm -rf "$TEMP_DIR" -} -trap cleanup EXIT - echo "=== TOCTOU Verification ===" -if [ -f "$PROJECT_ROOT/target/${PROFILE}/coreutils" ]; then - echo "Using multicall binary" - USE_MULTICALL=1 - COREUTILS_BIN="$PROJECT_ROOT/target/${PROFILE}/coreutils" -elif [ -f "$PROJECT_ROOT/target/${PROFILE}/mkfifo" ]; then - echo "Using individual binaries" - USE_MULTICALL=0 -else - echo "Error: No binaries found. Build first with 'cargo build --profile=${PROFILE}'" - exit 1 -fi +# shellcheck disable=SC2034 # read by check-common.sh once sourced +CHECK_UTILS="mkfifo touch head" +. "$(dirname "${BASH_SOURCE[0]}")/check-common.sh" cd "$TEMP_DIR" -util_cmd() { - if [ "$USE_MULTICALL" -eq 1 ]; then - echo "$COREUTILS_BIN $1" - else - echo "$PROJECT_ROOT/target/${PROFILE}/$1" - fi -} - -if [ "$USE_MULTICALL" -eq 1 ]; then - AVAILABLE_UTILS=$($COREUTILS_BIN --list) -else - AVAILABLE_UTILS="" - for util in mkfifo touch head; do - if [ -f "$PROJECT_ROOT/target/${PROFILE}/$util" ]; then - AVAILABLE_UTILS="$AVAILABLE_UTILS $util" - fi - done -fi - # mkfifo must not call a path-based chmod after creating the FIFO: the # second syscall would re-resolve the path and could be redirected by an # attacker who swaps the FIFO for a symlink in between (issue #10020). # After the fix, the kernel applies the requested mode atomically via # mkfifo with cleared umask. -if echo "$AVAILABLE_UTILS" | grep -q "mkfifo"; then - mkfifo_cmd=$(util_cmd mkfifo) +if mkfifo_cmd=$(util_cmd mkfifo); then rm -f test_fifo # mkfifo(3)/mkfifoat(3) are libc wrappers; the underlying syscall # is mknodat (or mknod on older kernels). Trace those plus any @@ -106,10 +59,9 @@ fi # Test touch - creating a file must use O_CREAT but never O_TRUNC, so that a # symlink planted in the metadata-check/open race window (#10019) is not # truncated. This observes the flags directly, which integration tests cannot. -if echo "$AVAILABLE_UTILS" | grep -q "touch"; then +if touch_cmd=$(util_cmd touch); then echo "" echo "Testing touch (create_no_truncate)..." - touch_cmd=$(util_cmd touch) strace -f -e trace=openat -o strace_touch_create.log $touch_cmd test_touch_new 2>/dev/null || true cat strace_touch_create.log if ! grep -q 'openat(.*test_touch_new.*O_CREAT' strace_touch_create.log; then @@ -126,10 +78,9 @@ fi # descriptor (fstat/statx on the fd), not from a separate path-based stat # performed before the open. A path stat followed by an open is a TOCTOU # window (#11972): the object named by the path can be swapped in between. -if echo "$AVAILABLE_UTILS" | grep -q "head"; then +if head_cmd=$(util_cmd head); then echo "" echo "Testing head (fstat_after_open)..." - head_cmd=$(util_cmd head) echo "headtest" > test_head_file.txt strace -f -e trace=openat,fstat,newfstatat,statx,stat,lstat \ -o strace_head_metadata.log $head_cmd -c 4 test_head_file.txt 2>/dev/null || true diff --git a/util/compare_gnu_result.py b/util/compare_gnu_result.py index e0b017e816b..6ff24477b6f 100755 --- a/util/compare_gnu_result.py +++ b/util/compare_gnu_result.py @@ -13,12 +13,14 @@ REPO_DEFAULT_BRANCH = environ.get("REPO_DEFAULT_BRANCH", "main") ONLY_INTERMITTENT = environ.get("ONLY_INTERMITTENT", "false") -NEW = json.load(open("gnu-result.json")) -OLD = json.load(open("main-gnu-result.json")) +with open("gnu-result.json") as f: + NEW = json.load(f) +with open("main-gnu-result.json") as f: + OLD = json.load(f) # Extract the specific results from the dicts -last = OLD[list(OLD.keys())[0]] -current = NEW[list(NEW.keys())[0]] +last = OLD[next(iter(OLD.keys()))] +current = NEW[next(iter(NEW.keys()))] pass_d = int(current["pass"]) - int(last["pass"]) diff --git a/util/compare_size_results.py b/util/compare_size_results.py index 742aaeb4a81..df8e4f2c444 100755 --- a/util/compare_size_results.py +++ b/util/compare_size_results.py @@ -51,7 +51,7 @@ def load_sizes(path): data = json.load(f) if not isinstance(data, dict): - raise ValueError(f"Unexpected JSON structure in {path}") + raise TypeError(f"Unexpected JSON structure in {path}") # date-keyed wrapper used in tracking JSON if data and all(isinstance(v, dict) and "sizes" in v for v in data.values()): diff --git a/util/compare_test_results.py b/util/compare_test_results.py index 0f586d5f1fa..51031f4ba64 100755 --- a/util/compare_test_results.py +++ b/util/compare_test_results.py @@ -55,18 +55,22 @@ def identify_test_changes(current_flat, reference_flat): # Find regressions (tests that were passing but now failing) regressions = [] for test_path, status in current_flat.items(): - if status in ("FAIL", "ERROR"): - if test_path in reference_flat: - if reference_flat[test_path] == "PASS": - regressions.append(test_path) + if ( + status in ("FAIL", "ERROR") + and test_path in reference_flat + and reference_flat[test_path] == "PASS" + ): + regressions.append(test_path) # Find fixes (tests that were failing but now passing) fixes = [] for test_path, status in reference_flat.items(): - if status in ("FAIL", "ERROR"): - if test_path in current_flat: - if current_flat[test_path] == "PASS": - fixes.append(test_path) + if ( + status in ("FAIL", "ERROR") + and test_path in current_flat + and current_flat[test_path] == "PASS" + ): + fixes.append(test_path) # Find newly skipped tests (were passing, now skipped) newly_skipped = [] @@ -239,8 +243,7 @@ def main(): if args.output and output_lines: with open(args.output, "w") as f: - for line in output_lines: - f.write(f"{line}\n") + f.writelines(f"{line}\n" for line in output_lines) # Return exit code based on whether we found regressions return 1 if real_regressions else 0 diff --git a/util/fetch-gnu.sh b/util/fetch-gnu.sh index 0a38357c97a..e6adeb76035 100755 --- a/util/fetch-gnu.sh +++ b/util/fetch-gnu.sh @@ -1,13 +1,25 @@ #!/bin/bash -e -ver="9.11" +ver="9.12" 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 > $ver -exit 0 backport=( - nproc/nproc-quota.sh # remove LD_PRELOAD + # https://github.com/coreutils/coreutils/issues/355 + env/env.sh + env/printenv.sh ) - for f in "${backport[@]}" +for f in "${backport[@]}" do curl -L ${repo}/raw/refs/heads/master/tests/$f > tests/$f - done +done + +# A test that does not exist in $ver at all is absent from its test list, so +# `make check` would silently never run it. Register those in both the automake +# input and the generated Makefile.in: configure derives Makefile from the +# latter, and build-gnu.sh deliberately keeps automake from re-running. +for f in "${backport[@]}"; do + grep -qF "tests/$f" tests/local.mk || + sed -i "s|^all_tests =.*|&\n tests/$f\t\t\t\t\\\\|" tests/local.mk + grep -qF "tests/$f" Makefile.in || + sed -i "s|^all_tests =.*|&\n tests/$f\t\t\t\t\\\\|" Makefile.in +done diff --git a/util/gnu-json-result.py b/util/gnu-json-result.py index 86c2f59d0a6..6f5a65b8f14 100644 --- a/util/gnu-json-result.py +++ b/util/gnu-json-result.py @@ -34,7 +34,7 @@ ) if result: current[path.name] = result.group(1) - except Exception as e: + except OSError as e: print(f"Error processing file {path}: {e}", file=sys.stderr) print(json.dumps(out, indent=2, sort_keys=True)) diff --git a/util/gnu-patches/error_msg_uniq.diff b/util/gnu-patches/error_msg_uniq.diff index 1c9e32fcb22..cfac53c5c90 100644 --- a/util/gnu-patches/error_msg_uniq.diff +++ b/util/gnu-patches/error_msg_uniq.diff @@ -53,6 +53,6 @@ Index: gnu/tests/uniq/uniq.pl + {ERR=>"error: invalid value 'badoption' for '--group[=]'\n\n" . + " [possible values: separate, prepend, append, both]\n\n" . + "For more information, try '--help'.\n"}], - ); - - # Locale related tests + # Test for read buffer overrun. + do { my $longline = "\360\237\230\200" . "A" x 255 . "\n"; + ['146', '-w256', {IN => $longline x 2}, {OUT => $longline}] }, diff --git a/util/gnu-patches/series b/util/gnu-patches/series index f9b7fd8262e..96e46b7eb97 100644 --- a/util/gnu-patches/series +++ b/util/gnu-patches/series @@ -12,3 +12,5 @@ test_mkdir_restorecon.patch error_msg_uniq.diff tests_numfmt.patch tests_pwd-long.patch +tests_getopt_vs_usage.patch +tests_usage_vs_refs.patch diff --git a/util/gnu-patches/tests_comm.pl.patch b/util/gnu-patches/tests_comm.pl.patch index 4b683cc893a..989ac441a50 100644 --- a/util/gnu-patches/tests_comm.pl.patch +++ b/util/gnu-patches/tests_comm.pl.patch @@ -1,7 +1,7 @@ -Index: gnu/tests/misc/comm.pl +Index: gnu/tests/comm/comm.pl =================================================================== ---- gnu.orig/tests/misc/comm.pl -+++ gnu/tests/misc/comm.pl +--- gnu.orig/tests/comm/comm.pl ++++ gnu/tests/comm/comm.pl @@ -73,18 +73,24 @@ my @Tests = # invalid missing command line argument (1) diff --git a/util/gnu-patches/tests_env_env-S.pl.patch b/util/gnu-patches/tests_env_env-S.pl.patch index 955ed2c1c3f..2ff931bc38a 100644 --- a/util/gnu-patches/tests_env_env-S.pl.patch +++ b/util/gnu-patches/tests_env_env-S.pl.patch @@ -36,7 +36,7 @@ Index: gnu/tests/env/env-S.pl + "For more information, try '--help'.\n" . + "$prog: use -[v]S to pass options in shebang lines\n"}], ['err_sp3', q['-v -S cat -n'], {EXIT=>125}, # embedded tab after -v -- {ERR=>"env: invalid option -- '\t'\n" . +- {ERR=>"env: invalid option -- '\\t'\n" . - "env: use -[v]S to pass options in shebang lines\n" . - "Try 'env --help' for more information.\n"}], + {ERR=>"error: unexpected argument '-\t' found\n\n" . diff --git a/util/gnu-patches/tests_getopt_vs_usage.patch b/util/gnu-patches/tests_getopt_vs_usage.patch new file mode 100644 index 00000000000..3a0bf1a19fc --- /dev/null +++ b/util/gnu-patches/tests_getopt_vs_usage.patch @@ -0,0 +1,18 @@ +Skip getopt_vs_usage.sh: it takes the option tables from GNU's own src/*.c and +greps our --help for each entry. There is little value in that here - every +option is covered individually by our own tests, and our --help is worded and +laid out differently from GNU's anyway. + +Index: gnu/tests/misc/getopt_vs_usage.sh +=================================================================== +--- gnu.orig/tests/misc/getopt_vs_usage.sh ++++ gnu/tests/misc/getopt_vs_usage.sh +@@ -18,6 +18,8 @@ + + . "${srcdir=.}/tests/init.sh"; + ++skip_ 'options are covered individually by our own tests' ++ + getopts() { + sed -n '/long_*opt.*\[/,/^}/{/^ *{/p}' "$abs_top_srcdir/src/$1.c" | + grep -Ev "(\"-|Deprecated|Obsolescent|Not in $1)" diff --git a/util/gnu-patches/tests_ls_no_cap.patch b/util/gnu-patches/tests_ls_no_cap.patch index 62b836f7962..a8db18930c7 100644 --- a/util/gnu-patches/tests_ls_no_cap.patch +++ b/util/gnu-patches/tests_ls_no_cap.patch @@ -6,15 +6,15 @@ index 99f0563bc..f7b9e7885 100755 skip_ "setcap doesn't work" LS_COLORS=ca=1; export LS_COLORS --strace -e capget ls --color=always > /dev/null 2> out || fail=1 +-strace -e trace=capget ls --color=always > /dev/null 2> out || fail=1 -$EGREP 'capget\(' out || skip_ "your ls doesn't call capget" -+strace -e listxattr ls --color=always > /dev/null 2> out || fail=1 ++strace -e trace=listxattr ls --color=always > /dev/null 2> out || fail=1 +$EGREP 'listxattr\(' out || skip_ "your ls doesn't call listxattr" LS_COLORS=ca=:; export LS_COLORS --strace -e capget ls --color=always > /dev/null 2> out || fail=1 +-strace -e trace=capget ls --color=always > /dev/null 2> out || fail=1 -$EGREP 'capget\(' out && fail=1 -+strace -e listxattr ls --color=always > /dev/null 2> out || fail=1 ++strace -e trace=listxattr ls --color=always > /dev/null 2> out || fail=1 +$EGREP 'listxattr\(' out && fail=1 Exit $fail diff --git a/util/gnu-patches/tests_pwd-long.patch b/util/gnu-patches/tests_pwd-long.patch index a26847e26f7..d5291137c27 100644 --- a/util/gnu-patches/tests_pwd-long.patch +++ b/util/gnu-patches/tests_pwd-long.patch @@ -4,19 +4,19 @@ Index: gnu/tests/pwd/pwd-long.sh +++ gnu/tests/pwd/pwd-long.sh @@ -19,7 +19,6 @@ - . "${srcdir=.}/tests/init.sh"; + . "${srcdir=.}/tests/init.sh"; path_prepend_ ./src print_ver_ pwd -uses_strace_ require_readable_root_ require_perl_ -@@ -27,11 +26,10 @@ +@@ -27,11 +26,10 @@ require_perl_ ARGV_0=$0 export ARGV_0 -# Disable the getcwd syscall if possible, so more of our code is exercised. -no_sys_getcwd() { -- strace -f -o /dev/null -e 'getcwd' -e fault=all:error=ENOSYS "$@" +- strace -f -o /dev/null -e trace=getcwd -e fault=all:error=ENOSYS "$@" -} -no_sys_getcwd true || no_sys_getcwd() { "$@"; } +# uutils: our pwd has no userspace getcwd reimplementation like GNU's; it diff --git a/util/gnu-patches/tests_usage_vs_refs.patch b/util/gnu-patches/tests_usage_vs_refs.patch new file mode 100644 index 00000000000..d372c30b788 --- /dev/null +++ b/util/gnu-patches/tests_usage_vs_refs.patch @@ -0,0 +1,18 @@ +Skip usage_vs_refs.sh: it cross-checks every option printed by --help against +references in GNU's doc/coreutils.texi. That manual is not part of this +implementation, so the test can only ever report our own options (-V, uptime +--pretty, hostname --fqdn, ...) as undocumented. + +Index: gnu/tests/misc/usage_vs_refs.sh +=================================================================== +--- gnu.orig/tests/misc/usage_vs_refs.sh ++++ gnu/tests/misc/usage_vs_refs.sh +@@ -18,6 +18,8 @@ + + . "${srcdir=.}/tests/init.sh"; + ++skip_ 'options are documented outside doc/coreutils.texi' ++ + + getopts() { + skip='--help|--version' # These refs treated specially diff --git a/util/gnu-unfixable-tests.txt b/util/gnu-unfixable-tests.txt new file mode 100644 index 00000000000..44d340d5863 --- /dev/null +++ b/util/gnu-unfixable-tests.txt @@ -0,0 +1,48 @@ +# spell-checker:ignore getmntent GETMNTENT xstat fopen +# GNU test-suite entries left out of the uutils pass/fail percentages. +# +# They do not check observable behavior, they check that the binary is built +# like GNU's: they place gdb breakpoints in GNU's own C sources, or interpose a +# specific libc entry point with LD_PRELOAD and give up when it is not called. +# Passing them would mean reshaping our internals to match GNU's rather than +# fixing anything a user can see. +# +# The list is consumed by the tracking repo +# (https://github.com/uutils/coreutils-tracking) so the numbers describe the +# tests we can actually influence. Tests that are merely hard, unimplemented or +# intermittent do NOT belong here - they stay counted on purpose. See +# https://github.com/uutils/coreutils/issues/13841 +# +# Format: one "/.log" entry per line, '#' starts a comment. That is +# the log name the results are keyed by; the source in the GNU tree is the same +# path with a .sh (or .pl) extension. + + +# --- Structurally impossible --------------------------------------------- +# Nothing we can write in Rust makes these run. + +# Both grep a line number out of GNU's C source ("^excise" in src/remove.c, +# "^tail_forever_inotify" in src/tail.c) and break there, so the breakpoint can +# only resolve for a binary compiled from those files. +rm/r-root.log +tail/inotify-race.log +tail/inotify-race2.log + +# require_gnu_ is "test $(uname) = GNU"; skipped on Linux for GNU coreutils too. +id/gnu-zero-uids.log + + +# --- Require matching GNU's libc entry points ---------------------------- +# LD_PRELOAD does fire for Rust binaries, but each of these overrides the one +# symbol GNU happens to call and self-skips otherwise. We would have to route +# through that same symbol - in df's case dropping /proc/self/mountinfo for +# getmntent/mtab - purely so the interception lands. + +# overrides __xstat/stat; std uses statx +cp/nfs-removal-race.log +# needs HAVE_GETMNTENT, overrides fopen on the mtab +df/skip-duplicates.log +# parses the C code to look for getopt usage +misc/getopt_vs_usage.sh +# overrides readdir; std uses readdir64 +rm/rm-readdir-fail.log diff --git a/util/publish.sh b/util/publish.sh index 356eb3692ea..f892e638c99 100755 --- a/util/publish.sh +++ b/util/publish.sh @@ -9,15 +9,20 @@ is_already_published() { local crate_name=$1 local crate_version=$2 - # Use the crates.io API to get the latest version of the crate - local latest_published_version - latest_published_version=$(curl -s https://crates.io/api/v1/crates/$crate_name | jq -r '.crate.max_version') + # Use the sparse index (meant for automated access, unlike the crates.io + # API which rejects requests without a browser-like user agent) and check + # whether this exact version has been published. + local prefix + case ${#crate_name} in + 1) prefix="1" ;; + 2) prefix="2" ;; + 3) prefix="3/${crate_name:0:1}" ;; + *) prefix="${crate_name:0:2}/${crate_name:2:2}" ;; + esac - if [ "$latest_published_version" = "$crate_version" ]; then - return 0 - else - return 1 - fi + curl -s "https://index.crates.io/$prefix/$crate_name" \ + | jq -r '.vers' 2>/dev/null \ + | grep -qx "$crate_version" } # Figure out any dependencies between the util via Cargo.toml diff --git a/util/remaining-gnu-error.py b/util/remaining-gnu-error.py index 809665dc950..00b70e503c7 100755 --- a/util/remaining-gnu-error.py +++ b/util/remaining-gnu-error.py @@ -2,13 +2,12 @@ # This script lists the GNU failing tests by size # Just like with util/run-gnu-test.sh, we expect the gnu sources # to be in ../ -import urllib.request - -import urllib -import os import glob import json +import os import sys +import urllib +import urllib.request base = "../gnu/tests/" @@ -19,7 +18,7 @@ "https://raw.githubusercontent.com/uutils/coreutils-tracking/main/aggregated-result.json", result_json, ) -except Exception as e: +except OSError as e: print(f"Failed to download the file: {e}") if not os.path.exists(result_json): print(f"Local file '{result_json}' not found. Exiting.") @@ -45,18 +44,18 @@ def show_list(list_test): for f in reversed(tests): if contains_require_root(f): - print("%s: %s / require_root" % (f, os.stat(f).st_size)) + print(f"{f}: {os.stat(f).st_size} / require_root") else: - print("%s: %s" % (f, os.stat(f).st_size)) - print("") - print("%s tests remaining" % len(tests)) + print(f"{f}: {os.stat(f).st_size}") + print() + print(f"{len(tests)} tests remaining") def contains_require_root(file_path): try: with open(file_path, "r") as file: return "require_root_" in file.read() - except IOError: + except OSError: return False @@ -78,7 +77,7 @@ def contains_require_root(file_path): try: list_of_files.remove(a) except ValueError: - print("Could not find test '%s'. Maybe update the GNU repo?" % a) + print(f"Could not find test '{a}'. Maybe update the GNU repo?") sys.exit(1) # if it is SKIP, show it @@ -94,11 +93,11 @@ def contains_require_root(file_path): print("===============") print("SKIP tests:") show_list(skip_tests) -print("") +print() print("===============") print("ERROR tests:") show_list(error_tests) -print("") +print() print("===============") print("FAIL tests:") show_list(list_of_files) diff --git a/util/run-gnu-test.sh b/util/run-gnu-test.sh index b562002dbfc..013119527b0 100755 --- a/util/run-gnu-test.sh +++ b/util/run-gnu-test.sh @@ -28,7 +28,10 @@ echo "path_UUTILS='${path_UUTILS}'" echo "path_GNU='${path_GNU}'" # Use GNU nproc for *BSD -NPROC=$(command -v ${path_GNU}/src/nproc||command -v nproc) +NPROC_BIN=$(command -v "${path_GNU}"/src/nproc||command -v nproc) +# `-j` wants a job count: handing it the path to nproc made make read it as a +# (bogus) goal and run with unlimited parallelism, which starves the CI runner. +NPROC=$("${NPROC_BIN}" 2>/dev/null) || NPROC=1 MAKEFLAGS="${MAKEFLAGS} -j ${NPROC}" export MAKEFLAGS ### @@ -90,6 +93,10 @@ fi export RUST_BACKTRACE=1 +# The GNU tests compare stderr byte for byte: never let a dev shell force a +# caret report into their pipes. +unset UUTILS_DIAG + # Determine if we have SELinux tests has_selinux_tests=false if test $# -ge 1; then diff --git a/util/run-gnu-tests-smack-ci.sh b/util/run-gnu-tests-smack-ci.sh index 3eaae1ea24d..a43c969f959 100755 --- a/util/run-gnu-tests-smack-ci.sh +++ b/util/run-gnu-tests-smack-ci.sh @@ -101,9 +101,9 @@ for TEST_PATH in $QEMU_TESTS; do rm -rf "$WORK" "$WORK.gz" cp -a "$QEMU_DIR/rootfs" "$WORK" - # Hardlink utilities for SMACK/ROOTFS tests + # symlink utilities for SMACK/ROOTFS tests for U in $("$REPO_DIR/target/${PROFILE}/coreutils" --list); do - ln -f "$REPO_DIR/target/${PROFILE}/coreutils" "$WORK/bin/$U" + ln -sf "$REPO_DIR/target/${PROFILE}/coreutils" "$WORK/bin/$U" done # Set test script path and user diff --git a/util/show-utils.BAT b/util/show-utils.BAT deleted file mode 100644 index 00e9df5a1f4..00000000000 --- a/util/show-utils.BAT +++ /dev/null @@ -1,32 +0,0 @@ -@setLocal -@echo off - -@rem ::# spell-checker:ignore (CMD) ERRORLEVEL -@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" -set "ME_dir=%~dp0." -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 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%" - -@:: `jq` available? -set "JQ=" -set "ERRORLEVEL=" -jq --version 1>NUL 2>&1 -if NOT ERRORLEVEL 1 ( set "JQ=jq" ) - -if NOT DEFINED JQ ( - echo WARN: missing `jq` ^(install with `scoop install jq`^)^; falling back to default ^(only fully cross-platform^) util list 1>&2 - echo %default_utils% -) else ( - cargo metadata %* --format-version 1 | jq -r "[.resolve.nodes[] | { id: .id, deps: [.deps[] | { name:.name, pkg:.pkg }] }] | .[] | select(.id|startswith(\"coreutils\")) | [.deps[] | select((.name|startswith(\"uu_\")) or (.pkg|startswith(\"uu_\")))] | [.[].pkg | match(\"^^\\w+\";\"g\")] | [.[].string | sub(\"^uu_\"; \"\")] | sort | join(\" \")" - REM cargo metadata %* --format-version 1 | jq -r "[.resolve.nodes[] | { id: .id, deps: [.deps[] | { name:.name, pkg:.pkg }] }] | .[] | select(.id|startswith(\"coreutils\")) | [.deps[] | select((.name|startswith(\"uu_\")) or (.pkg|startswith(\"uu_\")))] | [.[].pkg | match(\"^^\\w+\";\"g\")] | [.[].string] | sort | join(\" \")" -) diff --git a/util/show-utils.sh b/util/show-utils.sh index ff70fe25d49..c25be5968f3 100755 --- a/util/show-utils.sh +++ b/util/show-utils.sh @@ -1,35 +1,22 @@ #!/usr/bin/env bash -# spell-checker:ignore (shell) OSTYPE -# spell-checker:ignore (utils) cksum coreutils dircolors mkdir mktemp printenv printf readlink realpath grealpath rmdir shuf tsort unexpand +set -eo pipefail # spell-checker:ignore (jq) deps startswith -# Use GNU version for realpath on *BSD -REALPATH=$(command -v grealpath||command -v realpath) +cd -- "$(dirname -- "$0")/.." -ME="${0}" -ME_dir="$(dirname -- "${ME}")" -ME_parent_dir="$(dirname -- "${ME_dir}")" -ME_parent_dir_abs="$("${REALPATH}" -mP -- "${ME_parent_dir}" || "${REALPATH}" -- "${ME_parent_dir}")" - -# refs: , - -# 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}" -cd "${project_main_dir}" && - - # `jq` available? - if ! jq --version 1>/dev/null 2>&1; then - echo "WARN: missing \`jq\` (install with \`sudo apt install jq\`); falling back to default (only fully cross-platform) utility list" 1>&2 - echo "$default_utils" - else +# `jq` available? +if ! jq --version 1>/dev/null 2>&1; then + # refs: , + # default utility list + default_utils=$(cargo tree --depth 1 --features feat_common_core --format "{lib}" --prefix none | sed -n 's/^uu_//p') + echo "WARN: missing \`jq\` (install with \`sudo apt install jq\`); falling back to default (only fully cross-platform) utility list" 1>&2 + echo "$default_utils" +else # Find 'coreutils' id with regex # with cargo v1.76.0, id = "coreutils 0.0.26 (path+file://)" # with cargo >= v1.77.0 # - if local path != '<...>/coreutils' id = "path+file://#coreutils@0.0.26" # - if local path == '<...>/coreutils' id = "path+file:///coreutils#0.0.26" - cargo metadata "$@" --format-version 1 | jq -r '[.resolve.nodes[] | select(.id|match(".*coreutils[ |@|#]\\d+\\.\\d+\\.\\d+")) | .deps[] | select(.pkg|match("uu_")) | .name | sub("^uu_"; "")] | sort | join(" ")' - fi + cargo metadata "$@" --format-version 1 | jq -r '[.resolve.nodes[] | select(.id|match(".*coreutils[ |@|#]\\d+\\.\\d+\\.\\d+")) | .deps[] | select(.pkg|match("uu_")) | .name | sub("^uu_"; "")] | sort | join(" ")' +fi diff --git a/util/size-experiment.py b/util/size-experiment.py index d383c906e16..5e67f534751 100755 --- a/util/size-experiment.py +++ b/util/size-experiment.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 # spell-checker:ignore debuginfo -import subprocess -from itertools import product -import shutil import os +import shutil +import subprocess from collections import defaultdict +from itertools import product from pprint import pprint # Set to false if you've only changed the analysis and do not want to recompile diff --git a/util/test_compare_test_results.py b/util/test_compare_test_results.py index f10557c96f0..5b7d1a29465 100755 --- a/util/test_compare_test_results.py +++ b/util/test_compare_test_results.py @@ -3,16 +3,17 @@ Unit tests for the GNU test results comparison script. """ -import unittest import json -import tempfile import os -from unittest.mock import patch +import tempfile +import unittest from io import StringIO +from unittest.mock import patch + from util.compare_test_results import ( flatten_test_results, - load_ignore_list, identify_test_changes, + load_ignore_list, main, ) diff --git a/util/update-version.sh b/util/update-version.sh index d784655baf4..d810bb636d3 100755 --- a/util/update-version.sh +++ b/util/update-version.sh @@ -17,38 +17,31 @@ # 10) Create the release on github https://github.com/uutils/coreutils/releases/new # 11) Make sure we have good release notes -FROM="0.8.0" -TO="0.9.0" +FROM="0.12.0" +TO="0.13.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) +MANIFESTS=$(ls -1d Cargo.toml src/uu/*/Cargo.toml src/uu/stdbuf/src/libstdbuf/Cargo.toml src/uucore/Cargo.toml src/uucore_procs/Cargo.toml tests/uutests/Cargo.toml fuzz/uufuzz/Cargo.toml) -# update the version of all programs -#shellcheck disable=SC2086 -sed -i -e "s|version = \"$FROM\"|version = \"$TO\"|" $PROGS - -# Update uucore_procs -sed -i -e "s|version = \"$FROM\"|version = \"$TO\"|" src/uucore_procs/Cargo.toml - - -# Update the stdbuf stuff -sed -i -e "s|libstdbuf = { version=\"$FROM\"|libstdbuf = { version=\"$TO\"|" src/uu/stdbuf/Cargo.toml -sed -i -e "s|= { optional=true, version=\"$FROM\", package=\"uu_|= { optional=true, version=\"$TO\", package=\"uu_|g" Cargo.toml - -# Update the base32 dependency for basenc and base64 -sed -i -e "s|uu_base32 = { version=\">=$FROM\"|uu_base32 = { version=\">=$TO\"|" src/uu/base64/Cargo.toml src/uu/basenc/Cargo.toml +# Only two kinds of lines are rewritten, so that third party crates which +# happen to share our version number (md-5, sha1, sha2, sha3, ...) are left +# alone: +# 1) the crate's own version declaration, anchored at the start of the line +# 2) dependencies carrying a "path =" key, which are the in-tree uutils crates -# Update the ls dependency for dir and vdir -sed -i -e "s|uu_ls = { version = \">=$FROM\"|uu_ls = { version = \">=$TO\"|" src/uu/dir/Cargo.toml src/uu/vdir/Cargo.toml - -# Update uucore itself -sed -i -e "s|version = \"$FROM\"|version = \"$TO\"|" src/uucore/Cargo.toml -# Update crates using uucore +# 1) the "version = "X"" of each [package] (and of [workspace.package]) #shellcheck disable=SC2086 -sed -i -e "s|uucore = { version=\">=$FROM\",|uucore = { version=\">=$TO\",|" $PROGS -# Update crates using uucore_procs +sed -i -E "s|^version = \"$FROM\"$|version = \"$TO\"|" $MANIFESTS + +# 2) the in-tree dependencies, keeping the ">=" prefix when there is one #shellcheck disable=SC2086 -sed -i -e "s|uucore_procs = { version=\">=$FROM\",|uucore_procs = { version=\">=$TO\",|" $PROGS +sed -i -E "/path *=/ s|(version *= *\"(>=)?)$FROM\"|\1$TO\"|g" $MANIFESTS # Update Cargo.lock files cargo update --workspace cargo update --workspace --manifest-path fuzz/Cargo.toml + +# Sanity check: anything left pointing at the old version is either a third +# party crate (fine) or something this script missed (not fine) +echo "Remaining occurrences of $FROM - please review:" +#shellcheck disable=SC2086 +grep -n "$FROM" $MANIFESTS || echo " (none)"