Merge branch 'master' into master

This commit is contained in:
Thayne McCombs authored and GitHub committed 2026-10-21 00:23:18 -06:00
commit d5365d3fa0
43 files changed
+2389 -678

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# On Windows MSVC, statically link the C runtime so that the resulting EXE does
# not depend on the vcruntime DLL.
#
# See: https://github.com/sharkdp/fd/issues/1874
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
[target.i686-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
+4
View File
@@ -4,7 +4,11 @@ updates:
directory: "/"
schedule:
interval: "monthly"
cooldown:
default-days: 7
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
cooldown:
default-days: 7
+88 -77
View File
@@ -13,12 +13,17 @@ on:
tags:
- '*'
permissions:
contents: read
jobs:
crate_metadata:
name: Extract crate metadata
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Extract crate information
id: crate_metadata
run: |
@@ -41,7 +46,9 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- uses: actions/checkout@v4
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- run: cargo fmt -- --check
lint_check:
@@ -51,7 +58,9 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: actions/checkout@v4
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- run: cargo clippy --all-targets --all-features -- -Dwarnings
min_version:
@@ -60,7 +69,9 @@ jobs:
needs: crate_metadata
steps:
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install rust toolchain (v${{ needs.crate_metadata.outputs.msrv }})
uses: dtolnay/rust-toolchain@master
@@ -68,42 +79,50 @@ jobs:
toolchain: ${{ needs.crate_metadata.outputs.msrv }}
components: clippy
- name: Run clippy (on minimum supported rust version to prevent warnings we can't fix)
run: cargo clippy --locked --all-targets ${{ env.MSRV_FEATURES }}
run: cargo clippy --locked --all-targets "${MSRV_FEATURES}"
- name: Run tests
run: cargo test --locked ${{ env.MSRV_FEATURES }}
run: cargo test --locked "${MSRV_FEATURES}"
build:
name: ${{ matrix.job.target }} (${{ matrix.job.os }})
name: '${{ matrix.job.target }} (${{ matrix.job.os }})'
runs-on: ${{ matrix.job.os }}
needs: crate_metadata
permissions:
id-token: write
contents: write
attestations: write
strategy:
fail-fast: false
matrix:
job:
- { target: aarch64-unknown-linux-gnu , os: ubuntu-22.04, use-cross: true }
- { target: aarch64-unknown-linux-musl , os: ubuntu-22.04, use-cross: true }
- { target: arm-unknown-linux-gnueabihf , os: ubuntu-22.04, use-cross: true }
- { target: arm-unknown-linux-musleabihf, os: ubuntu-22.04, use-cross: true }
- { target: i686-pc-windows-msvc , os: windows-2022 }
- { target: i686-unknown-linux-gnu , os: ubuntu-22.04, use-cross: true }
- { target: i686-unknown-linux-musl , os: ubuntu-22.04, use-cross: true }
- { target: x86_64-apple-darwin , os: macos-13 }
- { target: aarch64-apple-darwin , os: macos-14 }
- { target: x86_64-pc-windows-gnu , os: windows-2022 }
- { target: x86_64-pc-windows-msvc , os: windows-2022 }
- { target: aarch64-unknown-linux-gnu , os: ubuntu-24.04, use-cross: true }
- { target: aarch64-unknown-linux-musl , os: ubuntu-24.04, use-cross: true }
- { target: arm-unknown-linux-gnueabihf , os: ubuntu-24.04, use-cross: true }
- { target: arm-unknown-linux-musleabihf, os: ubuntu-24.04, use-cross: true }
- { target: i686-pc-windows-msvc , os: windows-2025 }
- { target: i686-unknown-linux-gnu , os: ubuntu-24.04, use-cross: true }
- { target: i686-unknown-linux-musl , os: ubuntu-24.04, use-cross: true }
- { target: x86_64-apple-darwin , os: macos-26-intel }
- { target: aarch64-apple-darwin , os: macos-26 }
- { target: x86_64-pc-windows-gnu , os: windows-2025 }
- { target: x86_64-pc-windows-msvc , os: windows-2025 }
- { target: aarch64-pc-windows-msvc , os: windows-11-arm }
- { target: x86_64-unknown-linux-gnu , os: ubuntu-22.04, use-cross: true }
- { target: x86_64-unknown-linux-musl , os: ubuntu-22.04, use-cross: true }
- { target: x86_64-unknown-linux-gnu , os: ubuntu-24.04, use-cross: true }
- { target: x86_64-unknown-linux-musl , os: ubuntu-24.04, use-cross: true }
env:
BUILD_CMD: cargo
BUILD_CMD: "${{ matrix.job.use-cross && 'cross' || 'cargo' }}"
target: ${{ matrix.job.target }}
name: ${{ needs.crate_metadata.outputs.name }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install prerequisites
shell: bash
run: |
case ${{ matrix.job.target }} in
case ${target} in
arm-unknown-linux-*) sudo apt-get -y update ; sudo apt-get -y install gcc-arm-linux-gnueabihf ;;
aarch64-unknown-linux-gnu) sudo apt-get -y update ; sudo apt-get -y install gcc-aarch64-linux-gnu ;;
esac
@@ -112,21 +131,22 @@ jobs:
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.job.target }}
# On windows, for now build with 1.77.2, so that it works on windows 7.
# When we update the MSRV again, we'll need to revisit this, and probably drop support for Win7
toolchain: "${{ contains(matrix.job.target, 'windows-') && '1.77.2' || 'stable' }}"
toolchain: "stable"
- name: Install cross
if: matrix.job.use-cross
uses: taiki-e/install-action@v2
with:
tool: cross
- name: Overwrite build command env variable
if: matrix.job.use-cross
shell: bash
run: echo "BUILD_CMD=cross" >> $GITHUB_ENV
env:
cross_version: "v0.2.5"
package_name: "cross-x86_64-unknown-linux-gnu.tar.gz"
GH_TOKEN: "${{ github.token }}"
run: |
dir="$HOME/.local/bin/"
mkdir -p "$dir"
gh release download --repo cross-rs/cross \
--pattern "${package_name}" -O - "${cross_version}" \
| tar -C "$dir" -xz
echo "$dir" >> $GITHUB_PATH
echo "Installed cross $cross_version" >> $GITHUB_STEP_SUMMARY
- name: Show version information (Rust, cargo, GCC)
shell: bash
run: |
@@ -139,7 +159,7 @@ jobs:
- name: Build
shell: bash
run: $BUILD_CMD build --locked --release --target=${{ matrix.job.target }}
run: $BUILD_CMD build --locked --release --target="${target}"
- name: Set binary name & path
id: bin
@@ -147,13 +167,13 @@ jobs:
run: |
# Figure out suffix of binary
EXE_suffix=""
case ${{ matrix.job.target }} in
case ${target} in
*-pc-windows-*) EXE_suffix=".exe" ;;
esac;
# Setup paths
BIN_NAME="${{ needs.crate_metadata.outputs.name }}${EXE_suffix}"
BIN_PATH="target/${{ matrix.job.target }}/release/${BIN_NAME}"
BIN_NAME="${name}${EXE_suffix}"
BIN_PATH="target/${target}/release/${BIN_NAME}"
# Let subsequent steps know where to find the binary
echo "BIN_PATH=${BIN_PATH}" >> $GITHUB_OUTPUT
@@ -165,12 +185,17 @@ jobs:
run: |
# test only library unit tests and binary for arm-type targets
unset CARGO_TEST_OPTIONS
unset CARGO_TEST_OPTIONS ; case ${{ matrix.job.target }} in arm-* | aarch64-*) CARGO_TEST_OPTIONS="--bin ${{ needs.crate_metadata.outputs.name }}" ;; esac;
case ${target} in
arm-* | aarch64-*)
CARGO_TEST_OPTIONS="--bin=${name}" ;;
esac
echo "CARGO_TEST_OPTIONS=${CARGO_TEST_OPTIONS}" >> $GITHUB_OUTPUT
- name: Run tests
shell: bash
run: $BUILD_CMD test --locked --target=${{ matrix.job.target }} ${{ steps.test-options.outputs.CARGO_TEST_OPTIONS}}
env:
cargo_test_options: ${{ steps.test-options.outputs.CARGO_TEST_OPTIONS}}
run: $BUILD_CMD test --locked --target="${target}" "${cargo_test_options}"
- name: Generate completions
id: completions
@@ -180,38 +205,12 @@ jobs:
- name: Create tarball
id: package
shell: bash
run: |
PKG_suffix=".tar.gz" ; case ${{ matrix.job.target }} in *-pc-windows-*) PKG_suffix=".zip" ;; esac;
PKG_BASENAME=${{ needs.crate_metadata.outputs.name }}-v${{ needs.crate_metadata.outputs.version }}-${{ matrix.job.target }}
PKG_NAME=${PKG_BASENAME}${PKG_suffix}
echo "PKG_NAME=${PKG_NAME}" >> $GITHUB_OUTPUT
PKG_STAGING="${{ env.CICD_INTERMEDIATES_DIR }}/package"
ARCHIVE_DIR="${PKG_STAGING}/${PKG_BASENAME}/"
mkdir -p "${ARCHIVE_DIR}"
# Binary
cp "${{ steps.bin.outputs.BIN_PATH }}" "$ARCHIVE_DIR"
# README, LICENSE and CHANGELOG files
cp "README.md" "LICENSE-MIT" "LICENSE-APACHE" "CHANGELOG.md" "$ARCHIVE_DIR"
# Man page
cp 'doc/${{ needs.crate_metadata.outputs.name }}.1' "$ARCHIVE_DIR"
# Autocompletion files
cp -r autocomplete "${ARCHIVE_DIR}"
# base compressed package
pushd "${PKG_STAGING}/" >/dev/null
case ${{ matrix.job.target }} in
*-pc-windows-*) 7z -y a "${PKG_NAME}" "${PKG_BASENAME}"/* | tail -2 ;;
*) tar czf "${PKG_NAME}" "${PKG_BASENAME}"/* ;;
esac;
popd >/dev/null
# Let subsequent steps know where to find the compressed package
echo "PKG_PATH=${PKG_STAGING}/${PKG_NAME}" >> $GITHUB_OUTPUT
run: bash scripts/create-archive.sh
env:
BIN_PATH: ${{ steps.bin.outputs.BIN_PATH }}
TARGET: ${{ env.target }}
ARCHIVE_NAME: ${{ env.name }}-v${{ needs.crate_metadata.outputs.version }}-${{ env.target }}
PKG_STAGING: "${{ env.CICD_INTERMEDIATES_DIR }}/package"
- name: Create Debian package
id: debian-package
@@ -224,17 +223,21 @@ jobs:
BIN_PATH: ${{ steps.bin.outputs.BIN_PATH }}
- name: "Artifact upload: tarball"
uses: actions/upload-artifact@master
id: upload-tarball
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: ${{ steps.package.outputs.PKG_NAME }}
path: ${{ steps.package.outputs.PKG_PATH }}
compression-level: 0 # tarball is already compressed
- name: "Artifact upload: Debian package"
uses: actions/upload-artifact@master
id: upload-deb
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
if: steps.debian-package.outputs.DPKG_NAME
with:
name: ${{ steps.debian-package.outputs.DPKG_NAME }}
path: ${{ steps.debian-package.outputs.DPKG_PATH }}
compression-level: 0 # deb files are already compressed
- name: Check for release
id: is-release
@@ -243,8 +246,16 @@ jobs:
unset IS_RELEASE ; if [[ $GITHUB_REF =~ ^refs/tags/v[0-9].* ]]; then IS_RELEASE='true' ; fi
echo "IS_RELEASE=${IS_RELEASE}" >> $GITHUB_OUTPUT
- name: "Attest artifacts"
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4
if: steps.is-release.outputs.IS_RELEASE
with:
subject-path: |
${{ steps.package.outputs.PKG_PATH }}
${{ steps.debian-package.outputs.DPKG_PATH }}
- name: Publish archives and packages
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
if: steps.is-release.outputs.IS_RELEASE
with:
files: |
@@ -259,7 +270,7 @@ jobs:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: vedantmgoyal2009/winget-releaser@v2
- uses: vedantmgoyal9/winget-releaser@b7370d4d152efdce803a26cc6ca9eee58cb2ad54 # v2
with:
identifier: sharkdp.fd
installers-regex: '-pc-windows-msvc\.zip$'
+1
View File
@@ -1,3 +1,4 @@
target/
/autocomplete/
/package/
**/*.rs.bk
+72 -1
View File
@@ -1,4 +1,66 @@
# Upcoming release
# Upcoming Release
## Features
-
## Bugfixes
- Accept `--format` templates that start with `-` when passed as a separate argument, see #2126 (@vulragrag-star)
- Don't incorrectly escape newlines in error messages, see #2104
- Restore jemalloc as default allocator on supported systems
- Fix the `cargo binstall` release URL, which named the crate (`fd-find`) instead of the binary (`fd`) and fell back to a third-party mirror
# 10.5.0
## Features
- Add `--ignore-parent` option to override `--no-ignore-parent`, see #1958 (@tmchow)
- Add `--exact` option to match the entire filename exactly (literal, non-substring).
## Bugfixes
- Sanitize control characters and bidirectional override characters in filenames
when output goes to a terminal, to prevent terminal escape-sequence injection.
Also reject a placeholder as the executable for `--exec-batch`, while still
allowing it for `--exec`.
- Fix broken symlinks being incorrectly filtered out by `--min-depth` when following links (`--follow`), because their depth was not computed; see #1017 (@hexbinoct).
- Handle invalid working directories gracefully when using `--full-path`, see #1900 (@Xavrir).
- Fire the "search pattern contains a path separator" diagnostic for any pattern containing `/`, not just patterns that happen to name an existing directory. Preserves the legacy Windows behaviour that also flags native `\` separators when the pattern resolves to a real directory. See #1873.
- Also fire the "search pattern contains a path separator" diagnostic for `--and` patterns, not only the primary positional pattern. `--and` patterns are matched against the file name just like the primary pattern, so a path separator in them silently returned zero results. See #1873.
- Fix bug where passing "-" as a directory argument didn't actually search that directory, see #849 (@Sean-Kenneth-Doherty).
- Fix panic when `--changed-before`/`--changed-within` is given an out-of-range `@` Unix timestamp; the value is now rejected gracefully, see #2081 (@nikolauspschuetz).
# 10.4.2
## Bugfixes
- Fixed performance regression due to `--ignore-contain`; see #1913 and #1914
# 10.4.1
This is just a re-release of 10.4.0 due to an issue with the 10.4.0 release.
# 10.4.0
## Features
- Add `--ignore-contain` option to ignore directories containing a named entry (e.g. to ignore [`CACHEDIR.TAG`](https://bford.info/cachedir/)); see #1727 (@fischman).
## Bugfixes
- Fix Windows hyperlink generation for paths with spaces. (#1872)
- `--print0` combined with `--exec` will now print a `\0` between the output of each entry. Note that if there are multiple instances
of `--exec`, the `\0` will be between each _set_ of commands, _not_ between each individual command run. Fixes #1797.
- Several bugs were fixed by an update to the `ignore` library used for handling ignore rules
- #1506
- #1667
- #1813
- Fix completions for alias `fdfind` in deb release, see #1888 (@skane-lukas)
## Changes
- Minimum required rust version has been increased to 1.90.0. Notably, this means dropping fully support for intel Mac and Windows 7.
- Statically link the CRT for MSVC builds via Cargo config to avoid runtime DLL dependencies, see #1874 (@FidelSch)
# 10.3.0
## Features
@@ -15,9 +77,18 @@
- 'M' no longer means "month", as that could be confusing with minutes. Use "mo", "mos", "month" or "months" instead.
- month and year now account for variability in the calander rather than being a hard-coded number of seconds. That is probably
what you would expect, but it is a slight change in behavior.
- aarch64 Windows was added to CI and release artifacts
- Many dependencies were updated
- Better support building on Illumos (there is no automated testing, but some known issues were fixed)
## Other
This will be the last release that has been tested on x86_64 Mac OS, since GitHub is
dropping support for runners with that hardware.
It may also be the last release to use a version of Rust with tier-1 support for
x86_64/intel Macs and Windows 7.
# 10.2.0
+18 -1
View File
@@ -1,6 +1,6 @@
## Contributing to *fd*
**Thank you very much for considering to contribute to this project!**
**Thank you very much for considering contributing to this project!**
We welcome any form of contribution:
@@ -10,6 +10,23 @@ We welcome any form of contribution:
**Note**: Before you take the time to open a pull request, please open a ticket first. This will
give us the chance to discuss any potential changes first.
## Pull Request Expectations
1. Your contribution should be high quality, and best effort. In particular, please don't make a Pull Request
that was created using generative AI unless you have reviewed it yourself and understand what it does, and be
able to meaningfully answer questions and make changes to it as needed.
2. Your code should successfully compile and pass tests before publishing a Pull Request. It's understandable
if tests fail on one or two specific targets, but if the build doesn't pass for any targets, that is a signal
that the PR is low effort.
3. It is your responsibility to ensure that the generated code does not violate any copyrights or patents. This is
true of all contributions, but is especially relevant when using an LLM that may be generating output that is
very similar or even identical to existing code with an incompatible license.
4. If an AI coding assistant or other LLM tool was used, you must indicate that an AI tool was used, and to what extent it was used.
5. AI should not be used to generate comments when communicating with maintainers. We expect communication with the maintainers,
including PR descriptions and comments, to be from humans, not from an AI. Using an AI to assist with grammar, spelling, and
proofreading or translating is acceptable, as long as the content and voice of the
message is your own.
## Add an entry to the changelog
If your contribution changes the behavior of `fd` (as opposed to a typo-fix
Generated
+545 -216
View File
File diff suppressed because it is too large. Load diff
+46 -21
View File
@@ -1,6 +1,5 @@
[package]
authors = ["David Peter <mail@david-peter.de>"]
build = "build.rs"
categories = ["command-line-utilities"]
description = "fd is a simple, fast and user-friendly alternative to find."
exclude = ["/benchmarks/*"]
@@ -17,9 +16,9 @@ license = "MIT OR Apache-2.0"
name = "fd-find"
readme = "README.md"
repository = "https://github.com/sharkdp/fd"
version = "10.2.0"
edition= "2021"
rust-version = "1.77.2"
version = "10.5.0"
edition= "2024"
rust-version = "1.90.0"
[badges.appveyor]
repository = "sharkdp/fd"
@@ -31,37 +30,34 @@ repository = "sharkdp/fd"
name = "fd"
path = "src/main.rs"
[build-dependencies]
version_check = "0.9"
[dependencies]
aho-corasick = "1.1"
nu-ansi-term = "0.50"
argmax = "0.3.1"
ignore = "0.4.23"
regex = "1.11.1"
argmax = "0.4.0"
ignore = "0.4.28"
regex = "1.12.2"
regex-syntax = "0.8"
ctrlc = "3.2"
ctrlc = "3.5"
globset = "0.4"
anyhow = "1.0"
etcetera = "0.10"
normpath = "1.1.1"
etcetera = "0.11"
normpath = "1.5.1"
crossbeam-channel = "0.5.15"
clap_complete = {version = "4.5.52", optional = true}
clap_complete = {version = "4.6.5", optional = true}
faccess = "0.2.4"
jiff = "0.2.14"
jiff = "0.2.27"
[dependencies.clap]
version = "4.5.39"
version = "4.6.1"
features = ["suggestions", "color", "wrap_help", "cargo", "derive"]
[dependencies.lscolors]
version = "0.20"
version = "0.21"
default-features = false
features = ["nu-ansi-term"]
[target.'cfg(unix)'.dependencies]
nix = { version = "0.29.0", default-features = false, features = ["signal", "user", "hostname"] }
nix = { version = "0.31.1", default-features = false, features = ["signal", "user", "hostname"] }
[target.'cfg(all(unix, not(target_os = "redox")))'.dependencies]
libc = "0.2"
@@ -69,15 +65,27 @@ libc = "0.2"
# FIXME: Re-enable jemalloc on macOS
# jemalloc is currently disabled on macOS due to a bug in jemalloc in combination with macOS
# Catalina. See https://github.com/sharkdp/fd/issues/498 for details.
[target.'cfg(all(not(windows), not(target_os = "android"), not(target_os = "macos"), not(target_os = "freebsd"), not(target_os = "openbsd"), not(all(target_env = "musl", target_pointer_width = "32")), not(target_arch = "riscv64")))'.dependencies]
tikv-jemallocator = {version = "0.6.0", optional = true}
# This has to be kept in sync with src/main.rs where the allocator for
# the program is set.
[target.'cfg(all(not(windows), not(target_os = "android"), not(target_os = "macos"), not(target_os = "freebsd"), not(target_os = "openbsd"), not(target_os = "illumos"), not(all(target_env = "musl", target_pointer_width = "32")), not(target_arch = "riscv64")))'.dependencies]
tikv-jemallocator = {version = "0.7.0", optional = true}
[dev-dependencies]
diff = "0.1"
tempfile = "3.20"
tempfile = "3.27"
filetime = "0.2"
test-case = "3.3"
[profile.dev]
debug = "line-tables-only"
[profile.dev.package."*"]
debug = false
[profile.debugging]
inherits = "dev"
debug = true
[profile.release]
lto = true
strip = true
@@ -88,3 +96,20 @@ use-jemalloc = ["tikv-jemallocator"]
completions = ["clap_complete"]
base = ["use-jemalloc"]
default = ["use-jemalloc", "completions"]
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/fd-v{ version }-{ target }.{ archive-format }"
bin-dir = "{ bin }-v{ version }-{ target }/{ bin }{ binary-ext }"
pkg-fmt = "tgz"
[package.metadata.binstall.overrides.x86_64-pc-windows-msvc]
pkg-fmt = "zip"
[package.metadata.binstall.overrides.x86_64-pc-windows-gnu]
pkg-fmt = "zip"
[package.metadata.binstall.overrides.i686-pc-windows-msvc]
pkg-fmt = "zip"
[package.metadata.binstall.overrides.aarch64-pc-windows-msvc]
pkg-fmt = "zip"
+31 -4
View File
@@ -1,15 +1,22 @@
PROFILE=release
PROFILE?=release
EXE=target/$(PROFILE)/fd
prefix=/usr/local
bindir=$(prefix)/bin
datadir=$(prefix)/share
exe_name=fd
ifdef VERSION
ARCHIVE_NAME = fd-v$(VERSION)
else
ARCHIVE_NAME = fd
endif
export ARCHIVE_NAME
archive_path=package/$(ARCHIVE_NAME).tar.gz
$(EXE): Cargo.toml src/**/*.rs
cargo build --profile $(PROFILE) --locked
.PHONY: completions
completions: autocomplete/fd.bash autocomplete/fd.fish autocomplete/fd.ps1 autocomplete/_fd
completions: autocomplete/fd.bash autocomplete/fd.fish autocomplete/_fd.ps1 autocomplete/_fd autocomplete/_fdfind autocomplete/fdfind.bash autocomplete/fdfind.fish
comp_dir=@mkdir -p autocomplete
@@ -21,7 +28,7 @@ autocomplete/fd.fish: $(EXE)
$(comp_dir)
$(EXE) --gen-completions fish > $@
autocomplete/fd.ps1: $(EXE)
autocomplete/_fd.ps1: $(EXE)
$(comp_dir)
$(EXE) --gen-completions powershell > $@
@@ -29,9 +36,29 @@ autocomplete/_fd: contrib/completion/_fd
$(comp_dir)
cp $< $@
autocomplete/_fdfind: contrib/completion/_fdfind
$(comp_dir)
cp $< $@
autocomplete/fdfind.bash: contrib/completion/fdfind.bash
$(comp_dir)
cp $< $@
autocomplete/fdfind.fish: contrib/completion/fdfind.fish
$(comp_dir)
cp $< $@
archive: $(archive_path)
$(archive_path): completions $(EXE)
bash scripts/create-archive.sh
install: $(EXE) completions
install -Dm755 $(EXE) $(DESTDIR)$(bindir)/fd
install -Dm644 autocomplete/fd.bash $(DESTDIR)/$(datadir)/bash-completion/completions/$(exe_name)
install -Dm644 autocomplete/fd.fish $(DESTDIR)/$(datadir)/fish/vendor_completions.d/$(exe_name).fish
install -Dm644 autocomplete/_fd $(DESTDIR)/$(datadir)/zsh/site-functions/_$(exe_name)
install -Dm644 autocomplete/_fd $(DESTDIR)/$(datadir)/zsh/site-functions/_fd
install -Dm644 autocomplete/fdfind.bash $(DESTDIR)/$(datadir)/zsh/site-functions/fdfind.bash
install -Dm644 autocomplete/fdfind.fish $(DESTDIR)/$(datadir)/zsh/site-functions/fdfind.fish
install -Dm644 autocomplete/_fdfind $(DESTDIR)/$(datadir)/zsh/site-functions/_fdfind
install -Dm644 doc/fd.1 $(DESTDIR)/$(datadir)/man/man1/$(exe_name).1
+60 -7
View File
@@ -147,7 +147,7 @@ By default, *fd* only matches the filename of each file. However, using the `--f
you can match against the full path.
```bash
> fd -p -g '**/.git/config'
> fd -u -p -g '**/.git/config'
> fd -p '.*/lesson-\d+/[a-z]+.(jpg|png)'
```
@@ -176,6 +176,12 @@ fd -e h -e cpp -x clang-format -i
Note how the `-i` option to `clang-format` can be passed as a separate argument. This is why
we put the `-x` option last.
Any positional arguments after `-x` belong to the command template, not to `fd` itself. If you
also want to pass a pattern or search path, put `-x` last:
``` bash
fd pattern path -x echo
```
Find all `test_*.py` files and open them in your favorite editor:
``` bash
fd -g 'test_*.py' -X vim
@@ -206,7 +212,7 @@ See below for more details on the placeholder syntax.
The terminal output of commands run from parallel threads using `-x` will not be interlaced or garbled,
so `fd -x` can be used to rudimentarily parallelize a task run over many files.
An example of this is calculating the checksum of each individual file within a directory.
```
``` bash
fd -tf -x md5sum > file_checksums.txt
```
@@ -215,6 +221,9 @@ fd -tf -x md5sum > file_checksums.txt
The `-x` and `-X` options take a *command template* as a series of arguments (instead of a single string).
If you want to add additional options to `fd` after the command template, you can terminate it with a `\;`.
For example, `fd -x echo \; pattern path` treats `pattern path` as `fd` arguments instead of
passing them to `echo`. In practice, it is often clearer to write `fd pattern path -x echo`.
The syntax for generating commands is similar to that of [GNU Parallel](https://www.gnu.org/software/parallel/):
- `{}`: A placeholder token that will be replaced with the path of the search result
@@ -298,7 +307,7 @@ This is the output of `fd -h`. To see the full set of command-line options, use
also includes a much more detailed help text.
```
Usage: fd [OPTIONS] [pattern [path...]]
Usage: fd [OPTIONS] [pattern [path]...]
Arguments:
[pattern] the search pattern (a regular expression, unless '--glob' is used; optional)
@@ -315,11 +324,11 @@ Options:
-L, --follow Follow symbolic links
-p, --full-path Search full abs. path (default: filename only)
-d, --max-depth <depth> Set maximum search depth (default: none)
-E, --exclude <pattern> Exclude entries that match the given glob pattern
-E, --exclude <glob> Exclude entries that match the given glob pattern
-t, --type <filetype> Filter by type: file (f), directory (d/dir), symlink (l),
executable (x), empty (e), socket (s), pipe (p), char-device
(c), block-device (b)
-e, --extension <ext> Filter by file extension
-e, --extension <ext> Filter by extension
-S, --size <size> Limit results based on the size of files
--changed-within <date|dur> Filter by file modification time (newer than)
--changed-before <date|dur> Filter by file modification time (older than)
@@ -331,6 +340,7 @@ Options:
always, never]
--hyperlink[=<when>] Add hyperlinks to output paths [default: never] [possible
values: auto, always, never]
--ignore-contain <name> Ignore directories containing the named entry
-h, --help Print help (see more with '--help')
-V, --version Print version
```
@@ -339,7 +349,7 @@ Note that options can be given after the pattern and/or path as well.
## Benchmark
Let's search my home folder for files that end in `[0-9].jpg`. It contains ~750.000
Let's search my home folder for files that end in `[0-9].jpg`. It contains ~750,000
subdirectories and about a 4 million files. For averaging and statistical analysis, I'm using
[hyperfine](https://github.com/sharkdp/hyperfine). The following benchmarks are performed
with a "warm"/pre-filled disk-cache (results for a "cold" disk-cache show the same trends).
@@ -434,6 +444,11 @@ you can use `export -f my_function` to make available to child processes. You wo
need to call `fd -x bash -c 'my_function "$1"' bash`. For other use cases or shells, use
a (temporary) shell script.
### Placeholders in `-x`/`-X`
Depending on your shell, you may need to quote the placeholders (`{}`, `{/}`, `{//}`,
`{.}`, `{/.}`) to prevent the shell from interpreting them before `fd` sees them.
## Integration with other programs
### Using fd with `fzf`
@@ -666,6 +681,13 @@ You can install [the fd package](https://guix.gnu.org/en/packages/fd-8.1.1/) fro
guix install fd
```
### On Mise
You can use [mise](https://github.com/jdx/mise) to install `fd` with a command like this:
```
mise use -g fd@latest
```
### On NixOS / via Nix
You can use the [Nix package manager](https://nixos.org/nix/) to install `fd`:
@@ -701,7 +723,7 @@ With Rust's package manager [cargo](https://github.com/rust-lang/cargo), you can
```
cargo install fd-find
```
Note that rust version *1.77.2* or later is required.
Note that rust version *1.90.0* or later is required.
`make` is also needed for the build.
@@ -724,6 +746,37 @@ cargo test
cargo install --path .
```
### Completions
#### From Release Archives
Pre-built completion files are included in the release archives (`.tar.gz`/`.zip`) on the
[Releases page](https://github.com/sharkdp/fd/releases), in the `autocomplete` directory.
To use these completions:
- **bash**: Source the `fd.bash` file in your `~/.bashrc`, or place it in a directory that gets sourced automatically.
- **zsh**: Move `_fd` to a directory in your `fpath` (e.g., `~/.zfunc`).
- **fish**: Copy `fd.fish` to `~/.config/fish/completions/`.
- **powershell**: Source `_fd.ps1` from one of your [profile scripts](https://learn.microsoft.com/en-us/powershell/scripting/learn/shell/creating-profiles?view=powershell-7.5).
#### Generate from fd
You can also generate completions directly using `fd --gen-completions <shell>`:
```bash
# Bash
fd --gen-completions bash > ~/.local/share/bash-completion/completions/fd
# Zsh (ensure ~/.zfunc is in your fpath)
fd --gen-completions zsh > ~/.zfunc/_fd
# Fish
fd --gen-completions fish > ~/.config/fish/completions/fd.fish
# PowerShell
fd --gen-completions powershell >> $PROFILE
```
## Maintainers
- [sharkdp](https://github.com/sharkdp)
+1 -1
View File
@@ -17,7 +17,7 @@ To report a security vulnerability, please provide the following information:
- Provide a detailed description of the security vulnerability.
- Include as much information as possible to help us understand and address the issue.
Send this information, along with any additional relevant details, to <email AT somewhere or other channel>.
Send this information, along with any additional relevant details, to our [vulnerability reporting form](https://github.com/sharkdp/fd/security/advisories/new).
## Confidentiality
-12
View File
@@ -1,12 +0,0 @@
fn main() {
let min_version = "1.64";
match version_check::is_min_version(min_version) {
Some(true) => {}
// rustc version too small or can't figure it out
_ => {
eprintln!("'fd' requires rustc >= {min_version}");
std::process::exit(1);
}
}
}
+2 -2
View File
@@ -167,7 +167,7 @@ _fd() {
$no'(strip-cwd-prefix exec-cmds)--strip-cwd-prefix=-[When to strip ./]::when:(always never auto)'
+ and
'--and=[additional required search path]:pattern'
'--and=[additional required search pattern]:pattern'
+ args # positional arguments
@@ -217,7 +217,7 @@ _fd() {
'T:terabytes (10^12 = 1000^4 bytes)'
'Ki:kibibytes ( 2^10 = 1024 bytes)'
'Mi:mebibytes ( 2^20 = 1024^2 bytes)'
'Gi:gigibytes ( 2^30 = 1024^3 bytes)'
'Gi:gibibytes ( 2^30 = 1024^3 bytes)'
'Ti:tebibytes ( 2^40 = 1024^4 bytes)'
)
_describe -t units 'size limit units' suff -V 'units'
+2
View File
@@ -0,0 +1,2 @@
#compdef fdfind
_fd "$@"
+8
View File
@@ -0,0 +1,8 @@
# Source fd completions
source /usr/share/bash-completion/completions/fd
if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then
complete -F _fd -o nosort -o bashdefault -o default fdfind
else
complete -F _fd -o bashdefault -o default fdfind
fi
+1
View File
@@ -0,0 +1 @@
complete -c fdfind --wraps fd
Vendored
+34 -13
View File
@@ -94,8 +94,8 @@ regardless of whether you’re searching in a git repository or not. The flag ca
be overridden with '--require-git'.
.TP
.B \-\-no\-ignore\-parent
Show search results from files and directories that would otherwise be ignored by gitignore files in
parent directories.
Show search results from files and directories that would otherwise be ignored by
'.gitignore', '.ignore', or '.fdignore' files in parent directories.
.TP
.B \-s, \-\-case\-sensitive
Perform a case-sensitive search. By default, fd uses case-insensitive searches, unless the
@@ -114,7 +114,14 @@ Perform a regular-expression based search (default). This can be used to overrid
.TP
.B \-F, \-\-fixed\-strings
Treat the pattern as a literal string instead of a regular expression. Note that this also
performs substring comparison. If you want to match on an exact filename, consider using '\-\-glob'.
performs substring comparison. If you want to match on an exact filename, consider using '\-\-glob'
or '\-\-exact'.
.TP
.B \-\-exact
Perform an exact match. This is equivalent to '\-\-fixed\-strings' but requires the pattern to
match the entire filename (or the full path if '\-\-full\-path' is used), rather than a substring.
Special regex characters in the pattern are treated as literal characters.
Cannot be used together with '\-\-glob' or '\-\-fixed\-strings'.
.TP
.BI "\-\-and " pattern
Add additional required search patterns, all of which must be matched. Multiple additional
@@ -250,14 +257,16 @@ Examples:
.RE
.TP
.BI "\-e, \-\-extension " ext
Filter search results by file extension
Filter search results by extension
.IR ext .
This option can be used repeatedly to allow for multiple possible file extensions.
This option can be used repeatedly to allow for multiple possible extensions.
By default, extension filters apply to both files and directories. Use
\fB\-\-type\fR to restrict the search to specific types.
If you want to search for files without extension, you can use the regex '^[^.]+$'
as a normal search pattern.
.TP
.BI "\-E, \-\-exclude " pattern
.BI "\-E, \-\-exclude " glob
Exclude files/directories that match the given glob pattern.
This overrides any other ignore logic.
Multiple exclude patterns can be specified.
@@ -265,6 +274,10 @@ Examples:
\-\-exclude '*.pyc'
\-\-exclude node_modules
.TP
.BI "\-\-ignore-contain " name
Exclude directories that (directly) contain the given name.
This option can be specified multiple times.
.TP
.BI "\-\-ignore-file " path
Add a custom ignore-file in '.gitignore' format.
These files have a low precedence.
@@ -385,7 +398,7 @@ Examples:
\-\-owner :students
\-\-owner "!john:students"
.TP
.BI "\-\-base\-directory " path
.BI "-C, \-\-base\-directory " path
Change the current working directory of fd to the provided path. This means that search results will
be shown with respect to the given base path. Note that relative paths which are passed to fd via the
positional \fIpath\fR argument or the \fB\-\-search\-path\fR option will also be resolved relative to
@@ -435,13 +448,14 @@ Note that all subsequent positional arguments are considered to be arguments to
It is therefore recommended to place the \-x/\-\-exec option last. Alternatively, you can supply
a ';' argument to end the argument list and continue with more fd options.
Most shells require ';' to be escaped: '\\;'.
This option can be specified multiple times, in which case all commands are run for each
file found, in the order they are provided. In that case, you must supply a ';' argument for
all but the last commands.
Specify this option multiple times to run several commands for each file or directory found.
For each file or directory, fd runs these commands one after another in the order they appear
on the command line. In that case, you must supply a ';' argument for all but the last command.
When running in parallel, fd buffers command output and prints it together for each search result,
without interleaving it with command output for other results.
If parallelism is enabled, the order commands will be executed in is non-deterministic. And even with
--threads=1, the order is determined by the operating system and may not be what you expect. Thus, it is
recommended that you don't rely on any ordering of the results.
The order in which different search results are processed and their output is printed is not
guaranteed, even with --threads=1.
Before executing the command, any placeholder patterns in the command are replaced with the
corresponding values for the current file. The same placeholders are used as in the "\-\-format"
@@ -449,6 +463,9 @@ option.
If no placeholder is present, an implicit "{}" at the end is assumed.
If --print0 is also given, then a null character (\\0) will be printed between the output for each found entry.
This allows another program to easily distinguish the output for each file if the command(s) produce multiple lines.
Examples:
- find all *.zip files and unzip them:
@@ -462,6 +479,10 @@ Examples:
- Convert all *.jpg files to *.png files:
fd -e jpg -x convert {} {.}.png
- Run stat for each *.txt file, separated by null characters
fd -0 -e txt -x stat
.RE
.TP
.BI "\-X, \-\-exec-batch " command
+4 -1
View File
@@ -5,6 +5,9 @@ necessary changes for the upcoming release.
## Version bump
This section can be done by running `scripts/version-bump.sh` with the new version
as an argument.
- [ ] Create a new branch for the required changes for this release.
- [ ] Update version in `Cargo.toml`. Run `cargo build` to update `Cargo.lock`.
Make sure to `git add` the `Cargo.lock` changes as well.
@@ -21,7 +24,7 @@ necessary changes for the upcoming release.
new version).
- [ ] Review `-h`, `--help`, and the `man` page.
- [ ] Run `fd -h` and copy the output to the *"Command-line options"* section in
the README
the README. This can be done by running `gawk -i inplace -f scripts/update-help.awk README.md`.
- [ ] Push all changes and wait for CI to succeed (before continuing with the
next section).
- [ ] Optional: manually test the new features and command-line options described
-5
View File
@@ -1,5 +0,0 @@
<svg width="816" height="214" viewBox="0 0 816 214" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="816" height="214" fill="#B7FFA6"/>
<path d="M254.144 131V86.072H237.944V80.6H276.392V86.072H260.192V131H254.144ZM293.26 132.008C289.708 132.008 286.588 131.264 283.9 129.776C281.212 128.24 279.124 126.104 277.636 123.368C276.148 120.632 275.404 117.464 275.404 113.864V113C275.404 109.352 276.148 106.16 277.636 103.424C279.124 100.688 281.188 98.576 283.828 97.088C286.468 95.552 289.492 94.784 292.9 94.784C296.212 94.784 299.14 95.504 301.684 96.944C304.228 98.336 306.22 100.352 307.66 102.992C309.1 105.632 309.82 108.728 309.82 112.28V114.872H281.092C281.236 118.664 282.46 121.64 284.764 123.8C287.068 125.912 289.948 126.968 293.404 126.968C296.428 126.968 298.756 126.272 300.388 124.88C302.02 123.488 303.268 121.832 304.132 119.912L309.028 122.288C308.308 123.776 307.3 125.264 306.004 126.752C304.756 128.24 303.1 129.488 301.036 130.496C299.02 131.504 296.428 132.008 293.26 132.008ZM281.164 110.192H304.06C303.868 106.928 302.74 104.384 300.676 102.56C298.66 100.736 296.068 99.824 292.9 99.824C289.684 99.824 287.044 100.736 284.98 102.56C282.916 104.384 281.644 106.928 281.164 110.192ZM319.742 131V95.792H325.286V100.112H326.294C326.918 98.576 327.878 97.448 329.174 96.728C330.47 96.008 332.174 95.648 334.286 95.648H338.462V100.832H333.926C331.382 100.832 329.318 101.552 327.734 102.992C326.198 104.384 325.43 106.568 325.43 109.544V131H319.742ZM346.882 131V95.792H352.426V100.184H353.434C354.106 98.936 355.21 97.784 356.746 96.728C358.282 95.672 360.442 95.144 363.226 95.144C365.962 95.144 368.17 95.744 369.85 96.944C371.578 98.144 372.826 99.608 373.594 101.336H374.602C375.418 99.608 376.642 98.144 378.274 96.944C379.954 95.744 382.282 95.144 385.258 95.144C387.61 95.144 389.674 95.624 391.45 96.584C393.226 97.544 394.618 98.936 395.626 100.76C396.682 102.536 397.21 104.672 397.21 107.168V131H391.522V107.672C391.522 105.32 390.826 103.472 389.434 102.128C388.09 100.736 386.194 100.04 383.746 100.04C381.154 100.04 379.018 100.88 377.338 102.56C375.706 104.24 374.89 106.664 374.89 109.832V131H369.202V107.672C369.202 105.32 368.506 103.472 367.114 102.128C365.77 100.736 363.874 100.04 361.426 100.04C358.834 100.04 356.698 100.88 355.018 102.56C353.386 104.24 352.57 106.664 352.57 109.832V131H346.882ZM408.828 131V95.792H414.516V131H408.828ZM411.708 90.32C410.46 90.32 409.404 89.912 408.54 89.096C407.724 88.28 407.316 87.248 407.316 86C407.316 84.704 407.724 83.648 408.54 82.832C409.404 82.016 410.46 81.608 411.708 81.608C412.956 81.608 413.988 82.016 414.804 82.832C415.62 83.648 416.028 84.704 416.028 86C416.028 87.248 415.62 88.28 414.804 89.096C413.988 89.912 412.956 90.32 411.708 90.32ZM426.476 131V95.792H432.02V101.768H433.028C433.796 100.088 435.092 98.576 436.916 97.232C438.788 95.84 441.524 95.144 445.124 95.144C447.764 95.144 450.116 95.696 452.18 96.8C454.292 97.904 455.972 99.536 457.22 101.696C458.468 103.856 459.092 106.52 459.092 109.688V131H453.404V110.12C453.404 106.616 452.516 104.072 450.74 102.488C449.012 100.904 446.66 100.112 443.684 100.112C440.276 100.112 437.492 101.216 435.332 103.424C433.22 105.632 432.164 108.92 432.164 113.288V131H426.476ZM481.432 132.008C478.984 132.008 476.752 131.576 474.736 130.712C472.72 129.848 471.136 128.6 469.984 126.968C468.832 125.336 468.256 123.344 468.256 120.992C468.256 118.592 468.832 116.624 469.984 115.088C471.136 113.504 472.72 112.328 474.736 111.56C476.752 110.744 479.008 110.336 481.504 110.336H493.384V107.744C493.384 105.248 492.64 103.28 491.152 101.84C489.712 100.4 487.552 99.68 484.672 99.68C481.84 99.68 479.632 100.376 478.048 101.768C476.464 103.112 475.384 104.936 474.808 107.24L469.48 105.512C470.056 103.544 470.968 101.768 472.216 100.184C473.464 98.552 475.12 97.256 477.184 96.296C479.296 95.288 481.816 94.784 484.744 94.784C489.256 94.784 492.76 95.96 495.256 98.312C497.752 100.616 499 103.88 499 108.104V124.016C499 125.456 499.672 126.176 501.016 126.176H504.256V131H499.288C497.704 131 496.432 130.568 495.472 129.704C494.512 128.792 494.032 127.592 494.032 126.104V125.744H493.168C492.64 126.656 491.92 127.616 491.008 128.624C490.144 129.584 488.944 130.376 487.408 131C485.872 131.672 483.88 132.008 481.432 132.008ZM482.08 127.112C485.44 127.112 488.152 126.128 490.216 124.16C492.328 122.144 493.384 119.336 493.384 115.736V114.944H481.72C479.464 114.944 477.592 115.448 476.104 116.456C474.664 117.416 473.944 118.88 473.944 120.848C473.944 122.816 474.688 124.352 476.176 125.456C477.712 126.56 479.68 127.112 482.08 127.112ZM512.046 131V80.6H517.734V131H512.046ZM558.457 131V86.072H542.257V80.6H580.705V86.072H564.505V131H558.457ZM583.132 131V95.792H588.676V100.112H589.684C590.308 98.576 591.268 97.448 592.564 96.728C593.86 96.008 595.564 95.648 597.676 95.648H601.852V100.832H597.316C594.772 100.832 592.708 101.552 591.124 102.992C589.588 104.384 588.82 106.568 588.82 109.544V131H583.132ZM625.279 132.008C621.727 132.008 618.583 131.264 615.847 129.776C613.159 128.288 611.047 126.2 609.511 123.512C608.023 120.776 607.279 117.56 607.279 113.864V112.928C607.279 109.28 608.023 Line truncated
<path d="M73.72 88.384H88.696V68.8H96.184V88.384H114.616V95.296H96.184V126.208C96.184 128.128 97.048 129.088 98.776 129.088H112.312V136H96.088C93.912 136 92.12 135.296 90.712 133.888C89.368 132.48 88.696 130.688 88.696 128.512V95.296H73.72V88.384ZM132.789 140.608H177.525V147.52H132.789V140.608Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 7.2 KiB

+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
#
# Create a .tar.gz archive of the fd project for distribution
#
# Expected environment variables:
#
# TARGET: rust target to publish. Defaults to default rustc target
# BIN_PATH: path to fd binary
# PKG_STAGING: directory to use for staging files to include in the archive
# ARCHIVE_NAME: Name of output file, minus extension, defaults to "fd" with a version appended
# if VERSION is specified
# VERSION: version to publish
# GITHUB_OUTPUT: file to store github output variables in
if [[ -z "$TARGET" ]]; then
TARGET="$(rustc -vV | sed -n 's/host: //p')"
fi
PKG_suffix=".tar.gz"
EXE_suffix=""
case ${TARGET} in
*-pc-windows-*)
PKG_suffix=".zip"
EXE_suffix=".exe"
;;
esac
if [[ -z $BIN_PATH ]]; then
BIN_PATH=target/${PROFILE:-release}/fd
fi
if [[ -z "$ARCHIVE_NAME" ]]; then
ARCHIVE_NAME=fd
[[ -n "$VERSION" ]] && ARCHIVE_NAME+="-v$VERSION"
fi
PKG_NAME=${ARCHIVE_NAME}${PKG_suffix}
staging_dir="${PKG_STAGING:-package}"
ARCHIVE_DIR="${staging_dir}/${ARCHIVE_NAME}/"
mkdir -p "${ARCHIVE_DIR}"
# Binary
cp "${BIN_PATH}" "$ARCHIVE_DIR"
# README, LICENSE and CHANGELOG files
cp "README.md" "LICENSE-MIT" "LICENSE-APACHE" "CHANGELOG.md" "$ARCHIVE_DIR"
# Man page
cp "doc/fd.1" "$ARCHIVE_DIR"
# Autocompletion files
cp -r autocomplete "${ARCHIVE_DIR}"
# base compressed package
pushd "${staging_dir}/" >/dev/null
case ${TARGET} in
*-pc-windows-*) 7z -y a "${PKG_NAME}" "${ARCHIVE_NAME}"/* | tail -2 ;;
*) tar czf "${PKG_NAME}" "${ARCHIVE_NAME}"/* ;;
esac;
popd >/dev/null
if [[ -n "$GITHUB_OUTPUT" ]]; then
echo "PKG_NAME=${PKG_NAME}" >> $GITHUB_OUTPUT
# Let subsequent steps know where to find the compressed package
echo "PKG_PATH=${PKG_STAGING}/${PKG_NAME}" >> $GITHUB_OUTPUT
fi
+5 -4
View File
@@ -57,11 +57,12 @@ install -Dm644 "LICENSE-APACHE" "${DPKG_DIR}/usr/share/doc/${DPKG_BASENAME}/LICE
install -Dm644 "CHANGELOG.md" "${DPKG_DIR}/usr/share/doc/${DPKG_BASENAME}/changelog"
gzip -n --best "${DPKG_DIR}/usr/share/doc/${DPKG_BASENAME}/changelog"
# Create symlinks so fdfind can be used as well:
# Create symlink and completions so fdfind can be used as well:
ln -s "/usr/bin/fd" "${DPKG_DIR}/usr/bin/fdfind"
ln -s './fd.bash' "${DPKG_DIR}/usr/share/bash-completion/completions/fdfind"
ln -s './fd.fish' "${DPKG_DIR}/usr/share/fish/vendor_completions.d/fdfind.fish"
ln -s './_fd' "${DPKG_DIR}/usr/share/zsh/vendor-completions/_fdfind"
install -Dm644 'autocomplete/fdfind.bash' "${DPKG_DIR}/usr/share/bash-completion/completions/fdfind"
install -Dm644 'autocomplete/fdfind.fish' "${DPKG_DIR}/usr/share/fish/vendor_completions.d/fdfind.fish"
install -Dm644 'autocomplete/_fdfind' "${DPKG_DIR}/usr/share/zsh/vendor-completions/_fdfind"
cat > "${DPKG_DIR}/usr/share/doc/${DPKG_BASENAME}/copyright" <<EOF
Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/gawk -f
BEGIN {
inSection = 0
inBlock = 0
}
/Command-line options/ { inSection=1 }
inSection && /^```/ {
inBlock=1
inSection=0
# print the starting fence then move to next line
print
next
}
inBlock && /^```/ {
cmd="cargo run --release --quiet -- -h"
# Output the results of fd -h
u=0
while (cmd | getline line) {
# Skip everything before the usage line
if (line ~ /^Usage/) {
u=1;
}
if (u) {
print line
}
}
status = close(cmd)
if (status) {
print "failed to generate help output" > "/dev/stderr"
exit 1
}
inBlock = 0
}
!inBlock
+57 -18
View File
@@ -4,14 +4,13 @@ use std::time::Duration;
use anyhow::anyhow;
use clap::{
error::ErrorKind, value_parser, Arg, ArgAction, ArgGroup, ArgMatches, Command, Parser,
ValueEnum,
Arg, ArgAction, ArgGroup, ArgMatches, Command, Parser, ValueEnum, error::ErrorKind,
value_parser,
};
#[cfg(feature = "completions")]
use clap_complete::Shell;
use normpath::PathExt;
use crate::error::print_error;
use crate::exec::CommandSet;
use crate::filesystem;
#[cfg(unix)]
@@ -22,7 +21,7 @@ use crate::filter::SizeFilter;
#[command(
name = "fd",
version,
about = "A program to find entries in your filesystem",
about = "A program to find entries in your filesystem with regex and glob based matching. By default, fd respects gitignore rules, ignores hidden directories, and is case insensitive.",
after_long_help = "Bugs can be reported on GitHub: https://github.com/sharkdp/fd/issues",
max_term_width = 98,
args_override_self = true,
@@ -102,6 +101,7 @@ pub struct Opts {
/// Show search results from files and directories that would otherwise be
/// ignored by '.gitignore', '.ignore', or '.fdignore' files in parent directories.
/// The flag can be overridden with --ignore-parent.
#[arg(
long,
hide_short_help = true,
@@ -110,6 +110,10 @@ pub struct Opts {
)]
pub no_ignore_parent: bool,
/// Overrides --no-ignore-parent
#[arg(long, overrides_with = "no_ignore_parent", hide = true, action = ArgAction::SetTrue)]
ignore_parent: (),
/// Do not respect the global ignore file
#[arg(long, hide = true)]
pub no_global_ignore_file: bool,
@@ -168,17 +172,30 @@ pub struct Opts {
/// Treat the pattern as a literal string instead of a regular expression. Note
/// that this also performs substring comparison. If you want to match on an
/// exact filename, consider using '--glob'.
/// exact filename, consider using '--glob' or '--exact' instead.
#[arg(
long,
short = 'F',
alias = "literal",
hide_short_help = true,
help = "Treat pattern as literal string stead of regex",
help = "Treat pattern as literal string instead of regex",
long_help
)]
pub fixed_strings: bool,
/// Perform an exact match. This is equivalent to '--fixed-strings' but requires
/// the pattern to match the entire filename (or path if '--full-path' is used),
/// rather than a substring. Special regex characters in the pattern are treated
/// as literal characters.
#[arg(
long,
conflicts_with_all(["glob", "fixed_strings"]),
hide_short_help = true,
help = "Match the entire filename exactly (literal, non-substring)",
long_help
)]
pub exact: bool,
/// Add additional required search patterns, all of which must be matched. Multiple
/// additional patterns can be specified. The patterns are regular
/// expressions, unless '--glob' or '--fixed-strings' is used.
@@ -299,7 +316,7 @@ pub struct Opts {
#[arg(
long,
short = 'E',
value_name = "pattern",
value_name = "glob",
help = "Exclude entries that match the given glob pattern",
long_help
)]
@@ -359,8 +376,9 @@ pub struct Opts {
)]
pub filetype: Option<Vec<FileType>>,
/// (Additionally) filter search results by their file extension. Multiple
/// allowable file extensions can be specified.
/// Filter results by extension. By default, this matches all entry types
/// (including directories whose names end with the extension). Use `--type`
/// to restrict the search to specific types. Multiple extensions can be specified.
///
/// If you want to search for files without extension,
/// you can use the regex '^[^.]+$' as a normal search pattern.
@@ -368,7 +386,7 @@ pub struct Opts {
long = "extension",
short = 'e',
value_name = "ext",
help = "Filter by file extension",
help = "Filter by extension",
long_help
)]
pub extensions: Option<Vec<String>>,
@@ -463,7 +481,8 @@ pub struct Opts {
long,
value_name = "fmt",
help = "Print results according to template",
conflicts_with = "list_details"
conflicts_with = "list_details",
allow_hyphen_values = true
)]
pub format: Option<String>,
@@ -528,6 +547,10 @@ pub struct Opts {
)]
pub hyperlink: HyperlinkWhen,
/// Ignore directories containing the named entry.
#[arg(long, value_name = "name")]
pub ignore_contain: Vec<String>,
/// Set number of threads to use for searching & executing (default: number
/// of available CPU cores)
#[arg(long, short = 'j', value_name = "num", hide_short_help = true, value_parser = str::parse::<NonZeroUsize>)]
@@ -594,6 +617,7 @@ pub struct Opts {
/// relative to this directory.
#[arg(
long,
short = 'C',
value_name = "path",
hide_short_help = true,
help = "Change current working directory",
@@ -627,9 +651,10 @@ pub struct Opts {
/// The directory where the filesystem search is rooted (optional). If
/// omitted, search the current working directory.
/// If supplied, the pattern must come first.
#[arg(action = ArgAction::Append,
value_name = "path",
help = "the root directories for the filesystem search (optional)",
help = "the root directories for the filesystem search (optional, requires pattern first)",
long_help,
)]
path: Vec<PathBuf>,
@@ -686,10 +711,12 @@ impl Opts {
if filesystem::is_existing_directory(path) {
Some(self.normalize_path(path))
} else {
print_error(format!(
"Search path '{}' is not a directory.",
path.to_string_lossy()
));
// We use debug for the path to make it more readable if it has special characters
// Should we use a more reliable escape?
print_error!(
"Search path {:?} is not a directory.",
path.to_string_lossy().as_ref()
);
None
}
})
@@ -702,6 +729,10 @@ impl Opts {
} else if path == Path::new(".") {
// Change "." to "./" as a workaround for https://github.com/BurntSushi/ripgrep/pull/2711
PathBuf::from("./")
} else if path == Path::new("-") {
// Prefix "-" so the underlying walker treats it as a path.
// See sharkdp/fd#849.
Path::new(".").join(path)
} else {
path.to_path_buf()
}
@@ -865,9 +896,15 @@ impl clap::Args for Exec {
.help("Execute a command for each search result")
.long_help(
"Execute a command for each search result in parallel (use --threads=1 for sequential command execution). \
There is no guarantee of the order commands are executed in, and the order should not be depended upon. \
The order in which different search results are processed and their output is printed is not guaranteed, even with --threads=1. \
All positional arguments following --exec are considered to be arguments to the command - not to fd. \
It is therefore recommended to place the '-x'/'--exec' option last.\n\
It is therefore recommended to place the '-x'/'--exec' option last. \
Use '\\;' to terminate the command template if you need to continue passing fd arguments afterwards.\n\n\
Specify this option multiple times to run several commands for each file or directory found. \
For each file or directory, fd runs these commands one after another in the order they appear on the command line. \
Terminate each command except the last with '\\;'. \
When running in parallel, fd buffers command output and prints it together for each search result, \
without interleaving it with command output for other results.\n\n\
The following placeholders are substituted before the command is executed:\n \
'{}': path (of the current search result)\n \
'{/}': basename\n \
@@ -882,6 +919,8 @@ impl clap::Args for Exec {
fd -e zip -x unzip\n\n \
- find *.h and *.cpp files and run \"clang-format -i ..\" for each of them:\n\n \
fd -e h -e cpp -x clang-format -i\n\n \
- search within `src/` and echo each match (place `-x` last):\n\n \
fd . src -x echo\n\n \
- Convert all *.jpg files to *.png files:\n\n \
fd -e jpg -x convert {} {.}.png\
",
+6 -3
View File
@@ -15,9 +15,9 @@ pub struct Config {
/// Whether the search is case-sensitive or case-insensitive.
pub case_sensitive: bool,
/// Whether to search within the full file path or just the base name (filename or directory
/// name).
pub search_full_path: bool,
/// Cached current working directory for absolute path construction.
/// Populated when `--full-path` is set; `None` means search by filename only.
pub full_path_base: Option<PathBuf>,
/// Whether to ignore hidden files and directories (or not).
pub ignore_hidden: bool,
@@ -130,6 +130,9 @@ pub struct Config {
/// Whether or not to use hyperlinks on paths
pub hyperlink: bool,
/// Names that should stop traversal down their parent. (e.g. https://bford.info/cachedir/).
pub ignore_contain: Vec<String>,
}
impl Config {
+47 -11
View File
@@ -11,7 +11,9 @@ use crate::filesystem::strip_current_dir;
#[derive(Debug)]
enum DirEntryInner {
Normal(ignore::DirEntry),
BrokenSymlink(PathBuf),
// Broken symlinks reach us as walk errors rather than entries, so we carry
// over the depth the walker recorded on the error.
BrokenSymlink { path: PathBuf, depth: Option<usize> },
}
#[derive(Debug)]
@@ -31,9 +33,9 @@ impl DirEntry {
}
}
pub fn broken_symlink(path: PathBuf) -> Self {
pub fn broken_symlink(path: PathBuf, depth: Option<usize>) -> Self {
Self {
inner: DirEntryInner::BrokenSymlink(path),
inner: DirEntryInner::BrokenSymlink { path, depth },
metadata: OnceCell::new(),
style: OnceCell::new(),
}
@@ -42,23 +44,31 @@ impl DirEntry {
pub fn path(&self) -> &Path {
match &self.inner {
DirEntryInner::Normal(e) => e.path(),
DirEntryInner::BrokenSymlink(pathbuf) => pathbuf.as_path(),
DirEntryInner::BrokenSymlink { path, .. } => path.as_path(),
}
}
pub fn into_path(self) -> PathBuf {
match self.inner {
DirEntryInner::Normal(e) => e.into_path(),
DirEntryInner::BrokenSymlink(p) => p,
DirEntryInner::BrokenSymlink { path, .. } => path,
}
}
/// Returns the path as it should be presented to the user.
/// When stripping `./` would leave the path starting with `-`, keep the original
/// (with `./`) so downstream tools don't interpret it as an option.
pub fn stripped_path(&self, config: &Config) -> &Path {
let path = self.path();
if config.strip_cwd_prefix {
strip_current_dir(self.path())
let stripped = strip_current_dir(path);
if starts_with_dash(stripped) {
path
} else {
stripped
}
} else {
self.path()
path
}
}
@@ -74,7 +84,7 @@ impl DirEntry {
pub fn file_type(&self) -> Option<FileType> {
match &self.inner {
DirEntryInner::Normal(e) => e.file_type(),
DirEntryInner::BrokenSymlink(_) => self.metadata().map(|m| m.file_type()),
DirEntryInner::BrokenSymlink { .. } => self.metadata().map(|m| m.file_type()),
}
}
@@ -82,7 +92,7 @@ impl DirEntry {
self.metadata
.get_or_init(|| match &self.inner {
DirEntryInner::Normal(e) => e.metadata().ok(),
DirEntryInner::BrokenSymlink(path) => path.symlink_metadata().ok(),
DirEntryInner::BrokenSymlink { path, .. } => path.symlink_metadata().ok(),
})
.as_ref()
}
@@ -90,7 +100,7 @@ impl DirEntry {
pub fn depth(&self) -> Option<usize> {
match &self.inner {
DirEntryInner::Normal(e) => Some(e.depth()),
DirEntryInner::BrokenSymlink(_) => None,
DirEntryInner::BrokenSymlink { depth, .. } => *depth,
}
}
@@ -101,6 +111,10 @@ impl DirEntry {
}
}
fn starts_with_dash(path: &Path) -> bool {
path.as_os_str().as_encoded_bytes().first() == Some(&b'-')
}
impl PartialEq for DirEntry {
#[inline]
fn eq(&self, other: &Self) -> bool {
@@ -132,7 +146,7 @@ impl Colorable for DirEntry {
fn file_name(&self) -> OsString {
let name = match &self.inner {
DirEntryInner::Normal(e) => e.file_name(),
DirEntryInner::BrokenSymlink(path) => {
DirEntryInner::BrokenSymlink { path, .. } => {
// Path::file_name() only works if the last component is Normal,
// but we want it for all component types, so we open code it.
// Copied from LsColors::style_for_path_with_metadata().
@@ -153,3 +167,25 @@ impl Colorable for DirEntry {
self.metadata().cloned()
}
}
#[cfg(test)]
mod tests {
use super::starts_with_dash;
use std::path::Path;
#[test]
fn dash_prefixed_paths_detected() {
assert!(starts_with_dash(Path::new("-rf")));
assert!(starts_with_dash(Path::new("--delete")));
assert!(starts_with_dash(Path::new("-")));
}
#[test]
fn safe_paths_not_flagged() {
assert!(!starts_with_dash(Path::new("foo")));
assert!(!starts_with_dash(Path::new("./foo")));
assert!(!starts_with_dash(Path::new("sub/-rf")));
assert!(!starts_with_dash(Path::new("")));
assert!(!starts_with_dash(Path::new(" -rf")));
}
}
+50 -2
View File
@@ -1,3 +1,51 @@
pub fn print_error(msg: impl Into<String>) {
eprintln!("[fd error]: {}", msg.into());
use std::fmt::Display;
use std::io;
use crate::sanitize::write_sanitized;
/// Print an error message using a format string, sanitizing any arguments if necessary
macro_rules! print_error {
($fmt:literal, $($arg:expr),*) => {
eprintln!(concat!("[fd error]: ", $fmt), $($crate::error::SanitizeErr::sanitize($arg)),*)
}
}
/// Trait for specifying how to sanitize a type for error display, if needed.
pub(crate) trait SanitizeErr {
type Sanitized: Display;
fn sanitize(self) -> Self::Sanitized;
}
impl SanitizeErr for &str {
type Sanitized = Self;
fn sanitize(self) -> Self {
self
}
}
impl SanitizeErr for io::Error {
type Sanitized = SanitizedError<io::Error>;
fn sanitize(self) -> Self::Sanitized {
SanitizedError(self)
}
}
impl SanitizeErr for ignore::Error {
type Sanitized = SanitizedError<ignore::Error>;
fn sanitize(self) -> Self::Sanitized {
SanitizedError(self)
}
}
pub struct SanitizedError<E>(E);
impl<E: std::error::Error> Display for SanitizedError<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let raw = self.0.to_string();
write_sanitized(f, &raw)
}
}
+23 -19
View File
@@ -1,25 +1,23 @@
use std::io;
use std::io::Write;
use std::sync::Mutex;
use argmax::Command;
use crate::error::print_error;
use crate::exit_codes::ExitCode;
struct Outputs {
stdout: Vec<u8>,
stderr: Vec<u8>,
}
struct OutputBuffer<'a> {
output_permission: &'a Mutex<()>,
pub struct OutputBuffer {
null_separator: bool,
outputs: Vec<Outputs>,
}
impl<'a> OutputBuffer<'a> {
fn new(output_permission: &'a Mutex<()>) -> Self {
impl OutputBuffer {
pub fn new(null_separator: bool) -> Self {
Self {
output_permission,
null_separator,
outputs: Vec::new(),
}
}
@@ -29,17 +27,19 @@ impl<'a> OutputBuffer<'a> {
}
fn write(self) {
// avoid taking the lock if there is nothing to do
if self.outputs.is_empty() {
// Avoid taking the lock if there is nothing to do.
// If null_separator is true, then we still need to write the
// null separator, because the output may have been written directly
// to stdout
if self.outputs.is_empty() && !self.null_separator {
return;
}
// While this lock is active, this thread will be the only thread allowed
// to write its outputs.
let _lock = self.output_permission.lock().unwrap();
let stdout = io::stdout();
let stderr = io::stderr();
// While we hold these locks, only this thread will be able
// to write its outputs.
let mut stdout = stdout.lock();
let mut stderr = stderr.lock();
@@ -47,16 +47,20 @@ impl<'a> OutputBuffer<'a> {
let _ = stdout.write_all(&output.stdout);
let _ = stderr.write_all(&output.stderr);
}
if self.null_separator {
// If null_separator is enabled, then we should write a \0 at the end
// of the output for this entry
let _ = stdout.write_all(b"\0");
}
}
}
/// Executes a command.
pub fn execute_commands<I: Iterator<Item = io::Result<Command>>>(
cmds: I,
out_perm: &Mutex<()>,
mut output_buffer: OutputBuffer,
enable_output_buffering: bool,
) -> ExitCode {
let mut output_buffer = OutputBuffer::new(out_perm);
for result in cmds {
let mut cmd = match result {
Ok(cmd) => cmd,
@@ -96,14 +100,14 @@ pub fn execute_commands<I: Iterator<Item = io::Result<Command>>>(
pub fn handle_cmd_error(cmd: Option<&Command>, err: io::Error) -> ExitCode {
match (cmd, err) {
(Some(cmd), err) if err.kind() == io::ErrorKind::NotFound => {
print_error(format!(
"Command not found: {}",
cmd.get_program().to_string_lossy()
));
print_error!(
"Command not found: {:?}",
cmd.get_program().to_string_lossy().as_ref()
);
ExitCode::GeneralError
}
(_, err) => {
print_error(format!("Problem while executing command: {err}"));
print_error!("Problem while executing command: {}", err);
ExitCode::GeneralError
}
}
+4 -8
View File
@@ -1,8 +1,5 @@
use std::sync::Mutex;
use crate::config::Config;
use crate::error::print_error;
use crate::exit_codes::{merge_exitcodes, ExitCode};
use crate::exit_codes::{ExitCode, merge_exitcodes};
use crate::walk::WorkerResult;
use super::CommandSet;
@@ -13,7 +10,6 @@ use super::CommandSet;
pub fn job(
results: impl IntoIterator<Item = WorkerResult>,
cmd: &CommandSet,
out_perm: &Mutex<()>,
config: &Config,
) -> ExitCode {
// Output should be buffered when only running a single thread
@@ -27,7 +23,7 @@ pub fn job(
WorkerResult::Entry(dir_entry) => dir_entry,
WorkerResult::Error(err) => {
if config.show_filesystem_errors {
print_error(err.to_string());
print_error!("{}", err);
}
continue;
}
@@ -37,7 +33,7 @@ pub fn job(
let code = cmd.execute(
dir_entry.stripped_path(config),
config.path_separator.as_deref(),
out_perm,
config.null_separator,
buffer_output,
);
ret = merge_exitcodes([ret, code]);
@@ -57,7 +53,7 @@ pub fn batch(
WorkerResult::Entry(dir_entry) => Some(dir_entry.into_stripped_path(config)),
WorkerResult::Error(err) => {
if config.show_filesystem_errors {
print_error(err.to_string());
print_error!("{}", err);
}
None
}
+28 -15
View File
@@ -6,12 +6,12 @@ use std::io;
use std::iter;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Mutex;
use anyhow::{bail, Result};
use anyhow::{Result, bail};
use argmax::Command;
use crate::exit_codes::{merge_exitcodes, ExitCode};
use crate::exec::command::OutputBuffer;
use crate::exit_codes::{ExitCode, merge_exitcodes};
use crate::fmt::{FormatTemplate, Token};
use self::command::{execute_commands, handle_cmd_error};
@@ -43,7 +43,7 @@ impl CommandSet {
mode: ExecutionMode::OneByOne,
commands: input
.into_iter()
.map(CommandTemplate::new)
.map(|args| CommandTemplate::new(args, ExecutionMode::OneByOne))
.collect::<Result<_>>()?,
})
}
@@ -59,13 +59,10 @@ impl CommandSet {
commands: input
.into_iter()
.map(|args| {
let cmd = CommandTemplate::new(args)?;
let cmd = CommandTemplate::new(args, ExecutionMode::Batch)?;
if cmd.number_of_tokens() > 1 {
bail!("Only one placeholder allowed for batch commands");
}
if cmd.args[0].has_tokens() {
bail!("First argument of exec-batch is expected to be a fixed executable");
}
Ok(cmd)
})
.collect::<Result<Vec<_>>>()?,
@@ -80,14 +77,14 @@ impl CommandSet {
&self,
input: &Path,
path_separator: Option<&str>,
out_perm: &Mutex<()>,
null_separator: bool,
buffer_output: bool,
) -> ExitCode {
let commands = self
.commands
.iter()
.map(|c| c.generate(input, path_separator));
execute_commands(commands, out_perm, buffer_output)
execute_commands(commands, OutputBuffer::new(null_separator), buffer_output)
}
pub fn execute_batch<I>(&self, paths: I, limit: usize, path_separator: Option<&str>) -> ExitCode
@@ -220,7 +217,7 @@ struct CommandTemplate {
}
impl CommandTemplate {
fn new<I, S>(input: I) -> Result<Self>
fn new<I, S>(input: I, mode: ExecutionMode) -> Result<Self>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
@@ -245,6 +242,11 @@ impl CommandTemplate {
bail!("No executable provided for --exec or --exec-batch");
}
// A placeholder as the executable is meaningful for `--exec` but never for `--exec-batch`.
if mode == ExecutionMode::Batch && args[0].has_tokens() {
bail!("First argument of --exec-batch must be a fixed executable, not a placeholder");
}
// If a placeholder token was not supplied, append one at the end of the command.
if !has_placeholder {
args.push(FormatTemplate::Tokens(vec![Token::Placeholder]));
@@ -365,7 +367,8 @@ mod tests {
#[test]
fn tokens_with_literal_braces() {
let template = CommandTemplate::new(vec!["{{}}", "{{", "{.}}"]).unwrap();
let template =
CommandTemplate::new(vec!["{{}}", "{{", "{.}}"], ExecutionMode::OneByOne).unwrap();
assert_eq!(
generate_str(&template, "foo"),
vec!["{}", "{", "{.}", "foo"]
@@ -374,8 +377,9 @@ mod tests {
#[test]
fn tokens_with_literal_braces_and_placeholder() {
let template = CommandTemplate::new(vec!["{{{},end}"]).unwrap();
assert_eq!(generate_str(&template, "foo"), vec!["{foo,end}"]);
let template =
CommandTemplate::new(vec!["echo", "{{{},end}"], ExecutionMode::OneByOne).unwrap();
assert_eq!(generate_str(&template, "foo"), vec!["echo", "{foo,end}"]);
}
#[test]
@@ -421,7 +425,9 @@ mod tests {
#[test]
fn template_no_args() {
assert!(CommandTemplate::new::<Vec<_>, &'static str>(vec![]).is_err());
assert!(
CommandTemplate::new::<Vec<_>, &'static str>(vec![], ExecutionMode::OneByOne).is_err()
);
}
#[test]
@@ -429,6 +435,13 @@ mod tests {
assert!(CommandSet::new(vec![vec!["echo"], vec![]]).is_err());
}
#[test]
fn placeholder_as_executable_rejected() {
assert!(CommandSet::new(vec![vec!["{}"]]).is_ok());
assert!(CommandSet::new(vec![vec!["{/}", "arg"]]).is_ok());
assert!(CommandSet::new_batch(vec![vec!["{}"]]).is_err());
}
#[test]
fn generate_custom_path_separator() {
let arg = FormatTemplate::Tokens(vec![Token::Placeholder]);
+1 -1
View File
@@ -1,7 +1,7 @@
use std::process;
#[cfg(unix)]
use nix::sys::signal::{raise, signal, SigHandler, Signal};
use nix::sys::signal::{SigHandler, Signal, raise, signal};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitCode {
+2 -2
View File
@@ -100,13 +100,13 @@ pub fn is_pipe(_: fs::FileType) -> bool {
}
#[cfg(any(unix, target_os = "redox"))]
pub fn osstr_to_bytes(input: &OsStr) -> Cow<[u8]> {
pub fn osstr_to_bytes(input: &OsStr) -> Cow<'_, [u8]> {
use std::os::unix::ffi::OsStrExt;
Cow::Borrowed(input.as_bytes())
}
#[cfg(windows)]
pub fn osstr_to_bytes(input: &OsStr) -> Cow<[u8]> {
pub fn osstr_to_bytes(input: &OsStr) -> Cow<'_, [u8]> {
let string = input.to_string_lossy();
match string {
+69 -1
View File
@@ -1,10 +1,11 @@
use crate::cli::FileType;
use crate::dir_entry;
use crate::filesystem;
use faccess::PathExt;
/// Whether or not to show
#[derive(Default)]
#[derive(Default, Debug, PartialEq, Eq)]
pub struct FileTypes {
pub files: bool,
pub directories: bool,
@@ -18,6 +19,34 @@ pub struct FileTypes {
}
impl FileTypes {
pub fn from_types(types: &[FileType]) -> Self {
let mut file_types = FileTypes::default();
for value in types {
match value {
FileType::File => file_types.files = true,
FileType::Directory => file_types.directories = true,
FileType::Symlink => file_types.symlinks = true,
FileType::Executable => {
file_types.executables_only = true;
file_types.files = true;
}
FileType::Empty => file_types.empty_only = true,
FileType::BlockDevice => file_types.block_devices = true,
FileType::CharDevice => file_types.char_devices = true,
FileType::Socket => file_types.sockets = true,
FileType::Pipe => file_types.pipes = true,
}
}
// If only 'empty' was specified, search for both files and directories:
if file_types.empty_only && !(file_types.files || file_types.directories) {
file_types.files = true;
file_types.directories = true;
}
file_types
}
pub fn should_ignore(&self, entry: &dir_entry::DirEntry) -> bool {
if let Some(ref entry_type) = entry.file_type() {
(!self.files && entry_type.is_file())
@@ -41,3 +70,42 @@ impl FileTypes {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_only_implies_files_and_directories() {
let ft = FileTypes::from_types(&[FileType::Empty]);
assert!(ft.empty_only);
assert!(ft.files);
assert!(ft.directories);
}
#[test]
fn empty_with_explicit_file_keeps_directories_off() {
let ft = FileTypes::from_types(&[FileType::Empty, FileType::File]);
assert!(ft.empty_only);
assert!(ft.files);
assert!(!ft.directories);
}
#[test]
fn executable_implies_files() {
let ft = FileTypes::from_types(&[FileType::Executable]);
assert!(ft.executables_only);
assert!(ft.files);
assert!(!ft.directories);
}
#[test]
fn union_of_multiple_types() {
let ft = FileTypes::from_types(&[FileType::File, FileType::Symlink]);
assert!(ft.files);
assert!(ft.symlinks);
assert!(!ft.directories);
assert!(!ft.executables_only);
assert!(!ft.empty_only);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use nix::unistd::{Group, User};
use std::fs;
+60 -32
View File
@@ -1,4 +1,4 @@
use jiff::{civil::DateTime, tz::TimeZone, Span, Timestamp, Zoned};
use jiff::{Span, Timestamp, Zoned, civil::DateTime, tz::TimeZone};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -42,7 +42,7 @@ impl TimeFilter {
)
} else {
let timestamp_secs: u64 = s.strip_prefix('@')?.parse().ok()?;
Some(UNIX_EPOCH + Duration::from_secs(timestamp_secs))
UNIX_EPOCH.checked_add(Duration::from_secs(timestamp_secs))
}
}
@@ -114,16 +114,22 @@ mod tests {
assert!(!TimeFilter::before("2min").unwrap().applies_to(&t1m_ago));
let t10s_before = "2010-10-10 10:10:00";
assert!(!TimeFilter::before(t10s_before)
.unwrap()
.applies_to(&ref_time));
assert!(TimeFilter::before(t10s_before)
.unwrap()
.applies_to(&t1m_ago));
assert!(
!TimeFilter::before(t10s_before)
.unwrap()
.applies_to(&ref_time)
);
assert!(
TimeFilter::before(t10s_before)
.unwrap()
.applies_to(&t1m_ago)
);
assert!(TimeFilter::after(t10s_before)
.unwrap()
.applies_to(&ref_time));
assert!(
TimeFilter::after(t10s_before)
.unwrap()
.applies_to(&ref_time)
);
assert!(!TimeFilter::after(t10s_before).unwrap().applies_to(&t1m_ago));
let same_day = "2010-10-10";
@@ -142,16 +148,22 @@ mod tests {
ref_time = test_time.timestamp();
let t1m_ago = ref_time - Duration::from_secs(60);
let t10s_before = "2010-10-10T10:10:00+00:00";
assert!(!TimeFilter::before(t10s_before)
.unwrap()
.applies_to(&ref_time));
assert!(TimeFilter::before(t10s_before)
.unwrap()
.applies_to(&t1m_ago));
assert!(
!TimeFilter::before(t10s_before)
.unwrap()
.applies_to(&ref_time)
);
assert!(
TimeFilter::before(t10s_before)
.unwrap()
.applies_to(&t1m_ago)
);
assert!(TimeFilter::after(t10s_before)
.unwrap()
.applies_to(&ref_time));
assert!(
TimeFilter::after(t10s_before)
.unwrap()
.applies_to(&ref_time)
);
assert!(!TimeFilter::after(t10s_before).unwrap().applies_to(&t1m_ago));
let ref_timestamp = 1707723412u64; // Mon Feb 12 07:36:52 UTC 2024
@@ -166,17 +178,33 @@ mod tests {
let t1s_later = ref_time + Duration::from_secs(1);
// Timestamp only supported via '@' prefix
assert!(TimeFilter::before(&ref_timestamp.to_string()).is_none());
assert!(TimeFilter::before(&format!("@{ref_timestamp}"))
.unwrap()
.applies_to(&t1m_ago));
assert!(!TimeFilter::before(&format!("@{ref_timestamp}"))
.unwrap()
.applies_to(&t1s_later));
assert!(!TimeFilter::after(&format!("@{ref_timestamp}"))
.unwrap()
.applies_to(&t1m_ago));
assert!(TimeFilter::after(&format!("@{ref_timestamp}"))
.unwrap()
.applies_to(&t1s_later));
assert!(
TimeFilter::before(&format!("@{ref_timestamp}"))
.unwrap()
.applies_to(&t1m_ago)
);
assert!(
!TimeFilter::before(&format!("@{ref_timestamp}"))
.unwrap()
.applies_to(&t1s_later)
);
assert!(
!TimeFilter::after(&format!("@{ref_timestamp}"))
.unwrap()
.applies_to(&t1m_ago)
);
assert!(
TimeFilter::after(&format!("@{ref_timestamp}"))
.unwrap()
.applies_to(&t1s_later)
);
}
#[test]
fn out_of_range_unix_timestamp_is_rejected() {
// A '@' timestamp large enough to overflow SystemTime must return
// None rather than panicking.
assert!(TimeFilter::before(&format!("@{}", u64::MAX)).is_none());
assert!(TimeFilter::after(&format!("@{}", u64::MAX)).is_none());
}
}
+1 -1
View File
@@ -131,7 +131,7 @@ impl FormatTemplate {
Placeholder => {
s.push(Self::replace_separator(path.as_ref(), path_separator))
}
Text(ref string) => s.push(string),
Text(string) => s.push(string),
}
}
s
+1 -1
View File
@@ -58,7 +58,7 @@ fn host() -> &'static str {
#[cfg(not(unix))]
const fn host() -> &'static str {
""
"/"
}
#[cfg(test)]
+95 -47
View File
@@ -1,7 +1,10 @@
// needs to be first because it defines a macro
#[macro_use]
mod error;
mod cli;
mod config;
mod dir_entry;
mod error;
mod exec;
mod exit_codes;
mod filesystem;
@@ -11,6 +14,7 @@ mod fmt;
mod hyperlink;
mod output;
mod regex_helper;
mod sanitize;
mod walk;
use std::env;
@@ -18,7 +22,7 @@ use std::io::IsTerminal;
use std::path::Path;
use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use anyhow::{Context, Result, anyhow, bail};
use clap::{CommandFactory, Parser};
use globset::GlobBuilder;
use lscolors::LsColors;
@@ -36,12 +40,15 @@ use crate::regex_helper::{pattern_has_uppercase_char, pattern_matches_strings_wi
// We use jemalloc for performance reasons, see https://github.com/sharkdp/fd/pull/481
// FIXME: re-enable jemalloc on macOS, see comment in Cargo.toml file for more infos
// This has to be kept in sync with the Cargo.toml file section that declares a
// dependency on tikv-jemallocator.
#[cfg(all(
not(windows),
not(target_os = "android"),
not(target_os = "macos"),
not(target_os = "freebsd"),
not(target_os = "openbsd"),
not(target_os = "illumos"),
not(all(target_env = "musl", target_pointer_width = "32")),
not(target_arch = "riscv64"),
feature = "use-jemalloc"
@@ -62,7 +69,9 @@ fn main() {
exit_code.exit();
}
Err(err) => {
eprintln!("[fd error]: {err:#}");
// NB: we use eprintln directly instead of print_error!() because
// we sanitize anyhow errors at the generation site
eprintln!("[fd error]: {:#}", err);
ExitCode::GeneralError.exit();
}
}
@@ -128,35 +137,83 @@ fn set_working_dir(opts: &Opts) -> Result<()> {
if let Some(ref base_directory) = opts.base_directory {
if !filesystem::is_existing_directory(base_directory) {
return Err(anyhow!(
"The '--base-directory' path '{}' is not a directory.",
base_directory.to_string_lossy()
"The '--base-directory' path {:?} is not a directory.",
base_directory.to_string_lossy().as_ref()
));
}
env::set_current_dir(base_directory).with_context(|| {
format!(
"Could not set '{}' as the current working directory",
base_directory.to_string_lossy()
"Could not set {:?} as the current working directory",
base_directory.to_string_lossy().as_ref()
)
})?;
}
Ok(())
}
/// Detect if the user accidentally supplied a path instead of a search pattern
/// Detect if the user accidentally supplied a path instead of a search pattern.
///
/// Without `--full-path`, fd matches patterns against file names, so any pattern
/// containing a path separator can never match. This applies to the primary
/// positional pattern *and* to every `--and` pattern, since all of them are
/// chained together and matched against the file name (see `run`). Two cases per
/// pattern are worth a friendly error rather than silent "no results":
///
/// 1. The pattern contains '/'. '/' is always a path separator (including on
/// Windows) and has no regex meaning, so flagging it is safe and catches the
/// common Linux/macOS mistake of pasting a full path as the pattern.
/// 2. On Windows only, the pattern contains the native `\` separator *and*
/// names an existing directory on disk. We can't treat `\` as a pure
/// path-separator signal there because it is also the regex escape char,
/// so valid regex patterns like `\Ac` or `\d+` must still run. Requiring
/// that the pattern resolves to a real directory avoids those false
/// positives while preserving the legacy diagnostic for operators who
/// literally typed a directory path.
///
/// See https://github.com/sharkdp/fd/issues/1873.
fn ensure_search_pattern_is_not_a_path(opts: &Opts) -> Result<()> {
if !opts.full_path
&& opts.pattern.contains(std::path::MAIN_SEPARATOR)
&& Path::new(&opts.pattern).is_dir()
if opts.full_path {
return Ok(());
}
// Check the primary pattern and every `--and` pattern. They are all matched
// against the file name (see `run`), so a path separator in any of them is
// the same silent "no results" footgun.
for pattern in std::iter::once(&opts.pattern).chain(opts.exprs.iter().flatten()) {
ensure_single_search_pattern_is_not_a_path(pattern)?;
}
Ok(())
}
/// Apply the path-separator diagnostic to a single pattern. See
/// [`ensure_search_pattern_is_not_a_path`] for the rationale of each case.
fn ensure_single_search_pattern_is_not_a_path(pattern: &str) -> Result<()> {
// Start with the cheap check: '/' is always a path separator, including on
// Windows, and has no regex meaning, so flagging it is safe and catches the
// Linux/macOS mistake of pasting a full path as the pattern.
#[cfg_attr(not(windows), allow(unused_mut))]
let mut should_warn = pattern.contains('/');
// On Windows we additionally accept the native `\` separator, but only when
// the pattern actually resolves to an existing directory - `\` is also the
// regex escape char there, so valid patterns like `\Ac` or `\d+` must still
// run. The is_dir syscall is only needed when `should_warn` is still false,
// so short-circuit via `||` to avoid the stat call on the happy path.
#[cfg(windows)]
{
should_warn = should_warn
|| (pattern.contains(std::path::MAIN_SEPARATOR) && Path::new(pattern).is_dir());
}
if should_warn {
Err(anyhow!(
"The search pattern '{pattern}' contains a path-separation character ('{sep}') \
"The search pattern '{pattern}' contains a path-separation character \
and will not lead to any search results.\n\n\
If you want to search for all files inside the '{pattern}' directory, use a match-all pattern:\n\n \
fd . '{pattern}'\n\n\
Instead, if you want your pattern to match the full file path, use:\n\n \
fd --full-path '{pattern}'",
pattern = &opts.pattern,
sep = std::path::MAIN_SEPARATOR,
pattern = pattern,
))
} else {
Ok(())
@@ -167,6 +224,10 @@ fn build_pattern_regex(pattern: &str, opts: &Opts) -> Result<String> {
Ok(if opts.glob && !pattern.is_empty() {
let glob = GlobBuilder::new(pattern).literal_separator(true).build()?;
glob.regex().to_owned()
} else if opts.exact {
// Anchor the escaped pattern so the full filename (or path) must match exactly.
// Literal. No substring matching.
format!("^{}$", regex::escape(pattern))
} else if opts.fixed_strings {
// Treat pattern as literal string if '--fixed-strings' is used
regex::escape(pattern)
@@ -242,9 +303,18 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result<Config
let command = extract_command(&mut opts, colored_output)?;
let has_command = command.is_some();
let full_path_base = if opts.full_path {
Some(env::current_dir().context(
"Could not determine current directory. \
This is required for --full-path.",
)?)
} else {
None
};
Ok(Config {
case_sensitive,
search_full_path: opts.full_path,
full_path_base,
ignore_hidden: !(opts.hidden || opts.rg_alias_ignore()),
read_fdignore: !(opts.no_ignore || opts.rg_alias_ignore()),
read_vcsignore: !(opts.no_ignore || opts.rg_alias_ignore() || opts.no_ignore_vcs),
@@ -265,34 +335,10 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result<Config
ls_colors,
hyperlink,
interactive_terminal,
file_types: opts.filetype.as_ref().map(|values| {
use crate::cli::FileType::*;
let mut file_types = FileTypes::default();
for value in values {
match value {
File => file_types.files = true,
Directory => file_types.directories = true,
Symlink => file_types.symlinks = true,
Executable => {
file_types.executables_only = true;
file_types.files = true;
}
Empty => file_types.empty_only = true,
BlockDevice => file_types.block_devices = true,
CharDevice => file_types.char_devices = true,
Socket => file_types.sockets = true,
Pipe => file_types.pipes = true,
}
}
// If only 'empty' was specified, search for both files and directories:
if file_types.empty_only && !(file_types.files || file_types.directories) {
file_types.files = true;
file_types.directories = true;
}
file_types
}),
file_types: opts
.filetype
.as_ref()
.map(|values| FileTypes::from_types(values)),
extensions: opts
.extensions
.as_ref()
@@ -323,6 +369,7 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result<Config
actual_path_separator,
max_results: opts.max_results(),
strip_cwd_prefix: opts.strip_cwd_prefix(|| !(opts.null_separator || has_command)),
ignore_contain: opts.ignore_contain,
})
}
@@ -372,7 +419,7 @@ fn determine_ls_command(colored_output: bool) -> Result<Vec<&'static str>> {
// Assume ls is GNU ls
gnu_ls("ls")
} else {
// MacOS, DragonFlyBSD, FreeBSD
// macOS, DragonFlyBSD, FreeBSD
use std::process::{Command, Stdio};
// Use GNU ls, if available (support for --color=auto, better LS_COLORS support)
@@ -479,10 +526,11 @@ fn build_regex(pattern_regex: String, config: &Config) -> Result<regex::bytes::R
.build()
.map_err(|e| {
anyhow!(
"{}\n\nNote: You can use the '--fixed-strings' option to search for a \
literal string instead of a regular expression. Alternatively, you can \
"{}\n\nNote: You can search for literal substrings with '--fixed-strings' \
or literal strings with '--exact' options (instead of a regular expression). \
Alternatively, you can \
also use the '--glob' option to match on a glob pattern.",
e.to_string()
e
)
})
}
+33 -12
View File
@@ -7,6 +7,7 @@ use crate::config::Config;
use crate::dir_entry::DirEntry;
use crate::fmt::FormatTemplate;
use crate::hyperlink::PathUrl;
use crate::sanitize::sanitize_for_term;
fn replace_path_separator(path: &str, new_path_separator: &str) -> String {
path.replace(std::path::MAIN_SEPARATOR, new_path_separator)
@@ -15,11 +16,11 @@ fn replace_path_separator(path: &str, new_path_separator: &str) -> String {
// TODO: this function is performance critical and can probably be optimized
pub fn print_entry<W: Write>(stdout: &mut W, entry: &DirEntry, config: &Config) -> io::Result<()> {
let mut has_hyperlink = false;
if config.hyperlink {
if let Some(url) = PathUrl::new(entry.path()) {
write!(stdout, "\x1B]8;;{url}\x1B\\")?;
has_hyperlink = true;
}
if config.hyperlink
&& let Some(url) = PathUrl::new(entry.path())
{
write!(stdout, "\x1B]8;;{url}\x1B\\")?;
has_hyperlink = true;
}
if let Some(ref format) = config.format {
@@ -76,7 +77,12 @@ fn print_entry_format<W: Write>(
config.path_separator.as_deref(),
);
// TODO: support writing raw bytes on unix?
write!(stdout, "{}", output.to_string_lossy())
let s = output.to_string_lossy();
write!(
stdout,
"{}",
sanitize_for_term(&s, config.interactive_terminal)
)
}
// TODO: this function is performance critical and can probably be optimized
@@ -86,7 +92,6 @@ fn print_entry_colorized<W: Write>(
config: &Config,
ls_colors: &LsColors,
) -> io::Result<()> {
// Split the path between the parent and the last component
let mut offset = 0;
let path = entry.stripped_path(config);
let path_str = path.to_string_lossy();
@@ -112,14 +117,30 @@ fn print_entry_colorized<W: Write>(
.style_for_indicator(Indicator::Directory)
.map(Style::to_nu_ansi_term_style)
.unwrap_or_default();
write!(stdout, "{}", style.paint(parent_str))?;
let safe_parent = sanitize_for_term(&parent_str, config.interactive_terminal);
// We explicitly use prefix/suffix instead of paint so that we can use Sanitized
write!(
stdout,
"{}{}{}",
style.prefix(),
safe_parent,
style.suffix()
)?;
}
let style = entry
.style(ls_colors)
.map(Style::to_nu_ansi_term_style)
.unwrap_or_default();
write!(stdout, "{}", style.paint(&path_str[offset..]))?;
let safe_basename = sanitize_for_term(&path_str[offset..], config.interactive_terminal);
// We explicitly use prefix/suffix instead of paint so that we can use Sanitized
write!(
stdout,
"{}{}{}",
style.prefix(),
safe_basename,
style.suffix()
)?;
print_trailing_slash(
stdout,
@@ -143,7 +164,8 @@ fn print_entry_uncolorized_base<W: Write>(
if let Some(ref separator) = config.path_separator {
*path_string.to_mut() = replace_path_separator(&path_string, separator);
}
write!(stdout, "{path_string}")?;
let safe = sanitize_for_term(&path_string, config.interactive_terminal);
write!(stdout, "{safe}")?;
print_trailing_slash(stdout, entry, config, None)
}
@@ -165,10 +187,9 @@ fn print_entry_uncolorized<W: Write>(
use std::os::unix::ffi::OsStrExt;
if config.interactive_terminal || config.path_separator.is_some() {
// Fall back to the base implementation
print_entry_uncolorized_base(stdout, entry, config)
} else {
// Print path as raw bytes, allowing invalid UTF-8 filenames to be passed to other processes
// Piped output: raw bytes so invalid UTF-8 filenames reach downstream tools intact.
stdout.write_all(entry.stripped_path(config).as_os_str().as_bytes())?;
print_trailing_slash(stdout, entry, config, None)
}
+1 -1
View File
@@ -1,5 +1,5 @@
use regex_syntax::hir::Hir;
use regex_syntax::ParserBuilder;
use regex_syntax::hir::Hir;
/// Determine if a regex pattern contains a literal uppercase character.
pub fn pattern_has_uppercase_char(pattern: &str) -> bool {
+176
View File
@@ -0,0 +1,176 @@
//! TTY-output sanitization to prevent terminal escape injection via filenames.
use std::fmt::{Display, Formatter, Write};
/// True for any char that is neither printable nor permitted whitespace (only HT).
/// Covers C0/C1/DEL, bidi overrides, zero-width and format chars, and tag chars.
#[inline]
fn needs_escape(c: char) -> bool {
if c == '\t' {
return false;
}
c.is_control()
|| matches!(c,
'\u{00AD}' // soft hyphen (invisible)
| '\u{180E}' // Mongolian vowel separator
| '\u{200B}'..='\u{200F}' // zero-width + LRM/RLM
| '\u{202A}'..='\u{202E}' // bidi embedding/override
| '\u{2060}'..='\u{206F}' // word joiner, invisibles, deprecated formats
| '\u{FEFF}' // BOM / zero-width no-break space
| '\u{FFF9}'..='\u{FFFB}' // interlinear annotation
| '\u{E0000}'..='\u{E007F}' // language tags
)
}
/// Write the sanitized contents of `raw` to `f`.
pub fn write_sanitized(f: &mut std::fmt::Formatter<'_>, raw: &str) -> std::fmt::Result {
// Would it be faster to do a pass to see if we don't need an escape and write
// the whole string first? Maybe with a faster check just for non-control ASCII?
for c in raw.chars() {
if needs_escape(c) {
let v = c as u32;
if v <= 0xFF {
write!(f, "\\x{v:02X}")?;
} else {
write!(f, "\\u{{{v:04X}}}")?;
}
} else {
f.write_char(c)?;
}
}
Ok(())
}
// TODO: add something to sanitize paths directly instead of as strings.
/// A wrapper type that sanitizes the output to Display
pub(crate) struct SanitizedStr<'a> {
raw: &'a str,
/// If true, the content is safe to output without sanitizing,
/// either because it is trusted, or because it isn't going to a terminal.
is_safe: bool,
}
impl<'a> Display for SanitizedStr<'a> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if self.is_safe {
// We don't need to sanitize anything, just forward
self.raw.fmt(f)
} else {
write_sanitized(f, self.raw)
}
}
}
/// Sanitize for terminal output only; raw bytes pass through on pipes/files.
pub fn sanitize_for_term(raw: &str, is_terminal: bool) -> SanitizedStr<'_> {
SanitizedStr {
raw,
is_safe: !is_terminal,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sanitize_string(s: &str) -> String {
SanitizedStr {
raw: s,
is_safe: false,
}
.to_string()
}
#[test]
fn preserves_safe_content() {
for s in [
"hello.txt",
"résumé.pdf",
"文档.txt",
"🦀.rs",
"a\tb",
"a\u{FFFD}b",
] {
assert_eq!(sanitize_string(s), s);
}
}
#[test]
fn strips_osc52_clipboard_payload() {
let attack = "innocent\x1b]52;c;cHduZWQ=\x1b\\.txt";
let safe = sanitize_string(attack);
assert!(!safe.contains('\x1b'));
assert_eq!(safe, "innocent\\x1B]52;c;cHduZWQ=\\x1B\\.txt");
}
#[test]
fn strips_cr_output_forgery() {
assert_eq!(sanitize_string("A\rFAKE OUTPUT"), "A\\x0DFAKE OUTPUT");
}
#[test]
fn strips_osc8_hyperlink_injection() {
let attack = "phish\x1b]8;;https:evil.example\x1b\\phony.txt";
assert!(!sanitize_string(attack).contains('\x1b'));
}
#[test]
fn strips_del() {
assert_eq!(sanitize_string("a\x7fb"), "a\\x7Fb");
}
#[test]
fn strips_bel_and_null() {
assert_eq!(sanitize_string("a\x07b"), "a\\x07b");
assert_eq!(sanitize_string("a\0b"), "a\\x00b");
}
#[test]
fn escape_preserves_information() {
let s = "name\x1bX\x07Y.txt";
assert_eq!(sanitize_string(s), "name\\x1BX\\x07Y.txt");
}
#[test]
fn strips_c1_csi_and_osc_initiators() {
// U+009B is CSI, U+009D is OSC; dangerous on 8-bit-control terminals.
assert_eq!(sanitize_string("\u{9b}31m"), "\\x9B31m");
assert_eq!(sanitize_string("\u{9d}0;pwned\u{9c}"), "\\x9D0;pwned\\x9C");
}
#[test]
fn strips_bidi_overrides_and_zero_width() {
// Trojan-Source style RLO/LRO that flip rendered order of filename text.
assert_eq!(
sanitize_string("safe\u{202E}fil\u{202D}gnp.exe"),
"safe\\u{202E}fil\\u{202D}gnp.exe"
);
// Zero-width space and BOM are also format chars used to disguise filenames.
assert_eq!(sanitize_string("a\u{200B}b"), "a\\u{200B}b");
assert_eq!(sanitize_string("\u{FEFF}name"), "\\u{FEFF}name");
}
#[test]
fn keeps_legitimate_unicode_features() {
// Variation selectors (U+FE0F, U+E0100..) modify preceding glyphs in CJK/emoji
// and are legitimate in filenames. Private-use chars are used by icon fonts.
for s in [
"heart\u{2764}\u{FE0F}.txt",
"icon\u{E000}.cfg",
"cjk\u{6F22}\u{E0101}.txt",
] {
assert_eq!(sanitize_string(s), s);
}
}
#[test]
fn maybe_sanitize_passthrough_when_not_terminal() {
let attack = "x\x1by";
// Pipe context: bytes pass through unchanged (zero-copy).
let out = sanitize_for_term(attack, false).to_string();
assert_eq!(out, attack);
// TTY context: escapes apply.
assert_eq!(sanitize_for_term(attack, true).to_string(), "x\\x1By");
}
}
+202 -96
View File
@@ -8,8 +8,8 @@ use std::sync::{Arc, Mutex, MutexGuard};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use crossbeam_channel::{bounded, Receiver, RecvTimeoutError, SendError, Sender};
use anyhow::{Result, anyhow};
use crossbeam_channel::{Receiver, RecvTimeoutError, SendError, Sender, bounded};
use etcetera::BaseStrategy;
use ignore::overrides::{Override, OverrideBuilder};
use ignore::{WalkBuilder, WalkParallel, WalkState};
@@ -17,9 +17,8 @@ use regex::bytes::Regex;
use crate::config::Config;
use crate::dir_entry::DirEntry;
use crate::error::print_error;
use crate::exec;
use crate::exit_codes::{merge_exitcodes, ExitCode};
use crate::exit_codes::{ExitCode, merge_exitcodes};
use crate::filesystem;
use crate::output;
@@ -218,15 +217,15 @@ impl<'a, W: Write> ReceiverBuffer<'a, W> {
}
self.num_results += 1;
if let Some(max_results) = self.config.max_results {
if self.num_results >= max_results {
return self.stop();
}
if let Some(max_results) = self.config.max_results
&& self.num_results >= max_results
{
return self.stop();
}
}
WorkerResult::Error(err) => {
if self.config.show_filesystem_errors {
print_error(err.to_string());
print_error!("{}", err);
}
}
}
@@ -250,11 +249,11 @@ impl<'a, W: Write> ReceiverBuffer<'a, W> {
/// Output a path.
fn print(&mut self, entry: &DirEntry) -> Result<(), ExitCode> {
if let Err(e) = output::print_entry(&mut self.stdout, entry, self.config) {
if e.kind() != ::std::io::ErrorKind::BrokenPipe {
print_error(format!("Could not write to output: {e}"));
return Err(ExitCode::GeneralError);
}
if let Err(e) = output::print_entry(&mut self.stdout, entry, self.config)
&& e.kind() != ::std::io::ErrorKind::BrokenPipe
{
print_error!("Could not write to output: {}", e);
return Err(ExitCode::GeneralError);
}
if self.interrupt_flag.load(Ordering::Relaxed) {
@@ -368,18 +367,18 @@ impl WorkerState {
builder.add_custom_ignore_filename(".fdignore");
}
if config.read_global_ignore {
if let Ok(basedirs) = etcetera::choose_base_strategy() {
let global_ignore_file = basedirs.config_dir().join("fd").join("ignore");
if global_ignore_file.is_file() {
let result = builder.add_ignore(global_ignore_file);
match result {
Some(ignore::Error::Partial(_)) => (),
Some(err) => {
print_error(format!("Malformed pattern in global ignore file. {err}."));
}
None => (),
if config.read_global_ignore
&& let Ok(basedirs) = etcetera::choose_base_strategy()
{
let global_ignore_file = basedirs.config_dir().join("fd").join("ignore");
if global_ignore_file.is_file() {
let result = builder.add_ignore(global_ignore_file);
match result {
Some(ignore::Error::Partial(_)) => (),
Some(err) => {
print_error!("Malformed pattern in global ignore file. {}.", err);
}
None => (),
}
}
}
@@ -389,7 +388,7 @@ impl WorkerState {
match result {
Some(ignore::Error::Partial(_)) => (),
Some(err) => {
print_error(format!("Malformed pattern in custom ignore file. {err}."));
print_error!("Malformed pattern in custom ignore file. {}.", err);
}
None => (),
}
@@ -413,8 +412,6 @@ impl WorkerState {
if cmd.in_batch_mode() {
exec::batch(rx.into_iter().flatten(), cmd, config)
} else {
let out_perm = Mutex::new(());
thread::scope(|scope| {
// Each spawned job will store its thread handle in here.
let threads = config.threads;
@@ -423,8 +420,8 @@ impl WorkerState {
let rx = rx.clone();
// Spawn a job thread that will listen for and execute inputs.
let handle = scope
.spawn(|| exec::job(rx.into_iter().flatten(), cmd, &out_perm, config));
let handle =
scope.spawn(|| exec::job(rx.into_iter().flatten(), cmd, config));
// Push the handle of the spawned thread into the vector for later joining.
handles.push(handle);
@@ -449,11 +446,12 @@ impl WorkerState {
let quit_flag = self.quit_flag.as_ref();
let mut limit = 0x100;
if let Some(cmd) = &config.command {
if !cmd.in_batch_mode() && config.threads > 1 {
// Evenly distribute work between multiple receivers
limit = 1;
}
if let Some(cmd) = &config.command
&& !cmd.in_batch_mode()
&& config.threads > 1
{
// Evenly distribute work between multiple receivers
limit = 1;
}
let mut tx = BatchSender::new(tx.clone(), limit);
@@ -462,66 +460,50 @@ impl WorkerState {
return WalkState::Quit;
}
let entry = match entry {
Ok(ref e) if e.depth() == 0 => {
if let Ok(e) = &entry {
// If the entry is a directory that contains a
// "ignore contain" file", we want to skip this
// directory.
// Check the filetype first to avoid unnecessary
// syscalls.
if e.file_type().is_some_and(|t| t.is_dir()) {
let entry_path = e.path();
if config
.ignore_contain
.iter()
.any(|ic| entry_path.join(ic).exists())
{
return WalkState::Skip;
}
}
if e.depth() == 0 {
// Skip the root directory entry.
return WalkState::Continue;
}
}
let entry = match entry {
Ok(e) => DirEntry::normal(e),
Err(ignore::Error::WithPath {
path,
err: inner_err,
}) => match inner_err.as_ref() {
ignore::Error::Io(io_error)
if io_error.kind() == io::ErrorKind::NotFound
&& path
.symlink_metadata()
.ok()
.is_some_and(|m| m.file_type().is_symlink()) =>
{
DirEntry::broken_symlink(path)
}
_ => {
return match tx.send(WorkerResult::Error(ignore::Error::WithPath {
path,
err: inner_err,
})) {
Err(err) => match broken_symlink_from_err(err) {
Ok(entry) => entry,
Err(err) => {
return match tx.send(WorkerResult::Error(err)) {
Ok(_) => WalkState::Continue,
Err(_) => WalkState::Quit,
}
};
}
},
Err(err) => {
return match tx.send(WorkerResult::Error(err)) {
Ok(_) => WalkState::Continue,
Err(_) => WalkState::Quit,
}
}
};
if let Some(min_depth) = config.min_depth {
if entry.depth().map_or(true, |d| d < min_depth) {
return WalkState::Continue;
}
if let Some(min_depth) = config.min_depth
&& entry.depth().is_none_or(|d| d < min_depth)
{
return WalkState::Continue;
}
// Check the name first, since it doesn't require metadata
let entry_path = entry.path();
let search_str: Cow<OsStr> = if config.search_full_path {
let path_abs_buf = filesystem::path_absolute_form(entry_path)
.expect("Retrieving absolute path succeeds");
Cow::Owned(path_abs_buf.as_os_str().to_os_string())
} else {
match entry_path.file_name() {
Some(filename) => Cow::Borrowed(filename),
None => unreachable!(
"Encountered file system entry without a file name. This should only \
happen for paths like 'foo/bar/..' or '/' which are not supposed to \
appear in a file system traversal."
),
}
};
let search_str = search_str_for_entry(entry_path, config.full_path_base.as_deref());
if !patterns
.iter()
@@ -542,10 +524,10 @@ impl WorkerState {
}
// Filter out unwanted file types.
if let Some(ref file_types) = config.file_types {
if file_types.should_ignore(&entry) {
return WalkState::Continue;
}
if let Some(ref file_types) = config.file_types
&& file_types.should_ignore(&entry)
{
return WalkState::Continue;
}
#[cfg(unix)]
@@ -584,24 +566,24 @@ impl WorkerState {
// Filter out unwanted modification times
if !config.time_constraints.is_empty() {
let mut matched = false;
if let Some(metadata) = entry.metadata() {
if let Ok(modified) = metadata.modified() {
matched = config
.time_constraints
.iter()
.all(|tf| tf.applies_to(&modified));
}
if let Some(metadata) = entry.metadata()
&& let Ok(modified) = metadata.modified()
{
matched = config
.time_constraints
.iter()
.all(|tf| tf.applies_to(&modified));
}
if !matched {
return WalkState::Continue;
}
}
if config.is_printing() {
if let Some(ls_colors) = &config.ls_colors {
// Compute colors in parallel
entry.style(ls_colors);
}
if config.is_printing()
&& let Some(ls_colors) = &config.ls_colors
{
// Compute colors in parallel
entry.style(ls_colors);
}
let send_result = tx.send(WorkerResult::Entry(entry));
@@ -660,6 +642,73 @@ impl WorkerState {
}
}
/// If `err` is for a broken symlink, create a broken symlink DirEntry,
/// otherwise, return the original error.
fn broken_symlink_from_err(mut err: ignore::Error) -> Result<DirEntry, ignore::Error> {
use ignore::Error::*;
let mut depth = None;
let mut path = None;
let mut current = &mut err;
// Loop through the items in the chain, and extract the path and depth as we find them.
// Then once we get to the (terminal) io error, check that it is for a broken symlink.
loop {
match current {
WithPath { err, path: p } => {
path = Some(p);
current = err;
}
WithDepth { err, depth: d } => {
depth = Some(*d);
current = err;
}
Io(e) if e.kind() == io::ErrorKind::NotFound => {
if let Some(path) = path
&& path
.symlink_metadata()
.ok()
.is_some_and(|m| m.file_type().is_symlink())
{
// Since we no longer need the original error, we can take the PathBuf of the
// path out of it.
return Ok(DirEntry::broken_symlink(mem::take(path), depth));
} else {
// This is pretty unlikely.
return Err(err);
}
}
// We don't care about Loop because that won't happen
// for broken symlinks, and there are two paths involved
// and we don't care about WithLineNumber because that only applies
// to errors while parsing ignore files.
_ => return Err(err),
}
}
}
fn search_str_for_entry<'a>(
entry_path: &'a std::path::Path,
full_path_base: Option<&std::path::Path>,
) -> Cow<'a, OsStr> {
if let Some(cwd) = full_path_base {
// If full_path_base is some, that means that we need to return
// the absolute path
if entry_path.is_absolute() {
return Cow::Borrowed(entry_path.as_os_str());
}
let path = entry_path.strip_prefix(".").unwrap_or(entry_path);
Cow::Owned(cwd.join(path).into())
} else {
match entry_path.file_name() {
Some(filename) => Cow::Borrowed(filename),
None => unreachable!(
"Encountered file system entry without a file name. This should only \
happen for paths like 'foo/bar/..' or '/' which are not supposed to \
appear in a file system traversal."
),
}
}
}
/// Recursively scan the given search path for files / pathnames matching the patterns.
///
/// If the `--exec` argument was supplied, this will create a thread pool for executing
@@ -668,3 +717,60 @@ impl WorkerState {
pub fn scan(paths: &[PathBuf], patterns: Vec<Regex>, config: Config) -> Result<ExitCode> {
WorkerState::new(patterns, config).scan(paths)
}
#[cfg(test)]
mod tests {
use super::search_str_for_entry;
use std::path::{Path, PathBuf};
#[test]
fn search_str_for_entry_with_relative_path() {
let full_path_base = Some(Path::new("/home/user"));
assert_eq!(
search_str_for_entry(Path::new("foo/bar"), full_path_base),
PathBuf::from("/home/user/foo/bar")
);
}
#[test]
fn search_str_for_entry_strips_dot_prefix() {
let full_path_base = Some(Path::new("/home/user"));
assert_eq!(
search_str_for_entry(Path::new("./foo/bar"), full_path_base),
PathBuf::from("/home/user/foo/bar")
);
}
#[test]
fn search_str_for_entry_with_absolute_path() {
let full_path_base = Some(Path::new("/home/user"));
assert_eq!(
search_str_for_entry(Path::new("/absolute/path"), full_path_base),
PathBuf::from("/absolute/path")
);
}
#[test]
fn search_str_no_base_dir() {
assert_eq!(
search_str_for_entry(Path::new("./foo/bar"), None),
PathBuf::from("bar")
);
}
#[test]
fn search_str_no_base_dir_with_plain_relative_path() {
assert_eq!(
search_str_for_entry(Path::new("foo/bar"), None),
PathBuf::from("bar")
);
}
#[test]
fn search_str_no_base_dir_with_file_in_current_dir() {
assert_eq!(
search_str_for_entry(Path::new("foo"), None),
PathBuf::from("foo")
);
}
}
+18 -12
View File
@@ -74,18 +74,8 @@ fn create_config_directory_with_global_ignore(ignore_file_content: &str) -> io::
/// Find the *fd* executable.
fn find_fd_exe() -> PathBuf {
// Tests exe is in target/debug/deps, the *fd* exe is in target/debug
let root = env::current_exe()
.expect("tests executable")
.parent()
.expect("tests executable directory")
.parent()
.expect("fd executable directory")
.to_path_buf();
let exe_name = if cfg!(windows) { "fd.exe" } else { "fd" };
root.join(exe_name)
// Read the location of the fd executable from the environment
PathBuf::from(env::var("CARGO_BIN_EXE_fd").unwrap_or(env!("CARGO_BIN_EXE_fd").to_string()))
}
/// Format an error message for when *fd* did not exit successfully.
@@ -206,6 +196,22 @@ impl TestEnv {
Ok(broken_symlink_link)
}
pub fn create_symlink<P: AsRef<Path>>(
&mut self,
target: P,
link_path: P,
) -> io::Result<PathBuf> {
let root = self.test_root();
let target_path = root.join(target);
let link_path = root.join(link_path);
#[cfg(unix)]
unix::fs::symlink(&target_path, &link_path)?;
#[cfg(windows)]
windows::fs::symlink_file(&target_path, &link_path)?;
Ok(link_path)
}
/// Get the root directory for the tests.
pub fn test_root(&self) -> PathBuf {
self.temp_dir.path().to_path_buf()
+475 -30
View File
@@ -345,7 +345,7 @@ fn test_multi_file_with_missing() {
te.assert_error(
&["a.foo", "real", "fake"],
"[fd error]: Search path 'fake' is not a directory.",
"[fd error]: Search path \"fake\" is not a directory.",
);
te.assert_output(
@@ -362,18 +362,86 @@ fn test_multi_file_with_missing() {
te.assert_error(
&["", "real", "fake1", "fake2"],
"[fd error]: Search path 'fake1' is not a directory.
[fd error]: Search path 'fake2' is not a directory.",
"[fd error]: Search path \"fake1\" is not a directory.
[fd error]: Search path \"fake2\" is not a directory.",
);
te.assert_failure_with_error(
&["", "fake1", "fake2"],
"[fd error]: Search path 'fake1' is not a directory.
[fd error]: Search path 'fake2' is not a directory.
"[fd error]: Search path \"fake1\" is not a directory.
[fd error]: Search path \"fake2\" is not a directory.
[fd error]: No valid search paths given.",
);
}
/// Without --full-path, a pattern containing '/' should always produce the
/// path-separator diagnostic, even if the pattern does not name an existing
/// directory. Before the fix for sharkdp/fd#1873 this only fired when the
/// pattern happened to resolve to a real directory, so the common typo of
/// pasting a full path silently returned zero matches.
#[test]
fn test_pattern_with_forward_slash_is_rejected() {
let te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
// Pattern that is NOT a real directory; old behaviour: no warning.
te.assert_failure_with_error(
&["nonexistent/path"],
"[fd error]: The search pattern 'nonexistent/path' contains a path-separation character and will not lead to any search results.",
);
// Pattern that IS a real directory; old behaviour: warning. Must still fire.
te.assert_failure_with_error(
&["one/two/three"],
"[fd error]: The search pattern 'one/two/three' contains a path-separation character and will not lead to any search results.",
);
}
/// --full-path is the user's explicit opt-in to regex-over-full-path matching,
/// so a path-separation character in the pattern is expected and must not
/// trigger the diagnostic.
///
/// Gated off Windows: the actual match is regex-over-the-full-path, so a
/// forward-slash pattern only matches Unix-style paths. On Windows the OS
/// uses `\` and `one/two/c` (as a literal regex) does not match a real
/// entry — the behaviour this test is pinning (the diagnostic does not
/// fire) is covered by the fact that the invocation does not error.
#[test]
#[cfg(not(windows))]
fn test_pattern_with_forward_slash_allowed_with_full_path() {
let te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
te.assert_output(
&["--full-path", "one/two/c"],
"one/two/c.foo
one/two/C.Foo2",
);
}
/// `--and` patterns are matched against the file name exactly like the primary
/// pattern, so a path separator in any of them is the same silent "no results"
/// footgun and must trigger the same diagnostic. Regression for the sibling of
/// #1873 left unchecked by #1975 (only the positional pattern was validated).
#[test]
fn test_and_pattern_with_forward_slash_is_rejected() {
let te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
// Clean primary pattern, but a `--and` pattern carrying a path separator.
te.assert_failure_with_error(
&["foo", "--and", "nonexistent/path"],
"[fd error]: The search pattern 'nonexistent/path' contains a path-separation character and will not lead to any search results.",
);
// `--full-path` is the explicit opt-in and must still suppress the
// diagnostic for `--and` patterns too (the invocation runs and matches
// instead of erroring on the path separator).
#[cfg(not(windows))]
te.assert_output(
&["--full-path", "one/two/c", "--and", "two/c"],
"one/two/c.foo
one/two/C.Foo2",
);
}
/// Explicit root path
#[test]
fn test_explicit_root_path() {
@@ -416,6 +484,13 @@ fn test_explicit_root_path() {
);
}
#[test]
fn test_single_dash_root_path() {
let te = TestEnv::new(&["-"], &["-/foo"]);
te.assert_output(&[".", "-"], "./-/foo");
}
/// Regex searches
#[test]
fn test_regex_searches() {
@@ -1133,6 +1208,207 @@ fn test_min_depth() {
);
}
/// Minimum depth with a broken symlink (regression test for #1017)
///
/// A broken symlink, surfaced while following links, has no depth reported by
/// the walker, so --min-depth used to drop it unconditionally.
#[test]
fn test_min_depth_broken_symlink() {
let mut te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
te.create_broken_symlink("one/two/broken_symlink")
.expect("Failed to create broken symlink.");
// The broken symlink sits at depth 3, so it is kept up to that depth.
te.assert_output(
&[
"--follow",
"--type",
"symlink",
"--min-depth",
"3",
"broken_symlink",
],
"one/two/broken_symlink",
);
// A --min-depth beyond its actual depth must exclude it.
te.assert_output(
&[
"--follow",
"--type",
"symlink",
"--min-depth",
"4",
"broken_symlink",
],
"",
);
}
/// Minimum depth with a broken symlink combined with --absolute-path (#1017)
///
/// With --absolute-path the search root is made absolute before walking, so the
/// broken symlink's depth must still be computed relative to that root rather
/// than from the absolute path's full component count.
#[test]
fn test_min_depth_broken_symlink_absolute_path() {
let (mut te, abs_path) = get_test_env_with_abs_path(DEFAULT_DIRS, DEFAULT_FILES);
te.create_broken_symlink("one/two/broken_symlink")
.expect("Failed to create broken symlink.");
// The broken symlink sits at depth 3 relative to the (absolute) root.
te.assert_output(
&[
"--follow",
"--absolute-path",
"--type",
"symlink",
"--min-depth",
"3",
"broken_symlink",
],
&format!("{abs_path}/one/two/broken_symlink"),
);
// A --min-depth beyond its actual depth must exclude it.
te.assert_output(
&[
"--follow",
"--absolute-path",
"--type",
"symlink",
"--min-depth",
"4",
"broken_symlink",
],
"",
);
}
/// Minimum depth with a broken symlink under overlapping search roots (#1017)
///
/// When two search roots overlap, the walker visits the same broken symlink once
/// per root, at a different depth each time. Here `one/two/broken_symlink` is at
/// depth 2 under root `one` and at depth 1 under root `one/two`, so --min-depth 2
/// must keep the entry the walker reached via `one` and drop the one it reached
/// via `one/two`. A depth derived from the entry's path cannot distinguish the
/// two visits, since both carry the same path.
#[test]
fn test_min_depth_broken_symlink_overlapping_roots() {
let mut te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
te.create_broken_symlink("one/two/broken_symlink")
.expect("Failed to create broken symlink.");
// Only the route through `one` reaches depth 2.
te.assert_output(
&[
"--follow",
"--type",
"symlink",
"--min-depth",
"2",
"broken_symlink",
"one",
"one/two",
],
"one/two/broken_symlink",
);
// Both routes clear --min-depth 1, so it is reported once per root.
te.assert_output(
&[
"--follow",
"--type",
"symlink",
"--min-depth",
"1",
"broken_symlink",
"one",
"one/two",
],
"one/two/broken_symlink
one/two/broken_symlink",
);
}
/// Maximum depth with a broken symlink (#1017)
///
/// A broken symlink must be filtered by --max-depth like any other entry. The
/// default environment also exposes it through the followed `symlink` directory
/// (`symlink -> one/two`), so it is reachable at depth 2 as well as depth 3.
#[test]
fn test_max_depth_broken_symlink() {
let mut te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
te.create_broken_symlink("one/two/broken_symlink")
.expect("Failed to create broken symlink.");
// --max-depth 3 keeps both routes to the broken symlink.
te.assert_output(
&[
"--follow",
"--type",
"symlink",
"--max-depth",
"3",
"broken_symlink",
],
"one/two/broken_symlink
symlink/broken_symlink",
);
// A --max-depth below either route must exclude it.
te.assert_output(
&[
"--follow",
"--type",
"symlink",
"--max-depth",
"1",
"broken_symlink",
],
"",
);
}
/// Exact depth with a broken symlink (#1017)
///
/// A broken symlink must be kept only at its exact depth. It is reachable at
/// depth 3 (`one/two/broken_symlink`) and, through the followed `symlink`
/// directory, at depth 2 (`symlink/broken_symlink`).
#[test]
fn test_exact_depth_broken_symlink() {
let mut te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
te.create_broken_symlink("one/two/broken_symlink")
.expect("Failed to create broken symlink.");
// Only the depth-3 route matches --exact-depth 3.
te.assert_output(
&[
"--follow",
"--type",
"symlink",
"--exact-depth",
"3",
"broken_symlink",
],
"one/two/broken_symlink",
);
// Only the depth-2 route (via the followed symlink) matches --exact-depth 2,
// which confirms the depth is computed relative to the search root.
te.assert_output(
&[
"--follow",
"--type",
"symlink",
"--exact-depth",
"2",
"broken_symlink",
],
"symlink/broken_symlink",
);
}
/// Exact depth (--exact-depth)
#[test]
fn test_exact_depth() {
@@ -1199,7 +1475,7 @@ fn test_absolute_path() {
{abs_path}/one/two/three/d.foo
{abs_path}/one/two/three/directory_foo/
{abs_path}/symlink",
abs_path = &abs_path
abs_path = abs_path
),
);
@@ -1212,7 +1488,7 @@ fn test_absolute_path() {
{abs_path}/one/two/C.Foo2
{abs_path}/one/two/three/d.foo
{abs_path}/one/two/three/directory_foo/",
abs_path = &abs_path
abs_path = abs_path
),
);
}
@@ -1231,7 +1507,7 @@ fn test_implicit_absolute_path() {
{abs_path}/one/two/C.Foo2
{abs_path}/one/two/three/d.foo
{abs_path}/one/two/three/directory_foo/",
abs_path = &abs_path
abs_path = abs_path
),
);
}
@@ -1251,7 +1527,7 @@ fn test_normalized_absolute_path() {
{abs_path}/one/two/C.Foo2
{abs_path}/one/two/three/d.foo
{abs_path}/one/two/three/directory_foo/",
abs_path = &abs_path
abs_path = abs_path
),
);
}
@@ -1485,7 +1761,7 @@ fn test_symlink_as_root() {
{dir}/one/two/three/d.foo
{dir}/one/two/three/directory_foo/
{dir}/symlink",
dir = &parent_parent
dir = parent_parent
),
);
}
@@ -1505,7 +1781,7 @@ fn test_symlink_and_absolute_path() {
{abs_path}/{expected_path}/three/
{abs_path}/{expected_path}/three/d.foo
{abs_path}/{expected_path}/three/directory_foo/",
abs_path = &abs_path,
abs_path = abs_path,
expected_path = expected_path
),
);
@@ -1523,7 +1799,7 @@ fn test_symlink_as_absolute_root() {
{abs_path}/symlink/three/
{abs_path}/symlink/three/d.foo
{abs_path}/symlink/three/directory_foo/",
abs_path = &abs_path
abs_path = abs_path
),
);
}
@@ -1547,7 +1823,7 @@ fn test_symlink_and_full_path() {
"{abs_path}/{expected_path}/three/
{abs_path}/{expected_path}/three/d.foo
{abs_path}/{expected_path}/three/directory_foo/",
abs_path = &abs_path,
abs_path = abs_path,
expected_path = expected_path
),
);
@@ -1568,7 +1844,7 @@ fn test_symlink_and_full_path_abs_path() {
"{abs_path}/symlink/three/
{abs_path}/symlink/three/d.foo
{abs_path}/symlink/three/directory_foo/",
abs_path = &abs_path
abs_path = abs_path
),
);
}
@@ -1680,6 +1956,17 @@ fn format() {
parent=one/two/three
parent=one/two/three",
);
// Templates may start with '-' (e.g. markdown list items); see #2126.
te.assert_output(
&["foo", "--format", "- {/.}", "--path-separator=/"],
"- a
- b
- C
- c
- d
- directory_foo",
);
}
/// Shell script execution (--exec)
@@ -1697,7 +1984,7 @@ fn test_exec() {
{abs_path}/one/two/c.foo
{abs_path}/one/two/three/d.foo
{abs_path}/one/two/three/directory_foo",
abs_path = &abs_path
abs_path = abs_path
),
);
@@ -1765,12 +2052,10 @@ fn test_exec() {
}
}
// TODO test for windows
#[cfg(not(windows))]
#[test]
fn test_exec_multi() {
// TODO test for windows
if cfg!(windows) {
return;
}
let (te, abs_path) = get_test_env_with_abs_path(DEFAULT_DIRS, DEFAULT_FILES);
te.assert_output(
@@ -1798,7 +2083,7 @@ fn test_exec_multi() {
test c.foo
test d.foo
test directory_foo",
abs_path = &abs_path
abs_path = abs_path
),
);
@@ -1813,9 +2098,11 @@ fn test_exec_multi() {
e1 e2",
);
// We use printf here because we need to suppress a newline and
// echo -n is not POSIX-compliant.
te.assert_output(
&[
"foo", "--exec", "echo", "-n", "{/}: ", ";", "--exec", "echo", "{//}",
"foo", "--exec", "printf", "%s", "{/}: ", ";", "--exec", "printf", "%s\\n", "{//}",
],
"a.foo: .
b.foo: ./one
@@ -1826,6 +2113,21 @@ fn test_exec_multi() {
);
}
#[cfg(not(windows))]
#[test]
fn test_exec_nulls() {
let te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
te.assert_output(
&["foo", "--print0", "--exec", "printf", "p=%s"],
"p=./a.fooNULL
p=./one/b.fooNULL
p=./one/two/C.Foo2NULL
p=./one/two/c.fooNULL
p=./one/two/three/d.fooNULL
p=./one/two/three/directory_fooNULL",
);
}
#[test]
fn test_exec_batch() {
let (te, abs_path) = get_test_env_with_abs_path(DEFAULT_DIRS, DEFAULT_FILES);
@@ -1837,7 +2139,7 @@ fn test_exec_batch() {
&["--absolute-path", "foo", "--exec-batch", "echo"],
&format!(
"{abs_path}/a.foo {abs_path}/one/b.foo {abs_path}/one/two/C.Foo2 {abs_path}/one/two/c.foo {abs_path}/one/two/three/d.foo {abs_path}/one/two/three/directory_foo",
abs_path = &abs_path
abs_path = abs_path
),
);
@@ -1891,7 +2193,7 @@ fn test_exec_batch() {
te.assert_failure_with_error(
&["foo", "--exec-batch", "echo {}"],
"error: First argument of exec-batch is expected to be a fixed executable\n\
"error: First argument of --exec-batch must be a fixed executable, not a placeholder\n\
\n\
Usage: fd [OPTIONS] [pattern] [path]...\n\
\n\
@@ -2011,6 +2313,11 @@ fn test_exec_batch_with_limit() {
/// Shell script execution (--exec) with a custom --path-separator
#[test]
fn test_exec_with_separator() {
// On Windows, `echo` is only a shell builtin unless an `echo` executable is installed.
if cfg!(windows) && std::process::Command::new("echo").output().is_err() {
return;
}
let (te, abs_path) = get_test_env_with_abs_path(DEFAULT_DIRS, DEFAULT_FILES);
te.assert_output(
&[
@@ -2120,14 +2427,57 @@ fn test_fixed_strings() {
// Regex search, parens are treated as group
te.assert_output(&["download (1)"], "");
// Literal search, parens are treated as characters
// Literal search, parens are treated as characters. Case-insensitive by default.
te.assert_output(
&["--fixed-strings", "download (1)"],
"test2/Download (1).tar.gz",
);
// Combine with --case-sensitive
// Combine with --case-sensitive (unmatched).
te.assert_output(&["--fixed-strings", "--case-sensitive", "download (1)"], "");
// Combine with --case-sensitive (matched).
te.assert_output(
&["--fixed-strings", "--case-sensitive", "Download (1)"],
"test2/Download (1).tar.gz",
);
}
/// Literal search, non-substring (--exact)
#[test]
fn test_exact_literal_nonsubstring() {
let dirs = &["test1", "test2"];
let files = &[
"test1/a.foo",
"test1/aa.foo",
"test1/a.food",
"test1/ca.food",
"test1/a_foo",
"test2/Download (1).tar.gz",
];
let te = TestEnv::new(dirs, files);
// Literal search, dot is treated as character. Should match only the exact name,
// not "aa.foo", "a.food", or "ca.food".
te.assert_output(&["--exact", "a.foo"], "test1/a.foo");
// Literal search, parens are treated as characters. Substring should not match.
te.assert_output(&["--exact", "download (1)"], "");
// Literal search, parens are treated as characters. Case-insensitive by default.
te.assert_output(
&["--exact", "download (1).tar.gz"],
"test2/Download (1).tar.gz",
);
// Combine with --case-sensitive, should not match due to case mismatch.
te.assert_output(&["--exact", "--case-sensitive", "download (1).tar.gz"], "");
// Combine with --case-sensitive, exact match should match.
te.assert_output(
&["--exact", "--case-sensitive", "Download (1).tar.gz"],
"test2/Download (1).tar.gz",
);
}
/// Filenames with invalid UTF-8 sequences
@@ -2406,7 +2756,7 @@ fn test_base_directory() {
// Ignore base directory when absolute path is used
let (te, abs_path) = get_test_env_with_abs_path(DEFAULT_DIRS, DEFAULT_FILES);
let abs_base_dir = &format!("{abs_path}/one/two/", abs_path = &abs_path);
let abs_base_dir = &format!("{abs_path}/one/two/", abs_path = abs_path);
te.assert_output(
&["--base-directory", abs_base_dir, "foo", &abs_path],
&format!(
@@ -2416,7 +2766,7 @@ fn test_base_directory() {
{abs_path}/one/two/C.Foo2
{abs_path}/one/two/three/d.foo
{abs_path}/one/two/three/directory_foo/",
abs_path = &abs_path
abs_path = abs_path
),
);
}
@@ -2450,7 +2800,7 @@ fn test_max_results() {
assert_just_one_result_with_option("--max-results=1");
assert_just_one_result_with_option("-1");
// check that --max-results & -1 conflic with --exec
// check that --max-results & -1 conflict with --exec
te.assert_failure(&["thing", "--max-results=0", "--exec=cat"]);
te.assert_failure(&["thing", "-1", "--exec=cat"]);
te.assert_failure(&["thing", "--max-results=1", "-1", "--exec=cat"]);
@@ -2506,6 +2856,20 @@ fn test_exec_invalid_utf8() {
fn test_list_details() {
let te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
// On Windows, 'fd --list-details' needs GNU 'ls'.
if cfg!(windows)
&& std::process::Command::new("ls")
.arg("--version")
.output()
.is_err()
{
te.assert_failure_with_error(
&["--list-details"],
"[fd error]: 'fd --list-details' is not supported on Windows unless GNU 'ls' is installed.",
);
return;
}
// Make sure we can execute 'fd --list-details' without any errors.
te.assert_success_and_get_output(".", &["--list-details"]);
}
@@ -2601,7 +2965,7 @@ fn test_strip_cwd_prefix() {
/// When fd is ran from a non-existent working directory, but an existent
/// directory is passed in the arguments, it should still run fine
#[test]
#[cfg(not(windows))]
#[cfg(all(not(windows), not(target_os = "illumos")))]
fn test_invalid_cwd() {
let te = TestEnv::new(&[], &[]);
@@ -2682,7 +3046,7 @@ fn test_hyperlink() {
#[cfg(unix)]
let hostname = nix::unistd::gethostname().unwrap().into_string().unwrap();
#[cfg(not(unix))]
let hostname = "";
let hostname = "/";
let expected = format!(
"\x1b]8;;file://{}{}/a.foo\x1b\\a.foo\x1b]8;;\x1b\\",
@@ -2692,3 +3056,84 @@ fn test_hyperlink() {
te.assert_output(&["--hyperlink=always", "a.foo"], &expected);
}
#[test]
fn test_ignore_contain() {
let te = TestEnv::new(
&["include", "exclude", "exclude/sub", "other"],
&[
"top",
"include/foo",
"exclude/CACHEDIR.TAG",
"exclude/sub/nope",
"other/ignoremyparent",
],
);
let expected = "include/
include/foo
symlink
top";
te.assert_output(
&[
"--ignore-contain=CACHEDIR.TAG",
"--ignore-contain=ignoremyparent",
".",
],
expected,
);
}
#[test]
fn test_ignore_contain_precedence_over_depth_check() {
let te = TestEnv::new(
&["include", "exclude", "exclude/sub"],
&[
"top",
"include/foo",
"exclude/CACHEDIR.TAG",
"exclude/sub/nope",
],
);
let expected = "include/foo";
te.assert_output(
&["--ignore-contain=CACHEDIR.TAG", "--min-depth=2", "."],
expected,
);
}
#[test]
fn test_ignore_contain_precedence_over_root_check() {
let te = TestEnv::new(&["include"], &["CACHEDIR.TAG", "top", "include/foo"]);
let expected = "";
te.assert_output(&["--ignore-contain=CACHEDIR.TAG", "."], expected);
}
// The error message is probably OS-specific.
// This is also somewhat fragile as it depends on the error message
// from creating an executable, and the debug formatting of strings
// could possibly change in the future.
#[cfg(unix)]
#[test]
fn test_sanitize_exec_error_msg() {
let mut te = TestEnv::new(&[], &[]);
te.create_broken_symlink("Hello\x1b\r World!\x7fwith\u{9b}\u{1F600}\u{200B}a\u{FEFF}b")
.expect("failed to create symlink");
te.assert_error(&["Hello", "--exec", "{}"],
"[fd error]: Command not found: \"./Hello\\u{1b}\\r World!\\u{7f}with\\u{9b}\u{1F600}\\u{200b}a\\u{feff}b\""
);
}
// Windows doesn't let us make files containing \x1b so only test on unix
#[cfg(unix)]
#[test]
fn test_sanitize_recursive_link_msg() {
let mut te = TestEnv::new(&["loop"], &[]);
te.create_symlink("loop", "loop/foo\x1bb")
.expect("failed to create symlink");
te.assert_error(
&["--follow", "--show-errors", "foo"],
"[fd error]: File system loop found: ./loop/foo\\x1Bb points to an ancestor ./loop",
);
}