1use std::borrow::Cow;
7
8use crate::commit_encoding;
9use crate::error::{Error, Result};
10use crate::ident::{
11 committer_timestamp_for_until_filter_bytes, committer_unix_seconds_for_ordering_bytes,
12};
13use smallvec::SmallVec;
14
15use super::{parse_oid_field, CommitData, HashAlgo, ObjectId};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CommitRef<'a> {
20 headers_block: &'a [u8],
21 message: &'a [u8],
22 tree: ObjectId,
23 parents: SmallVec<[ObjectId; 2]>,
24 author_raw: Cow<'a, [u8]>,
25 committer_raw: Cow<'a, [u8]>,
26 encoding: Option<&'a [u8]>,
27}
28
29impl<'a> CommitRef<'a> {
30 pub fn parse(data: &'a [u8]) -> Result<Self> {
39 parse_one_pass(data)
40 }
41
42 #[must_use]
44 pub fn tree(&self) -> ObjectId {
45 self.tree
46 }
47
48 #[must_use]
50 pub fn parents(&self) -> &[ObjectId] {
51 &self.parents
52 }
53
54 #[must_use]
56 pub fn author_raw(&self) -> &[u8] {
57 &self.author_raw
58 }
59
60 #[must_use]
62 pub fn committer_raw(&self) -> &[u8] {
63 &self.committer_raw
64 }
65
66 #[must_use]
68 pub fn encoding_raw(&self) -> Option<&[u8]> {
69 self.encoding
70 }
71
72 #[must_use]
74 pub fn extra_headers(&self) -> Cow<'_, [u8]> {
75 collect_extra_header_bytes(self.headers_block)
76 }
77
78 #[must_use]
80 pub fn message_bytes(&self) -> &[u8] {
81 self.message
82 }
83
84 fn encoding_label(&self) -> Option<&str> {
85 self.encoding.and_then(|b| std::str::from_utf8(b).ok())
86 }
87
88 #[must_use]
90 pub fn author(&self) -> String {
91 commit_encoding::decode_bytes(self.encoding_label(), &self.author_raw)
92 }
93
94 #[must_use]
96 pub fn committer(&self) -> String {
97 commit_encoding::decode_bytes(self.encoding_label(), &self.committer_raw)
98 }
99
100 #[must_use]
102 pub fn message(&self) -> Cow<'_, str> {
103 decode_message(self.encoding.is_some(), self.encoding_label(), self.message)
104 }
105
106 #[must_use]
108 pub fn committer_timestamp(&self) -> i64 {
109 committer_timestamp_for_until_filter_bytes(&self.committer_raw)
110 }
111
112 #[must_use]
114 pub fn committer_unix_seconds_for_ordering(&self) -> i64 {
115 committer_unix_seconds_for_ordering_bytes(&self.committer_raw)
116 }
117
118 #[must_use]
120 pub fn author_unix_seconds_for_ordering(&self) -> i64 {
121 committer_unix_seconds_for_ordering_bytes(&self.author_raw)
122 }
123
124 #[must_use]
126 pub fn to_bytes(&self) -> Vec<u8> {
127 let mut out = Vec::with_capacity(self.headers_block.len() + 1 + self.message.len());
128 out.extend_from_slice(self.headers_block);
129 out.push(b'\n');
130 out.extend_from_slice(self.message);
131 out
132 }
133
134 #[must_use]
136 pub fn to_owned(&self) -> CommitData {
137 let encoding = self.encoding_label().map(str::to_owned);
138 let message = self.message();
139 let body = self.message;
140 let has_non_utf8_encoding = encoding.as_deref().is_some_and(|label| {
141 !label.eq_ignore_ascii_case("utf-8") && !label.eq_ignore_ascii_case("utf8")
142 });
143 let raw_message = if body.is_empty() {
144 None
145 } else if has_non_utf8_encoding
146 || std::str::from_utf8(body).is_err()
147 || !body.ends_with(b"\n")
148 {
149 Some(body.to_vec())
150 } else {
151 None
152 };
153 CommitData {
154 tree: self.tree,
155 parents: self.parents.iter().copied().collect(),
156 author: self.author(),
157 committer: self.committer(),
158 author_raw: self.author_raw.to_vec(),
159 committer_raw: self.committer_raw.to_vec(),
160 encoding,
161 message: message.into_owned(),
162 raw_message,
163 extra_headers: self.extra_headers().into_owned(),
164 }
165 }
166}
167
168#[derive(Clone, Copy, PartialEq, Eq)]
169enum HeaderKind {
170 Tree,
171 Parent,
172 Author,
173 Committer,
174 Encoding,
175 Extra,
176 Malformed,
177}
178
179fn classify_header_line(line: &[u8]) -> HeaderKind {
180 if line.starts_with(b"tree ") {
181 HeaderKind::Tree
182 } else if line.starts_with(b"parent ") {
183 HeaderKind::Parent
184 } else if line.starts_with(b"author ") {
185 HeaderKind::Author
186 } else if line.starts_with(b"committer ") {
187 HeaderKind::Committer
188 } else if line.starts_with(b"encoding ") {
189 HeaderKind::Encoding
190 } else if line.is_empty() || line.first() == Some(&b' ') {
191 HeaderKind::Malformed
192 } else {
193 HeaderKind::Extra
194 }
195}
196
197fn header_value(line: &[u8], prefix_len: usize) -> &[u8] {
198 line.get(prefix_len..).unwrap_or_default()
199}
200
201fn parse_oid_fast(value: &[u8]) -> Result<ObjectId> {
202 if value.len() == HashAlgo::Sha1.hex_len() {
203 return ObjectId::from_hex_ascii(value);
204 }
205 parse_oid_field(value)
206}
207
208struct CommitScan<'a> {
210 headers_block: &'a [u8],
211 message: &'a [u8],
212 tree: Option<ObjectId>,
213 parents: SmallVec<[ObjectId; 2]>,
214 author_raw: Option<Cow<'a, [u8]>>,
215 committer_raw: Option<Cow<'a, [u8]>>,
216 encoding: Option<&'a [u8]>,
217}
218
219fn scan_commit(data: &[u8], lenient: bool) -> Result<CommitScan<'_>> {
225 #[derive(Clone, Copy)]
226 enum Continuation {
227 Author,
228 Committer,
229 MultilineExtra,
230 Ignore,
231 }
232
233 let mut pos = 0usize;
234 let mut tree = None;
235 let mut parents = SmallVec::<[ObjectId; 2]>::new();
236 let mut author_raw: Option<Cow<'_, [u8]>> = None;
237 let mut committer_raw: Option<Cow<'_, [u8]>> = None;
238 let mut encoding: Option<&[u8]> = None;
239 let mut cont = Continuation::Ignore;
240
241 let mut headers_end = 0usize;
242 let mut message: &[u8] = &[];
243 let mut has_blank_line = false;
244
245 while pos < data.len() {
246 let line_start = pos;
247 let mut line_end = pos;
248 while line_end < data.len() && data[line_end] != b'\n' {
249 line_end += 1;
250 }
251 let line = &data[line_start..line_end];
252 let after_nl = (line_end + 1).min(data.len());
253
254 if line.is_empty() {
255 headers_end = line_start;
256 message = data.get(after_nl..).unwrap_or_default();
257 has_blank_line = true;
258 break;
259 }
260
261 if line.first() == Some(&b' ') {
262 let rest = line.get(1..).unwrap_or_default();
263 let merged = match cont {
264 Continuation::Author => merge_identity_continuation(&mut author_raw, rest),
265 Continuation::Committer => merge_identity_continuation(&mut committer_raw, rest),
266 Continuation::MultilineExtra | Continuation::Ignore => Ok(()),
267 };
268 if !lenient {
269 merged?;
270 }
271 pos = after_nl;
272 continue;
273 }
274
275 match classify_header_line(line) {
276 HeaderKind::Tree => {
277 match parse_oid_fast(header_value(line, 5)) {
278 Ok(oid) => tree = Some(oid),
279 Err(err) if !lenient => return Err(err),
280 Err(_) => {}
281 }
282 cont = Continuation::Ignore;
283 }
284 HeaderKind::Parent => {
285 match parse_oid_fast(header_value(line, 7)) {
286 Ok(oid) => parents.push(oid),
287 Err(err) if !lenient => return Err(err),
288 Err(_) => {}
289 }
290 cont = Continuation::Ignore;
291 }
292 HeaderKind::Author => {
293 author_raw = Some(Cow::Borrowed(header_value(line, 7)));
294 cont = Continuation::Author;
295 }
296 HeaderKind::Committer => {
297 committer_raw = Some(Cow::Borrowed(header_value(line, 10)));
298 cont = Continuation::Committer;
299 }
300 HeaderKind::Encoding => {
301 let rest = header_value(line, 9);
302 if std::str::from_utf8(rest).is_ok() {
303 encoding = Some(rest);
304 } else if !lenient {
305 return Err(Error::CorruptObject("encoding is not UTF-8".to_owned()));
306 }
307 cont = Continuation::Ignore;
308 }
309 HeaderKind::Extra => {
310 cont = Continuation::MultilineExtra;
311 }
312 HeaderKind::Malformed => {
313 if !lenient {
314 return Err(Error::CorruptObject(
315 "malformed commit header line".to_owned(),
316 ));
317 }
318 cont = Continuation::Ignore;
319 }
320 }
321 pos = after_nl;
322 }
323
324 if !has_blank_line {
325 headers_end = data.len();
326 }
327 Ok(CommitScan {
328 headers_block: &data[..headers_end],
329 message,
330 tree,
331 parents,
332 author_raw,
333 committer_raw,
334 encoding,
335 })
336}
337
338fn parse_one_pass<'a>(data: &'a [u8]) -> Result<CommitRef<'a>> {
339 let scan = scan_commit(data, false)?;
340 let author_bytes = scan
341 .author_raw
342 .ok_or_else(|| Error::CorruptObject("commit missing author header".to_owned()))?;
343 let committer_bytes = scan
344 .committer_raw
345 .ok_or_else(|| Error::CorruptObject("commit missing committer header".to_owned()))?;
346
347 Ok(CommitRef {
348 headers_block: scan.headers_block,
349 message: scan.message,
350 tree: scan
351 .tree
352 .ok_or_else(|| Error::CorruptObject("commit missing tree header".to_owned()))?,
353 parents: scan.parents,
354 author_raw: author_bytes,
355 committer_raw: committer_bytes,
356 encoding: scan.encoding,
357 })
358}
359
360pub(crate) fn commit_author_and_message_lenient(data: &[u8]) -> (Option<String>, String) {
368 let Ok(scan) = scan_commit(data, true) else {
369 return (None, String::new());
370 };
371 let label = scan.encoding.and_then(|b| std::str::from_utf8(b).ok());
372 let author = scan
373 .author_raw
374 .map(|raw| commit_encoding::decode_bytes(label, &raw));
375 let message = decode_message(scan.encoding.is_some(), label, scan.message).into_owned();
376 (author, message)
377}
378
379fn decode_message<'a>(has_encoding: bool, label: Option<&str>, body: &'a [u8]) -> Cow<'a, str> {
380 if has_encoding {
381 return Cow::Owned(commit_encoding::decode_bytes(label, body));
382 }
383 match std::str::from_utf8(body) {
384 Ok(s) => Cow::Borrowed(s),
385 Err(_) => Cow::Owned(commit_encoding::decode_bytes(None, body)),
386 }
387}
388
389fn collect_extra_header_bytes(headers_block: &[u8]) -> Cow<'_, [u8]> {
390 let mut extra = ExtraScan::default();
391 let mut pos = 0usize;
392 #[derive(Clone, Copy)]
393 enum Cont {
394 MultilineExtra,
395 Ignore,
396 }
397 let mut cont = Cont::Ignore;
398 while pos < headers_block.len() {
399 let line_start = pos;
400 let mut line_end = pos;
401 while line_end < headers_block.len() && headers_block[line_end] != b'\n' {
402 line_end += 1;
403 }
404 let line = &headers_block[line_start..line_end];
405 let after_nl = (line_end + 1).min(headers_block.len());
406 if line.first() == Some(&b' ') {
407 if matches!(cont, Cont::MultilineExtra) {
408 extra.push_line(line_start, after_nl);
409 }
410 pos = after_nl;
411 continue;
412 }
413 let kind = classify_header_line(line);
414 match kind {
415 HeaderKind::Tree | HeaderKind::Parent | HeaderKind::Author | HeaderKind::Committer => {
416 cont = Cont::Ignore;
417 extra.mark_structured_gap();
418 }
419 HeaderKind::Encoding => {
420 cont = Cont::Ignore;
421 extra.mark_structured_gap();
422 }
423 HeaderKind::Extra => {
424 cont = Cont::MultilineExtra;
425 extra.push_line(line_start, after_nl);
426 }
427 HeaderKind::Malformed => {
428 cont = Cont::Ignore;
429 }
430 }
431 pos = after_nl;
432 }
433 extra.finish(headers_block)
434}
435
436#[derive(Default)]
437struct ExtraScan {
438 start: Option<usize>,
439 end: usize,
440 structured_gap: bool,
441 piece_ranges: SmallVec<[(usize, usize); 4]>,
442}
443
444impl ExtraScan {
445 fn mark_structured_gap(&mut self) {
446 if self.start.is_some() {
447 self.structured_gap = true;
448 }
449 }
450
451 fn push_line(&mut self, line_start: usize, after_nl: usize) {
452 if self.start.is_none() {
453 self.start = Some(line_start);
454 self.end = after_nl;
455 return;
456 }
457 if !self.structured_gap && self.end == line_start {
458 self.end = after_nl;
459 } else {
460 self.structured_gap = true;
461 self.piece_ranges.push((line_start, after_nl));
462 }
463 }
464
465 fn finish<'a>(self, block: &'a [u8]) -> Cow<'a, [u8]> {
466 if self.start.is_none() && self.piece_ranges.is_empty() {
467 return Cow::Borrowed(&[]);
468 }
469 if self.structured_gap || !self.piece_ranges.is_empty() {
470 let mut out = Vec::new();
471 if let Some(start) = self.start {
472 out.extend_from_slice(&block[start..self.end]);
473 }
474 for &(from, to) in &self.piece_ranges {
475 out.extend_from_slice(&block[from..to]);
476 }
477 Cow::Owned(out)
478 } else if let Some(start) = self.start {
479 Cow::Borrowed(&block[start..self.end])
480 } else {
481 Cow::Borrowed(&[])
482 }
483 }
484}
485
486fn merge_identity_continuation(slot: &mut Option<Cow<'_, [u8]>>, rest: &[u8]) -> Result<()> {
487 let merged = slot
488 .as_mut()
489 .ok_or_else(|| Error::CorruptObject("orphan header continuation".to_owned()))?;
490 match merged {
491 Cow::Borrowed(first) => {
492 let mut buf = first.to_vec();
493 buf.extend_from_slice(rest);
494 *merged = Cow::Owned(buf);
495 }
496 Cow::Owned(buf) => buf.extend_from_slice(rest),
497 }
498 Ok(())
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn lenient_author_and_message_match_strict_parse_on_valid_commits() {
507 let latin1: &[u8] = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
508 author J\xf6rg <j@example.com> 1 +0100\n\
509 committer C <c@example.com> 2 +0000\n\
510 encoding ISO-8859-1\n\
511 \n\
512 Gr\xfc\xdfe\n";
513 let continued: &[u8] = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
514 author A\n Continued <a@example.com> 1 +0000\n\
515 committer C <c@example.com> 2 +0000\n\
516 gpgsig -----BEGIN-----\n sig\n -----END-----\n\
517 \n\
518 subject\n\nbody\n";
519 for data in [latin1, continued] {
520 let strict = CommitRef::parse(data).expect("valid commit");
521 let (author, message) = commit_author_and_message_lenient(data);
522 assert_eq!(author.as_deref(), Some(strict.author().as_str()));
523 assert_eq!(message, strict.message());
524 }
525 }
526
527 #[test]
528 fn lenient_author_and_message_tolerate_missing_and_malformed_headers() {
529 let tree = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n";
530 let no_committer = format!("{tree}author A <a@x> 1700000000 +0000\n\nmessage\n");
531 assert!(CommitRef::parse(no_committer.as_bytes()).is_err());
532 assert_eq!(
533 commit_author_and_message_lenient(no_committer.as_bytes()),
534 (
535 Some("A <a@x> 1700000000 +0000".to_owned()),
536 "message\n".to_owned()
537 )
538 );
539
540 let no_author = format!("{tree}committer C <c@x> 2 +0000\n\n\nsubject\n");
541 assert!(CommitRef::parse(no_author.as_bytes()).is_err());
542 assert_eq!(
543 commit_author_and_message_lenient(no_author.as_bytes()),
544 (None, "\nsubject\n".to_owned())
545 );
546
547 let malformed =
548 format!("{tree}parent not-an-oid\n\n bad continuation\nauthor A <a@x> 1 +0000\n\nm\n");
549 assert_eq!(
550 commit_author_and_message_lenient(malformed.as_bytes()).1,
551 " bad continuation\nauthor A <a@x> 1 +0000\n\nm\n",
552 "the first blank line still ends the headers"
553 );
554
555 let mut odd_lines = tree.as_bytes().to_vec();
556 odd_lines.extend_from_slice(b"encoding \xff\n orphan\nauthor A <a@x> 1 +0000\n\nm\n");
557 assert!(CommitRef::parse(&odd_lines).is_err());
558 let (author, message) = commit_author_and_message_lenient(&odd_lines);
559 assert_eq!(author.as_deref(), Some("A <a@x> 1 +0000"));
560 assert_eq!(message, "m\n");
561
562 let no_blank = format!("{tree}author A <a@x> 1 +0000\ncommitter C <c@x> 2 +0000\n");
563 let parsed = CommitRef::parse(no_blank.as_bytes()).expect("headers-only commit");
564 assert_eq!(parsed.message(), "");
565 assert_eq!(
566 commit_author_and_message_lenient(no_blank.as_bytes()),
567 (Some("A <a@x> 1 +0000".to_owned()), String::new())
568 );
569 assert_eq!(
570 commit_author_and_message_lenient(b""),
571 (None, String::new())
572 );
573 }
574
575 #[test]
576 fn commit_ref_roundtrip_vectors() {
577 let cases: &[&[u8]] = &[
578 concat!(
579 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n",
580 "parent 1111111111111111111111111111111111111111\n",
581 "author A <a@example.com> 1 +0000\n",
582 "committer C <c@example.com> 2 +0000\n",
583 "\n",
584 "ignored body\n",
585 )
586 .as_bytes(),
587 concat!(
588 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n",
589 "author A U Thor <author@example.com> 1 +0000\n",
590 "committer C O Mitter <committer@example.com> 1 +0000\n",
591 "gpgsig -----BEGIN PGP SIGNATURE-----\n",
592 " abcdef\n",
593 " -----END PGP SIGNATURE-----\n",
594 "\n",
595 "msg\n",
596 )
597 .as_bytes(),
598 concat!(
599 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n",
600 "author A <a@example.com> 1 +0000\n",
601 "committer C <c@example.com> 1 +0000\n",
602 "encoding ISO-8859-1\n",
603 "gpgsig -----BEGIN PGP SIGNATURE-----\n",
604 " line2\n",
605 " -----END PGP SIGNATURE-----\n",
606 "mergetag object 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n",
607 " type commit\n",
608 " tag merge-tag\n",
609 " tagger T <t@example.com> 1 +0000\n",
610 " -----BEGIN PGP SIGNATURE-----\n",
611 " inner\n",
612 " -----END PGP SIGNATURE-----\n",
613 "change-id test-change-id\n",
614 "\n",
615 "msg-only\n",
616 )
617 .as_bytes(),
618 concat!(
619 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n",
620 "author A <a@example.com> 1 +0000\n",
621 "committer C <c@example.com> 1 +0000\n",
622 "change-id interleaved-change\n",
623 "encoding UTF-8\n",
624 "gpgsig -----BEGIN PGP SIGNATURE-----\n",
625 " sig\n",
626 " -----END PGP SIGNATURE-----\n",
627 "\n",
628 "body\n",
629 )
630 .as_bytes(),
631 b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
632author A <a@example.com> 1 +0000\n\
633committer C <c@example.com> 1 +0000\n\
634\n\
635no-final-newline",
636 ];
637 for raw in cases {
638 let parsed = super::super::parse_commit(raw).expect("parse_commit");
639 let cref = CommitRef::parse(raw).expect("CommitRef::parse");
640 assert_eq!(cref.to_owned(), parsed, "to_owned mismatch");
641 assert_eq!(cref.to_bytes(), *raw, "byte-identical serialize");
642 }
643 }
644
645 fn median(mut samples: Vec<u128>) -> u128 {
646 samples.sort_unstable();
647 samples[samples.len() / 2]
648 }
649
650 fn bench_commit_bodies(count: usize) -> Vec<Vec<u8>> {
651 let empty = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
652 let ident = "Bench <b@example.com> 1700000000 +0000";
653 let mut bodies = Vec::with_capacity(count);
654 let mut parent = empty.to_owned();
655 for i in 0..count {
656 let body = format!(
657 "tree {empty}\nparent {parent}\nauthor {ident}\ncommitter {ident}\nchange-id bench-{i:06}\n\nm{i}\n"
658 );
659 parent = format!("{:040x}", i + 1);
660 bodies.push(body.into_bytes());
661 }
662 bodies
663 }
664
665 #[test]
666 fn commit_ref_parse_at_least_2x_faster_than_parse_commit_100k() {
667 if cfg!(debug_assertions) {
668 return;
669 }
670 let bodies = bench_commit_bodies(100_000);
671 for body in &bodies[..128] {
672 let _ = CommitRef::parse(body).expect("parse");
673 let _ = super::super::parse_commit(body).expect("parse_commit");
674 }
675
676 let mut parse_commit_medians = Vec::with_capacity(9);
677 let mut commit_ref_medians = Vec::with_capacity(9);
678 for run in 0..9 {
679 let use_commit_ref = run % 2 == 0;
680 let start = std::time::Instant::now();
681 if use_commit_ref {
682 for body in &bodies {
683 std::hint::black_box(CommitRef::parse(body).expect("parse"));
684 }
685 } else {
686 for body in &bodies {
687 std::hint::black_box(super::super::parse_commit(body).expect("parse_commit"));
688 }
689 }
690 let elapsed = start.elapsed().as_nanos();
691 if use_commit_ref {
692 commit_ref_medians.push(elapsed);
693 } else {
694 parse_commit_medians.push(elapsed);
695 }
696 }
697 let parse_commit_median = median(parse_commit_medians);
698 let commit_ref_median = median(commit_ref_medians);
699 let ratio = parse_commit_median as f64 / commit_ref_median as f64;
700 assert!(
701 ratio >= 2.0,
702 "CommitRef::parse median {commit_ref_median}ns vs parse_commit {parse_commit_median}ns (ratio {ratio:.2}x, need >=2x)"
703 );
704 }
705}