lace_sans_io/records/
hash.rs1use 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#[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}