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