From 793f53b5f0fe2671e57032160faa6806d50f3371 Mon Sep 17 00:00:00 2001 From: Lorenz Leutgeb Date: Thu, 2 Oct 2025 23:42:58 +0200 Subject: [PATCH] oid: New crate For further details on the crate see its documentation in `crates/radicle-oid/src/lib.rs`. Changes to `crates/radicle{,-crypto}/Cargo.toml` are to preserve the default features of their dependencies `serde` and `schemars`, which are disabled to support `radicle-oid` being `no_std`. --- Cargo.lock | 13 + Cargo.toml | 7 +- crates/radicle-crypto/Cargo.toml | 2 +- crates/radicle-oid/Cargo.toml | 31 ++ crates/radicle-oid/src/lib.rs | 575 +++++++++++++++++++++++++++++++ crates/radicle/Cargo.toml | 2 +- 6 files changed, 626 insertions(+), 4 deletions(-) create mode 100644 crates/radicle-oid/Cargo.toml create mode 100644 crates/radicle-oid/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 77c0662b..f3be1efd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2960,6 +2960,19 @@ dependencies = [ "winpipe", ] +[[package]] +name = "radicle-oid" +version = "0.1.0" +dependencies = [ + "git2", + "gix-hash", + "qcheck", + "qcheck-macros", + "radicle-git-ref-format", + "schemars", + "serde", +] + [[package]] name = "radicle-protocol" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 56580781..185eedb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ cyphernet = "0.5.2" dunce = "1.0.5" fastrand = { version = "2.0.0", default-features = false } git-ref-format-core = { version = "0.3.0", default-features = false } +git-ref-format = { version = "0.3.0", default-features = false } git2 = { version = "0.19.0", default-features = false } human-panic = "2" itertools = "0.14" @@ -49,14 +50,16 @@ radicle-crypto = { version = "0.14", path = "crates/radicle-crypto" } radicle-dag = { version = "0.10", path = "crates/radicle-dag" } radicle-fetch = { version = "0.16", path = "crates/radicle-fetch" } radicle-git-ext = { version = "0.8", default-features = false } +radicle-git-ref-format = { version = "0.1.0", path = "crates/radicle-git-ref-format", default-features = false } radicle-node = { version = "0.16", path = "crates/radicle-node" } +radicle-oid = { version = "0.1.0", path = "crates/radicle-oid", default-features = false } radicle-protocol = { version = "0.4", path = "crates/radicle-protocol" } radicle-signals = { version = "0.11", path = "crates/radicle-signals" } radicle-ssh = { version = "0.10", path = "crates/radicle-ssh", default-features = false } radicle-systemd = { version = "0.11", path = "crates/radicle-systemd" } radicle-term = { version = "0.16", path = "crates/radicle-term" } -schemars = { version = "1.0.4" } -serde = "1.0" +schemars = { version = "1.0.4", default-features = false } +serde = { version = "1.0", default-features = false } serde_json = "1.0" shlex = "1.1.0" signature = "2.2" diff --git a/crates/radicle-crypto/Cargo.toml b/crates/radicle-crypto/Cargo.toml index e3533d23..2315128f 100644 --- a/crates/radicle-crypto/Cargo.toml +++ b/crates/radicle-crypto/Cargo.toml @@ -25,7 +25,7 @@ multibase = { workspace = true } qcheck = { workspace = true, optional = true } git-ref-format-core = { workspace = true, optional = true } radicle-ssh = { workspace = true, optional = true } -serde = { workspace = true, features = ["derive"] } +serde = { workspace = true, features = ["derive", "std"] } signature = { workspace = true } sqlite = { workspace = true, features = ["bundled"], optional = true } ssh-key = { version = "0.6.3", default-features = false, features = ["std", "encryption", "getrandom"], optional = true } diff --git a/crates/radicle-oid/Cargo.toml b/crates/radicle-oid/Cargo.toml new file mode 100644 index 00000000..da2ad495 --- /dev/null +++ b/crates/radicle-oid/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "radicle-oid" +description = "Radicle representation of Git object identifiers" +homepage.workspace = true +repository.workspace = true +version = "0.1.0" +edition.workspace = true +license.workspace = true +keywords = ["radicle", "git", "oid"] +rust-version.workspace = true + +# For documentation of features refer to the module documentation in `./lib.rs` +[features] +default = ["sha1", "std"] +gix = ["dep:gix-hash"] +std = [] +sha1 = [] + +[dependencies] +git2 = { workspace = true, optional = true, default-features = false } +gix-hash = { version = "0.15.1", optional = true, default-features = false } +qcheck = { workspace = true, optional = true, default-features = false } +radicle-git-ref-format = { workspace = true, optional = true, default-features = false } +schemars = { workspace = true, optional = true, default-features = false } +serde = { workspace = true, optional = true, default-features = false } + +[dev-dependencies] +git2 = { workspace = true } +gix-hash = { version = "0.15.1" } +qcheck = { workspace = true } +qcheck-macros = { workspace = true } \ No newline at end of file diff --git a/crates/radicle-oid/src/lib.rs b/crates/radicle-oid/src/lib.rs new file mode 100644 index 00000000..e01fc7dd --- /dev/null +++ b/crates/radicle-oid/src/lib.rs @@ -0,0 +1,575 @@ +#![no_std] + +//! This is a `no_std` crate which carries the struct [`Oid`] that represents +//! Git object identifiers. Currently, only SHA-1 digests are supported. +//! +//! # Feature Flags +//! +//! The default features are `sha1` and `std`. +//! +//! ## `sha1` +//! +//! Enabled by default, since SHA-1 is commonly used. Currently, this feature is +//! also *required* to build the crate. In the future, after support for other +//! hashes is added, it might become possible to build the crate without support +//! for SHA-1. +//! +//! ## `std` +//! +//! [`Hash`]: ::doc_std::hash::Hash +//! +//! Enabled by default, since it is expected that most dependents will use the +//! standard library. +//! +//! Provides an implementation of [`Hash`]. +//! +//! ## `git2` +//! +//! [`git2::Oid`]: ::git2::Oid +//! +//! Provides conversions to/from [`git2::Oid`]. +//! +//! Note that as of version 0.19.0, +//! +//! ## `gix` +//! +//! [`ObjectId`]: ::gix_hash::ObjectId +//! +//! Provides conversions to/from [`ObjectId`]. +//! +//! ## `schemars` +//! +//! [`JsonSchema`]: ::schemars::JsonSchema +//! +//! Provides an implementation of [`JsonSchema`]. +//! +//! ## `serde` +//! +//! [`Serialize`]: ::serde::ser::Serialize +//! [`Deserialize`]: ::serde::de::Deserialize +//! +//! Provides implementations of [`Serialize`] and [`Deserialize`]. +//! +//! ## `qcheck` +//! +//! [`qcheck::Arbitrary`]: ::qcheck::Arbitrary +//! +//! Provides an implementation of [`qcheck::Arbitrary`]. +//! +//! ## `radicle-git-ref-format` +//! +//! [`radicle_git_ref_format::Component`]: ::radicle_git_ref_format::Component +//! [`radicle_git_ref_format::RefString`]: ::radicle_git_ref_format::RefString +//! +//! Conversion to [`radicle_git_ref_format::Component`] +//! (and also [`radicle_git_ref_format::RefString`]). + +#[cfg(doc)] +extern crate std as doc_std; + +extern crate alloc; + +// Remove this once other hashes (e.g., SHA-256, and potentially others) +// are supported, and this crate can build without [`Oid::Sha1`]. +#[cfg(not(feature = "sha1"))] +compile_error!("The `sha1` feature is required."); + +const SHA1_DIGEST_LEN: usize = 20; + +#[derive(PartialEq, Eq, Ord, PartialOrd, Clone, Copy)] +#[non_exhaustive] +pub enum Oid { + Sha1([u8; SHA1_DIGEST_LEN]), +} + +/// Conversions to/from SHA-1. +// Note that we deliberately do not implement `From<[u8; 20]>` and `Into<[u8; 20]>`, +// for forwards compatibility: What if another hash with digests of the same +// length becomes popular? +impl Oid { + pub fn from_sha1(digest: [u8; SHA1_DIGEST_LEN]) -> Self { + Self::Sha1(digest) + } + + pub fn into_sha1(&self) -> Option<[u8; SHA1_DIGEST_LEN]> { + match self { + Oid::Sha1(digest) => Some(*digest), + } + } + + pub fn sha1_zero() -> Self { + Self::Sha1([0u8; SHA1_DIGEST_LEN]) + } +} + +/// Interaction with zero. +impl Oid { + /// Test whether all bytes in this object identifier are zero. + /// See also [`::git2::Oid::is_zero`]. + pub fn is_zero(&self) -> bool { + match self { + Oid::Sha1(ref array) => array.iter().all(|b| *b == 0), + } + } +} + +impl AsRef<[u8]> for Oid { + fn as_ref(&self) -> &[u8] { + match self { + Oid::Sha1(ref array) => array, + } + } +} + +impl From for alloc::boxed::Box<[u8]> { + fn from(oid: Oid) -> Self { + match oid { + Oid::Sha1(array) => alloc::boxed::Box::new(array), + } + } +} + +pub mod str { + use super::{Oid, SHA1_DIGEST_LEN}; + use core::str; + + /// Length of the string representation of a SHA-1 digest in hexadecimal notation. + pub(super) const SHA1_DIGEST_STR_LEN: usize = SHA1_DIGEST_LEN * 2; + + impl str::FromStr for Oid { + type Err = error::ParseOidError; + + fn from_str(s: &str) -> Result { + use error::ParseOidError::*; + + let len = s.len(); + if len != SHA1_DIGEST_STR_LEN { + return Err(Len(len)); + } + + let mut bytes = [0u8; SHA1_DIGEST_LEN]; + for i in 0..SHA1_DIGEST_LEN { + bytes[i] = u8::from_str_radix(&s[i * 2..=i * 2 + 1], 16) + .map_err(|source| At { index: i, source })?; + } + + Ok(Self::Sha1(bytes)) + } + } + + pub mod error { + use core::{fmt, num}; + + use super::SHA1_DIGEST_STR_LEN; + + pub enum ParseOidError { + Len(usize), + At { + index: usize, + source: num::ParseIntError, + }, + } + + impl fmt::Display for ParseOidError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use ParseOidError::*; + match self { + Len(len) => { + write!(f, "invalid length (have {len}, want {SHA1_DIGEST_STR_LEN})") + } + At { index, source } => write!( + f, + "parse error at byte {index} (characters {} and {}): {source}", + index * 2, + index * 2 + 1 + ), + } + } + } + + impl fmt::Debug for ParseOidError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } + } + + impl core::error::Error for ParseOidError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + ParseOidError::At { source, .. } => Some(source), + _ => None, + } + } + } + } + + pub use error::ParseOidError; + + #[cfg(test)] + mod test { + use super::*; + use alloc::string::ToString; + use qcheck_macros::quickcheck; + + #[test] + fn fixture() { + assert_eq!( + "123456789abcdef0123456789abcdef012345678" + .parse::() + .unwrap(), + Oid::from_sha1([ + 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, + 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, + ]) + ); + } + + #[test] + fn zero() { + assert_eq!( + "0000000000000000000000000000000000000000" + .parse::() + .unwrap(), + Oid::sha1_zero() + ); + } + + #[quickcheck] + fn git2_roundtrip(oid: Oid) { + let other = git2::Oid::from(oid); + let other = other.to_string(); + let other = other.parse::().unwrap(); + assert_eq!(oid, other); + } + + #[quickcheck] + fn gix_roundrip(oid: Oid) { + let other = gix_hash::ObjectId::from(oid); + let other = other.to_string(); + let other = other.parse::().unwrap(); + assert_eq!(oid, other); + } + } +} + +mod fmt { + use alloc::format; + use core::fmt; + + use super::Oid; + + impl fmt::Display for Oid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Oid::Sha1(digest) => + // SAFETY (for all 20 blocks below): The length of `digest` is + // known to be `SHA1_DIGEST_LEN`, which is 20. + // The indices below are manually verified to not be out of bounds. + format!( + "{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + unsafe { digest.get_unchecked(0) }, + unsafe { digest.get_unchecked(1) }, + unsafe { digest.get_unchecked(2) }, + unsafe { digest.get_unchecked(3) }, + unsafe { digest.get_unchecked(4) }, + unsafe { digest.get_unchecked(5) }, + unsafe { digest.get_unchecked(6) }, + unsafe { digest.get_unchecked(7) }, + unsafe { digest.get_unchecked(8) }, + unsafe { digest.get_unchecked(9) }, + unsafe { digest.get_unchecked(10) }, + unsafe { digest.get_unchecked(11) }, + unsafe { digest.get_unchecked(12) }, + unsafe { digest.get_unchecked(13) }, + unsafe { digest.get_unchecked(14) }, + unsafe { digest.get_unchecked(15) }, + unsafe { digest.get_unchecked(16) }, + unsafe { digest.get_unchecked(17) }, + unsafe { digest.get_unchecked(18) }, + unsafe { digest.get_unchecked(19) }, + ).fmt(f) + } + } + } + + impl fmt::Debug for Oid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } + } + + #[cfg(test)] + mod test { + use super::*; + use alloc::string::ToString; + use qcheck_macros::quickcheck; + + #[test] + fn fixture() { + assert_eq!( + Oid::from_sha1([ + 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, + 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, + ]) + .to_string(), + "123456789abcdef0123456789abcdef012345678" + ); + } + + #[test] + fn zero() { + assert_eq!( + Oid::sha1_zero().to_string(), + "0000000000000000000000000000000000000000" + ); + } + + #[quickcheck] + fn git2(oid: Oid) { + assert_eq!(oid.to_string(), git2::Oid::from(oid).to_string()); + } + + #[quickcheck] + fn gix(oid: Oid) { + assert_eq!(oid.to_string(), gix_hash::ObjectId::from(oid).to_string()); + } + } +} + +#[cfg(feature = "std")] +mod std { + extern crate std; + + use super::Oid; + + mod hash { + use std::hash; + + use super::*; + + #[allow(clippy::derived_hash_with_manual_eq)] + impl hash::Hash for Oid { + fn hash(&self, state: &mut H) { + state.write(self.as_ref()); + } + } + } +} + +#[cfg(any(feature = "gix", test))] +mod gix { + use gix_hash::ObjectId as Other; + + use super::Oid; + + impl From for Oid { + fn from(other: Other) -> Self { + match other { + Other::Sha1(digest) => Self::Sha1(digest), + } + } + } + + impl From for Other { + fn from(oid: Oid) -> Other { + match oid { + Oid::Sha1(digest) => Other::Sha1(digest), + } + } + } + + impl core::cmp::PartialEq for Oid { + fn eq(&self, other: &Other) -> bool { + match (self, other) { + (Oid::Sha1(a), Other::Sha1(b)) => a == b, + } + } + } + + impl AsRef for Oid { + fn as_ref(&self) -> &gix_hash::oid { + match self { + Oid::Sha1(digest) => gix_hash::oid::from_bytes_unchecked(digest), + } + } + } + + #[cfg(test)] + mod test { + use super::*; + use gix_hash::Kind; + + #[test] + fn zero() { + assert!(Oid::sha1_zero() == Other::null(Kind::Sha1)); + } + } +} + +#[cfg(any(feature = "git2", test))] +mod git2 { + use ::git2::Oid as Other; + + use super::*; + + const EXPECT: &str = "git2::Oid must be exactly 20 bytes long"; + + impl From for Oid { + fn from(other: Other) -> Self { + Self::Sha1(other.as_bytes().try_into().expect(EXPECT)) + } + } + + impl From for Other { + fn from(oid: Oid) -> Self { + match oid { + Oid::Sha1(array) => Other::from_bytes(&array).expect(EXPECT), + } + } + } + + impl From<&Oid> for Other { + fn from(oid: &Oid) -> Self { + match oid { + Oid::Sha1(array) => Other::from_bytes(array).expect(EXPECT), + } + } + } + + impl core::cmp::PartialEq for Oid { + fn eq(&self, other: &Other) -> bool { + other.as_bytes() == AsRef::<[u8]>::as_ref(&self) + } + } + + #[cfg(test)] + mod test { + use super::*; + + #[test] + fn zero() { + assert!(Oid::sha1_zero() == Other::zero()); + } + } +} + +#[cfg(any(test, feature = "qcheck"))] +mod test { + mod qcheck { + use ::qcheck::{Arbitrary, Gen}; + + use crate::*; + + impl Arbitrary for Oid { + fn arbitrary(g: &mut Gen) -> Self { + let slice = [0u8; SHA1_DIGEST_LEN]; + g.fill(slice); + Self::Sha1(slice) + } + } + } +} + +#[cfg(feature = "serde")] +mod serde { + mod ser { + use ::serde::ser; + + use crate::*; + + impl ser::Serialize for Oid { + fn serialize(&self, serializer: S) -> Result + where + S: ser::Serializer, + { + serializer.collect_str(self) + } + } + } + + mod de { + use core::fmt; + + use ::serde::de; + + use crate::*; + + impl<'de> de::Deserialize<'de> for Oid { + fn deserialize(deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + struct OidVisitor; + + impl<'de> de::Visitor<'de> for OidVisitor { + type Value = Oid; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + use crate::str::SHA1_DIGEST_STR_LEN; + write!(f, "a Git object identifier (SHA-1 digest in hexadecimal notation; {SHA1_DIGEST_STR_LEN} characters; {SHA1_DIGEST_LEN} bytes)") + } + + fn visit_str(self, s: &str) -> Result + where + E: de::Error, + { + s.parse().map_err(de::Error::custom) + } + } + + deserializer.deserialize_str(OidVisitor) + } + } + } +} + +#[cfg(feature = "radicle-git-ref-format")] +mod radicle_git_ref_format { + use ::radicle_git_ref_format::{Component, RefString}; + + use super::*; + + impl From<&Oid> for Component<'_> { + fn from(id: &Oid) -> Self { + Component::from_refstr(RefString::from(id)) + .expect("Git object identifiers are valid component strings") + } + } + + impl From<&Oid> for RefString { + fn from(id: &Oid) -> Self { + RefString::try_from(alloc::format!("{id}")) + .expect("Git object identifiers are valid reference strings") + } + } +} + +#[cfg(feature = "schemars")] +mod schemars { + use alloc::{borrow::Cow, format}; + + use ::schemars::{json_schema, JsonSchema, Schema, SchemaGenerator}; + + use super::Oid; + + impl JsonSchema for Oid { + fn schema_name() -> Cow<'static, str> { + "Oid".into() + } + + fn schema_id() -> Cow<'static, str> { + concat!(module_path!(), "::Oid").into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + use crate::{str::SHA1_DIGEST_STR_LEN, SHA1_DIGEST_LEN}; + json_schema!({ + "description": format!("A Git object identifier (SHA-1 digest in hexadecimal notation; {SHA1_DIGEST_STR_LEN} characters; {SHA1_DIGEST_LEN} bytes)"), + "type": "string", + "maxLength": SHA1_DIGEST_STR_LEN, + "minLength": SHA1_DIGEST_STR_LEN, + "pattern": format!("^[0-9a-fA-F]{{{SHA1_DIGEST_STR_LEN}}}$"), + }) + } + } +} diff --git a/crates/radicle/Cargo.toml b/crates/radicle/Cargo.toml index 0eb90480..4135fc49 100644 --- a/crates/radicle/Cargo.toml +++ b/crates/radicle/Cargo.toml @@ -36,7 +36,7 @@ radicle-cob = { workspace = true } radicle-crypto = { workspace = true, features = ["git-ref-format-core", "ssh", "sqlite", "cyphernet"] } radicle-git-ext = { workspace = true, features = ["serde"] } radicle-ssh = { workspace = true } -schemars = { workspace = true, optional = true } +schemars = { workspace = true, optional = true, features = ["derive", "std"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } serde-untagged = "0.1.7"