use std::{fmt, str::FromStr}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use thiserror::Error; /// A lowercase SHA-256 digest. #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] pub struct Digest(String); impl Digest { /// Returns the canonical hexadecimal spelling. #[must_use] pub fn as_str(&self) -> &str { &self.0 } } impl FromStr for Digest { type Err = IdentifierError; fn from_str(value: &str) -> Result { if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err(IdentifierError::Digest); } Ok(Self(value.to_ascii_lowercase())) } } impl fmt::Display for Digest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.0) } } /// A random 128-bit invocation identifier encoded as 32 lowercase hex digits. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] pub struct InvocationId(String); impl InvocationId { /// Parses a canonical invocation identifier. pub fn parse(value: &str) -> Result { if value.len() != 32 || !value .bytes() .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) { return Err(IdentifierError::Invocation); } Ok(Self(value.to_owned())) } } /// Identifier validation failure. #[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] pub enum IdentifierError { /// SHA-256 spelling is invalid. #[error("digest must contain exactly 64 hexadecimal characters")] Digest, /// Invocation identifier spelling is invalid. #[error("invocation ID must contain exactly 32 lowercase hexadecimal characters")] Invocation, } #[cfg(test)] #[allow(clippy::expect_used)] mod tests { use std::str::FromStr as _; use super::Digest; #[test] fn digest_accepts_hex_and_canonicalizes_case() { let digest = Digest::from_str(&"A".repeat(64)).expect("valid digest"); assert_eq!(digest.as_str(), "a".repeat(64)); assert!(Digest::from_str(&"a".repeat(63)).is_err()); } }