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 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}