Skip to content

fix(patch_set): accept CRLF line endings in git extended headers - #89

Closed
Kshot3000 wants to merge 1 commit into
bmwill:masterfrom
Kshot3000:fix/patch-set-crlf-git-headers
Closed

Kshot3000 wants to merge 1 commit into
bmwill:masterfrom
Kshot3000:fix/patch-set-crlf-git-headers

Conversation

@Kshot3000

Copy link
Copy Markdown

The bug

PatchSet::parse with ParseOptions::gitdiff() fails on patch files that use CRLF line endings whenever a git extended header carries a value. GitHeader::parse extracts header values through strip_line_ending, which stripped only the trailing \n, so in a CRLF patch every extracted value keeps its \r — and a file mode then fails the exact match in FileMode::from_str:

error parsing patches at byte 0: invalid file mode: 100644\r

(the \r is invisible in most terminals, which makes the message read as if the perfectly valid 100644 were rejected).

Reproduced against diffy 0.5.2 with the new-file patch from the downstream report — LF parses fine, the identical bytes with CRLF fail:

let lf = "diff --git a/sass/ext/_true.scss b/sass/ext/_true.scss\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d3f18165a7c5601ad806777478fa35e2e601cfc0\n--- /dev/null\n+++ b/sass/ext/_true.scss\n@@ -0,0 +1,3 @@\n+@function error($error) {\n+  @error $error;\n+}\n";
let crlf = lf.replace('\n', "\r\n");
// PatchSet::parse(crlf) -> Err(InvalidFileMode("100644\r"))

The fix

strip_line_ending now also strips a trailing \r. This is the behavior the function's TODO already pointed at (GNU patch strips trailing CRs from CRLF patches), and it is scoped to git extended header lines only — hunk content lines are handled by the patch parser itself, so a patch whose content legitimately ends lines with \r is unaffected beyond the header metadata. Rename/copy paths and the diff --git fallback line get the same treatment since they flow through the same helper.

This complements #87, which fixed CRLF handling for the ---/+++ headers in the classic Patch parser; the patch_set git-header path had the same class of bug in its mode/rename/copy extraction.

Downstream report

pnpm 12 parses patch files with PatchSet::parse (pnpm/crates/patching), and pnpm/pnpm#16641 reports ERR_PNPM_INVALID_PATCH … invalid file mode: 100644 for a CRLF patch file that creates a new file — a regression from pnpm 11, whose JS parser accepted it. Fixing it here restores that for every PatchSet consumer.

Verification

  • New regression tests crlf_new_file_with_content and crlf_mode_only_change in patch_set::tests: both fail unpatched (InvalidFileMode("100644\r")), pass patched, and the parsed operations/modes match the LF equivalents exactly.
  • Full suite: cargo test --all-features — 131 lib tests pass (129 before + the 2 new), plus integration and doc tests; cargo fmt --check clean.

No bounty is attached to this — offered freely. Tips welcome: PayPal kyleblake0659@gmail.com · BTC 3GnR7TWBXAB3pPztBWpNF4LMNEX5yX8vZK · ETH 0xA1d3CEB7bD707847c3c6aB59d30D385FE8DD85Fc · SOL DLg1ua1ufewQ81J7dQomwWhXzhZreq4jEwTaQJR31Exb

GitHeader::parse extracts header values (file modes, rename/copy
paths, the diff --git line) via strip_line_ending, which stripped
only the trailing \n. In a patch file saved with CRLF line endings
(e.g. on Windows) every extracted value therefore kept its trailing
\r, and parsing a mode header failed outright:

    error parsing patches at byte 0: invalid file mode: 100644\r

Strip a trailing \r as well, mirroring how GNU patch strips trailing
CRs from CRLF patches (the behavior the TODO on strip_line_ending
already pointed at). This only affects git extended header lines;
hunk content is handled by the patch parser itself.

Downstream report: pnpm/pnpm#16641, where a CRLF patch file that
creates a new file fails with ERR_PNPM_INVALID_PATCH on pnpm 12
(pnpm parses patches with diffy's PatchSet::parse).

Tests: crlf_new_file_with_content and crlf_mode_only_change fail on
the unpatched parser with InvalidFileMode("100644\r") and pass with
this change; the full suite (131 lib tests) passes.
zkochan pushed a commit to tunglambk/pnpm that referenced this pull request Oct 8, 2026
`diffy`'s `GitHeader::parse` strips only the trailing `\n` from a git
extended header, so on a patch file saved with CRLF line endings every
value it extracts keeps its `\r`: a file mode fails the exact match, and
a rename, copy, or `diff --git` fallback path comes out with an invalid
character. `PatchSet::parse` is what `apply_patch_to_dir` uses, so a CRLF
patch that creates, deletes, or changes the mode of a file failed with
`ERR_PNPM_INVALID_PATCH` (pnpm#16641).

Strip the `\r` from those header lines before parsing. Hunk lines are
left alone: a `\r` there is content, and an LF patch for a CRLF file
depends on it being preserved.

bmwill/diffy#89 fixes the same defect upstream and is still open with no
release past 0.5.2, so the tolerance lives in the patching crate's
pre-parse layer for now, next to the existing no-newline-marker
tolerance.

pnpm 11 is not affected: `@pnpm/patch-package` trims every header value
it extracts with `.trim()`.
zkochan added a commit to pnpm/pnpm that referenced this pull request Oct 8, 2026
`diffy`'s `GitHeader::parse` strips only the trailing `\n` from a git
extended header, so on a patch file saved with CRLF line endings every
value it extracts keeps its `\r`: a file mode fails the exact match, and
a rename, copy, or `diff --git` fallback path comes out with an invalid
character. `PatchSet::parse` is what `apply_patch_to_dir` uses, so a CRLF
patch that creates, deletes, or changes the mode of a file failed with
`ERR_PNPM_INVALID_PATCH` (#16641).

Strip the `\r` from those header lines before parsing. Hunk lines are
left alone: a `\r` there is content, and an LF patch for a CRLF file
depends on it being preserved.

bmwill/diffy#89 fixes the same defect upstream and is still open with no
release past 0.5.2, so the tolerance lives in the patching crate's
pre-parse layer for now, next to the existing no-newline-marker
tolerance.

pnpm 11 is not affected: `@pnpm/patch-package` trims every header value
it extracts with `.trim()`.

close #16641

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
bmwill pushed a commit that referenced this pull request Oct 8, 2026
Previously, only the `---`/`+++` headers accepted CRLF line endings (#87).
The git extended headers kept the `\r` in their values, so in a CRLF patch
mode lines failed with `InvalidFileMode("100644\r")`, renames and copies
failed with `InvalidCharInUnquotedFilename`, and mode-only changes failed
with `InvalidDiffGitPath`. A `git format-patch` email with CRLF line
endings also kept its commit message, because the separator was matched
as `\n---\n`, so a message line starting with `diff --git` began a bogus
patch. The same bug was reported in #89.

With this commit, `Text::strip_line_ending` strips either `\n` or `\r\n`,
and every parser that reads patch syntax uses it: the git extended
headers, the `---`/`+++` filenames, the binary patch parser (which had
two hand-written copies), and the email separator. A `\r` that no `\n`
follows is not a line ending, as in #87.

Hunk lines are unchanged: they keep their `\r` as content, which matches
`git apply`, and the docs for `Line` and `PatchSet` now say so. GNU patch
instead strips the `\r` from every line of a CRLF patch.

Also make the gitdiff test helper check that the CRLF form of each input
parses the same as the LF form, and add tests for format-patch emails,
hunk content, the bytes API, and a lone `\r`.
@bmwill

bmwill commented Oct 8, 2026

Copy link
Copy Markdown
Owner

Thanks for the report and bug fix! I took the opportunity to look at CRLF handling in a few other places (and fix some other bugs that I found) and ended up fixing the bug in a slightly different way in #90.

Thanks again for helping make diffy better, I really appreciate it!

@bmwill bmwill closed this Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants