Skip to main content

lace_sans_io/records/
hash.rs

1//! Fixed-size Lace H3 record hash text.
2//!
3//! Current record hashes are canonical ASCII text of the form
4//! `T.<43 B64A chars>.H3`, where `T` is `B`, `P`, or `S` and the payload is a
5//! 32-byte BLAKE3 digest encoded with Base64-Ascend.
6
7use std::fmt;
8use std::str::FromStr;
9
10use crate::typed_text::{
11    BLOB_RECORD_HASH_LEAD, PLEX_RECORD_HASH_LEAD, SEAL_RECORD_HASH_LEAD, TYPED_H3_TEXT_LEN,
12    TYPED_TEXT_B64A_LEN, TypedH3Text, TypedTextError,
13};
14
15pub const RECORD_HASH_TEXT_LEN: usize = TYPED_H3_TEXT_LEN;
16pub const RECORD_HASH_B64A_LEN: usize = TYPED_TEXT_B64A_LEN;
17
18/// Record kind identified by the record glyph line.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub enum RecordKind {
21    Blob,
22    Plex,
23    Seal,
24}
25
26impl RecordKind {
27    pub fn code(self) -> &'static str {
28        match self {
29            Self::Blob => "B",
30            Self::Plex => "P",
31            Self::Seal => "S",
32        }
33    }
34
35    pub fn lead_byte(self) -> u8 {
36        match self {
37            Self::Blob => BLOB_RECORD_HASH_LEAD,
38            Self::Plex => PLEX_RECORD_HASH_LEAD,
39            Self::Seal => SEAL_RECORD_HASH_LEAD,
40        }
41    }
42
43    pub fn name(self) -> &'static str {
44        match self {
45            Self::Blob => "Blob",
46            Self::Plex => "Plex",
47            Self::Seal => "Seal",
48        }
49    }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum HashTextError {
54    InvalidLength { expected: usize, got: usize },
55    InvalidType(u8),
56    InvalidFormat(String),
57    InvalidPayload(String),
58}
59
60impl From<TypedTextError> for HashTextError {
61    fn from(value: TypedTextError) -> Self {
62        match value {
63            TypedTextError::InvalidLength { expected, got } => {
64                Self::InvalidLength { expected, got }
65            }
66            TypedTextError::InvalidLead { byte } | TypedTextError::WrongLead { byte } => {
67                Self::InvalidType(byte)
68            }
69            TypedTextError::InvalidFormat(message) => Self::InvalidFormat(message.into()),
70            TypedTextError::InvalidSuffix => Self::InvalidFormat("expected T.<b64a>.H3".into()),
71            TypedTextError::InvalidPayload(message) => Self::InvalidPayload(message),
72        }
73    }
74}
75
76impl fmt::Display for HashTextError {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::InvalidLength { expected, got } => {
80                write!(
81                    f,
82                    "invalid record hash length: expected {expected}, got {got}"
83                )
84            }
85            Self::InvalidType(byte) => {
86                if byte.is_ascii_graphic() {
87                    write!(f, "invalid record hash type: {}", *byte as char)
88                } else {
89                    write!(f, "invalid record hash type: 0x{byte:02x}")
90                }
91            }
92            Self::InvalidFormat(s) => write!(f, "invalid record hash format: {s}"),
93            Self::InvalidPayload(s) => write!(f, "invalid record hash payload: {s}"),
94        }
95    }
96}
97
98impl std::error::Error for HashTextError {}
99
100#[repr(transparent)]
101#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
102pub struct RecordHash(TypedH3Text);
103
104impl RecordHash {
105    pub fn parse(text: &str) -> Result<Self, HashTextError> {
106        Ok(Self(TypedH3Text::parse_h3(text, is_record_hash_lead)?))
107    }
108
109    pub(crate) unsafe fn from_validated_h3_bytes_unchecked(bytes: &[u8]) -> Self {
110        Self(unsafe { TypedH3Text::from_validated_bytes_unchecked(bytes) })
111    }
112
113    pub fn from_digest(kind: RecordKind, digest: [u8; 32]) -> Self {
114        Self(
115            TypedH3Text::from_h3_payload(kind.lead_byte(), &digest)
116                .expect("record kind uses a registered record hash lead"),
117        )
118    }
119
120    pub fn as_str(&self) -> &str {
121        self.0.as_str()
122    }
123
124    pub fn as_bytes(&self) -> &[u8; RECORD_HASH_TEXT_LEN] {
125        self.0.as_bytes()
126    }
127
128    pub fn typed_text(&self) -> TypedH3Text {
129        self.0
130    }
131
132    pub fn kind(&self) -> RecordKind {
133        match self.0.type_byte() {
134            BLOB_RECORD_HASH_LEAD => RecordKind::Blob,
135            PLEX_RECORD_HASH_LEAD => RecordKind::Plex,
136            SEAL_RECORD_HASH_LEAD => RecordKind::Seal,
137            _ => unreachable!("RecordHash type byte is validated"),
138        }
139    }
140
141    pub fn digest(&self) -> [u8; 32] {
142        self.0.payload().expect("RecordHash payload is valid B64A")
143    }
144}
145
146fn is_record_hash_lead(lead: u8) -> bool {
147    matches!(
148        lead,
149        BLOB_RECORD_HASH_LEAD | PLEX_RECORD_HASH_LEAD | SEAL_RECORD_HASH_LEAD
150    )
151}
152
153impl fmt::Debug for RecordHash {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        f.debug_tuple("RecordHash").field(&self.as_str()).finish()
156    }
157}
158
159impl fmt::Display for RecordHash {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        f.write_str(self.as_str())
162    }
163}
164
165impl AsRef<str> for RecordHash {
166    fn as_ref(&self) -> &str {
167        self.as_str()
168    }
169}
170
171impl FromStr for RecordHash {
172    type Err = HashTextError;
173    fn from_str(s: &str) -> Result<Self, Self::Err> {
174        Self::parse(s)
175    }
176}
177
178impl TryFrom<&str> for RecordHash {
179    type Error = HashTextError;
180    fn try_from(value: &str) -> Result<Self, Self::Error> {
181        Self::parse(value)
182    }
183}
184
185impl TryFrom<String> for RecordHash {
186    type Error = HashTextError;
187    fn try_from(value: String) -> Result<Self, Self::Error> {
188        Self::parse(&value)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn record_hash_rejects_non_record_type() {
198        let mut h = RecordHash::from_digest(RecordKind::Blob, [0; 32]).to_string();
199        h.replace_range(0..1, "V");
200        assert!(RecordHash::parse(&h).is_err());
201    }
202
203    #[test]
204    fn record_hash_rejects_noncanonical_payload() {
205        assert!(RecordHash::parse("B.0000000000000000000000000000000000000000001.H3").is_err());
206    }
207}