Skip to main content

git_proc/
commit_id.rs

1//! Git commit object ID type with validation.
2
3crate::cow_str_newtype! {
4    /// A validated git commit object ID.
5    ///
6    /// Accepts full SHA-1 (40 hex characters) or SHA-256 (64 hex characters)
7    /// strings. Both lowercase and uppercase hex digits are allowed and the
8    /// input case is preserved.
9    ///
10    /// Abbreviated SHAs are deliberately rejected: this type is intended for
11    /// pinned-commit use cases where the caller has the full object ID.
12    pub struct CommitId, CommitIdError(InvalidCommitId), "invalid commit id"
13}
14
15impl CommitId {
16    const fn validate(input: &str) -> Result<(), CommitIdError> {
17        let bytes = input.as_bytes();
18        let len = bytes.len();
19
20        if len == 0 {
21            return Err(CommitIdError(InvalidCommitId::Empty));
22        }
23        if len != 40 && len != 64 {
24            return Err(CommitIdError(InvalidCommitId::InvalidLength));
25        }
26
27        let mut index = 0;
28        while index < bytes.len() {
29            if !bytes[index].is_ascii_hexdigit() {
30                return Err(CommitIdError(InvalidCommitId::ContainsNonHexCharacter));
31            }
32            index += 1;
33        }
34
35        Ok(())
36    }
37}
38
39/// Reasons a string fails to parse as a commit ID.
40#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
41pub enum InvalidCommitId {
42    #[error("commit id cannot be empty")]
43    Empty,
44    #[error("commit id must be 40 (SHA-1) or 64 (SHA-256) hex characters")]
45    InvalidLength,
46    #[error("commit id must contain only hex characters")]
47    ContainsNonHexCharacter,
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    const SHA1: &str = "0123456789abcdef0123456789abcdef01234567";
55    const SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
56
57    #[test]
58    fn test_valid_sha1() {
59        let id: CommitId = SHA1.parse().unwrap();
60        assert_eq!(id.as_str(), SHA1);
61    }
62
63    #[test]
64    fn test_valid_sha256() {
65        let id: CommitId = SHA256.parse().unwrap();
66        assert_eq!(id.as_str(), SHA256);
67    }
68
69    #[test]
70    fn test_uppercase_accepted() {
71        let id: CommitId = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF".parse().unwrap();
72        assert_eq!(id.as_str(), "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF");
73    }
74
75    #[test]
76    fn test_empty() {
77        assert!(matches!(
78            "".parse::<CommitId>(),
79            Err(CommitIdError(InvalidCommitId::Empty))
80        ));
81    }
82
83    #[test]
84    fn test_abbreviated_rejected() {
85        assert!(matches!(
86            "abc1234".parse::<CommitId>(),
87            Err(CommitIdError(InvalidCommitId::InvalidLength))
88        ));
89    }
90
91    #[test]
92    fn test_wrong_length() {
93        assert!(matches!(
94            "abc".parse::<CommitId>(),
95            Err(CommitIdError(InvalidCommitId::InvalidLength))
96        ));
97        // 41 chars
98        assert!(matches!(
99            "0123456789abcdef0123456789abcdef012345670".parse::<CommitId>(),
100            Err(CommitIdError(InvalidCommitId::InvalidLength))
101        ));
102    }
103
104    #[test]
105    fn test_non_hex() {
106        // 40 chars but contains 'g'
107        let bad = "g123456789abcdef0123456789abcdef01234567";
108        assert_eq!(bad.len(), 40);
109        assert!(matches!(
110            bad.parse::<CommitId>(),
111            Err(CommitIdError(InvalidCommitId::ContainsNonHexCharacter))
112        ));
113    }
114
115    #[test]
116    fn test_from_static_or_panic() {
117        let id = CommitId::from_static_or_panic(SHA1);
118        assert_eq!(id.as_str(), SHA1);
119    }
120
121    #[test]
122    fn test_serde_roundtrip() {
123        let id: CommitId = SHA1.parse().unwrap();
124        let json = serde_json::to_string(&id).unwrap();
125        assert_eq!(json, format!("\"{SHA1}\""));
126        let back: CommitId = serde_json::from_str(&json).unwrap();
127        assert_eq!(back.as_str(), SHA1);
128    }
129}