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