mirror of
https://github.com/lotabout/skim.git
synced 2026-09-10 07:16:23 -04:00
feat: add cargo-fuzz targets for hand-rolled text parsers (#1106)
* feat: add cargo-fuzz targets for hand-rolled text parsers
Skim's most panic-prone code is the hand-written byte/char-index
bookkeeping over untrusted input: ANSI stripping, --nth/--with-nth field
extraction, the fuzzy matching algorithms, the query->engine->match
pipeline, and the --bind key-map parser. Add five cargo-fuzz (libFuzzer)
targets covering each, asserting real invariants (char-boundary safety,
monotonic index mappings, match indices in bounds) rather than just
catching panics, plus a CI workflow that runs them on every push/PR
touching src/ or fuzz/ and a longer nightly session via cron.
* ci: wire fuzz targets into the existing test matrix
Replace the standalone fuzz.yml workflow with a `fuzz` job in the main
test.yml matrix, running each of the 5 fuzz targets for 60s (5 minutes
total per CI run) alongside nextest/clippy/msrv.
* ci: remove standalone fuzz workflow
Superseded by the fuzz job now in test.yml.
* ci: run fuzz job as a single job on the platform matrix
Reuse the existing linux/macos/windows matrix instead of a separate
per-target matrix; run all 5 fuzz targets sequentially in one step
(60s each, 5 minutes total). cargo-fuzz doesn't support Windows, so
the fuzzing step is skipped there while still installing the
toolchain for consistency with the rest of the matrix.
* ci: reuse existing yaml anchors in the fuzz job
Use *toolchain instead of a bespoke nightly-install step, matching
the coverage job's pattern of letting `cargo +nightly` auto-provision
the toolchain on demand.
* nix: add cargo-fuzz to the tests devShell
Makes cargo-fuzz available via `nix develop` alongside the other test
tooling, matching what CI installs for the fuzz job.
* ci: install cargo-fuzz via taiki-e/install-action
Matches how the other CI-only cargo subcommands (nextest, cargo-msrv,
cargo-llvm-cov) are installed, and is faster than compiling it from
source with cargo install.
* ci: force the native host target for cargo-fuzz
cargo-fuzz was picking a statically-linked musl target on the runner,
which fails since ASan can't link against a static libc. Pass the
actual host triple (from `rustc -vV`) explicitly so the sanitizer
build always targets the dynamically-linked gnu/darwin toolchain.
* ci: skip the whole fuzz job on windows
Rather than skipping just the fuzzing step, exclude the job entirely
for the windows-latest matrix entry via a job-level `if`, since
cargo-fuzz/libFuzzer has no Windows support.
* ci: gate the fuzz job with runner.os instead of matrix.os
Matches the runner.os-based conditionals already used elsewhere in
this workflow (the linux/macos dependency install steps) rather than
comparing matrix.os directly.
* ci: enable the fuzz job on windows without ASan
cargo-fuzz does support Windows, but AddressSanitizer on the MSVC
target needs the separate "C++ AddressSanitizer" VS component plus a
PATH tweak for its DLL, which this runner doesn't have configured.
Rather than skip the job, disable the sanitizer on Windows only
(--sanitizer none) and keep coverage-guided fuzzing there; our
targets assert via plain Rust panics so they don't depend on ASan.
* test: assert exact char_idx correctness in ansi_strip fuzz target
Replace the bounds-only char_idx check with an exact-equality check
against the char position of byte_pos in the original string. This
subsumes (and is stronger than) the monotonicity CodeRabbit flagged,
since strictly-increasing byte positions on char boundaries always
imply strictly-increasing char positions.
* ci: skip windows in fuzz job, scope job permissions
CI showed the Windows fuzz build fails with a real MSVC linker error
(LNK2001: unresolved __start/__stop___sancov_pcs) even with
--sanitizer none: MSVC's linker doesn't synthesize the section
boundary symbols that libFuzzer's coverage instrumentation requires,
so this is unrelated to the earlier ASan/PATH discussion and isn't
fixable by a sanitizer flag. Skip Windows via step-level `if`
(job-level `if` can't reference runner/matrix contexts). Also add an
explicit contents:read permissions block to the job.
* fix(fzy): fix unicode case-folding inconsistency causing overflow panic
The new fuzzy_match fuzz target found a real crash: FzyMatcher panicked
with "attempt to multiply with overflow" on choice="ű\0\0\0\u{1e}ű",
pattern="Űű".
Root cause: fzy_score's case-insensitive comparison used
char::to_ascii_lowercase (a no-op on non-ASCII letters like Ű/ű), while
the shared cheap_matches() prefilter (and the other matchers) use the
Unicode-aware char_equal(). This let cheap_matches accept a pattern
that fzy_score's own DP could then never actually align, since needle
char 'Ű' never matched any haystack position under ASCII-only folding.
The DP's SCORE_MIN sentinel ("impossible") isn't an absorbing element
under plain integer addition, so the broken alignment accumulated to a
value close to, but not exactly, SCORE_MIN, which then overflowed on
the final *SCORE_TO_SKIM conversion since only the exact sentinel was
special-cased.
Fix is_match to use the shared char_equal() so fzy.rs's case folding
matches cheap_matches and the other two matchers (skim.rs, clangd.rs
already do this). Also switch internal_to_skim_score to saturating_mul
as defense in depth, since fzy_score structurally always returns
Some(..) and has no other way to signal "no valid alignment" to the
caller.
* ci: try lld-link to get windows fuzzing working (no ASan)
MSVC ASan is documented broken on GitHub-hosted Windows runners
(actions/runner-images#8891 — ASan binaries crash with
STATUS_DLL_INIT_FAILED even with the runtime DLL on PATH, unresolved
upstream), so it's not viable here regardless of our config. Separately,
the sancov coverage instrumentation cargo-fuzz needs doesn't link with
MSVC's link.exe at all (missing __start/__stop section symbols).
Try switching the Windows leg to rustc's bundled LLD linker
(-C linker-flavor=lld-link -C link-self-contained=+linker) with
--sanitizer none, to at least get coverage-guided fuzzing (no ASan)
working there. Validating live against this PR's CI.
* ci: revert windows fuzzing attempt, exclude it again
The lld-link experiment ruled out the remaining option: LLD's COFF
driver hit the exact same missing __start/__stop___sancov_* symbols as
MSVC's link.exe. This confirms the section-boundary-symbol synthesis
libFuzzer's coverage instrumentation needs simply isn't implemented
for the COFF/Windows target in current LLVM/rustc — an upstream gap,
not a linker choice or CI config problem. Combined with MSVC ASan
being separately documented broken on GH-hosted Windows runners
(actions/runner-images#8891), there's no remaining avenue to try from
the workflow side. Back to excluding Windows from the fuzz job.
* ci: try windows fuzzing with default sanitizer + msvc dev env
Previous Windows attempts both used --sanitizer none, which removes
the ASan runtime that (on Windows) supplies the __start/__stop section
symbol shims libFuzzer's coverage instrumentation needs -- neither
linker synthesizes those on COFF. That's very likely why they failed
to link. Revert to the default sanitizer (address) and add
ilammy/msvc-dev-cmd to put the MSVC ASan DLL directory on PATH, per
the cargo-fuzz Windows setup guide and actions/runner-images#8891.
Testing live whether this builds, and whether the previously-reported
STATUS_DLL_INIT_FAILED runtime crash still reproduces on this runner
image.
* ci: point cargo at the real MSVC linker on windows
msvc-dev-cmd correctly set up Path, but Git Bash prepends its own
usr/bin ahead of it, so cargo picked up Git's coreutils `link`
(hardlink tool) instead of MSVC's link.exe. Set
CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER explicitly using
VCToolsInstallDir (set by msvc-dev-cmd) to sidestep PATH ordering
entirely.
* fix(event): parse_action returns None instead of panicking on missing args
The keymap_parse fuzz target found a real crash: KeyMap::from("/:if-")
panicked ("no arg specified for event if-") since parse_action's
documented behavior was to panic on if-* actions missing their
argument, even though the function already returns Option<Action> and
every other malformed/unrecognized action already resolves to None
via the surrounding parse_action_chain/KeyMap plumbing.
Fixed that case, and while checking for the same pattern elsewhere in
the function found four more reachable panics of the same kind
(add-char, execute, execute-silent, set-preview-cmd, set-query parsed
without their required argument), confirmed each panics via a small
repro before fixing. All now return None like every other malformed
action, consistent with the function's existing contract, instead of
panicking on user-supplied --bind strings.
* ci: add a single aggregate status check for branch rulesets
Add a ci-success job that depends on every other job in the workflow
and fails if any of them failed or were cancelled (tolerating
deploy-coverage-page's expected skip off master). This gives branch
protection / repository rulesets one stable check name to require,
instead of enumerating every matrix leg (nextest (linux), fuzz
(windows), etc.) individually.
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8a1f2783a8
commit
026a622a9d
76
.github/workflows/test.yml
vendored
76
.github/workflows/test.yml
vendored
|
|
@ -201,3 +201,79 @@ jobs:
|
|||
tool: cargo-msrv@0.19
|
||||
- name: MSRV Verify
|
||||
run: cargo msrv verify
|
||||
|
||||
fuzz:
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ${{matrix.os}}
|
||||
strategy:
|
||||
matrix: *matrix
|
||||
steps:
|
||||
- *checkout
|
||||
- *toolchain
|
||||
- *cache
|
||||
- name: Set up MSVC dev environment
|
||||
# Without a sanitizer, libFuzzer's coverage instrumentation fails to
|
||||
# link on Windows: neither MSVC's link.exe nor LLD's COFF driver
|
||||
# synthesize the __start/__stop section-boundary symbols it needs
|
||||
# (an ELF/Mach-O-only linker feature). MSVC's AddressSanitizer
|
||||
# runtime provides an equivalent shim for those symbols, so it's
|
||||
# required for the link to succeed at all, not just extra bug
|
||||
# detection. It needs its DLL directory on PATH at run time, which
|
||||
# this action sets up (see
|
||||
# https://rust-fuzz.github.io/book/cargo-fuzz/windows/setup.html).
|
||||
if: runner.os == 'Windows'
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-fuzz@0.13
|
||||
- name: Run fuzz targets
|
||||
shell: bash
|
||||
# 5 targets * 60s = 5 minutes of fuzzing total per run.
|
||||
# Force the actual host target: the sanitizer build can't link
|
||||
# against a statically-linked libc (e.g. if CARGO_BUILD_TARGET
|
||||
# defaults to a musl target elsewhere in the matrix).
|
||||
run: |
|
||||
host_target="$(rustc +nightly -vV | sed -n 's/^host: //p')"
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
# Git Bash's own coreutils `link` (for hardlinks) sits ahead of
|
||||
# the MSVC one on PATH within this shell, so cargo/rustc would
|
||||
# otherwise invoke the wrong `link.exe`. Point at the real MSVC
|
||||
# linker explicitly, using the dev env ilammy/msvc-dev-cmd set up.
|
||||
export CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER="${VCToolsInstallDir}bin\\HostX64\\x64\\link.exe"
|
||||
fi
|
||||
for target in ansi_strip field_extract fuzzy_match query_match keymap_parse; do
|
||||
cargo +nightly fuzz run "$target" --target "$host_target" -- -max_total_time=60
|
||||
done
|
||||
- name: Upload crash artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fuzz-crashes-${{ matrix.build }}
|
||||
path: fuzz/artifacts/
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Single aggregate status check for branch protection / repository rulesets,
|
||||
# so they don't need to enumerate every matrix leg by name individually.
|
||||
ci-success:
|
||||
name: CI Success
|
||||
if: always()
|
||||
needs:
|
||||
- nextest
|
||||
- coverage
|
||||
- deploy-coverage-page
|
||||
- clippy
|
||||
- rustfmt
|
||||
- clippy-no-default-features
|
||||
- msrv
|
||||
- fuzz
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check all required jobs succeeded
|
||||
run: |
|
||||
echo "${{ toJson(needs) }}"
|
||||
if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" || "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
|
||||
echo "One or more required jobs failed or were cancelled."
|
||||
exit 1
|
||||
fi
|
||||
echo "All required jobs passed (deploy-coverage-page is expected to skip off master)."
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
2. Run: `TSAN_OPTIONS="detect_deadlocks=1" cargo +nightly nextest run --profile tsan --target x86_64-unknown-linux-gnu`
|
||||
- Lint: `cargo clippy`
|
||||
- Format: `cargo +nightly fmt` (check only: `cargo +nightly fmt --check`)
|
||||
- Fuzz (requires nightly + `cargo install cargo-fuzz`): `cargo +nightly fuzz run <target>` — see `fuzz/README.md` for target list
|
||||
|
||||
## Code Style
|
||||
- Format with 120 char line width (defined in .rustfmt.toml)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
cargo-nextest
|
||||
cargo-insta
|
||||
cargo-llvm-cov
|
||||
cargo-fuzz
|
||||
tmux
|
||||
];
|
||||
utils = with pkgs; [
|
||||
|
|
|
|||
4
fuzz/.gitignore
vendored
Normal file
4
fuzz/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
target
|
||||
corpus
|
||||
artifacts
|
||||
coverage
|
||||
2416
fuzz/Cargo.lock
generated
Normal file
2416
fuzz/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
52
fuzz/Cargo.toml
Normal file
52
fuzz/Cargo.toml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
[package]
|
||||
name = "skim-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2024"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
arbitrary = { version = "1", features = ["derive"] }
|
||||
libfuzzer-sys = "0.4"
|
||||
regex = "1"
|
||||
|
||||
[dependencies.skim]
|
||||
path = ".."
|
||||
default-features = false
|
||||
|
||||
[[bin]]
|
||||
name = "ansi_strip"
|
||||
path = "fuzz_targets/ansi_strip.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "field_extract"
|
||||
path = "fuzz_targets/field_extract.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzzy_match"
|
||||
path = "fuzz_targets/fuzzy_match.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "query_match"
|
||||
path = "fuzz_targets/query_match.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "keymap_parse"
|
||||
path = "fuzz_targets/keymap_parse.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
57
fuzz/README.md
Normal file
57
fuzz/README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Fuzzing
|
||||
|
||||
This directory contains [`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)
|
||||
(libFuzzer) targets for skim's hand-written, untrusted-input-facing parsers:
|
||||
text that flows in from stdin, `--ansi` sequences, `--nth`/`--with-nth` field
|
||||
specs, the search query syntax, and `--bind` key maps. These are exactly the
|
||||
places where skim does manual byte/char-index bookkeeping on attacker- or
|
||||
data-controlled strings, which is the most panic-prone code in the project.
|
||||
|
||||
## Targets
|
||||
|
||||
| Target | Exercises |
|
||||
|-------------------|----------------------------------------------------------------------------|
|
||||
| `ansi_strip` | `helper::item::strip_ansi` — ANSI escape stripping & byte/char index map |
|
||||
| `field_extract` | `field::{FieldRange, get_string_by_field, parse_matching_fields, parse_transform_fields}` — `--nth`/`--with-nth` |
|
||||
| `fuzzy_match` | `fuzzy_matcher::{skim, fzy, clangd}` — the fuzzy matching algorithms |
|
||||
| `query_match` | `Matcher::create_engine_factory` + `DefaultSkimItem` — the full query → engine → match pipeline (exact/regex/AND-OR/fuzzy, with ANSI) |
|
||||
| `keymap_parse` | `binds::KeyMap` — the `--bind` key-map parser |
|
||||
|
||||
Each target asserts more than "doesn't panic" where a cheap invariant is
|
||||
available (e.g. reported match indices must be valid char indices into the
|
||||
matched text, index mappings must stay monotonic and land on char
|
||||
boundaries).
|
||||
|
||||
## Running
|
||||
|
||||
Install `cargo-fuzz` (requires a nightly toolchain):
|
||||
|
||||
```sh
|
||||
cargo install cargo-fuzz
|
||||
```
|
||||
|
||||
Run a target:
|
||||
|
||||
```sh
|
||||
cargo +nightly fuzz run ansi_strip
|
||||
```
|
||||
|
||||
Run for a bounded time (useful in CI or for a quick check):
|
||||
|
||||
```sh
|
||||
cargo +nightly fuzz run query_match -- -max_total_time=60
|
||||
```
|
||||
|
||||
## Reproducing a crash
|
||||
|
||||
`cargo fuzz run` writes failing inputs to `fuzz/artifacts/<target>/`. Replay one with:
|
||||
|
||||
```sh
|
||||
cargo +nightly fuzz run <target> fuzz/artifacts/<target>/crash-<hash>
|
||||
```
|
||||
|
||||
## Adding a target
|
||||
|
||||
Add a new `fuzz_targets/<name>.rs`, register it in `fuzz/Cargo.toml`'s
|
||||
`[[bin]]` list, and prefer asserting a real invariant of the function under
|
||||
test (bounds, monotonicity, round-tripping) rather than only catching panics.
|
||||
37
fuzz/fuzz_targets/ansi_strip.rs
Normal file
37
fuzz/fuzz_targets/ansi_strip.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use skim::helper::item::strip_ansi;
|
||||
|
||||
// `strip_ansi` hand-parses ESC sequences while tracking a byte/char index
|
||||
// mapping back to the original string. It is run on every line read from
|
||||
// stdin when `--ansi` is set, so it must never panic on adversarial input
|
||||
// and the mapping it returns must stay internally consistent.
|
||||
fuzz_target!(|input: &str| {
|
||||
let (stripped, mapping) = strip_ansi(input);
|
||||
|
||||
assert_eq!(
|
||||
mapping.len(),
|
||||
stripped.chars().count(),
|
||||
"mapping length must match the number of chars in the stripped string"
|
||||
);
|
||||
|
||||
let mut prev_byte_pos = None;
|
||||
for &(byte_pos, char_idx) in &mapping {
|
||||
assert!(
|
||||
input.is_char_boundary(byte_pos),
|
||||
"byte_pos {byte_pos} is not a char boundary in the original string"
|
||||
);
|
||||
// char_idx must be exactly the char position of byte_pos in the
|
||||
// original string; this is stronger than (and implies) monotonicity.
|
||||
let expected_char_idx = input[..byte_pos].chars().count();
|
||||
assert_eq!(
|
||||
char_idx, expected_char_idx,
|
||||
"char_idx must equal the char position of byte_pos in the original string"
|
||||
);
|
||||
if let Some(prev) = prev_byte_pos {
|
||||
assert!(prev < byte_pos, "byte positions in mapping must be strictly increasing");
|
||||
}
|
||||
prev_byte_pos = Some(byte_pos);
|
||||
}
|
||||
});
|
||||
47
fuzz/fuzz_targets/field_extract.rs
Normal file
47
fuzz/fuzz_targets/field_extract.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#![no_main]
|
||||
|
||||
use arbitrary::Arbitrary;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use regex::Regex;
|
||||
use skim::field::{FieldRange, get_string_by_field, parse_matching_fields, parse_transform_fields};
|
||||
|
||||
// Fuzzes the --nth/--with-nth field range parser and extractor, which slices
|
||||
// arbitrary user-supplied text on an arbitrary user-supplied delimiter regex.
|
||||
#[derive(Arbitrary, Debug)]
|
||||
struct FieldFuzzInput<'a> {
|
||||
delimiter_pattern: &'a str,
|
||||
text: &'a str,
|
||||
range_specs: Vec<&'a str>,
|
||||
}
|
||||
|
||||
fuzz_target!(|input: FieldFuzzInput| {
|
||||
// Bound the delimiter pattern length so we spend fuzzing time on the
|
||||
// field logic rather than on the regex engine's own parser.
|
||||
if input.delimiter_pattern.len() > 32 {
|
||||
return;
|
||||
}
|
||||
let Ok(delimiter) = Regex::new(input.delimiter_pattern) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let fields: Vec<FieldRange> = input
|
||||
.range_specs
|
||||
.iter()
|
||||
.filter_map(|s| FieldRange::from_str(s))
|
||||
.collect();
|
||||
|
||||
// `fields` can repeat/overlap ranges, so the transformed text is not
|
||||
// bounded by the input length; just check it doesn't panic.
|
||||
let _ = parse_transform_fields(&delimiter, input.text, &fields);
|
||||
|
||||
for (begin, end) in parse_matching_fields(&delimiter, input.text, &fields) {
|
||||
assert!(begin <= end, "field range must not be inverted");
|
||||
assert!(end <= input.text.len(), "field range must stay within the text");
|
||||
// Slicing must not panic: begin/end must land on char boundaries.
|
||||
let _ = &input.text[begin..end];
|
||||
}
|
||||
|
||||
for field in &fields {
|
||||
let _ = get_string_by_field(&delimiter, input.text, field);
|
||||
}
|
||||
});
|
||||
39
fuzz/fuzz_targets/fuzzy_match.rs
Normal file
39
fuzz/fuzz_targets/fuzzy_match.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#![no_main]
|
||||
|
||||
use arbitrary::Arbitrary;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use skim::fuzzy_matcher::FuzzyMatcher;
|
||||
use skim::fuzzy_matcher::clangd::ClangdMatcher;
|
||||
use skim::fuzzy_matcher::fzy::FzyMatcher;
|
||||
use skim::fuzzy_matcher::skim::SkimMatcherV2;
|
||||
|
||||
// Fuzzes the fuzzy matching algorithms directly on arbitrary unicode
|
||||
// (choice, pattern) pairs. These run a lot of hand-written index/DP-matrix
|
||||
// arithmetic over `char` boundaries, so they're prone to panics (overflow,
|
||||
// out-of-bounds) on adversarial unicode input, and the returned match
|
||||
// indices must always be valid character indices into `choice`.
|
||||
#[derive(Arbitrary, Debug)]
|
||||
struct MatchInput<'a> {
|
||||
choice: &'a str,
|
||||
pattern: &'a str,
|
||||
}
|
||||
|
||||
fuzz_target!(|input: MatchInput| {
|
||||
let skim_matcher = SkimMatcherV2::default();
|
||||
let fzy_matcher = FzyMatcher::default();
|
||||
let clangd_matcher = ClangdMatcher::default();
|
||||
|
||||
let matchers: [&dyn FuzzyMatcher; 3] = [&skim_matcher, &fzy_matcher, &clangd_matcher];
|
||||
|
||||
let num_chars = input.choice.chars().count();
|
||||
for matcher in matchers {
|
||||
if let Some((_score, indices)) = matcher.fuzzy_indices(input.choice, input.pattern) {
|
||||
for &idx in &indices {
|
||||
assert!(
|
||||
idx < num_chars,
|
||||
"match index {idx} out of bounds for choice with {num_chars} chars"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
10
fuzz/fuzz_targets/keymap_parse.rs
Normal file
10
fuzz/fuzz_targets/keymap_parse.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use skim::binds::KeyMap;
|
||||
|
||||
// Fuzzes the `--bind` key-map parser, which splits an arbitrary user-supplied
|
||||
// string on commas and colons to build key/action bindings.
|
||||
fuzz_target!(|input: &str| {
|
||||
let _ = KeyMap::from(input);
|
||||
});
|
||||
58
fuzz/fuzz_targets/query_match.rs
Normal file
58
fuzz/fuzz_targets/query_match.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
#![no_main]
|
||||
|
||||
use arbitrary::Arbitrary;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use regex::Regex;
|
||||
use skim::helper::item::DefaultSkimItem;
|
||||
use skim::matcher::Matcher;
|
||||
use skim::{SkimItem, SkimOptions};
|
||||
|
||||
// End-to-end fuzz of the query -> engine -> match pipeline: builds a real
|
||||
// `DefaultSkimItem` (exercising ANSI stripping / field transforms) and
|
||||
// matches it with an engine built the same way skim builds it from CLI
|
||||
// options (exact/regex/andor/fuzzy-algorithm wrapping), using an arbitrary
|
||||
// query string. Checks that matching never panics and that any reported
|
||||
// match range stays within the bounds of the text that was actually matched.
|
||||
#[derive(Arbitrary, Debug)]
|
||||
struct QueryInput<'a> {
|
||||
query: &'a str,
|
||||
text: &'a str,
|
||||
exact: bool,
|
||||
regex: bool,
|
||||
ansi: bool,
|
||||
case: u8,
|
||||
}
|
||||
|
||||
fuzz_target!(|input: QueryInput| {
|
||||
// The regex engine path takes the query as a user-supplied pattern; keep
|
||||
// it short so fuzzing time goes into skim's logic, not regex parsing.
|
||||
if input.regex && input.query.len() > 32 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut options = SkimOptions::default();
|
||||
options.exact = input.exact;
|
||||
options.regex = input.regex;
|
||||
options.case = match input.case % 3 {
|
||||
0 => skim::CaseMatching::Respect,
|
||||
1 => skim::CaseMatching::Ignore,
|
||||
_ => skim::CaseMatching::Smart,
|
||||
};
|
||||
|
||||
let factory = Matcher::create_engine_factory(&options);
|
||||
let engine = factory.create_engine_with_case(input.query, options.case);
|
||||
|
||||
let delimiter = Regex::new(" ").unwrap();
|
||||
let item = DefaultSkimItem::new(input.text, input.ansi, &[], &[], &delimiter);
|
||||
|
||||
if let Some(result) = engine.match_item(&item) {
|
||||
let matched_text = item.text();
|
||||
let num_chars = matched_text.chars().count();
|
||||
for idx in result.range_char_indices(&matched_text) {
|
||||
assert!(
|
||||
idx <= num_chars,
|
||||
"matched char index {idx} out of bounds ({num_chars} chars)"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -40,7 +40,7 @@ use std::cell::RefCell;
|
|||
|
||||
use thread_local::ThreadLocal;
|
||||
|
||||
use crate::fuzzy_matcher::util::cheap_matches;
|
||||
use crate::fuzzy_matcher::util::{char_equal, cheap_matches};
|
||||
use crate::fuzzy_matcher::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -123,20 +123,8 @@ fn precompute_bonus(haystack: &[char]) -> Vec<i64> {
|
|||
}
|
||||
|
||||
#[inline]
|
||||
fn is_match(
|
||||
needle: &[char],
|
||||
haystack: &[char],
|
||||
lower_needle: &[char],
|
||||
lower_haystack: &[char],
|
||||
case_sensitive: bool,
|
||||
i: usize,
|
||||
j: usize,
|
||||
) -> bool {
|
||||
if case_sensitive {
|
||||
needle[i] == haystack[j]
|
||||
} else {
|
||||
lower_needle[i] == lower_haystack[j]
|
||||
}
|
||||
fn is_match(needle: &[char], haystack: &[char], case_sensitive: bool, i: usize, j: usize) -> bool {
|
||||
char_equal(needle[i], haystack[j], case_sensitive)
|
||||
}
|
||||
|
||||
/// Core fzy scoring without typos.
|
||||
|
|
@ -162,8 +150,6 @@ fn fzy_score(
|
|||
return Some(SCORE_MAX);
|
||||
}
|
||||
|
||||
let lower_needle: Vec<char> = needle.iter().map(char::to_ascii_lowercase).collect();
|
||||
let lower_haystack: Vec<char> = haystack.iter().map(char::to_ascii_lowercase).collect();
|
||||
let match_bonus = precompute_bonus(haystack);
|
||||
|
||||
if positions.is_some() {
|
||||
|
|
@ -176,7 +162,7 @@ fn fzy_score(
|
|||
let mut prev_score = SCORE_MIN;
|
||||
let gap = if n == 1 { SCORE_GAP_TRAILING } else { SCORE_GAP_INNER };
|
||||
for j in 0..m {
|
||||
if is_match(needle, haystack, &lower_needle, &lower_haystack, case_sensitive, 0, j) {
|
||||
if is_match(needle, haystack, case_sensitive, 0, j) {
|
||||
let score = i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + match_bonus[j];
|
||||
d_matrix[0][j] = score;
|
||||
prev_score = score;
|
||||
|
|
@ -197,7 +183,7 @@ fn fzy_score(
|
|||
SCORE_GAP_INNER
|
||||
};
|
||||
for j in 0..m {
|
||||
if is_match(needle, haystack, &lower_needle, &lower_haystack, case_sensitive, i, j) {
|
||||
if is_match(needle, haystack, case_sensitive, i, j) {
|
||||
let mut score = SCORE_MIN;
|
||||
if j > 0 {
|
||||
let prev_m = m_matrix[i - 1][j - 1];
|
||||
|
|
@ -259,7 +245,7 @@ fn fzy_score(
|
|||
let old_d = d_row[j];
|
||||
let old_m = m_row[j];
|
||||
|
||||
if is_match(needle, haystack, &lower_needle, &lower_haystack, case_sensitive, i, j) {
|
||||
if is_match(needle, haystack, case_sensitive, i, j) {
|
||||
let score = if i == 0 {
|
||||
i64::try_from(j).unwrap_or(i64::MAX) * SCORE_GAP_LEADING + match_bonus[j]
|
||||
} else if j > 0 {
|
||||
|
|
@ -687,7 +673,11 @@ fn internal_to_skim_score(score: i64) -> ScoreType {
|
|||
} else if score == SCORE_MIN {
|
||||
ScoreType::MIN / 2
|
||||
} else {
|
||||
score * SCORE_TO_SKIM
|
||||
// Saturate rather than panic: the DP sentinel (SCORE_MIN) can end up
|
||||
// offset by a few accumulated bonuses/penalties along a path that's
|
||||
// still effectively "no match" (see the fuzz-found case-folding bug
|
||||
// this fixed), landing close to but not exactly on SCORE_MIN/SCORE_MAX.
|
||||
score.saturating_mul(SCORE_TO_SKIM)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -300,9 +300,8 @@ pub enum Action {
|
|||
|
||||
/// Parses an action string into an Action enum
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if an `if-*` action is specified without its required argument.
|
||||
/// Returns `None` if the action is unrecognized, or an `if-*` action is
|
||||
/// specified without its required argument.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
#[must_use]
|
||||
pub fn parse_action(raw_action: &str) -> Option<Action> {
|
||||
|
|
@ -324,7 +323,7 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
|
|||
let then_arg;
|
||||
let mut otherwise_arg = None;
|
||||
|
||||
let if_arg = arg.unwrap_or_else(|| panic!("no arg specified for event {action}"));
|
||||
let if_arg = arg?;
|
||||
if if_arg.contains('+') {
|
||||
let split = if_arg.split_once('+');
|
||||
match split {
|
||||
|
|
@ -346,18 +345,19 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
|
|||
"if-query-not-empty" => Some(Action::IfQueryNotEmpty(then_arg, otherwise_arg)),
|
||||
_ => None,
|
||||
}
|
||||
} else if matches!(
|
||||
action,
|
||||
"add-char" | "execute" | "execute-silent" | "set-preview-cmd" | "set-query"
|
||||
) && arg.is_none()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
exhaustive_match! {
|
||||
action => Option<Action>;
|
||||
{
|
||||
"abort" => Some(Abort),
|
||||
"accept" => Some(Accept(arg)),
|
||||
"add-char" => Some(AddChar(
|
||||
arg.unwrap_or_default()
|
||||
.chars()
|
||||
.next()
|
||||
.expect("add-char should have an argument"),
|
||||
)),
|
||||
"add-char" => Some(AddChar(arg.unwrap_or_default().chars().next().unwrap_or_default())),
|
||||
"append-and-select" => Some(AppendAndSelect),
|
||||
"backward-char" => Some(BackwardChar),
|
||||
"backward-delete-char" => Some(BackwardDeleteChar),
|
||||
|
|
@ -372,8 +372,8 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
|
|||
"deselect-all" => Some(DeselectAll),
|
||||
"down" => Some(Down(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"end-of-line" => Some(EndOfLine),
|
||||
"execute" => Some(Execute(arg.expect("execute event should have argument"))),
|
||||
"execute-silent" => Some(ExecuteSilent(arg.expect("execute-silent event should have argument"))),
|
||||
"execute" => Some(Execute(arg.unwrap_or_default())),
|
||||
"execute-silent" => Some(ExecuteSilent(arg.unwrap_or_default())),
|
||||
"first" => Some(First),
|
||||
"forward-char" => Some(ForwardChar),
|
||||
"forward-word" => Some(ForwardWord),
|
||||
|
|
@ -405,8 +405,8 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
|
|||
"select-all" => Some(SelectAll),
|
||||
"select-row" => Some(SelectRow(arg.and_then(|s| s.parse().ok()).unwrap_or_default())),
|
||||
"set-header" => Some(SetHeader(arg)),
|
||||
"set-preview-cmd" => Some(SetPreviewCmd(arg.expect("set-preview-cmd action needs a value"))),
|
||||
"set-query" => Some(SetQuery(arg.expect("set-query action needs a value"))),
|
||||
"set-preview-cmd" => Some(SetPreviewCmd(arg.unwrap_or_default())),
|
||||
"set-query" => Some(SetQuery(arg.unwrap_or_default())),
|
||||
"toggle" => Some(Toggle),
|
||||
"toggle-all" => Some(ToggleAll),
|
||||
"toggle-in" => Some(ToggleIn),
|
||||
|
|
|
|||
Loading…
Reference in a new issue