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