Skip to content

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 21 Aug 08:02
· 9 commits to master since this release

Fixed

  • A hunk header whose ranges were separated by something other than one ASCII space — a tab, two
    spaces, or a non-ASCII whitespace character such as U+00A0 — parsed, and the header was then
    rebuilt on the way out with a plain space. A diff git calls a corrupt patch came back out as
    one git accepts, at exit 0. Such a header is now refused as malformed (exit 2), the same as any
    other header hunkpick cannot reproduce byte for byte.

  • The sign of a hunk range was not checked at all: @@ +1,3 +1,3 @@, @@ -1,3 1,3 @@,
    @@ -+1,3 +1,3 @@ and @@ -1,+3 +1,3 @@ all parsed and were rendered back as
    @@ -1,3 +1,3 @@ at exit 0, while git reads every one of them as a corrupt patch. The leading
    - and + are now required, and a range component has to be plain ASCII digits.

  • Selecting by a content id shared by several sub-hunks scaled as the square of the number of
    matches, in both time and memory. Reading every id out of list --json and passing them back
    — the documented batch flow — names such an id once per sub-hunk, and each naming both
    appended a pick per match and re-ran the collision check over every match. A diff of 8 000
    identical changes (150 KB) took 10 s and 3 GB of RSS; it is now a fraction of a second. The
    same accumulator defect was reachable through a repeated *.

  • The cap on how many indices a selector may materialise (1048576) applied to one selector
    while the number of selectors is bounded only by the length of the command line: 200 copies of
    1-1048576 — 2.6 KB of arguments against a four-sub-hunk diff — reached 1.6 GB of RSS before
    any index was compared against the diff. The cap is now one allowance for the whole
    invocation, and its message says so.

  • split now applies the same trailing-newline rule as select. A diff that arrived without
    its final newline keeps that property only while the result still ends on the line the input
    ended on, and a cut that drops the piece holding the tail does not: hunkpick split 1 --at 3
    on such a diff ended its output mid-line at exit 0, and git apply called the result a
    corrupt patch. select was taught this in 0.8.1; split was not.

  • The defect a scheduled fuzzing run found — a hunk header declaring six old lines over a body
    of three, whose selection then failed the tool's own output check — was closed by making the
    fuzz target skip such input, and nothing recorded it afterwards. The precondition is now
    stated on select, the crate example screens its input the way the CLI does, and
    select_carries_an_inconsistent_header_into_its_result holds the shape, which until now
    existed only in one machine's fuzz/artifacts/.

  • The selectors fuzz target recounts a hunk header from its body instead of skipping the
    input. Skipping cost roughly a third of the parsed stream — the mutations libFuzzer favours
    break the header-to-body correspondence more often than not — so most of what it generated no
    longer reached the selection code the target exists for.

  • Fuzz seeds are checked by tests/fuzz_seeds.rs, which found three that had stopped reaching
    the code they were written for: simple-content-id named a made-up id, two-files-path-index
    named a path absent from its own diff, and two-files-star used a bare * on a multi-file
    diff, which is a usage error. Each was silently skipped by the target rather than reported. A
    seed for the @id collision path was added, and the byte-identical parse/ and roundtrip/
    seed directories were merged into one diff/.

  • The property tests reach two shapes they could not generate before: an edit whose changed
    lines repeat (so several sub-hunks share a content id, and a selection by that id is checked
    against id_count from list --json), and a git format-patch signature after the last
    hunk (so the trailing-line shift in split_file_hunk is covered by something other than one
    example).

  • Fuzzing is run from scripts/fuzz-all.sh and scripts/fuzz-repro.sh instead of a command
    copied out of the guides. The documented command was missing mkdir -p fuzz/corpus/<target>,
    and the corpus directory is gitignored, so on a fresh clone libFuzzer refused to start; the
    scheduled CI run had already failed on exactly that and gained the step, while the guides did
    not. fuzz/seeds/README.md also omitted the nightly override and the explicit target triple
    that CONTRIBUTING.md calls mandatory, and the local command had no -timeout=10, so one hung
    input could swallow a short run's whole budget. The scripts carry all four, and a test checks
    that every script the documentation names exists and is executable.

  • Documentation no longer contradicts the tool. Two README examples still recommended
    --verify-result-diff-git on a git diff | hunkpick ... | git apply --cached pipeline after
    the README gained the paragraph explaining that such a command reports a correct result as a
    failure (exit 70); both now show a patch file against a tree at the pre-patch state, and a test
    reads the shell blocks of README.md and CONTRIBUTING.md so the shape cannot come back.
    hunkpick --help claimed "every subset applies" for @L while select --help and the README
    already listed the two exceptions. The README described the selector index ceiling as per
    selector, which it no longer is, and called two of the four lossy JSON text fields lossy.

  • Only git's own verdict now decides exit 70 for --verify-result-diff-git. git apply --check
    answers 1, and only 1, when a patch does not apply; every other status is git failing before it
    looked at the diff. A repository git refuses to read (fatal: bad config line …, exit 128) was
    reported as git apply --check rejected the result diff, and a git killed by a signal — the OOM
    killer, a timeout — produced the same sentence with nothing after the colon. Both now name the
    status and exit 74, so exit 70 keeps meaning what ADR 0013 reserves it for: a result hunkpick
    itself produced.

  • -C DIR naming something that is not a directory is reported against that argument. A mistyped
    path reaches Command::spawn as the same NotFound a missing git binary does, so both printed
    failed to run git: No such file or directory (os error 2) at exit 74 and sent the caller after
    their git installation instead of after their own typo. The path is now checked before git is
    started and named in the message at exit 2; if git still fails to start, the message says which
    directory it was to run in.

  • The one non-ASCII character in hunkpick's own output is gone. The encoding diagnostic added in
    this cycle carried an em dash, and it is addressed by construction to a Windows user, whose
    console on cp866 or cp1251 shows it as mojibake in the middle of the sentence. The guard added
    alongside it covered the help texts only, so the character came back in an error message the
    same week; it now covers every string literal of src/ outside the test modules.

  • One copy of the plumbing that runs git with a diff on its stdin. gitenv::feed_and_wait
    replaces three near-identical blocks — the result-diff check and two test helpers, two of them
    added last cycle — that had already drifted apart in how they treat a failed write. It also
    closes the failure path they shared: the child is now kept in hand and killed if waiting on it
    fails, because std::thread::scope parks until every thread it started has finished, and the
    writing thread can be blocked in a write nobody is going to read. The failure
    was a hang rather than a diagnostic, and the fallback test command documented in CONTRIBUTING
    has no per-test timeout. The stub file names that keep a developer's git configuration out of
    the test repositories moved to gitenv::insulate_config, next to the list of variables that
    was consolidated there last cycle, and tests/git_config_isolation.rs now poisons
    GIT_CONFIG_SYSTEM as well as GIT_CONFIG_GLOBAL, so dropping either one turns the suite red.

  • The crates.io token no longer reaches cargo publish --dry-run in the release workflow. A dry
    run never asks the registry for credentials, but it does run a verification build, which
    compiles the package along with the build scripts and proc macros of its dependencies — all of
    them inheriting the step's environment. A rehearsal (workflow_dispatch with an empty tag)
    builds whatever branch the dialog names, so the step handed a credential good for every crate
    of the account, and outliving any access to this repository, to code from an arbitrary branch.
    The publishing step keeps its own copy of the token.

  • The release pipeline checks the public API too. cargo semver-checks check-release was a CI
    gate only, and CI runs on branch pushes: a tag on a commit CI never saw, or one whose version
    was bumped after the last run, reached the irreversible cargo publish with the API unchecked.
    The release workflow now runs the same gate for the same reason its test, lint and MSRV jobs
    are duplicated there.

  • The documented local loop covers what CI gates. The fuzz directory is a workspace of its own,
    so the --all in cargo fmt/cargo clippy never reached it, while CI lints it as a separate
    step: a fuzz target edited locally passed every documented command and failed the pull request.
    The two commands are in CONTRIBUTING.md now, along with cargo-semver-checks as a tool the
    project expects; RELEASING.md runs the semver and MSRV checks before the release commit, and
    the README says its list is the everyday loop and points at CONTRIBUTING.md for the full set
    rather than claiming to be it.

  • RELEASING.md describes the pipeline that exists. Its table of jobs still began with test and
    attributed the tag, manifest, lock and changelog checks to publish, while they have their own
    first job (verify-metadata) that every other job waits on — the same document said so three
    sections further down, contradicting its own table, and an operator reading it would have gone
    looking in the wrong job's log. The intro counted four artefacts that must agree where there
    are five: fuzz/Cargo.lock is checked too. The job numbers in "If something fails" follow the
    table again.

  • Six test gaps this cycle opened are closed. The four byte-order marks are checked one by one,
    by name: the single test asserting the message says "UTF-16" also passed when a UTF-32LE input
    was misdiagnosed as UTF-16LE, which is the mistake the arm order guards against. An I/O failure
    on stdout is now held by a test (/dev/full, exit 74 with the diagnostic) instead of by a
    comment. The seven repository-locating variables get a case each, with a count assertion that
    fails on a name added to the list and not to the table. The differential timings in the source
    cover all six tests that draw their case count from the same place, not three, and the
    documented soak (HUNKPICK_DIFF_CASES=2000) comes with the timeout it needs — measured, the
    slowest test goes from 4.5 s to 44.5 s against a 60 s kill. The linearity check takes the
    fastest of three runs per size, so a scheduler pause on a shared runner no longer moves the
    verdict. And a property-test counterexample found in CI is uploaded as an artifact, the way a
    fuzzer crash already was: the seed lives only on the runner, and a re-run draws a new one.

  • CI jobs fetch dependencies through the retry action the release workflow has always used. A
    flaky crates.io download — the curl 55 class that cargo's own net.retry does not treat as
    spurious — failed a push run and needed a manual re-run, while the workflow that runs a few
    times a month was protected and the one that runs on every push was not.

  • The fuzz search accepts a budget it can finish. minutes had no upper bound while the job is
    killed at 90, so a larger value ended the run as a failure rather than as a completed search;
    it is now capped at 80. A manual dispatch also no longer cancels a scheduled search in
    progress: the corpus survives either way, but the search itself restarted from nothing.

  • The line-length rule in .editorconfig describes the repository again. It claimed 100 columns
    for every file while 73 lines were longer, including one added in the same cycle: rustfmt
    enforces the width for code but leaves comments and string literals alone, and nobody was
    checking those. The Rust and workflow lines are wrapped, and a test keeps them that way.
    Markdown is exempted with the reason stated — a table row is one line by definition and the
    widest here is 252 columns, and the README links by full URL because it is also the crates.io
    page — as is the one-line description in Cargo.toml.

  • HUNKPICK_DIFF_CASES, the knob for how many generated cases the differential tests run, no
    longer accepts what it cannot do. A value that does not parse was replaced by the default in
    silence, so HUNKPICK_DIFF_CASES=2OOO (letters for zeros) produced a run indistinguishable
    from the soak it was meant to be; it now says so on stderr. A value past u64::MAX / 40
    panicked inside the test's own arithmetic with "attempt to multiply with overflow", a message
    about the wrong thing entirely; the knob is bounded at a million, which no run could finish
    anyway.

  • The one place in the repository that built a command line as a string now quotes what it puts
    there. The terminal-hint test runs hunkpick under script -c, which hands its argument to a
    shell, so the build path was parsed as shell text: a space in it — a checkout under one, or a
    CARGO_TARGET_DIR pointing at one — split the command (reproduced), and a metacharacter in it
    would have been executed during an ordinary cargo test. The path is not attacker-supplied,
    so this is hardening rather than a hole.

  • "Does this release run publish?" is decided in one place. The expression stood in four
    verbatim copies — the publish step, the release notes, the Release itself and the asset upload
    job — and had already been rewritten once in all four at the same time, which is the cost of
    that arrangement measured. The tag-resolving action answers it as a publish output now, and
    the unused rehearsal output it advertised is gone: a value computed, documented and read by
    nobody sends a reader looking for the place it is used.

  • Code that clippy has no rule for. emit_selection had grown to 79 lines and kept "does the
    result end on the line the input ended on" in a variable the loop overwrote once per file,
    correct only because the last iteration wrote last — a continue added later would have left
    it describing the wrong file, and nothing downstream checks that. Building one file entry is a
    function now, and it returns the answer with the entry it belongs to; a test holds the property
    across a multi-file diff. resolve_hunk parsed its index twice and built the string for its
    error message on the success path as well. GitCheckError and FeedError implement
    Error::source, so a caller walking the chain reaches the operating system's own answer. And
    the selectors fuzz target drops its lossy non-Unix fallback: the targets are built for
    x86_64-unknown-linux-gnu, and a fallback that changes the bytes fuzzes something other than
    what the CLI passes in.

  • The banners in tests/edge_corpus.rs name the test they introduce instead of numbering it. The
    numbering broke the first time a test was added without one: from that point "test 14" stood
    above the fifteenth test, and the last cycle added both an unnumbered test and a new number in
    the same range, so two banners claimed the same position. The numbers are gone, and a test
    compares the set of banners with the set of #[test] functions, which a comment asking for the
    convention could not.

  • The CI tool pins have a way to be noticed. taiki-e/install-action is pinned by SHA and
    Dependabot bumps it weekly, but the five tool: <crate>@<version> values it is handed name a
    crates.io version inside a with: value, which no ecosystem reads — the action's version says
    nothing about the version of what it installs, and the cargo ecosystem is off by design. They
    had the same standing as the dtolnay/rust-toolchain SHAs, which RELEASING.md already refreshes
    by hand, and now they are refreshed alongside them, with the command that names the stale ones.
    cargo-about was one minor behind (0.9.1 against 0.9.2) and is up to date.

  • Every artifact upload states how long it is kept. A release archive keeps its seven days — it
    is a hand-off between two jobs of one run, and upload-assets attaches it to the Release
    afterwards — while a fuzzer's crashing input and a property-test counterexample now say 90
    rather than inheriting it: each is the only copy of an input found by a run nobody was watching.

  • A published archive can be traced back to the run that built it. Each one now carries a build
    provenance attestation alongside its .sha256, verifiable with
    gh attestation verify <archive> --repo VitalyOstanin/hunkpick. The checksum only says the
    download arrived intact, and it is served from the same Release page as the archive, so
    whoever could replace one could replace the other. Only a publishing run attests: a rehearsal
    builds whatever branch the dialog names.

  • The x86_64-apple-darwin archive is built on an Intel runner (macos-15-intel) instead of
    being cross-compiled on Apple Silicon. verify-archive.sh runs the packaged binary and checks
    the version it reports, and it can only do that for an archive built for the host — so one of
    the four published archives had never been executed by anything before it reached users.

  • The notices file inside an archive is checked for content, not just for presence.
    THIRD-PARTY-NOTICES.md is generated and gitignored, so a truncated or placeholder copy shows
    up nowhere: verify-archive.sh now requires licence sections naming the crates they cover and
    the licence texts themselves, and package-archive.sh refuses a notices file older than
    Cargo.lock, which is the shape a hand-packed archive takes when the file was left over from
    an earlier dependency set. It is also generated once for the whole release rather than once
    per archive: about.toml names every release target, so the file does not depend on the host
    that produced it, and one copy is the only way to be sure the four archives agree.

  • Trailing lines are placed by their tag even when they arrive out of order. FileDiff::trailer
    pairs each line with the number of hunks before it, and emitting walks the list once, which
    needs it ordered; the order was held by a debug_assert! alone. In the release build — the
    one anybody runs — an out-of-order entry came out after the wrong hunk at exit 0, a silently
    corrupted diff rather than a refusal. Nothing in the crate builds the list that way, but the
    field is public. emit now orders what it is given, stably, and only when the one linear scan
    that decides says it has to.

  • The same measurement is written the same way in both places that carry it. src/emit.rs and
    the 0.8.0 entry below disagreed on the size of the diff behind the trailer timing — 9 MB
    against 7 MB — and a reader cannot tell which run the number came from when it comes in two
    sizes; the code comment, written next to the measurement, keeps its figure. The three decimal
    commas among them are points, like every other number in the project.

  • One statement of why the fuzzing commands are spelled the way they are. The explanation of
    the target triple stood in CONTRIBUTING.md, in ci.yml and in both fuzz scripts; the
    satellites now point at the section that explains all four unobvious parts of the command,
    because four copies of a reason drift into four different reasons.

  • The shell scripts are checked, not just written. Seven of them carry release-critical logic —
    packaging, archive verification, the changelog and tag checks — and nothing looked at them:
    CI now runs shellcheck scripts/*.sh (preinstalled on the runner, clean today), and a test
    holds them to the four-space indent .editorconfig asks for, which shellcheck has no opinion
    about.

Changed

  • A UTF-16 stream with no byte-order mark is now named as an encoding problem instead of being
    reported as binary input. iconv -t UTF-16LE and UnicodeEncoding($false, $false) write such
    a stream, and "binary input: NUL byte found" sent the reader looking for a binary file in the
    pipeline rather than at the encoding of their own patch. The diff is still not re-encoded
    (ADR 0005) — the message says what to run.
  • Library API. split::split_patch_hunk is the entry point for splitting a hunk of a whole
    patch: it does what split_file_hunk does and additionally keeps Patch::no_trailing_newline
    honest, which a function taking one FileDiff cannot. split_file_hunk no longer panics on an
    address the patch does not have — a binary entry, or a hunk index past the end — but returns
    the new SplitError::NotATextEntry and SplitError::OutOfBounds. The CLI resolves addresses
    before it splits, so this only concerns a direct library caller; adding the two variants is a
    breaking change for a caller that matches SplitError exhaustively.
  • Library API. cli and gitenv are #[doc(hidden)]: service modules the binary and the
    test crates need (neither can see a pub(crate) item), not a contract anyone should build on.
    The cargo semver-checks gate added in 0.8.1 counts a new CLI flag as a new public field of an
    exhaustively constructible struct, so without this every release that adds a flag would have to
    be a minor one on account of a type no library user calls. parser::is_combined_marker, made
    public in 0.8.0 for a guard in the binary, is private again; the guard itself moved into the
    library as parser::looks_like_a_diff, so the list of lines that open a diff lives next to the
    code that reads them.
  • Library API. gitenv gained feed_and_wait, FeedError, insulate_config and the two
    file-name constants it uses. Additions only; nothing existing changed shape.
  • Library API. validate::GitCheckError::Spawn carries the working directory alongside the
    io::Error (Spawn { source, dir } instead of Spawn(io::Error)), and the new variant
    Failed { code, stderr } reports a git that never reached a verdict. Both are breaking changes
    for a caller that constructs or exhaustively matches GitCheckError.
  • The MSRV gate now builds on the minimum supported version it names. rust-toolchain.toml
    pins the repository to stable, and a toolchain file wins over an installed default, so both
    the CI job and the release job that publish waits on compiled on stable and reported success
    without ever seeing 1.85. Both now set RUSTUP_TOOLCHAIN first, as the fuzzing setup already
    did, and run cargo check --locked --all-targets so the dev-dependencies are compiled too —
    proptest is pinned to ~1.11 for an MSRV reason that cargo build never reached.
  • git apply --check, run for --verify-result-diff-git, now runs in the C locale. Its stderr
    is shown next to hunkpick's own ASCII-English text, so an inherited locale used to put another
    language — or U+FFFD, where the bytes were not UTF-8 — in front of the user.
  • The fuzzing dictionary and the committed seed corpus now carry bytes above ASCII: a path in
    git's quoted form with octal escapes, content that is not valid UTF-8, and the byte-order
    marks the input guard names. The header defect above was reachable only through such bytes and
    had survived fuzzing.