git_xcrypt/rules/eol.rs
1//! Line endings — the part git normally does, which we have to do ourselves.
2//!
3//! Git converts on the far side of the filter, so for an encrypted path it would
4//! always hit ciphertext rather than content. The conversion therefore moves
5//! here, and it moves **asymmetrically**:
6//!
7//! * clean never reads git's configuration. The same file has to yield the same
8//! plaintext on every machine, or the ciphertext differs and determinism dies.
9//! * smudge does read it. That is the one moment where machines are allowed to
10//! differ, and where they should — but only when the configuration asks. Since
11//! 2026-08-11 the case where nothing asks writes the stored bytes back
12//! unchanged rather than the platform's own ending, so two machines differ
13//! because someone chose it and not by default; see [`resolve_output`].
14//!
15//! Whether a file was normalised at all is recorded in the header's flag bit, so
16//! smudge never has to ask `.git-xcrypt` — which also removes a real race, since
17//! git does not promise to write `.git-xcrypt` before the files it filters.
18
19use crate::rules::declaration::{EolMode, TextMode};
20
21/// Whether git — and therefore we — would treat this content as binary.
22///
23/// A byte-for-byte port of git's `gather_stats` plus `convert_is_binary`,
24/// measured against git 2.55 rather than taken from documentation. Binary means
25/// a NUL byte anywhere, a lone `CR` — one not followed by `LF` — or too many
26/// disallowed control characters relative to printable ones.
27///
28/// The three details that make it a port rather than an approximation, each of
29/// which git-xcrypt got wrong before and each of which moves real files across
30/// the boundary:
31///
32/// * `CR` and `LF` are counted as line endings and go into **neither** bucket;
33/// * `DEL` (`0x7f`) counts as non-printable, despite being above `0x20`;
34/// * of the bytes below `0x20` only `BS`, `TAB`, `FF` and `ESC` are forgiven;
35/// * a **trailing `SUB`** (`0x1a`, the DOS end-of-file marker) is taken back off
36/// the non-printable count after the scan. One byte, only the last one.
37///
38/// Bytes at or above `0x80` count as printable, which is why UTF-8 text is
39/// recognised as text. The whole content is scanned; the 8000-byte window
40/// belongs to a different heuristic, the one `git diff` uses to print
41/// `Binary files differ`.
42///
43/// The lone-`CR` rule is not decoration. Without it, content such as
44/// `a\r\r\nb` normalises to `a\r\nb`, which normalises again to `a\nb` — so the
45/// conversion is not closed over its own output, the working tree comes back
46/// different after a checkout and `git status` reports a file nobody edited.
47/// Git avoids that the same way, by declining to convert at all.
48///
49/// This rule is **frozen with the format from 2026-08-04**, and not before: the
50/// trailing-`SUB` correction landed on that date (roadmap S-08), deliberately
51/// ahead of the first release. Changing it moves the text/binary boundary, so
52/// every file that crosses the boundary encrypts differently — after a release
53/// that stops being a fix and becomes a new `suite`.
54#[must_use]
55pub fn looks_binary(content: &[u8]) -> bool {
56 let mut printable = 0usize;
57 let mut nonprintable = 0usize;
58
59 for (index, &byte) in content.iter().enumerate() {
60 match byte {
61 // CR and LF are counted as line endings and land in neither
62 // bucket. Counting them as printable would inflate the left side
63 // of the ratio below and call binary content text.
64 b'\r' => {
65 if content.get(index + 1) != Some(&b'\n') {
66 return true;
67 }
68 }
69 b'\n' => {}
70 0 => return true,
71 // BS, TAB, FF and ESC are the control bytes git forgives.
72 0x08 | b'\t' | 0x0c | 0x1b => printable += 1,
73 // DEL counts against the content, same as the other controls.
74 0x01..0x20 | 0x7f => nonprintable += 1,
75 _ => printable += 1,
76 }
77 }
78
79 // git's `gather_stats` closes with this, verbatim:
80 //
81 // /* If file ends with EOF then don't count this EOF as non-printable. */
82 // if (size >= 1 && buf[size-1] == '\032')
83 // stats->nonprintable--;
84 //
85 // A `SUB` is below 0x20 and is not one of the four forgiven bytes, so the
86 // loop above has always counted it; this takes exactly one back. Measured on
87 // git 2.55: with `* text=auto`, `a\r\n\x1a` is stored as `61 0a 1a` — git
88 // normalised the CRLF, so it read the file as text. `saturating_sub` rather
89 // than `-`: a debug build's overflow panic on the filter path would abort
90 // every git operation in the repository, and a file that is nothing but a
91 // `SUB` reaches zero here.
92 if content.last() == Some(&0x1a) {
93 nonprintable = nonprintable.saturating_sub(1);
94 }
95
96 (printable >> 7) < nonprintable
97}
98
99/// Whether the content should be normalised, given its declared mode.
100///
101/// Unlike git, the answer never depends on the index. Git keeps CRLF for a file
102/// that entered the repository with it, even after `core.autocrlf` is switched
103/// on — which makes its verdict a function of history, not content. Ours has to
104/// be a pure function of content or the same file encrypts differently depending
105/// on where it has been.
106#[must_use]
107pub fn should_normalise(mode: TextMode, content: &[u8]) -> bool {
108 match mode {
109 TextMode::Text => true,
110 TextMode::Binary => false,
111 TextMode::Auto => !looks_binary(content),
112 }
113}
114
115/// Replaces every `CRLF` with `LF`.
116///
117/// A lone `CR` is left alone: it is not a line ending git would have produced,
118/// and rewriting it would corrupt content that merely happens to contain the
119/// byte.
120///
121/// **Not idempotent in general** — `\r\r\n` collapses to `\r\n` and would
122/// collapse again to `\n` on a second pass, exactly as git's own conversion
123/// does. Under `text=auto`, which is the default and so the mode almost every
124/// path is in, that never happens: content carrying a lone `CR` is classified
125/// binary by [`looks_binary`] and is never normalised at all.
126///
127/// **An explicit `text` bypasses that classifier**, so a path declared
128/// `secrets/*.sh text` whose content holds `\r\r\n` does not round-trip: clean
129/// stores `\r\n`, smudge writes it back, and the next clean collapses it again,
130/// so `git status` reports the file as modified until it is added again — which
131/// stores the collapsed bytes. Git does the same thing with an explicit `text`
132/// attribute, and measured on 2.55 it does **not** warn about it even with
133/// `core.safecrlf=warn`.
134///
135/// That is not the only shape, and not the worst one. Mixed `CRLF` and lone `LF`
136/// is normalised under plain `text=auto` too, so the default mode loses the
137/// distinction as well — and there `git status` stays *clean* while the working
138/// tree changes, because the new bytes normalise to the same plaintext.
139/// [`survives_the_round_trip`] answers for both, and the filter warns; Open
140/// Decision 8 in `context/foundation/zalozenia.md` closed on 2026-08-06.
141/// Recorded here rather than claimed away, because an earlier version of this
142/// comment asserted the invariant held everywhere.
143#[must_use]
144pub fn normalise_to_lf(content: &[u8]) -> Vec<u8> {
145 let mut out = Vec::with_capacity(content.len());
146 let mut index = 0;
147
148 while index < content.len() {
149 if content[index] == b'\r' && content.get(index + 1) == Some(&b'\n') {
150 out.push(b'\n');
151 index += 2;
152 } else {
153 out.push(content[index]);
154 index += 1;
155 }
156 }
157
158 out
159}
160
161/// Rewrites LF as CRLF.
162#[must_use]
163pub fn to_crlf(content: &[u8]) -> Vec<u8> {
164 let mut out = Vec::with_capacity(content.len() + content.len() / 16);
165 for (index, &byte) in content.iter().enumerate() {
166 if byte == b'\n' && (index == 0 || content[index - 1] != b'\r') {
167 out.push(b'\r');
168 }
169 out.push(byte);
170 }
171 out
172}
173
174/// What the working tree should receive, given the declaration and git's config.
175///
176/// The table is measured, not guessed: `autocrlf=true` yields CRLF and ignores
177/// `core.eol`, `autocrlf=input` yields LF and ignores it too, and only
178/// `autocrlf=false` lets `core.eol` decide.
179///
180/// **One row deliberately departs from git's own table, since 2026-08-11: with
181/// `core.autocrlf` false or unset and `core.eol` unset, this writes the stored
182/// bytes back unchanged rather than the platform's own ending.** That is the
183/// configuration in which git converts *nothing*, and until this change
184/// declaring a path secret opted it into a conversion the same file would never
185/// have received while stored in the clear. Measured on git 2.55, two throwaway
186/// repositories, a declared path and an undeclared one holding identical bytes:
187///
188/// | config | undeclared, git decides | declared, we decided | agreed? |
189/// | --- | --- | --- | --- |
190/// | `autocrlf=true` | `LF` in, `CRLF` out | `CRLF` out | yes |
191/// | `autocrlf=input` | `CRLF` in, `LF` out | `LF` out | yes |
192/// | `autocrlf=false`, `eol` unset | `LF` in, `LF` out | **`CRLF` out** | **no** |
193/// | `autocrlf=false`, `eol=lf` | `CRLF` in, `CRLF` out | **`LF` out** | **no** |
194///
195/// `git status` was clean in all four, so nothing signalled either mismatch. The
196/// third row is what this arm fixes; the fourth is the check-in half — `clean`
197/// normalises before the header can record which ending was there — and no
198/// choice made here can bring that back, so it stays a documented limit rather
199/// than a fixed one. What the change does buy for it is that the answer stops
200/// depending on the platform: after this, a declared path on Windows and on
201/// Linux receives identical bytes unless something explicitly asks otherwise,
202/// which is what §Non-Functional Requirements means by no differences across
203/// machines.
204///
205/// An explicit `core.eol=native` still selects the platform's ending, because
206/// that is a user asking for it rather than a default nobody chose, and it is
207/// the way back to the previous behaviour without editing `.git-xcrypt`. So is
208/// `eol=native` on the pattern, which outranks all of this.
209///
210/// Nothing here touches a stored byte: `clean` never reads configuration, the
211/// ciphertext is unchanged, and whatever this writes normalises back to the same
212/// plaintext — so the repository stays clean across the change.
213#[must_use]
214pub fn resolve_output(
215 declared: Option<EolMode>,
216 autocrlf: Option<&str>,
217 eol: Option<&str>,
218) -> EolMode {
219 if let Some(mode) = declared {
220 return mode;
221 }
222
223 match autocrlf.map(str::to_ascii_lowercase).as_deref() {
224 Some("input") => EolMode::Lf,
225 // `core.autocrlf` is a git boolean plus the special value `input`, and
226 // git accepts every boolean spelling here: `1`, `yes` and `on` are as
227 // valid as `true`. Matching only `true` silently downgraded them to
228 // `false` and wrote LF where the user asked for CRLF.
229 Some(value) if is_git_true(value) => EolMode::Crlf,
230 _ => match eol.map(str::to_ascii_lowercase).as_deref() {
231 Some("crlf") => EolMode::Crlf,
232 Some("lf") => EolMode::Lf,
233 Some("native") => EolMode::Native,
234 // Unset, empty, or a value git would not recognise: nobody asked for
235 // a conversion, so there is none. The stored plaintext is already
236 // LF, so this is the same thing the binary path does — the header
237 // decides whether the content was normalised, and the configuration
238 // only ever converts when it says so out loud.
239 _ => EolMode::Lf,
240 },
241 }
242}
243
244/// Whether **git** would write `CRLF` into the working tree for a path it
245/// converts itself.
246///
247/// Git's `text_eol_is_crlf`: `core.autocrlf` first, `core.eol` only while it is
248/// false, the platform when neither says anything. It is asked about a different
249/// subject than [`resolve_output`] — not "what should we write" but "is git
250/// about to expand `LF` to `CRLF` in bytes it hands us" — and since 2026-08-11
251/// the two answers differ in one row, so it computes its own rather than
252/// borrowing. With `core.eol` unset git's default *is* `native`, and a path some
253/// foreign line declared `text` really does get expanded; reading our own
254/// narrower answer here would have made this say "git left it alone" about a
255/// checkout that had just eaten the `CR` bytes out of a ciphertext, which is the
256/// one question this function exists to answer.
257///
258/// That question only comes up on the smudge path, and only once an
259/// authentication tag has already failed. Git's check-out order is blob, then
260/// git's conversion, then smudge, so on a path some attribute line pulled out
261/// from under the managed `-text`, the tag is handed bytes that were never
262/// stored — and the file is fine while the message says it is not.
263#[must_use]
264pub fn git_writes_crlf(autocrlf: Option<&str>, core_eol: Option<&str>) -> bool {
265 writes_crlf_where(autocrlf, core_eol, cfg!(windows))
266}
267
268/// The platform-independent core, for the same reason [`apply_where`] has one.
269fn writes_crlf_where(autocrlf: Option<&str>, core_eol: Option<&str>, native_is_crlf: bool) -> bool {
270 match autocrlf.map(str::to_ascii_lowercase).as_deref() {
271 Some("input") => false,
272 Some(value) if is_git_true(value) => true,
273 _ => match core_eol.map(str::to_ascii_lowercase).as_deref() {
274 Some("crlf") => true,
275 Some("lf") => false,
276 // Git's documented default for `core.eol` is `native`, and unlike
277 // our own output this must keep saying so: the subject here is a
278 // path git converts, where the default really does apply.
279 _ => native_is_crlf,
280 },
281 }
282}
283
284/// Whether a configuration value is one of git's spellings of true.
285///
286/// Shared with `status`, which has to read `filter.git-xcrypt.required` by the
287/// same rule: two answers to "is this git boolean true" is one answer too many.
288fn is_git_true(value: &str) -> bool {
289 crate::git::config::is_true(value)
290}
291
292/// Applies a resolved line-ending mode to content that was normalised to LF.
293#[must_use]
294pub fn apply(content: &[u8], mode: EolMode) -> Vec<u8> {
295 apply_where(content, mode, cfg!(windows))
296}
297
298/// The platform-independent core, so both arms are testable from either machine.
299///
300/// `native_is_crlf` is `cfg!(windows)` in production and an argument here for the
301/// same reason `repo::with_separator` takes a separator: this arm decides the
302/// bytes that land in a **Windows** working tree, and the development machine is
303/// not Windows, so without the parameter it would be covered by CI alone.
304///
305/// It is the smudge half of the asymmetry this module exists for — clean never
306/// reads the platform, smudge is the one place that may — so the invariant worth
307/// pinning is that the two still meet: whatever this writes out has to normalise
308/// back to exactly what came in, or the same file yields a different blob on
309/// Windows and `git status` reports a file nobody edited.
310#[must_use]
311fn apply_where(content: &[u8], mode: EolMode, native_is_crlf: bool) -> Vec<u8> {
312 match mode {
313 EolMode::Lf => content.to_vec(),
314 EolMode::Crlf => to_crlf(content),
315 EolMode::Native => {
316 if native_is_crlf {
317 to_crlf(content)
318 } else {
319 content.to_vec()
320 }
321 }
322 }
323}
324
325/// Whether the working tree's own bytes can still be recovered from what
326/// `clean` is about to store.
327///
328/// Normalisation maps several working trees onto one plaintext, so for some
329/// content the original is simply gone. This asks about *that* — whether the
330/// information survives — and deliberately **not** whether the bytes change.
331/// The distinction is the whole design of this predicate, and it is where we
332/// part company with git's `core.safecrlf`.
333///
334/// Git asks the wider question: it counts `CRLF` and lone `LF` before and after
335/// a simulated round trip, so on a machine with `core.autocrlf=true` an ordinary
336/// LF-only file warns — measured on 2.55, `* text=auto` and `a\nb\nc\n` give
337/// `LF will be replaced by CRLF the next time Git touches it`. Git can afford
338/// that because `safecrlf` defaults to **false**; the warning is opt-in. Ours is
339/// always on and has no knob, so the wider question would put a line on `stderr`
340/// for every text file in every Windows checkout — and a warning that fires on
341/// healthy content is worse than none, because it teaches the reader to skip the
342/// two that mean something.
343///
344/// Recoverable means some line-ending mode reproduces the original, which is the
345/// question a *uniform* file always answers yes to and the two lossy shapes
346/// always answer no to. Measured on git 2.55, verdict = working tree compared
347/// byte for byte after `add`, `commit`, `rm`, `checkout`:
348///
349/// | content | git, `safecrlf=warn` | this |
350/// | --- | --- | --- |
351/// | `a\nb\nc\n`, out `CRLF` | warns | quiet — comes back as uniform `CRLF`, stable from then on |
352/// | `a\r\nb\r\nc\r\n`, out `LF` | warns | quiet — mirror image |
353/// | `a\r\nb\nc\r\n` mixed | warns | **catches** |
354/// | `a\r\r\nb` under `text` | **silent**, byte lost | **catches** |
355/// | `a\r\r\nb` under `auto` | silent, untouched | quiet — agrees, [`looks_binary`] declines to convert |
356///
357/// Git misses the fourth row because its two counters cannot see a lone `CR`:
358/// one `CRLF` goes in and one comes out, so the totals agree while a byte is
359/// gone.
360///
361/// Being a question about information rather than bytes, the answer needs no
362/// [`EolMode`] and reads no configuration — so it is the same on every machine,
363/// which is what keeps it from being one more thing that behaves differently on
364/// Windows.
365///
366/// The two lossy shapes fail differently and a caller must not promise either:
367/// mixed endings come back changed with `git status` **clean**, because the next
368/// clean normalises the new bytes to the plaintext already stored; `CR` before
369/// `CRLF` collapses one byte further on each pass and does show up as modified.
370#[must_use]
371pub fn normalisation_is_reversible(text: TextMode, content: &[u8]) -> bool {
372 if !should_normalise(text, content) {
373 // Stored verbatim, so nothing can be lost. Asked first because it is the
374 // answer for every binary file and for anything declared `-text`, which
375 // is also the remedy the warning built on this recommends.
376 return true;
377 }
378
379 let normalised = normalise_to_lf(content);
380 // One of the two directions has to reproduce the original. `Lf` succeeds for
381 // content that had no `CRLF` to begin with, `Crlf` for content whose every
382 // `LF` was part of one — that is, for a file that is uniform either way.
383 normalised == content || to_crlf(&normalised) == content
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn every_git_spelling_of_true_means_crlf() {
392 // `""` is deliberately absent from this list, and `true` stands in for
393 // the value-less `[core]\n\tautocrlf` line — which is what
394 // `gitconfig::get` now hands over for it. Measured on git 2.55 with a
395 // repository whose file carries `* text`:
396 //
397 // `autocrlf` (no `=`) → checkout writes CRLF
398 // `autocrlf = ` (empty) → checkout writes LF
399 //
400 // The two used to arrive here as the same empty string, so the second
401 // wrote CRLF where git writes LF — see the false half of this test.
402 for spelling in ["true", "TRUE", "yes", "on", "1"] {
403 assert_eq!(
404 resolve_output(None, Some(spelling), None),
405 EolMode::Crlf,
406 "`core.autocrlf = {spelling}` is true to git and must be to us"
407 );
408 }
409 for spelling in ["false", "no", "off", "0", ""] {
410 assert_eq!(
411 resolve_output(None, Some(spelling), Some("lf")),
412 EolMode::Lf,
413 "`core.autocrlf = {spelling}` is false to git, so core.eol decides"
414 );
415 }
416 }
417
418 #[test]
419 fn gits_own_check_out_conversion_follows_the_measured_table() {
420 // Measured on git 2.55, 2026-08-05, in a throwaway repository: a blob
421 // holding lone `LF` bytes, the path declared `text` so git's binary
422 // detection is out of the way, `rm` and `git checkout --`.
423 //
424 // autocrlf=true -> CRLF, the file came back expanded
425 // autocrlf=input -> LF, untouched
426 // autocrlf=false core.eol=crlf -> CRLF, expanded
427 // autocrlf=false core.eol=native -> LF on macOS, untouched
428 //
429 // The third row is worth the ink: `core.eol` on its own reaches nothing
430 // — with no `text` attribute in force git leaves even a plain text blob
431 // alone — but once a foreign line sets `text`, `core.eol=crlf` expands
432 // exactly as `autocrlf=true` does.
433 for (autocrlf, core_eol, expected) in [
434 (Some("true"), None, true),
435 (Some("input"), None, false),
436 (Some("input"), Some("crlf"), false),
437 (Some("false"), Some("crlf"), true),
438 (Some("false"), Some("lf"), false),
439 ] {
440 assert_eq!(
441 git_writes_crlf(autocrlf, core_eol),
442 expected,
443 "autocrlf={autocrlf:?} eol={core_eol:?} disagrees with git 2.55"
444 );
445 }
446
447 // The row whose answer is the machine's, pinned on both machines rather
448 // than left to whichever one happens to run the suite.
449 for core_eol in [None, Some("native"), Some("")] {
450 assert!(
451 writes_crlf_where(Some("false"), core_eol, true),
452 "a Windows checkout expands, so a converted ciphertext breaks there"
453 );
454 assert!(
455 !writes_crlf_where(Some("false"), core_eol, false),
456 "a Unix checkout leaves the bytes alone"
457 );
458 }
459 }
460
461 /// The one row where our output and git's table are allowed to disagree.
462 ///
463 /// Both halves matter and they pull opposite ways, which is why they are
464 /// asserted together: [`resolve_output`] must stop converting where nobody
465 /// asked, and [`git_writes_crlf`] must keep saying that git converts there —
466 /// it is asked about a path a foreign `text` line pulled out from under the
467 /// managed `-text`, and answering "left alone" would blame a healthy
468 /// configuration for a checkout that just ate the `CR` bytes out of a
469 /// ciphertext.
470 #[test]
471 fn nothing_asked_for_a_conversion_so_we_write_none_where_git_still_would() {
472 for autocrlf in [None, Some("false"), Some("0"), Some("off")] {
473 for core_eol in [None, Some(""), Some("nonsense")] {
474 assert_eq!(
475 resolve_output(None, autocrlf, core_eol),
476 EolMode::Lf,
477 "autocrlf={autocrlf:?} eol={core_eol:?}: git converts nothing \
478 here, so a declared path must come back as it was stored"
479 );
480 // Same inputs, the other question, the other answer.
481 assert!(
482 writes_crlf_where(autocrlf, core_eol, true),
483 "autocrlf={autocrlf:?} eol={core_eol:?}: git's own default \
484 for core.eol is native, and a Windows checkout expands"
485 );
486 }
487 }
488
489 // Asking for the platform explicitly still gets it — that is the way
490 // back to the old behaviour without touching `.git-xcrypt`, and the
491 // pattern's own `eol=native` outranks the configuration entirely.
492 assert_eq!(
493 resolve_output(None, Some("false"), Some("native")),
494 EolMode::Native
495 );
496 assert_eq!(
497 resolve_output(Some(EolMode::Native), Some("false"), None),
498 EolMode::Native
499 );
500
501 // And the rows that were never in question stay where they were.
502 assert_eq!(resolve_output(None, Some("true"), None), EolMode::Crlf);
503 assert_eq!(resolve_output(None, Some("input"), None), EolMode::Lf);
504 assert_eq!(
505 resolve_output(None, Some("false"), Some("crlf")),
506 EolMode::Crlf
507 );
508 }
509
510 #[test]
511 fn the_native_mode_writes_what_each_platform_asks_for_and_still_round_trips() {
512 // `EolMode::Native` is what `resolve_output` returns whenever
513 // `core.autocrlf` is false and `core.eol` is unset or `native` — an
514 // ordinary configuration — and its CRLF arm has never run on the
515 // development machine. Parameterised rather than left to CI, the same
516 // way `repo::with_separator` is. Verified to bite: forcing the arm to
517 // the Unix answer fails the first assertion below.
518 let stored = b"one\ntwo\nthree\n";
519
520 assert_eq!(
521 apply_where(stored, EolMode::Native, true),
522 b"one\r\ntwo\r\nthree\r\n",
523 "a Windows working tree must receive CRLF"
524 );
525 assert_eq!(
526 apply_where(stored, EolMode::Native, false),
527 stored,
528 "a Unix working tree must receive the bytes unchanged"
529 );
530
531 // The other two modes do not consult the platform at all, which is what
532 // makes the measured configuration table portable.
533 for native_is_crlf in [true, false] {
534 assert_eq!(apply_where(stored, EolMode::Lf, native_is_crlf), stored);
535 assert_eq!(
536 apply_where(stored, EolMode::Crlf, native_is_crlf),
537 b"one\r\ntwo\r\nthree\r\n"
538 );
539 }
540
541 // The invariant that spans the asymmetry: smudge may write CRLF, but the
542 // next clean has to normalise back to exactly the plaintext that was
543 // encrypted, or the same file gives a different blob on Windows and
544 // `git status` reports a file nobody edited, for good.
545 for content in [
546 &b"one\ntwo\nthree\n"[..],
547 b"",
548 b"no trailing newline",
549 b"\n",
550 b"blank\n\nlines\n",
551 ] {
552 for native_is_crlf in [true, false] {
553 let written = apply_where(content, EolMode::Native, native_is_crlf);
554 assert_eq!(
555 normalise_to_lf(&written),
556 content,
557 "{content:?} did not survive the Windows round trip"
558 );
559 }
560 }
561 }
562
563 #[test]
564 fn only_content_whose_original_is_unrecoverable_is_called_out() {
565 // The quiet rows carry more weight than the loud ones. This question is
566 // asked on every `git add` of every encrypted file and the answer has no
567 // knob to turn it off, so a predicate that fires on healthy content is
568 // worse than no predicate: it teaches the reader to skip the two lines
569 // that mean something. Every row is measured against git 2.55, verdict
570 // by byte-for-byte comparison after `add`, `commit`, `rm`, `checkout`.
571 let mixed = &b"a\r\nb\nc\r\n"[..];
572 let cr_before_crlf = &b"a\r\r\nb"[..];
573
574 // Mixed endings lose the distinction between the two kinds, and the
575 // default mode loses it as readily as an explicit `text`.
576 for text in [TextMode::Auto, TextMode::Text] {
577 assert!(
578 !normalisation_is_reversible(text, mixed),
579 "{text:?} must not promise mixed endings can come back"
580 );
581 }
582
583 // A `CR` before `CRLF` is the shape git's own counters miss: measured on
584 // 2.55, `* text` with `core.safecrlf=warn` stores `a\r\nb` in silence.
585 assert!(should_normalise(TextMode::Text, cr_before_crlf));
586 assert!(!normalisation_is_reversible(TextMode::Text, cr_before_crlf));
587
588 // …and under `text=auto` it cannot arise, because the lone `CR` makes
589 // `looks_binary` decline to convert at all — agreeing with git, which
590 // also leaves the file untouched there.
591 assert!(!should_normalise(TextMode::Auto, cr_before_crlf));
592 assert!(normalisation_is_reversible(TextMode::Auto, cr_before_crlf));
593
594 // The quiet side, and the row that made this predicate narrower than
595 // git's: a uniform file is recoverable whichever ending it uses, so an
596 // ordinary LF-only file must stay silent even though a Windows checkout
597 // will hand it back as CRLF. Git warns there; we must not.
598 for text in [TextMode::Auto, TextMode::Text] {
599 for content in [
600 &b"one\ntwo\n"[..],
601 b"one\r\ntwo\r\n",
602 b"",
603 b"no trailing newline",
604 b"\n",
605 b"\r\n",
606 b"blank\n\nlines\n",
607 b"blank\r\n\r\nlines\r\n",
608 ] {
609 assert!(
610 normalisation_is_reversible(text, content),
611 "{text:?} must stay quiet about the uniform {content:?}"
612 );
613 }
614 }
615
616 // Verbatim storage cannot lose anything, so `binary` — the remedy the
617 // warning recommends — had better answer yes to both lossy shapes.
618 for content in [mixed, cr_before_crlf, &b"\0\r\nbinary\n"[..]] {
619 assert!(
620 normalisation_is_reversible(TextMode::Binary, content),
621 "declaring {content:?} binary must make the round trip exact"
622 );
623 }
624 }
625}