grit_lib/
commit_encoding.rs1use 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
15pub(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
24pub(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
40pub(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
55pub(crate) fn worktree_latin1_to_utf8_bytes(bytes: &[u8]) -> Vec<u8> {
57 decode_latin1(bytes).into_bytes()
58}
59
60pub(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#[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 if c < 0x80 {
83 continue;
84 }
85 let mut bytes = 0usize;
88 let mut cc = c;
89 while cc & 0x40 != 0 {
90 cc <<= 1;
91 bytes += 1;
92 }
93 if !(1..=3).contains(&bytes) {
95 return Some(bad_offset);
96 }
97 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 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 if codepoint & 0x1f_f800 == 0xd800 {
119 return Some(bad_offset);
120 }
121 if codepoint & 0xfffe == 0xfffe {
124 return Some(bad_offset);
125 }
126 if (0xfdd0..=0xfdef).contains(&codepoint) {
128 return Some(bad_offset);
129 }
130 }
131 None
132}
133
134#[must_use]
136pub fn is_strict_utf8(buf: &[u8]) -> bool {
137 find_invalid_utf8(buf).is_none()
138}
139
140#[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#[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#[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#[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#[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#[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#[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 assert!(is_strict_utf8("Ábçdèfg はれひほふ".as_bytes()));
272 assert!(is_strict_utf8(b"\x1b$B$O$l$R$[$U\x1b(B"));
274 }
275
276 #[test]
277 fn strict_utf8_rejects_surrogates() {
278 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 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 assert!(core::str::from_utf8(b"\xf4\x8f\xbf\xbe").is_ok());
294 assert!(!is_strict_utf8(b"\xf4\x8f\xbf\xbe"));
295 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}