1use crate::error::{Error, bail, ensure};
2use crate::topics::Topics;
3use bitcoin::{Txid, hashes::Hash};
4
5pub struct Urma;
6
7impl Urma {
8 pub const MAGIC: [u8; 4] = *b"URMA";
9 pub const VERSION: u8 = 0;
10 pub const PREFIX_BYTES: usize = 8;
11 pub const CHUNK_BYTES: usize = 32_768;
12 pub const PRIVATE_HEADER_BYTES: usize = 64;
13 pub const METADATA_BYTES: usize = 48;
14 pub const IV_BYTES: usize = 16;
15 pub const BODY_OFFSET: usize = 80;
16 pub const BODY_BYTES: usize = 32_816;
17 pub const MAC_BYTES: usize = 32;
18 pub const MAC_OFFSET: usize = 32_896;
19 pub const PRIVATE_RECORD_BYTES: usize = 32_928;
20 pub const MAX_PRIVATE_BYTES: u64 = 140_737_488_322_560;
21 pub const MAX_PUBLIC_BYTES: usize = 32_768;
22 pub const MAX_PROFILE_BYTES: usize = 128;
23 pub const PROFILE_IDENTIFIER_BYTES: usize = 8;
24 pub const AVATAR_SIDE: usize = 16;
25 pub const AVATAR_PIXELS: usize = Self::AVATAR_SIDE * Self::AVATAR_SIDE;
26 pub const AVATAR_BYTES: usize = Self::AVATAR_PIXELS / 2;
27 pub const AVATAR_PALETTE: [&'static str; 16] = [
28 "#000000", "#0000AA", "#00AA00", "#00AAAA", "#AA0000", "#AA00AA", "#AA5500", "#AAAAAA",
29 "#555555", "#5555FF", "#55FF55", "#55FFFF", "#FF5555", "#FF55FF", "#FFFF55", "#FFFFFF",
30 ];
31 pub const CONTAINER_HEADER_BYTES: usize = 12;
32 pub const CONTENT_DOMAIN: &'static [u8] = b"URMA/V0/private/content";
33 pub const AUTHENTICATION_DOMAIN: &'static [u8] = b"URMA/V0/private/authentication";
34 pub const DISCOVERY_DOMAIN: &'static [u8] = b"URMA/V0/private/discovery";
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38#[repr(u8)]
39pub enum RecordKind {
40 Private = 1,
41 Post = 2,
42 Container = 3,
43 Reply = 4,
44 Profile = 5,
45 Avatar = 6,
46 DataPart = 7,
47 LeafManifest = 8,
48 RootManifest = 9,
49 WirePost = 10,
50 WireReply = 11,
51 ProfileRecord = 12,
52}
53
54impl RecordKind {
55 pub fn byte(self) -> u8 {
56 match self {
57 Self::Private => 1,
58 Self::Post => 2,
59 Self::Container => 3,
60 Self::Reply => 4,
61 Self::Profile => 5,
62 Self::Avatar => 6,
63 Self::DataPart => 7,
64 Self::LeafManifest => 8,
65 Self::RootManifest => 9,
66 Self::WirePost => 10,
67 Self::WireReply => 11,
68 Self::ProfileRecord => 12,
69 }
70 }
71
72 pub fn prefix(self) -> [u8; 8] {
73 [b'U', b'R', b'M', b'A', Urma::VERSION, self.byte(), 0, 0]
74 }
75
76 pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
77 ensure!(bytes.len() >= Urma::PREFIX_BYTES, "truncated URMA prefix");
78 if bytes[..4] != Urma::MAGIC {
79 return Err(Error::Unsupported("unsupported record marker".into()));
80 }
81 if bytes[4] != Urma::VERSION {
82 return Err(Error::Unsupported("unsupported URMA version".into()));
83 }
84 if bytes[6..8] != [0, 0] {
85 return Err(Error::Unsupported("unsupported URMA flags".into()));
86 }
87 match bytes[5] {
88 1 => Ok(Self::Private),
89 2 => Ok(Self::Post),
90 3 => Ok(Self::Container),
91 4 => Ok(Self::Reply),
92 5 => Ok(Self::Profile),
93 6 => Ok(Self::Avatar),
94 7 => Ok(Self::DataPart),
95 8 => Ok(Self::LeafManifest),
96 9 => Ok(Self::RootManifest),
97 10 => Ok(Self::WirePost),
98 11 => Ok(Self::WireReply),
99 12 => Ok(Self::ProfileRecord),
100 kind => Err(Error::Unsupported(format!("unsupported URMA kind {kind}"))),
101 }
102 }
103}
104
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum ContentType {
107 Opaque,
108 Text,
109 Image,
110 Audio,
111 Video,
112}
113
114impl ContentType {
115 pub fn code(self) -> u32 {
116 match self {
117 Self::Opaque => 0,
118 Self::Text => 1,
119 Self::Image => 2,
120 Self::Audio => 3,
121 Self::Video => 4,
122 }
123 }
124
125 pub fn from_code(code: u32) -> Result<Self, Error> {
126 match code {
127 0 => Ok(Self::Opaque),
128 1 => Ok(Self::Text),
129 2 => Ok(Self::Image),
130 3 => Ok(Self::Audio),
131 4 => Ok(Self::Video),
132 value => Err(Error::Unsupported(format!(
133 "unsupported private content type {value}"
134 ))),
135 }
136 }
137}
138
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub enum PublicRecord {
141 Post(String),
142 Reply {
143 target: Txid,
144 text: String,
145 },
146 Profile(String),
147 Avatar(Box<[u8; Urma::AVATAR_BYTES]>),
148 WirePost {
149 topics: Topics,
150 text: String,
151 },
152 WireReply {
153 target: Txid,
154 topics: Topics,
155 text: String,
156 },
157 ProfileRecord {
158 profile: [u8; 8],
159 payload: Vec<u8>,
160 },
161}
162
163impl PublicRecord {
164 pub fn kind(&self) -> RecordKind {
165 match self {
166 Self::Post(..) => RecordKind::Post,
167 Self::Reply { .. } => RecordKind::Reply,
168 Self::Profile(..) => RecordKind::Profile,
169 Self::Avatar(..) => RecordKind::Avatar,
170 Self::WirePost { .. } => RecordKind::WirePost,
171 Self::WireReply { .. } => RecordKind::WireReply,
172 Self::ProfileRecord { .. } => RecordKind::ProfileRecord,
173 }
174 }
175
176 pub fn encode(&self) -> Result<Vec<u8>, Error> {
177 if let Self::WirePost { topics, text } | Self::WireReply { topics, text, .. } = self {
178 let mut bytes = self.kind().prefix().to_vec();
179 if let Self::WireReply { target, .. } = self {
180 bytes.extend_from_slice(target.as_byte_array());
181 }
182 bytes.extend_from_slice(&topics.encode_body(text)?);
183 ensure!(
184 bytes.len() <= Urma::MAX_PUBLIC_BYTES,
185 "public record exceeds 32 KiB"
186 );
187 return Ok(bytes);
188 }
189 let body_size = match self {
190 Self::Post(text) => text.len(),
191 Self::Profile(text) => {
192 ensure!(
193 text.len() <= Urma::MAX_PROFILE_BYTES,
194 "profile body exceeds {} UTF-8 bytes",
195 Urma::MAX_PROFILE_BYTES
196 );
197 text.len()
198 }
199 Self::Reply { text, .. } => text
200 .len()
201 .checked_add(32)
202 .ok_or_else(|| Error::Invalid("reply size overflow".into()))?,
203 Self::Avatar(pixels) => pixels.len(),
204 Self::ProfileRecord { payload, .. } => payload
205 .len()
206 .checked_add(Urma::PROFILE_IDENTIFIER_BYTES)
207 .ok_or_else(|| Error::Invalid("profile record size overflow".into()))?,
208 Self::WirePost { .. } | Self::WireReply { .. } => {
209 bail!("structured record handled separately")
210 }
211 };
212 ensure!(
213 body_size <= Urma::MAX_PUBLIC_BYTES - Urma::PREFIX_BYTES,
214 "public record exceeds 32 KiB"
215 );
216 let mut bytes = Vec::with_capacity(Urma::PREFIX_BYTES + body_size);
217 bytes.extend_from_slice(&self.kind().prefix());
218 match self {
219 Self::Post(text) | Self::Profile(text) => bytes.extend_from_slice(text.as_bytes()),
220 Self::Reply { target, text } => {
221 bytes.extend_from_slice(target.as_byte_array());
222 bytes.extend_from_slice(text.as_bytes());
223 }
224 Self::Avatar(pixels) => bytes.extend_from_slice(pixels.as_slice()),
225 Self::ProfileRecord { profile, payload } => {
226 bytes.extend_from_slice(profile);
227 bytes.extend_from_slice(payload);
228 }
229 Self::WirePost { .. } | Self::WireReply { .. } => {
230 bail!("structured record handled separately")
231 }
232 }
233 ensure!(
234 bytes.len() <= Urma::MAX_PUBLIC_BYTES,
235 "public record exceeds 32 KiB"
236 );
237 Ok(bytes)
238 }
239
240 pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
241 let kind = RecordKind::parse(bytes)?;
242 ensure!(
243 bytes.len() <= Urma::MAX_PUBLIC_BYTES,
244 "public record exceeds 32 KiB"
245 );
246 let body = &bytes[Urma::PREFIX_BYTES..];
247 match kind {
248 RecordKind::Post => Ok(Self::Post(std::str::from_utf8(body)?.to_owned())),
249 RecordKind::Profile => {
250 ensure!(
251 body.len() <= Urma::MAX_PROFILE_BYTES,
252 "profile body exceeds {} UTF-8 bytes",
253 Urma::MAX_PROFILE_BYTES
254 );
255 Ok(Self::Profile(std::str::from_utf8(body)?.to_owned()))
256 }
257 RecordKind::Reply => {
258 ensure!(body.len() >= 32, "truncated reply target");
259 Ok(Self::Reply {
260 target: Txid::from_byte_array(body[..32].try_into()?),
261 text: std::str::from_utf8(&body[32..])?.to_owned(),
262 })
263 }
264 RecordKind::Avatar => {
265 ensure!(
266 body.len() == Urma::AVATAR_BYTES,
267 "avatar body must contain exactly {} packed pixel bytes",
268 Urma::AVATAR_BYTES
269 );
270 Ok(Self::Avatar(Box::new(body.try_into()?)))
271 }
272 RecordKind::ProfileRecord => {
273 ensure!(
274 body.len() >= Urma::PROFILE_IDENTIFIER_BYTES,
275 "truncated profile identifier"
276 );
277 Ok(Self::ProfileRecord {
278 profile: body[..Urma::PROFILE_IDENTIFIER_BYTES].try_into()?,
279 payload: body[Urma::PROFILE_IDENTIFIER_BYTES..].to_vec(),
280 })
281 }
282 RecordKind::WirePost => {
283 let (topics, text) = Topics::decode_body(body)?;
284 Ok(Self::WirePost { topics, text })
285 }
286 RecordKind::WireReply => {
287 ensure!(body.len() >= 32, "truncated reply target");
288 let target = Txid::from_byte_array(body[..32].try_into()?);
289 let (topics, text) = Topics::decode_body(&body[32..])?;
290 Ok(Self::WireReply {
291 target,
292 topics,
293 text,
294 })
295 }
296 RecordKind::Private
297 | RecordKind::Container
298 | RecordKind::DataPart
299 | RecordKind::LeafManifest
300 | RecordKind::RootManifest => bail!("not an atomic public record"),
301 }
302 }
303}
304
305pub fn chunk_count(length: u64) -> Result<u32, Error> {
306 ensure!(
307 (1..=Urma::MAX_PRIVATE_BYTES).contains(&length),
308 "private length outside representational bounds"
309 );
310 Ok(u32::try_from(
311 length.div_ceil(u64::try_from(Urma::CHUNK_BYTES)?),
312 )?)
313}