Skip to main content

git_core/
commit.rs

1use crate::error::{GitError, Result};
2use crate::hash::GitHash;
3
4#[derive(Debug, Clone)]
5pub struct Signature {
6    pub name: String,
7    pub email: String,
8    /// Unix timestamp (seconds since epoch).
9    pub timestamp: i64,
10    /// UTC offset in seconds (e.g. +0800 → 28800).
11    pub tz_offset_secs: i32,
12}
13
14#[derive(Debug, Clone)]
15pub struct CommitObject {
16    pub hash: GitHash,
17    pub tree: GitHash,
18    pub parents: Vec<GitHash>,
19    pub author: Signature,
20    pub committer: Signature,
21    pub message: String,
22    /// True iff a `gpgsig` header was present (the commit is cryptographically
23    /// signed). The signature's validity is not checked here — only its
24    /// presence, which is what a signing-policy audit reasons about.
25    pub is_signed: bool,
26}
27
28impl CommitObject {
29    /// Parse the raw commit object bytes (after the object header is stripped).
30    pub fn parse(hash: GitHash, data: &[u8]) -> Result<Self> {
31        let text = std::str::from_utf8(data)
32            .map_err(|e| GitError::InvalidObject(format!("commit not valid UTF-8: {e}")))?;
33
34        let mut tree = None;
35        let mut parents = Vec::new();
36        let mut author = None;
37        let mut committer = None;
38        let mut is_signed = false;
39        let mut message_start = text.len();
40
41        for (i, line) in text.lines().enumerate() {
42            if line.is_empty() {
43                // Blank line separates header from message body.
44                let byte_pos = text
45                    .char_indices()
46                    .filter(|(_, c)| *c == '\n')
47                    .nth(i)
48                    .map_or(text.len(), |(pos, _)| pos + 1);
49                message_start = byte_pos;
50                break;
51            }
52            if let Some(rest) = line.strip_prefix("tree ") {
53                tree = Some(GitHash::from_hex(rest.trim())?);
54            } else if let Some(rest) = line.strip_prefix("parent ") {
55                parents.push(GitHash::from_hex(rest.trim())?);
56            } else if let Some(rest) = line.strip_prefix("author ") {
57                author = Some(parse_signature(rest)?);
58            } else if let Some(rest) = line.strip_prefix("committer ") {
59                committer = Some(parse_signature(rest)?);
60            } else if line.strip_prefix("gpgsig ").is_some() {
61                // A signed commit carries `gpgsig <signature>`; its continuation
62                // lines start with a single space and match no header prefix, so
63                // they fall through harmlessly. We record only the presence —
64                // signature validity is out of scope for the reader.
65                is_signed = true;
66            }
67        }
68
69        Ok(Self {
70            hash,
71            tree: tree.ok_or_else(|| GitError::InvalidObject("commit missing tree".into()))?,
72            parents,
73            author: author
74                .ok_or_else(|| GitError::InvalidObject("commit missing author".into()))?,
75            committer: committer
76                .ok_or_else(|| GitError::InvalidObject("commit missing committer".into()))?,
77            message: text[message_start..].to_string(),
78            is_signed,
79        })
80    }
81}
82
83fn parse_signature(s: &str) -> Result<Signature> {
84    // Format: "Name <email> timestamp tz_offset"
85    let err = || GitError::InvalidObject(format!("invalid signature: {s:?}"));
86
87    let email_end = s.rfind('>').ok_or_else(err)?;
88    let email_start = s.rfind('<').ok_or_else(err)?;
89    if email_start >= email_end {
90        return Err(err());
91    }
92    let name = s[..email_start].trim().to_string();
93    let email = s[email_start + 1..email_end].to_string();
94
95    let rest = s[email_end + 1..].trim();
96    let mut parts = rest.split_whitespace();
97    let ts: i64 = parts.next().and_then(|t| t.parse().ok()).ok_or_else(err)?;
98    let tz = parts.next().unwrap_or("+0000");
99
100    let sign = if tz.starts_with('-') { -1i32 } else { 1 };
101    let tz_digits = tz.trim_start_matches(['+', '-']);
102    let tz_offset_secs = if tz_digits.len() == 4 && tz_digits.is_ascii() {
103        let hh: i32 = tz_digits[..2].parse().unwrap_or(0);
104        let mm: i32 = tz_digits[2..].parse().unwrap_or(0);
105        sign * (hh * 3600 + mm * 60)
106    } else {
107        0
108    };
109
110    Ok(Signature {
111        name,
112        email,
113        timestamp: ts,
114        tz_offset_secs,
115    })
116}
117
118#[cfg(test)]
119mod tests {
120    use super::parse_signature;
121
122    #[test]
123    fn tz_field_that_is_a_single_multibyte_char_does_not_panic() {
124        // Regression: `tz_digits.len() == 4` is a *byte* length, but slicing
125        // `[..2]`/`[2..]` assumes char boundaries. A 4-byte UTF-8 char (byte
126        // length 4) is not sliceable at index 2. Found by the commit fuzzer:
127        // "end byte index 2 is not a char boundary; it is inside '\u{d168a}'".
128        let sig = parse_signature("n <e> 0 \u{d168a}").expect("parses");
129        assert_eq!(sig.tz_offset_secs, 0, "malformed tz degrades to offset 0");
130    }
131
132    #[test]
133    fn ascii_tz_offset_still_parses() {
134        let sig = parse_signature("Ada <ada@x> 1136239445 +0530").expect("parses");
135        assert_eq!(sig.tz_offset_secs, 5 * 3600 + 30 * 60);
136        let neg = parse_signature("Ada <ada@x> 1136239445 -0800").expect("parses");
137        assert_eq!(neg.tz_offset_secs, -8 * 3600);
138    }
139}