Skip to main content

grit_lib/
commit_encoding.rs

1//! Git commit encoding labels (`encoding` header, `i18n.commitEncoding`) mapped to codecs.
2//!
3//! Git's `ISO-8859-1` is strict Latin-1; `encoding_rs` maps that label to Windows-1252, so we
4//! handle Latin-1 separately.
5
6use encoding_rs::Encoding;
7
8pub(crate) fn is_iso_8859_1(label: &str) -> bool {
9    matches!(
10        label.trim().to_ascii_lowercase().as_str(),
11        "iso-8859-1" | "iso8859-1" | "latin1" | "latin-1"
12    )
13}
14
15/// Map each byte to U+0000..U+00FF for Git-style line diffs on non-UTF-8 blobs.
16pub(crate) fn decode_latin1(bytes: &[u8]) -> String {
17    let mut s = String::with_capacity(bytes.len());
18    for &b in bytes {
19        s.push(char::from_u32(u32::from(b)).unwrap_or('\u{FFFD}'));
20    }
21    s
22}
23
24/// Encode Unicode to Latin-1 bytes when every scalar is in U+0000..=U+00FF.
25///
26/// Used for `working-tree-encoding` smudge; returns `None` if any character is unrepresentable
27/// (Git reports a conversion failure instead of substituting bytes).
28pub(crate) fn encode_latin1_strict(unicode: &str) -> Option<Vec<u8>> {
29    let mut out = Vec::with_capacity(unicode.len());
30    for c in unicode.chars() {
31        let cp = u32::from(c);
32        if cp > 0xFF {
33            return None;
34        }
35        out.push(cp as u8);
36    }
37    Some(out)
38}
39
40/// Encode a string produced by [`decode_latin1`] back to raw bytes for patch output.
41pub(crate) fn encode_latin1_lossy(unicode: &str) -> Vec<u8> {
42    unicode
43        .chars()
44        .map(|c| {
45            let cp = u32::from(c);
46            if cp <= 0xFF {
47                cp as u8
48            } else {
49                b'?'
50            }
51        })
52        .collect()
53}
54
55/// Latin-1 working-tree bytes → UTF-8 blob bytes (Git `working-tree-encoding` clean filter).
56pub(crate) fn worktree_latin1_to_utf8_bytes(bytes: &[u8]) -> Vec<u8> {
57    decode_latin1(bytes).into_bytes()
58}
59
60/// UTF-8 blob bytes → Latin-1 working-tree bytes (Git smudge filter).
61pub(crate) fn utf8_bytes_to_worktree_latin1(utf8: &[u8]) -> Option<Vec<u8>> {
62    let s = std::str::from_utf8(utf8).ok()?;
63    encode_latin1_strict(s)
64}
65
66/// Find the offset of the first byte that is not part of a strictly valid UTF-8
67/// sequence, mirroring Git's `find_invalid_utf8` (commit.c).
68///
69/// This is stricter than [`core::str::from_utf8`]: in addition to rejecting
70/// malformed/overlong sequences and surrogates, it also rejects the Unicode
71/// non-characters `U+xxFFFE`, `U+xxFFFF`, and the range `U+FDD0..=U+FDEF`, which
72/// Rust's standard library accepts. Returns `None` when the whole buffer is valid.
73#[must_use]
74pub fn find_invalid_utf8(buf: &[u8]) -> Option<usize> {
75    const MAX_CODEPOINT: [u32; 4] = [0x7f, 0x7ff, 0xffff, 0x10ffff];
76    let mut i = 0usize;
77    while i < buf.len() {
78        let c = buf[i];
79        let bad_offset = i;
80        i += 1;
81        // Simple US-ASCII? No worries.
82        if c < 0x80 {
83            continue;
84        }
85        // Count how many more high bits are set: that's how many more bytes
86        // this sequence should have.
87        let mut bytes = 0usize;
88        let mut cc = c;
89        while cc & 0x40 != 0 {
90            cc <<= 1;
91            bytes += 1;
92        }
93        // Must be between 1 and 3 more bytes.
94        if !(1..=3).contains(&bytes) {
95            return Some(bad_offset);
96        }
97        // Do we have that many bytes?
98        if buf.len() - i < bytes {
99            return Some(bad_offset);
100        }
101        let mut codepoint = (u32::from(cc) & 0x7f) >> bytes;
102        let min_val = MAX_CODEPOINT[bytes - 1] + 1;
103        let max_val = MAX_CODEPOINT[bytes];
104        // Verify that they are good continuation bytes.
105        for _ in 0..bytes {
106            let b = buf[i];
107            codepoint = (codepoint << 6) | (u32::from(b) & 0x3f);
108            if b & 0xc0 != 0x80 {
109                return Some(bad_offset);
110            }
111            i += 1;
112        }
113        if codepoint < min_val || codepoint > max_val {
114            return Some(bad_offset);
115        }
116        // Reject the UTF-16 surrogate block (U+D800..=U+DFFF): it has no
117        // legal UTF-8 encoding.
118        if codepoint & 0x1f_f800 == 0xd800 {
119            return Some(bad_offset);
120        }
121        // The last two code points of every plane (..FFFE and ..FFFF) are
122        // permanent non-characters.
123        if codepoint & 0xfffe == 0xfffe {
124            return Some(bad_offset);
125        }
126        // So is anything in the range U+FDD0..=U+FDEF.
127        if (0xfdd0..=0xfdef).contains(&codepoint) {
128            return Some(bad_offset);
129        }
130    }
131    None
132}
133
134/// Whether `buf` is strictly valid UTF-8 per Git's rules (see [`find_invalid_utf8`]).
135#[must_use]
136pub fn is_strict_utf8(buf: &[u8]) -> bool {
137    find_invalid_utf8(buf).is_none()
138}
139
140/// Git stores the commit message body with a trailing newline when non-empty.
141#[must_use]
142pub fn ensure_body_trailing_newline(mut bytes: Vec<u8>) -> Vec<u8> {
143    if !bytes.is_empty() && !bytes.ends_with(b"\n") {
144        bytes.push(b'\n');
145    }
146    bytes
147}
148
149/// Resolve an encoding label the way Git uses it in config and commit objects.
150///
151/// Git accepts names like `eucJP` that [`Encoding::for_label`] does not recognize.
152/// ISO-8859-1 is handled separately as strict Latin-1 and returns `None`.
153#[must_use]
154pub fn resolve(label: &str) -> Option<&'static Encoding> {
155    let t = label.trim();
156    if t.is_empty() || is_iso_8859_1(t) {
157        return None;
158    }
159    let normalized = t.replace('_', "-");
160    let lower = normalized.to_ascii_lowercase();
161    let mapped = match lower.as_str() {
162        "eucjp" => "euc-jp",
163        "cp932" | "mskanji" | "sjis" => "shift_jis",
164        _ => normalized.as_str(),
165    };
166    Encoding::for_label(mapped.as_bytes()).or_else(|| Encoding::for_label(t.as_bytes()))
167}
168
169/// Encode `unicode` for storage in a commit message body using Git's encoding name.
170#[must_use]
171pub fn encode_unicode(label: &str, unicode: &str) -> Option<Vec<u8>> {
172    let t = label.trim();
173    let raw = if is_iso_8859_1(t) {
174        encode_latin1_lossy(unicode)
175    } else {
176        let enc = resolve(t)?;
177        let (cow, _, _) = enc.encode(unicode);
178        cow.into_owned()
179    };
180    Some(ensure_body_trailing_newline(raw))
181}
182
183/// Encode a single header field (author/committer line) without adding a trailing newline.
184#[must_use]
185pub fn encode_header_text(label: &str, unicode: &str) -> Option<Vec<u8>> {
186    let t = label.trim();
187    if is_iso_8859_1(t) {
188        return Some(encode_latin1_lossy(unicode));
189    }
190    let enc = resolve(t)?;
191    let (cow, _, _) = enc.encode(unicode);
192    Some(cow.into_owned())
193}
194
195/// Decode `bytes` using Git's encoding name, or lossy UTF-8 if unknown.
196#[must_use]
197pub fn decode_bytes(label: Option<&str>, bytes: &[u8]) -> String {
198    if let Some(l) = label {
199        if is_iso_8859_1(l) {
200            return decode_latin1(bytes);
201        }
202        if let Some(enc) = resolve(l) {
203            let (cow, _) = enc.decode_without_bom_handling(bytes);
204            return cow.into_owned();
205        }
206    }
207    String::from_utf8_lossy(bytes).into_owned()
208}
209
210/// Prepare a commit message for storage per `i18n.commitEncoding` (or equivalent).
211///
212/// When the configured encoding is not UTF-8, returns [`Some`] raw bytes for the body
213/// and sets `encoding` in the commit object; otherwise UTF-8 is stored without an
214/// `encoding` header.
215#[must_use]
216pub fn finalize_stored_commit_message(
217    message: String,
218    commit_encoding: Option<&str>,
219) -> (String, Option<String>, Option<Vec<u8>>) {
220    let is_utf8 = match commit_encoding {
221        None => true,
222        Some(e) => e.eq_ignore_ascii_case("utf-8") || e.eq_ignore_ascii_case("utf8"),
223    };
224    if is_utf8 {
225        return (message, None, None);
226    }
227    let Some(label) = commit_encoding.filter(|s| !s.trim().is_empty()) else {
228        return (message, None, None);
229    };
230    let Some(raw) = encode_unicode(label, &message) else {
231        return (message, None, None);
232    };
233    (message, Some(label.to_owned()), Some(raw))
234}
235
236/// Raw `author` / `committer` header payloads for a new commit object.
237///
238/// When `encoding` is unset or UTF-8, returns empty vectors so
239/// [`crate::objects::serialize_commit`] writes the Unicode [`String`] fields as UTF-8.
240/// When `encoding` is non-UTF-8, encodes the full identity lines (name, email, timestamp)
241/// for storage in that charset.
242#[must_use]
243pub fn identity_raw_for_serialized_commit(
244    encoding: &Option<String>,
245    author: &str,
246    committer: &str,
247) -> (Vec<u8>, Vec<u8>) {
248    let is_utf8 = match encoding.as_deref() {
249        None => true,
250        Some(e) => e.eq_ignore_ascii_case("utf-8") || e.eq_ignore_ascii_case("utf8"),
251    };
252    if is_utf8 {
253        return (Vec::new(), Vec::new());
254    }
255    let Some(label) = encoding.as_deref() else {
256        return (Vec::new(), Vec::new());
257    };
258    let author_raw = encode_header_text(label, author).unwrap_or_default();
259    let committer_raw = encode_header_text(label, committer).unwrap_or_default();
260    (author_raw, committer_raw)
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn strict_utf8_accepts_plain_ascii_and_multibyte() {
269        assert!(is_strict_utf8(b"Commit message\n"));
270        // Valid multi-byte UTF-8 (Latin small letter a with acute, CJK).
271        assert!(is_strict_utf8("Ábçdèfg はれひほふ".as_bytes()));
272        // ISO-2022-JP is a 7-bit encoding using ESC control bytes; valid UTF-8.
273        assert!(is_strict_utf8(b"\x1b$B$O$l$R$[$U\x1b(B"));
274    }
275
276    #[test]
277    fn strict_utf8_rejects_surrogates() {
278        // Encoded surrogate U+D800 (ED A0 80) — invalid in UTF-8.
279        assert_eq!(find_invalid_utf8(b"abc\xed\xa0\x80"), Some(3));
280        assert!(!is_strict_utf8(b"\xed\xa0\x80"));
281    }
282
283    #[test]
284    fn strict_utf8_rejects_overlong_sequences() {
285        // Overlong encoding of U+0029 and the C0 A0 "fake space".
286        assert!(!is_strict_utf8(b"\xe0\x82\xa9"));
287        assert!(!is_strict_utf8(b"\xc0\xa0"));
288    }
289
290    #[test]
291    fn strict_utf8_rejects_noncharacters_rust_would_accept() {
292        // U+10FFFE non-character: F4 8F BF BE.
293        assert!(core::str::from_utf8(b"\xf4\x8f\xbf\xbe").is_ok());
294        assert!(!is_strict_utf8(b"\xf4\x8f\xbf\xbe"));
295        // U+FDD0 (in the U+FDD0..=U+FDEF non-character block): EF B7 90.
296        assert!(core::str::from_utf8(b"\xef\xb7\x90").is_ok());
297        assert!(!is_strict_utf8(b"\xef\xb7\x90"));
298    }
299
300    #[test]
301    fn latin1_round_trips_through_encode_and_decode() {
302        let unicode = "Áéí óú";
303        let encoded = encode_header_text("ISO8859-1", unicode).expect("latin1 encodes");
304        assert_eq!(encoded, vec![0xC1, 0xE9, 0xED, 0x20, 0xF3, 0xFA]);
305        assert_eq!(decode_bytes(Some("ISO8859-1"), &encoded), unicode);
306    }
307
308    #[test]
309    fn iso8859_1_aliases_for_worktree_encoding() {
310        for label in ["latin1", "ISO8859-1", "ISO-8859-1", "latin-1", "  LATIN1  "] {
311            assert!(is_iso_8859_1(label), "expected Latin-1 alias: {label}");
312        }
313    }
314
315    #[test]
316    fn worktree_latin1_round_trip_bytes() {
317        let worktree = b"h\xe9llo\n";
318        let utf8_blob = worktree_latin1_to_utf8_bytes(worktree);
319        assert_eq!(utf8_blob, b"h\xc3\xa9llo\n");
320        assert_eq!(
321            utf8_bytes_to_worktree_latin1(&utf8_blob).as_deref(),
322            Some(worktree.as_slice())
323        );
324    }
325
326    #[test]
327    fn worktree_latin1_smudge_rejects_unrepresentable_utf8() {
328        let euro_utf8 = b"\xe2\x82\xac\n";
329        assert!(utf8_bytes_to_worktree_latin1(euro_utf8).is_none());
330        assert_eq!(
331            encode_latin1_lossy(std::str::from_utf8(euro_utf8).unwrap()),
332            b"?\n",
333            "commit-message path stays lossy"
334        );
335    }
336}