Skip to main content

weavatrix_git/
object.rs

1use crate::{HashKind, ObjectId, Result, error::invalid};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum ObjectKind {
5    Commit,
6    Tree,
7    Blob,
8    Tag,
9}
10
11impl ObjectKind {
12    pub(crate) fn parse(value: &[u8]) -> Result<Self> {
13        match value {
14            b"commit" => Ok(Self::Commit),
15            b"tree" => Ok(Self::Tree),
16            b"blob" => Ok(Self::Blob),
17            b"tag" => Ok(Self::Tag),
18            _ => Err(invalid(format!(
19                "unknown object kind {}",
20                String::from_utf8_lossy(value)
21            ))),
22        }
23    }
24
25    #[must_use]
26    pub const fn as_str(self) -> &'static str {
27        match self {
28            Self::Commit => "commit",
29            Self::Tree => "tree",
30            Self::Blob => "blob",
31            Self::Tag => "tag",
32        }
33    }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Object {
38    pub id: ObjectId,
39    pub kind: ObjectKind,
40    pub data: Vec<u8>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Signature {
45    pub name: String,
46    pub email: String,
47    pub timestamp: i64,
48    pub timezone_minutes: i16,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Commit {
53    pub id: ObjectId,
54    pub tree: ObjectId,
55    pub parents: Vec<ObjectId>,
56    pub author: Option<Signature>,
57    pub committer: Option<Signature>,
58    pub encoding: Option<String>,
59    pub message: Vec<u8>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct CommitMetadata {
64    pub id: ObjectId,
65    pub tree: ObjectId,
66    pub parents: Vec<ObjectId>,
67    pub committer_time: i64,
68}
69
70impl Commit {
71    pub(crate) fn parse(id: ObjectId, data: &[u8], max_parents: usize) -> Result<Self> {
72        let (headers, message) = split_headers(data)?;
73        let mut tree = None;
74        let mut parents = Vec::new();
75        let mut author = None;
76        let mut committer = None;
77        let mut encoding = None;
78        for (name, value) in headers {
79            match name {
80                b"tree" => tree = Some(parse_oid(value, id.kind())?),
81                b"parent" => {
82                    if parents.len() >= max_parents {
83                        return Err(crate::GitError::LimitExceeded {
84                            resource: "commit parents",
85                            limit: max_parents,
86                        });
87                    }
88                    parents.push(parse_oid(value, id.kind())?);
89                }
90                b"author" => author = parse_signature(value),
91                b"committer" => committer = parse_signature(value),
92                b"encoding" => encoding = Some(String::from_utf8_lossy(value).into_owned()),
93                _ => {}
94            }
95        }
96        Ok(Self {
97            id,
98            tree: tree.ok_or_else(|| invalid("commit has no tree header"))?,
99            parents,
100            author,
101            committer,
102            encoding,
103            message: message.to_vec(),
104        })
105    }
106
107    #[must_use]
108    pub fn message_lossy(&self) -> String {
109        String::from_utf8_lossy(&self.message).into_owned()
110    }
111
112    #[must_use]
113    pub fn summary_lossy(&self) -> String {
114        let message = String::from_utf8_lossy(&self.message);
115        message.lines().next().unwrap_or_default().to_owned()
116    }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct Tag {
121    pub id: ObjectId,
122    pub target: ObjectId,
123    pub target_kind: ObjectKind,
124    pub name: String,
125    pub tagger: Option<Signature>,
126    pub message: Vec<u8>,
127}
128
129impl Tag {
130    pub(crate) fn parse(id: ObjectId, data: &[u8]) -> Result<Self> {
131        let (headers, message) = split_headers(data)?;
132        let mut target = None;
133        let mut target_kind = None;
134        let mut name = None;
135        let mut tagger = None;
136        for (key, value) in headers {
137            match key {
138                b"object" => target = Some(parse_oid(value, id.kind())?),
139                b"type" => target_kind = Some(ObjectKind::parse(value)?),
140                b"tag" => name = Some(String::from_utf8_lossy(value).into_owned()),
141                b"tagger" => tagger = parse_signature(value),
142                _ => {}
143            }
144        }
145        Ok(Self {
146            id,
147            target: target.ok_or_else(|| invalid("tag has no object header"))?,
148            target_kind: target_kind.ok_or_else(|| invalid("tag has no type header"))?,
149            name: name.ok_or_else(|| invalid("tag has no tag header"))?,
150            tagger,
151            message: message.to_vec(),
152        })
153    }
154}
155
156type Headers<'a> = Vec<(&'a [u8], &'a [u8])>;
157
158fn split_headers(data: &[u8]) -> Result<(Headers<'_>, &[u8])> {
159    let separator = data
160        .windows(2)
161        .position(|value| value == b"\n\n")
162        .ok_or_else(|| invalid("object headers are not terminated"))?;
163    let mut headers = Vec::new();
164    for line in data[..separator].split(|byte| *byte == b'\n') {
165        if line.first() == Some(&b' ') {
166            continue;
167        }
168        let split = line
169            .iter()
170            .position(|byte| *byte == b' ')
171            .ok_or_else(|| invalid("object header has no value"))?;
172        headers.push((&line[..split], &line[split + 1..]));
173    }
174    Ok((headers, &data[separator + 2..]))
175}
176
177fn parse_oid(value: &[u8], kind: HashKind) -> Result<ObjectId> {
178    ObjectId::from_hex_for(
179        std::str::from_utf8(value).map_err(|_| invalid("object id is not ASCII"))?,
180        kind,
181    )
182}
183
184pub(crate) fn parse_signature(value: &[u8]) -> Option<Signature> {
185    let text = std::str::from_utf8(value).ok()?;
186    let email_start = text.rfind(" <")?;
187    let email_end = text[email_start + 2..]
188        .find("> ")?
189        .saturating_add(email_start + 2);
190    let mut tail = text[email_end + 2..].split_ascii_whitespace();
191    let timestamp = tail.next()?.parse().ok()?;
192    let timezone = tail.next()?;
193    if timezone.len() != 5 {
194        return None;
195    }
196    let sign = if timezone.starts_with('-') { -1 } else { 1 };
197    let hours: i16 = timezone[1..3].parse().ok()?;
198    let minutes: i16 = timezone[3..5].parse().ok()?;
199    Some(Signature {
200        name: text[..email_start].to_owned(),
201        email: text[email_start + 2..email_end].to_owned(),
202        timestamp,
203        timezone_minutes: sign * (hours * 60 + minutes),
204    })
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn parses_commit_headers_and_signature() {
213        let id: ObjectId = "1111111111111111111111111111111111111111".parse().unwrap();
214        let tree = "2222222222222222222222222222222222222222";
215        let raw = format!(
216            "tree {tree}\nauthor Ada <ada@example.com> 42 +0230\ncommitter Ada <ada@example.com> 43 +0230\n\nsubject\nbody\n"
217        );
218        let commit = Commit::parse(id, raw.as_bytes(), 16).unwrap();
219        assert_eq!(commit.tree.to_string(), tree);
220        assert_eq!(commit.summary_lossy(), "subject");
221        assert_eq!(commit.author.unwrap().timezone_minutes, 150);
222    }
223
224    #[test]
225    fn parses_parents_encoding_and_annotated_tag() {
226        let id: ObjectId = "1111111111111111111111111111111111111111".parse().unwrap();
227        let target = "2222222222222222222222222222222222222222";
228        let raw = format!(
229            "tree {target}\nparent {target}\nencoding ISO-8859-1\n\
230             author Invalid\ncommitter Ada <ada@example.com> 43 -0130\n\nmessage"
231        );
232        let commit = Commit::parse(id, raw.as_bytes(), 2).unwrap();
233        assert_eq!(commit.parents.len(), 1);
234        assert_eq!(commit.encoding.as_deref(), Some("ISO-8859-1"));
235        assert!(commit.author.is_none());
236        assert_eq!(commit.committer.as_ref().unwrap().timezone_minutes, -90);
237        assert_eq!(commit.message_lossy(), "message");
238
239        let raw = format!(
240            "object {target}\ntype blob\ntag v1.0\n\
241             tagger Ada <ada@example.com> 44 +0000\n\nrelease"
242        );
243        let tag = Tag::parse(id, raw.as_bytes()).unwrap();
244        assert_eq!(tag.target.to_string(), target);
245        assert_eq!(tag.target_kind, ObjectKind::Blob);
246        assert_eq!(tag.name, "v1.0");
247        assert_eq!(tag.message, b"release");
248    }
249
250    #[test]
251    fn rejects_malformed_objects_and_parent_limit() {
252        let id: ObjectId = "1111111111111111111111111111111111111111".parse().unwrap();
253        assert!(Commit::parse(id, b"author x\n\nmessage", 1).is_err());
254        let target = "2222222222222222222222222222222222222222";
255        let raw = format!("tree {target}\nparent {target}\nparent {target}\n\nmessage");
256        assert!(Commit::parse(id, raw.as_bytes(), 1).is_err());
257        assert!(Tag::parse(id, b"object bad\n\nmessage").is_err());
258        assert!(ObjectKind::parse(b"unknown").is_err());
259        assert_eq!(ObjectKind::Tree.as_str(), "tree");
260        assert_eq!(ObjectKind::Tag.as_str(), "tag");
261    }
262}