use std::fmt;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use sha2::{Digest as _, Sha256};
use crate::diagnostic::{Diagnostic, DiagnosticCategory};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FingerprintAlgorithm {
Sha256,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CanonicalizationVersion(String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FingerprintDomain(String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SemanticProfileId(String);
fn validate_name(
value: String,
kind: &'static str,
require_version: bool,
) -> Result<String, Diagnostic> {
let valid = !value.is_empty()
&& value.len() <= 255
&& value.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'/' | b'_' | b'-')
})
&& !value.starts_with(['.', '/', '_', '-'])
&& !value.ends_with(['.', '/', '_', '-'])
&& (!require_version
|| value.rsplit_once("/v").is_some_and(|(_, version)| {
!version.is_empty() && version.bytes().all(|b| b.is_ascii_digit())
}));
if valid {
Ok(value)
} else {
Err(Diagnostic::stable(
DiagnosticCategory::Integrity,
"invalid_fingerprint_identifier",
"fingerprint metadata identifier is malformed",
)
.with_detail("identifier_kind", kind))
}
}
macro_rules! fingerprint_name {
($name:ident, $versioned:expr) => {
impl $name {
pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
Ok(Self(validate_name(
value.into(),
stringify!($name),
$versioned,
)?))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for $name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Self::new(String::deserialize(deserializer)?).map_err(D::Error::custom)
}
}
};
}
fingerprint_name!(CanonicalizationVersion, true);
fingerprint_name!(FingerprintDomain, false);
fingerprint_name!(SemanticProfileId, true);
pub fn ensure_canonicalization_version(
actual: &CanonicalizationVersion,
supported: &CanonicalizationVersion,
) -> Result<(), Diagnostic> {
if actual == supported {
Ok(())
} else {
Err(Diagnostic::stable(
DiagnosticCategory::InvalidContract,
"unsupported_canonicalization_version",
"canonicalization version is not supported",
)
.with_detail("actual", actual.as_str().to_owned())
.with_detail("supported", supported.as_str().to_owned()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FingerprintDigest([u8; 32]);
impl FingerprintDigest {
pub fn from_hex(value: &str) -> Result<Self, Diagnostic> {
if value.len() != 64
|| value
.bytes()
.any(|b| !b.is_ascii_digit() && !(b'a'..=b'f').contains(&b))
{
return Err(Diagnostic::stable(
DiagnosticCategory::Integrity,
"invalid_fingerprint_digest",
"fingerprint digest must be 64 lowercase hexadecimal characters",
));
}
let mut bytes = [0_u8; 32];
for (index, output) in bytes.iter_mut().enumerate() {
*output = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).map_err(|_| {
Diagnostic::stable(
DiagnosticCategory::Integrity,
"invalid_fingerprint_digest",
"fingerprint digest contains invalid hexadecimal",
)
})?;
}
Ok(Self(bytes))
}
pub fn to_hex(self) -> String {
self.0.iter().map(|byte| format!("{byte:02x}")).collect()
}
pub const fn bytes(self) -> [u8; 32] {
self.0
}
}
impl Serialize for FingerprintDigest {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_hex())
}
}
impl<'de> Deserialize<'de> for FingerprintDigest {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Self::from_hex(&String::deserialize(deserializer)?).map_err(D::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fingerprint {
domain: FingerprintDomain,
algorithm: FingerprintAlgorithm,
canonicalization: CanonicalizationVersion,
#[serde(skip_serializing_if = "Option::is_none")]
semantic_profile: Option<SemanticProfileId>,
digest: FingerprintDigest,
}
impl Fingerprint {
pub fn compute(
domain: FingerprintDomain,
canonicalization: CanonicalizationVersion,
semantic_profile: Option<SemanticProfileId>,
canonical_bytes: &[u8],
) -> Self {
let mut hasher = Sha256::new();
hasher.update(b"typebridge.fingerprint/v1\0");
hash_field(&mut hasher, domain.as_str().as_bytes());
hash_field(&mut hasher, canonicalization.as_str().as_bytes());
match &semantic_profile {
Some(profile) => {
hasher.update([1]);
hash_field(&mut hasher, profile.as_str().as_bytes());
}
None => hasher.update([0]),
}
hash_field(&mut hasher, canonical_bytes);
let mut digest = [0_u8; 32];
digest.copy_from_slice(&hasher.finalize());
Self {
domain,
algorithm: FingerprintAlgorithm::Sha256,
canonicalization,
semantic_profile,
digest: FingerprintDigest(digest),
}
}
pub fn domain(&self) -> &FingerprintDomain {
&self.domain
}
pub const fn algorithm(&self) -> FingerprintAlgorithm {
self.algorithm
}
pub fn canonicalization(&self) -> &CanonicalizationVersion {
&self.canonicalization
}
pub fn semantic_profile(&self) -> Option<&SemanticProfileId> {
self.semantic_profile.as_ref()
}
pub const fn digest(&self) -> FingerprintDigest {
self.digest
}
}
fn hash_field(hasher: &mut Sha256, value: &[u8]) {
hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
hasher.update(value);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fingerprint_is_golden_and_domain_separated() {
let canonicalization =
CanonicalizationVersion::new("typebridge.canonical-json/v1").unwrap();
let payload = br#"{"kind":"long","value":"9007199254740993"}"#;
let first = Fingerprint::compute(
FingerprintDomain::new("test.value").unwrap(),
canonicalization.clone(),
None,
payload,
);
assert_eq!(
first.digest().to_hex(),
"cbe437dc731095f176ab19a4494c0ee53e491bded9a50627208a9bf022576ce9"
);
let other = Fingerprint::compute(
FingerprintDomain::new("test.other").unwrap(),
canonicalization,
None,
payload,
);
assert_ne!(first.digest(), other.digest());
}
#[test]
fn canonicalization_versions_validate_and_fail_closed() {
for value in [
"",
"typebridge.canonical-json",
"Typebridge.canonical-json/v1",
"typebridge.canonical-json/vx",
] {
assert_eq!(
CanonicalizationVersion::new(value)
.unwrap_err()
.code()
.as_str(),
"invalid_fingerprint_identifier",
);
}
let supported = CanonicalizationVersion::new("typebridge.canonical-json/v1").unwrap();
let unknown = CanonicalizationVersion::new("typebridge.canonical-json/v2").unwrap();
assert!(ensure_canonicalization_version(&supported, &supported).is_ok());
assert_eq!(
ensure_canonicalization_version(&unknown, &supported)
.unwrap_err()
.code()
.as_str(),
"unsupported_canonicalization_version",
);
}
}