Skip to main content

radicle_oid/
lib.rs

1#![no_std]
2
3//! This is a `no_std` crate which carries the struct [`Oid`] that represents
4//! Git object identifiers. Currently, only SHA-1 digests are supported.
5//!
6//! # Feature Flags
7//!
8//! The default features are `sha1` and `std`.
9//!
10//! ## `sha1`
11//!
12//! Enabled by default, since SHA-1 is commonly used. Currently, this feature is
13//! also *required* to build the crate. In the future, after support for other
14//! hashes is added, it might become possible to build the crate without support
15//! for SHA-1.
16//!
17//! ## `std`
18//!
19//! [`Hash`]: ::doc_std::hash::Hash
20//!
21//! Enabled by default, since it is expected that most dependents will use the
22//! standard library.
23//!
24//! Provides an implementation of [`Hash`].
25//!
26//! ## `git2`
27//!
28//! [`git2::Oid`]: ::git2::Oid
29//!
30//! Provides conversions to/from [`git2::Oid`].
31//!
32//! Note that as of version 0.19.0,
33//!
34//! ## `gix`
35//!
36//! [`ObjectId`]: ::gix_hash::ObjectId
37//!
38//! Provides conversions to/from [`ObjectId`].
39//!
40//! ## `schemars`
41//!
42//! [`JsonSchema`]: ::schemars::JsonSchema
43//!
44//! Provides an implementation of [`JsonSchema`].
45//!
46//! ## `serde`
47//!
48//! [`Serialize`]: ::serde::ser::Serialize
49//! [`Deserialize`]: ::serde::de::Deserialize
50//!
51//! Provides implementations of [`Serialize`] and [`Deserialize`].
52//!
53//! ## `qcheck`
54//!
55//! [`qcheck::Arbitrary`]: ::qcheck::Arbitrary
56//!
57//! Provides an implementation of [`qcheck::Arbitrary`].
58//!
59//! ## `radicle-git-ref-format`
60//!
61//! [`radicle_git_ref_format::Component`]: ::radicle_git_ref_format::Component
62//! [`radicle_git_ref_format::RefString`]: ::radicle_git_ref_format::RefString
63//!
64//! Conversion to [`radicle_git_ref_format::Component`]
65//! (and also [`radicle_git_ref_format::RefString`]).
66
67#[cfg(doc)]
68extern crate std as doc_std;
69
70extern crate alloc;
71
72// Remove this once other hashes (e.g., SHA-256, and potentially others)
73// are supported, and this crate can build without [`Oid::Sha1`].
74#[cfg(not(feature = "sha1"))]
75compile_error!("The `sha1` feature is required.");
76
77#[derive(PartialEq, Eq, Ord, PartialOrd, Clone, Copy)]
78#[non_exhaustive]
79pub enum Oid {
80    Sha1([u8; Self::LEN_SHA1]),
81}
82
83/// Conversions to/from SHA-1.
84// Note that we deliberately do not implement `From<[u8; 20]>` and `Into<[u8; 20]>`,
85// for forwards compatibility: What if another hash with digests of the same
86// length becomes popular?
87impl Oid {
88    /// The length of a SHA-1 object identifier in bytes.
89    pub const LEN_SHA1: usize = 20;
90
91    /// A SHA-1 object identifier with all digest bytes set to zero.
92    /// This is sometimes used as a sentinel value to indicate the absence of
93    /// an object.
94    /// To compare whether an object identifier is zero, prefer the method
95    /// [`Oid::is_zero`] over checking equality with this constant.
96    pub const ZERO_SHA1: Self = Self::Sha1([0u8; Self::LEN_SHA1]);
97
98    pub fn from_sha1(digest: [u8; Self::LEN_SHA1]) -> Self {
99        Self::Sha1(digest)
100    }
101
102    pub fn into_sha1(&self) -> Option<[u8; Self::LEN_SHA1]> {
103        match self {
104            Oid::Sha1(digest) => Some(*digest),
105        }
106    }
107}
108
109/// Interaction with zero.
110impl Oid {
111    /// Test whether all bytes in this object identifier are zero.
112    /// See also [`::git2::Oid::is_zero`].
113    pub fn is_zero(&self) -> bool {
114        match self {
115            Oid::Sha1(array) => array.iter().all(|b| *b == 0),
116        }
117    }
118}
119
120impl AsRef<[u8]> for Oid {
121    fn as_ref(&self) -> &[u8] {
122        match self {
123            Oid::Sha1(array) => array,
124        }
125    }
126}
127
128impl From<Oid> for alloc::boxed::Box<[u8]> {
129    fn from(oid: Oid) -> Self {
130        match oid {
131            Oid::Sha1(array) => alloc::boxed::Box::new(array),
132        }
133    }
134}
135
136pub mod str {
137    use super::Oid;
138    use core::str;
139
140    /// Length of the string representation of a SHA-1 digest in hexadecimal notation.
141    pub(super) const SHA1_DIGEST_STR_LEN: usize = Oid::LEN_SHA1 * 2;
142
143    impl str::FromStr for Oid {
144        type Err = error::ParseOidError;
145
146        fn from_str(s: &str) -> Result<Self, Self::Err> {
147            use error::ParseOidError::*;
148
149            let len = s.len();
150            if len != SHA1_DIGEST_STR_LEN {
151                return Err(Len(len));
152            }
153
154            let mut bytes = [0u8; Oid::LEN_SHA1];
155            for i in 0..Oid::LEN_SHA1 {
156                bytes[i] = u8::from_str_radix(&s[i * 2..=i * 2 + 1], 16)
157                    .map_err(|source| At { index: i, source })?;
158            }
159
160            Ok(Self::Sha1(bytes))
161        }
162    }
163
164    pub mod error {
165        use core::{fmt, num};
166
167        use super::SHA1_DIGEST_STR_LEN;
168
169        pub enum ParseOidError {
170            Len(usize),
171            At {
172                index: usize,
173                source: num::ParseIntError,
174            },
175        }
176
177        impl fmt::Display for ParseOidError {
178            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179                use ParseOidError::*;
180                match self {
181                    Len(len) => {
182                        write!(f, "invalid length (have {len}, want {SHA1_DIGEST_STR_LEN})")
183                    }
184                    At { index, source } => write!(
185                        f,
186                        "parse error at byte {index} (characters {} and {}): {source}",
187                        index * 2,
188                        index * 2 + 1
189                    ),
190                }
191            }
192        }
193
194        impl fmt::Debug for ParseOidError {
195            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196                fmt::Display::fmt(self, f)
197            }
198        }
199
200        impl core::error::Error for ParseOidError {
201            fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
202                match self {
203                    ParseOidError::At { source, .. } => Some(source),
204                    _ => None,
205                }
206            }
207        }
208    }
209
210    pub use error::ParseOidError;
211
212    #[cfg(test)]
213    mod test {
214        use super::*;
215        use alloc::string::ToString;
216        use qcheck_macros::quickcheck;
217
218        #[test]
219        fn fixture() {
220            assert_eq!(
221                "123456789abcdef0123456789abcdef012345678"
222                    .parse::<Oid>()
223                    .unwrap(),
224                Oid::from_sha1([
225                    0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a,
226                    0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78,
227                ])
228            );
229        }
230
231        #[test]
232        fn zero() {
233            assert_eq!(
234                "0000000000000000000000000000000000000000"
235                    .parse::<Oid>()
236                    .unwrap(),
237                Oid::ZERO_SHA1
238            );
239        }
240
241        #[quickcheck]
242        fn git2_roundtrip(oid: Oid) {
243            let other = git2::Oid::from(oid);
244            let other = other.to_string();
245            let other = other.parse::<Oid>().unwrap();
246            assert_eq!(oid, other);
247        }
248
249        #[quickcheck]
250        fn gix_roundtrip(oid: Oid) {
251            let other = gix_hash::ObjectId::from(oid);
252            let other = other.to_string();
253            let other = other.parse::<Oid>().unwrap();
254            assert_eq!(oid, other);
255        }
256    }
257}
258
259mod fmt {
260    use alloc::format;
261    use core::fmt;
262
263    use super::Oid;
264
265    impl fmt::Display for Oid {
266        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267            match self {
268                Oid::Sha1(digest) =>
269                // SAFETY (for all 20 blocks below): The length of `digest` is
270                // known to be `SHA1_DIGEST_LEN`, which is 20.
271                // The indices below are manually verified to not be out of bounds.
272                format!(
273                    "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
274                    unsafe { digest.get_unchecked(0) },
275                    unsafe { digest.get_unchecked(1) },
276                    unsafe { digest.get_unchecked(2) },
277                    unsafe { digest.get_unchecked(3) },
278                    unsafe { digest.get_unchecked(4) },
279                    unsafe { digest.get_unchecked(5) },
280                    unsafe { digest.get_unchecked(6) },
281                    unsafe { digest.get_unchecked(7) },
282                    unsafe { digest.get_unchecked(8) },
283                    unsafe { digest.get_unchecked(9) },
284                    unsafe { digest.get_unchecked(10) },
285                    unsafe { digest.get_unchecked(11) },
286                    unsafe { digest.get_unchecked(12) },
287                    unsafe { digest.get_unchecked(13) },
288                    unsafe { digest.get_unchecked(14) },
289                    unsafe { digest.get_unchecked(15) },
290                    unsafe { digest.get_unchecked(16) },
291                    unsafe { digest.get_unchecked(17) },
292                    unsafe { digest.get_unchecked(18) },
293                    unsafe { digest.get_unchecked(19) },
294                ).fmt(f)
295            }
296        }
297    }
298
299    impl fmt::Debug for Oid {
300        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301            fmt::Display::fmt(self, f)
302        }
303    }
304
305    #[cfg(test)]
306    mod test {
307        use super::*;
308        use alloc::string::ToString;
309        use qcheck_macros::quickcheck;
310
311        #[test]
312        fn fixture() {
313            assert_eq!(
314                Oid::from_sha1([
315                    0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a,
316                    0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78,
317                ])
318                .to_string(),
319                "123456789abcdef0123456789abcdef012345678"
320            );
321        }
322
323        #[test]
324        fn zero() {
325            assert_eq!(
326                Oid::ZERO_SHA1.to_string(),
327                "0000000000000000000000000000000000000000"
328            );
329        }
330
331        #[quickcheck]
332        fn git2(oid: Oid) {
333            assert_eq!(oid.to_string(), git2::Oid::from(oid).to_string());
334        }
335
336        #[quickcheck]
337        fn gix(oid: Oid) {
338            assert_eq!(oid.to_string(), gix_hash::ObjectId::from(oid).to_string());
339        }
340    }
341}
342
343#[cfg(feature = "std")]
344mod std {
345    extern crate std;
346
347    use super::Oid;
348
349    mod hash {
350        use std::hash;
351
352        use super::*;
353
354        #[allow(clippy::derived_hash_with_manual_eq)]
355        impl hash::Hash for Oid {
356            fn hash<H: hash::Hasher>(&self, state: &mut H) {
357                let bytes: &[u8] = self.as_ref();
358                std::hash::Hash::hash(bytes, state)
359            }
360        }
361    }
362}
363
364#[cfg(any(feature = "gix", test))]
365mod gix {
366    use gix_hash::ObjectId as Other;
367
368    use super::Oid;
369
370    impl From<Other> for Oid {
371        fn from(other: Other) -> Self {
372            match other {
373                Other::Sha1(digest) => Self::Sha1(digest),
374                _ => unimplemented!("conversion from {other:?} into radicle_oid::Oid"),
375            }
376        }
377    }
378
379    impl From<Oid> for Other {
380        fn from(oid: Oid) -> Other {
381            match oid {
382                Oid::Sha1(digest) => Other::Sha1(digest),
383            }
384        }
385    }
386
387    impl core::cmp::PartialEq<Other> for Oid {
388        fn eq(&self, other: &Other) -> bool {
389            match (self, other) {
390                (Oid::Sha1(a), Other::Sha1(b)) => a == b,
391                _ => unimplemented!("conversion from {other:?} into radicle_oid::Oid"),
392            }
393        }
394    }
395
396    impl AsRef<gix_hash::oid> for Oid {
397        fn as_ref(&self) -> &gix_hash::oid {
398            match self {
399                Oid::Sha1(digest) => gix_hash::oid::from_bytes_unchecked(digest),
400            }
401        }
402    }
403
404    #[cfg(test)]
405    mod test {
406        use super::*;
407        use gix_hash::Kind;
408
409        #[test]
410        fn zero() {
411            assert!(Oid::ZERO_SHA1 == Other::null(Kind::Sha1));
412        }
413    }
414}
415
416#[cfg(any(feature = "git2", test))]
417mod git2 {
418    use ::git2::Oid as Other;
419
420    use super::*;
421
422    const EXPECT: &str = "git2::Oid must be exactly 20 bytes long";
423
424    impl From<Other> for Oid {
425        fn from(other: Other) -> Self {
426            Self::Sha1(other.as_bytes().try_into().expect(EXPECT))
427        }
428    }
429
430    impl From<Oid> for Other {
431        fn from(oid: Oid) -> Self {
432            match oid {
433                Oid::Sha1(array) => Other::from_bytes(&array).expect(EXPECT),
434            }
435        }
436    }
437
438    impl From<&Oid> for Other {
439        fn from(oid: &Oid) -> Self {
440            match oid {
441                Oid::Sha1(array) => Other::from_bytes(array).expect(EXPECT),
442            }
443        }
444    }
445
446    impl core::cmp::PartialEq<Other> for Oid {
447        fn eq(&self, other: &Other) -> bool {
448            other.as_bytes() == AsRef::<[u8]>::as_ref(&self)
449        }
450    }
451
452    #[cfg(test)]
453    mod test {
454        use super::*;
455
456        #[test]
457        fn zero() {
458            assert!(Oid::ZERO_SHA1 == Other::zero());
459        }
460    }
461}
462
463#[cfg(any(test, feature = "qcheck"))]
464mod test {
465    mod qcheck {
466        use ::qcheck::{Arbitrary, Gen};
467
468        use crate::*;
469
470        impl Arbitrary for Oid {
471            fn arbitrary(g: &mut Gen) -> Self {
472                Self::Sha1(<[u8; Oid::LEN_SHA1]>::arbitrary(g))
473            }
474        }
475    }
476}
477
478#[cfg(feature = "serde")]
479mod serde {
480    mod ser {
481        use ::serde::ser;
482
483        use crate::*;
484
485        impl ser::Serialize for Oid {
486            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
487            where
488                S: ser::Serializer,
489            {
490                serializer.collect_str(self)
491            }
492        }
493    }
494
495    mod de {
496        use core::fmt;
497
498        use ::serde::de;
499
500        use crate::*;
501
502        impl<'de> de::Deserialize<'de> for Oid {
503            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
504            where
505                D: de::Deserializer<'de>,
506            {
507                struct OidVisitor;
508
509                impl<'de> de::Visitor<'de> for OidVisitor {
510                    type Value = Oid;
511
512                    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
513                        write!(
514                            f,
515                            "a Git object identifier (SHA-1 digest in hexadecimal notation; {} characters; {} bytes)",
516                            crate::str::SHA1_DIGEST_STR_LEN,
517                            Oid::LEN_SHA1
518                        )
519                    }
520
521                    fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
522                    where
523                        E: de::Error,
524                    {
525                        s.parse().map_err(de::Error::custom)
526                    }
527                }
528
529                deserializer.deserialize_str(OidVisitor)
530            }
531        }
532    }
533}
534
535#[cfg(feature = "radicle-git-ref-format")]
536mod radicle_git_ref_format {
537    use ::radicle_git_ref_format::{Component, RefString};
538
539    use super::*;
540
541    impl From<&Oid> for Component<'_> {
542        fn from(id: &Oid) -> Self {
543            Component::from_refstr(RefString::from(id))
544                .expect("Git object identifiers are valid component strings")
545        }
546    }
547
548    impl From<&Oid> for RefString {
549        fn from(id: &Oid) -> Self {
550            RefString::try_from(alloc::format!("{id}"))
551                .expect("Git object identifiers are valid reference strings")
552        }
553    }
554}
555
556#[cfg(feature = "schemars")]
557mod schemars {
558    use alloc::{borrow::Cow, format};
559
560    use ::schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
561
562    use super::Oid;
563
564    impl JsonSchema for Oid {
565        fn schema_name() -> Cow<'static, str> {
566            "Oid".into()
567        }
568
569        fn schema_id() -> Cow<'static, str> {
570            concat!(module_path!(), "::Oid").into()
571        }
572
573        fn json_schema(_: &mut SchemaGenerator) -> Schema {
574            use crate::str::SHA1_DIGEST_STR_LEN;
575            json_schema!({
576                "description": format!(
577                    "A Git object identifier (SHA-1 digest in hexadecimal notation; {SHA1_DIGEST_STR_LEN} characters; {} bytes)",
578                    Oid::LEN_SHA1,
579                ),
580                "type": "string",
581                "maxLength": SHA1_DIGEST_STR_LEN,
582                "minLength": SHA1_DIGEST_STR_LEN,
583                "pattern":  format!("^[0-9a-fA-F]{{{SHA1_DIGEST_STR_LEN}}}$"),
584            })
585        }
586    }
587}