Skip to main content

urma_core/
format.rs

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}