smolboid/daemon/src/dispatch.rs

291 lines
10 KiB
Rust
Raw Normal View History

//! Mail-to-action translation (PLAN.md, Management mail): a post mail is
//! bare text or a block whose only field is Reply-To; a command mail opens a
//! §5.5 block. Pure — the caller owns admission, retention and error replies.
use smolboid_core::feed::MailLine;
use smolboid_core::rules::validate_text;
use smolboid_core::time;
use smolboid_core::{repost_body, reply_to_body};
use crate::state::PruneMode;
/// One fetched mail, opened and verified by the caller.
pub struct Incoming {
pub sender: [u8; 32],
pub epk: [u8; 32],
pub sig: [u8; 64],
pub time: i64,
pub body: Vec<u8>,
}
pub enum Action {
Post {
timestamp: String,
text: String,
/// The Reply-To value the body carried, when the ordinary-send block
/// form was used; bound in the feed's `smol:reply_to` metadata.
reply_to: Option<String>,
mail: MailLine,
},
Repost {
timestamp: String,
args: String,
reply_to: Option<String>,
mail: MailLine,
},
Commands(Vec<Command>),
/// Nothing to do: recognized by the rules as benign, never published.
Ignore,
}
pub enum Command {
Nick(String),
Delete(String),
Prune(PruneMode),
}
/// The `Twt-` frontmatter fields a command mail may carry; unknown keys are
/// preserved upstream and never alter behaviour (SPEC.md §5.5).
const F_NICK: &str = "twt-nick";
const F_DELETE: &str = "twt-delete";
const F_REPOST: &str = "twt-repost";
const F_PRUNE: &str = "twt-prune";
const F_REPLY_TO: &str = "reply-to";
/// Fails closed: a block with unreconstructible fields is never published.
pub fn dispatch(incoming: &Incoming) -> Result<Action, String> {
let text = std::str::from_utf8(&incoming.body).map_err(|_| "body is not UTF-8".to_owned())?;
if !text.starts_with("---\n") {
// The bare post form: the body is the twt, byte for byte.
return post(incoming, text, None);
}
let (fields, rest) = fumi_core::message::parse_frontmatter(text);
if fields.is_empty() {
// An empty or malformed block: nothing reliable, nothing published.
return Ok(Action::Ignore);
}
let reply_to = fields.get(F_REPLY_TO).cloned();
let beyond_reply_to: Vec<&String> = fields
.keys()
.filter(|k| k.as_str() != F_REPLY_TO)
.collect();
// A post in the ordinary-send form: a block whose only field is Reply-To.
if beyond_reply_to.is_empty() {
if let Some(reply_to) = &reply_to {
if text != reply_to_body(reply_to, &rest) {
return Err("post mail is not in canonical form".to_owned());
}
return post(incoming, &rest, Some(reply_to.clone()));
}
}
// A repost: the Twt-Repost field, optionally with the same Reply-To an
// ordinary send carries; both must reconstruct byte-exactly.
if fields.len() <= 2 {
if let Some(args) = fields.get(F_REPOST) {
let canonical_no = repost_body(args, None);
let canonical_yes = repost_body(args, reply_to.as_deref());
if text == canonical_no || text == canonical_yes {
validate_text(args).map_err(|e| e.to_string())?;
return Ok(Action::Repost {
timestamp: time::render(incoming.time),
args: args.clone(),
reply_to,
mail: MailLine {
sender: incoming.sender,
epk: incoming.epk,
sig: incoming.sig,
},
});
}
return Err("repost mail is not in canonical form".to_owned());
}
}
// A block with extra fields and no Twt field: the author plainly meant to
// publish, and silence would hide the problem.
if !fields.contains_key(F_NICK)
&& !fields.contains_key(F_DELETE)
&& !fields.contains_key(F_PRUNE)
&& !fields.contains_key(F_REPOST)
{
return Err(
"post mail carries fields beyond Reply-To (Subject, headers or an Accept token); \
resend the bare text"
.to_owned(),
);
}
let mut commands = Vec::new();
if let Some(nick) = fields.get(F_NICK) {
if !crate::feedstore::valid_username(nick) {
return Err("nick must be 1-30 bytes of a-z, 0-9 or hyphen".to_owned());
}
commands.push(Command::Nick(nick.clone()));
}
if let Some(timestamp) = fields.get(F_DELETE) {
if time::parse(timestamp).is_none() {
return Err("Twt-Delete needs an RFC 3339 UTC timestamp".to_owned());
}
commands.push(Command::Delete(timestamp.clone()));
}
if let Some(mode) = fields.get(F_PRUNE) {
commands.push(Command::Prune(match mode.as_str() {
"fifo" => PruneMode::Fifo,
"off" => PruneMode::Off,
other => return Err(format!("unknown prune mode `{other}`, want fifo or off")),
}));
}
// A mixed mail is a command mail; the body is ignored, never posted.
if commands.is_empty() {
Ok(Action::Ignore)
} else {
Ok(Action::Commands(commands))
}
}
fn post(incoming: &Incoming, text: &str, reply_to: Option<String>) -> Result<Action, String> {
validate_text(text).map_err(|e| e.to_string())?;
Ok(Action::Post {
timestamp: time::render(incoming.time),
text: text.to_owned(),
reply_to,
mail: MailLine {
sender: incoming.sender,
epk: incoming.epk,
sig: incoming.sig,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
fn incoming(body: &str) -> Incoming {
Incoming {
sender: [1; 32],
epk: [2; 32],
sig: [3; 64],
time: 1_760_000_000,
body: body.as_bytes().to_vec(),
}
}
#[test]
fn plain_body_is_a_post() {
let Action::Post { timestamp, text, reply_to, mail } = dispatch(&incoming("hello smolverse")).unwrap() else {
panic!("expected a post");
};
assert_eq!(timestamp, "2025-10-09T08:53:20Z");
assert_eq!(text, "hello smolverse");
assert!(reply_to.is_none());
assert_eq!(mail.sender, [1; 32]);
}
#[test]
fn ordinary_send_form_is_a_post() {
// What `fumi send --body x` emits: a block with only Reply-To.
let body = "---\nReply-To: smol://tobias@smol.place/k\n---\nhello smolverse";
let Action::Post { text, reply_to, .. } = dispatch(&incoming(body)).unwrap() else {
panic!("expected a post");
};
assert_eq!(text, "hello smolverse");
assert_eq!(reply_to.as_deref(), Some("smol://tobias@smol.place/k"));
}
#[test]
fn dashed_body_is_still_a_post() {
// A single line can never open a block, so nothing is escaped.
let Action::Post { text, .. } = dispatch(&incoming("--- dashed opening")).unwrap() else {
panic!("expected a post");
};
assert_eq!(text, "--- dashed opening");
}
#[test]
fn post_rules_are_enforced_with_reasons() {
assert!(dispatch(&incoming(&"a".repeat(161))).is_err());
assert!(dispatch(&incoming("a\tb")).is_err());
let bad = Incoming { body: vec![0xff, 0xfe], ..incoming("") };
assert!(dispatch(&bad).is_err());
}
#[test]
fn extra_fields_make_a_rejected_post() {
// A Subject, or the Accept token of an accepted recipient.
assert!(dispatch(&incoming("---\nReply-To: smol://a/b\nSubject: hi\n---\nbody\n")).is_err());
assert!(dispatch(&incoming("---\nAccept: token\n---\nbody\n")).is_err());
}
#[test]
fn canonical_repost_command_publishes() {
let args = "@<dana https://smol.place/twtxt/dana.txt> #abc1234";
let no_reply_to = format!("---\nTwt-Repost: {args}\n---\n");
let Action::Repost { args: got, reply_to, mail, .. } =
dispatch(&incoming(&no_reply_to)).unwrap() else {
panic!("expected a repost");
};
assert_eq!(got, args);
assert!(reply_to.is_none());
assert_eq!(mail.sig, [3; 64]);
// The ordinary-send form, Twt-Repost before Reply-To as fumi emits.
let with_reply_to = format!("---\nTwt-Repost: {args}\nReply-To: smol://tobias@s/k\n---\n");
let Action::Repost { args: got, reply_to, .. } =
dispatch(&incoming(&with_reply_to)).unwrap() else {
panic!("expected a repost");
};
assert_eq!(got, args);
assert_eq!(reply_to.as_deref(), Some("smol://tobias@s/k"));
}
#[test]
fn noncanonical_reposts_are_rejected() {
let args = "@<d u> #abc";
assert!(dispatch(&incoming(&format!("---\nTwt-Repost: {args} \n---\n"))).is_err());
assert!(dispatch(&incoming(&format!("---\nTwt-Repost: {args}\n---\nextra\n"))).is_err());
assert!(dispatch(&incoming(&format!(
"---\nTwt-Repost: {args}\nTwt-Nick: x\n---\n"
)))
.is_err());
// Reply-To after the closing marker, wrong order relative to the
// canonical forms.
assert!(dispatch(&incoming(&format!(
"---\nReply-To: smol://a/b\nTwt-Repost: {args}\n---\n"
)))
.is_err());
}
#[test]
fn command_mails_apply_all_recognized_fields() {
let body = "---\nTwt-Nick: toby\nTwt-Prune: fifo\nSubject: ignored\n---\nignored body\n";
let Action::Commands(cmds) = dispatch(&incoming(body)).unwrap() else {
panic!("expected commands");
};
assert_eq!(cmds.len(), 2);
assert!(matches!(cmds[0], Command::Nick(ref n) if n == "toby"));
assert!(matches!(cmds[1], Command::Prune(PruneMode::Fifo)));
}
#[test]
fn command_values_are_validated() {
assert!(dispatch(&incoming("---\nTwt-Nick: Bad Nick\n---\n")).is_err());
assert!(dispatch(&incoming("---\nTwt-Delete: not-a-date\n---\n")).is_err());
assert!(dispatch(&incoming("---\nTwt-Prune: sometimes\n---\n")).is_err());
}
#[test]
fn odd_mail_shapes_fail_closed_to_ignore() {
// Malformed block, empty escape block, only unknown fields with a
// Twt- field absent — but pure unknown-field blocks are rejected
// as posts, so the ignore set is the malformed and empty ones.
assert!(matches!(dispatch(&incoming("---\nno colon here\n---\n")), Ok(Action::Ignore)));
assert!(matches!(dispatch(&incoming("---\n---\n")), Ok(Action::Ignore)));
}
}