1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6pub const MAX_BODY: usize = 8192;
7pub const MAX_TITLE: usize = 256;
8pub const MAX_QUERY: usize = 512;
9pub const MAX_LIMIT: usize = 16;
10pub const PAGE_BYTES: usize = 24 * 1024;
11pub const PAGE_BODY_BYTES: usize = 16 * 1024;
12pub const SNIPPET_BYTES: usize = 400;
13pub const NAMESPACE: &str = "forum";
14pub const META_KEY: &str = "_synaps_forum";
15
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct Author {
19 pub actor: String,
20 pub group: String,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub parent: Option<String>,
23}
24impl Author {
25 pub fn fresh() -> Self {
26 Self {
27 actor: format!("actor-{}", uuid::Uuid::new_v4().simple()),
28 group: format!("group-{}", uuid::Uuid::new_v4().simple()),
29 parent: None,
30 }
31 }
32 pub fn child(&self) -> Self {
33 Self {
34 actor: format!("actor-{}", uuid::Uuid::new_v4().simple()),
35 group: self.group.clone(),
36 parent: Some(self.actor.clone()),
37 }
38 }
39 pub fn validate(&self) -> Result<(), String> {
40 if !prefixed_hex(&self.actor, "actor-", 32)
41 || !prefixed_hex(&self.group, "group-", 32)
42 || self
43 .parent
44 .as_ref()
45 .is_some_and(|p| !prefixed_hex(p, "actor-", 32) || p == &self.actor)
46 {
47 return Err("invalid forum author".into());
48 }
49 Ok(())
50 }
51}
52impl Default for Author {
53 fn default() -> Self {
54 Self::fresh()
55 }
56}
57
58pub fn is_digest(s: &str) -> bool {
59 hex(s, 64)
60}
61fn hex(s: &str, length: usize) -> bool {
62 s.len() == length
63 && s.bytes()
64 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
65}
66fn prefixed_hex(s: &str, prefix: &str, length: usize) -> bool {
67 s.strip_prefix(prefix).is_some_and(|tail| hex(tail, length))
68}
69pub fn valid_id(s: &str) -> bool {
70 prefixed_hex(s, "msg-", 64)
71}
72pub fn valid_project(s: &str) -> bool {
73 prefixed_hex(s, "p", 16) && s != "p0000000000000000"
74}
75fn text(s: &str, max: usize, multiline: bool) -> bool {
76 s.len() <= max
77 && !s
78 .chars()
79 .any(|c| c.is_control() && !(multiline && matches!(c, '\n' | '\t')))
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct Post {
85 pub request_key: String,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub thread_id: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub reply_to: Option<String>,
90 pub title: String,
91 pub body: String,
92 pub retention_days: u32,
93}
94impl Post {
95 pub fn validate(&self) -> Result<(), String> {
96 if self.request_key.is_empty()
97 || self.request_key.len() > 64
98 || !self
99 .request_key
100 .bytes()
101 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
102 {
103 return Err("forum request_key must be 1..64 ASCII letters/digits/._-".into());
104 }
105 if !(1..=365).contains(&self.retention_days)
106 || !text(&self.body, MAX_BODY, true)
107 || self.body.trim().is_empty()
108 || !text(&self.title, MAX_TITLE, false)
109 {
110 return Err("forum post exceeds text or retention bounds".into());
111 }
112 match &self.thread_id {
113 None if self.title.trim().is_empty() || self.reply_to.is_some() => {
114 return Err("forum root requires a title and no reply_to".into())
115 }
116 Some(id) if !valid_id(id) || !self.title.is_empty() => {
117 return Err("forum reply requires an exact thread ID and empty title".into())
118 }
119 _ => {}
120 }
121 if self.reply_to.as_ref().is_some_and(|id| !valid_id(id)) {
122 return Err("invalid forum reply_to ID".into());
123 }
124 Ok(())
125 }
126}
127fn hash_parts(parts: &[&[u8]]) -> String {
128 let mut h = Sha256::new();
129 for part in parts {
130 h.update((part.len() as u64).to_be_bytes());
131 h.update(part);
132 }
133 format!("{:x}", h.finalize())
134}
135
136#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct Envelope {
139 pub version: u32,
140 pub project: String,
141 pub author: Author,
142 pub request_key: String,
143 pub thread_id: String,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub reply_to: Option<String>,
146 pub title: String,
147 pub retention_days: u32,
148 pub digest: String,
149}
150impl Envelope {
151 pub fn new(project: &str, author: Author, post: &Post) -> Result<Self, String> {
152 post.validate()?;
153 author.validate()?;
154 if !valid_project(project) {
155 return Err("invalid forum repository scope".into());
156 }
157 let digest = hash_parts(&[
158 b"synaps-forum-post/1",
159 project.as_bytes(),
160 author.actor.as_bytes(),
161 author.group.as_bytes(),
162 author.parent.as_deref().unwrap_or("").as_bytes(),
163 post.request_key.as_bytes(),
164 post.thread_id.as_deref().unwrap_or("").as_bytes(),
165 post.reply_to.as_deref().unwrap_or("").as_bytes(),
166 post.title.as_bytes(),
167 post.body.as_bytes(),
168 &post.retention_days.to_be_bytes(),
169 ]);
170 Ok(Self {
171 version: 1,
172 project: project.into(),
173 author,
174 request_key: post.request_key.clone(),
175 thread_id: post
176 .thread_id
177 .clone()
178 .unwrap_or_else(|| format!("msg-{digest}")),
179 reply_to: post.reply_to.clone(),
180 title: post.title.clone(),
181 retention_days: post.retention_days,
182 digest,
183 })
184 }
185 pub fn id(&self) -> String {
186 format!("msg-{}", self.digest)
187 }
188 pub fn is_root(&self) -> bool {
189 self.thread_id == self.id()
190 }
191 pub fn source(&self) -> String {
192 format!("forum:{}", self.id())
193 }
194 pub fn validate_metadata(&self) -> Result<(), String> {
195 if self.version != 1
196 || !valid_project(&self.project)
197 || !is_digest(&self.digest)
198 || !valid_id(&self.thread_id)
199 {
200 return Err("invalid forum metadata".into());
201 }
202 self.author.validate()?;
203 let p = self.post("validation body".into());
204 p.validate()
205 }
206 pub fn post(&self, body: String) -> Post {
207 Post {
208 request_key: self.request_key.clone(),
209 thread_id: (!self.is_root()).then(|| self.thread_id.clone()),
210 reply_to: self.reply_to.clone(),
211 title: self.title.clone(),
212 body,
213 retention_days: self.retention_days,
214 }
215 }
216 pub fn validate(&self, body: &str) -> Result<(), String> {
217 self.validate_metadata()?;
218 let expected = Self::new(&self.project, self.author.clone(), &self.post(body.into()))?;
219 if *self != expected {
220 return Err("forum digest mismatch".into());
221 }
222 Ok(())
223 }
224}
225#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct PostRequest {
228 pub author: Author,
229 pub post: Post,
230}
231
232#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum Status {
235 Created,
236 Duplicate,
237 Tombstoned,
238}
239#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct Receipt {
242 pub id: String,
243 pub thread_id: String,
244 pub digest: String,
245 pub status: Status,
246 pub timestamp_ms: Option<u64>,
247}
248#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct Cursor {
251 pub timestamp_ms: u64,
252 pub id: String,
253}
254impl Cursor {
255 pub fn validate(&self) -> Result<(), String> {
256 if self.timestamp_ms > i64::MAX as u64 || !valid_id(&self.id) {
257 return Err("invalid forum cursor".into());
258 }
259 Ok(())
260 }
261}
262#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct Read {
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub thread_id: Option<String>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub query: Option<String>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub after: Option<Cursor>,
271 pub limit: usize,
272}
273impl Read {
274 pub fn validate(&self) -> Result<(), String> {
275 if self.limit == 0
276 || self.limit > MAX_LIMIT
277 || self.thread_id.as_ref().is_some_and(|s| !valid_id(s))
278 || self
279 .query
280 .as_ref()
281 .is_some_and(|s| !text(s, MAX_QUERY, false))
282 {
283 return Err("invalid forum read bounds".into());
284 }
285 if let Some(c) = &self.after {
286 c.validate()?;
287 }
288 Ok(())
289 }
290}
291impl Default for Read {
292 fn default() -> Self {
293 Self {
294 thread_id: None,
295 query: None,
296 after: None,
297 limit: 8,
298 }
299 }
300}
301#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(deny_unknown_fields)]
303pub struct Entry {
304 pub id: String,
305 pub timestamp_ms: u64,
306 pub envelope: Envelope,
307 pub body: String,
308 pub truncated: bool,
309}
310impl Entry {
311 pub fn cursor(&self) -> Cursor {
312 Cursor {
313 timestamp_ms: self.timestamp_ms,
314 id: self.id.clone(),
315 }
316 }
317 pub fn validate(&self) -> Result<(), String> {
318 self.cursor().validate()?;
319 self.envelope.validate_metadata()?;
320 if self.id != self.envelope.id() || !text(&self.body, MAX_BODY, true) {
321 return Err("invalid forum entry".into());
322 }
323 if self.truncated {
324 if self.body.len() > SNIPPET_BYTES || !self.envelope.is_root() {
325 return Err("invalid forum snippet".into());
326 }
327 } else {
328 self.envelope.validate(&self.body)?;
329 }
330 Ok(())
331 }
332}
333#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(deny_unknown_fields)]
335pub struct Page {
336 pub entries: Vec<Entry>,
337 pub next: Option<Cursor>,
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 fn post() -> Post {
344 Post {
345 request_key: "finding-1".into(),
346 thread_id: None,
347 reply_to: None,
348 title: "Finding".into(),
349 body: "Synthetic useful note".into(),
350 retention_days: 30,
351 }
352 }
353 fn author() -> Author {
354 Author {
355 actor: format!("actor-{}", "a".repeat(32)),
356 group: format!("group-{}", "b".repeat(32)),
357 parent: None,
358 }
359 }
360 #[test]
361 fn digest_roundtrip_and_domain_separation() {
362 let p = post();
363 let e = Envelope::new("p1111111111111111", author(), &p).unwrap();
364 e.validate(&p.body).unwrap();
365 assert_eq!(e, Envelope::new("p1111111111111111", author(), &p).unwrap());
366 assert_ne!(
367 e.id(),
368 Envelope::new("p2222222222222222", author(), &p)
369 .unwrap()
370 .id()
371 );
372 let mut changed = p.clone();
373 changed.request_key.push('2');
374 assert_ne!(
375 e.id(),
376 Envelope::new("p1111111111111111", author(), &changed)
377 .unwrap()
378 .id()
379 );
380 assert!(e.validate("tampered note").is_err());
381 }
382 #[test]
383 fn exact_digest_vector() {
384 let e = Envelope::new("p1111111111111111", author(), &post()).unwrap();
385 assert_eq!(
386 e.digest,
387 "79f1294388dea6ade99737365b1f0fb55e8afe447ad9c25f5c568e03476a7ccf"
388 );
389 }
390 #[test]
391 fn bounds_and_author_validation() {
392 let mut p = post();
393 p.body = "x".repeat(MAX_BODY + 1);
394 assert!(p.validate().is_err());
395 p = post();
396 p.body = "hidden\u{1b}[0m".into();
397 assert!(p.validate().is_err());
398 p = post();
399 p.retention_days = 0;
400 assert!(p.validate().is_err());
401 p = post();
402 p.title.clear();
403 assert!(p.validate().is_err());
404 let a = author();
405 let child = a.child();
406 assert_eq!(a.group, child.group);
407 assert_eq!(child.parent, Some(a.actor.clone()));
408 assert_ne!(a.actor, child.actor);
409 child.validate().unwrap();
410 }
411 #[test]
412 fn replies_require_exact_thread_and_empty_title() {
413 let mut p = post();
414 let root = Envelope::new("p1111111111111111", author(), &p).unwrap();
415 p.thread_id = Some(root.id());
416 assert!(p.validate().is_err());
417 p.title.clear();
418 let reply = Envelope::new("p1111111111111111", author(), &p).unwrap();
419 assert!(!reply.is_root());
420 reply.validate(&p.body).unwrap();
421 p.reply_to = Some("../../bad".into());
422 assert!(p.validate().is_err());
423 }
424}