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