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