feat: parse mailbox requests and factor out the address claim
dispatch::request recognises Twt-Register, Twt-Help, Twt-Admit, Twt-Block and Twt-Registration, validating values but deciding nothing: whether the sender may do what they asked is the caller's business, as with the rest of this module. One command per mail. Two requests have no sensible order, and a request mixed with a feed command would make the reply ambiguous about what took effect. Twt-Register takes an optional nick, validated against the nick rule rather than the path rule, since the value lands in feed metadata. dispatch::claimed is follow_rotation's Reply-To extraction, lifted out and documented for what it actually proves: the sender signed this URI. It does not prove they hold the mailbox it names. follow_rotation is safe on that alone only because it additionally demands a feed already bound to the address and a signed chain from the key on file; a registration path has neither and must resolve the address itself. Twt-Help ignores any value rather than refusing one — failing a request for help on a typo would be perverse. Also narrows i18n's id list back to the ids looked up by name: generating it from the catalogue made that test assert the catalogue against itself. The reason-* ids are covered by walking the enum instead, which cannot drift.
This commit is contained in:
parent
ff289671a5
commit
03e54fea49
5 changed files with 254 additions and 18 deletions
|
|
@ -71,3 +71,12 @@ reason-reply-to-changed =
|
|||
your Reply-To does not match the feed's bound author { $author }; send from
|
||||
that address or mail the operator
|
||||
reason-no-record-at = no record at { $timestamp }
|
||||
|
||||
## Mailbox requests — register, help and the admin commands
|
||||
|
||||
reason-one-command-per-mail =
|
||||
one command per mail: send Twt-Register, Twt-Help, Twt-Admit, Twt-Block or
|
||||
Twt-Registration on its own, and feed commands in a separate mail
|
||||
reason-bad-address = `{ $got }` is not a smolmail address
|
||||
reason-registration-mode-unknown =
|
||||
unknown registration mode `{ $got }`, want open, closed or silent
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ use smolboid_core::rules::validate_text;
|
|||
use smolboid_core::time;
|
||||
use smolboid_core::{repost_body, reply_to_body};
|
||||
|
||||
use fumi_core::address::Address;
|
||||
|
||||
use crate::reason::Reason;
|
||||
use crate::state::PruneMode;
|
||||
use crate::state::{Mode, PruneMode};
|
||||
|
||||
/// One fetched mail, opened and verified by the caller.
|
||||
pub struct Incoming {
|
||||
|
|
@ -47,6 +49,26 @@ pub enum Command {
|
|||
Prune(PruneMode),
|
||||
}
|
||||
|
||||
/// A request that is about the mailbox rather than about a feed's contents.
|
||||
/// These are answered before `dispatch` is consulted, because several of them
|
||||
/// come from senders who have no feed at all.
|
||||
// Consumed by the mail loop's request handling; until then only the tests
|
||||
// construct these.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Request {
|
||||
/// Create a feed for the sender, optionally with a starting nick.
|
||||
Register { nick: Option<String> },
|
||||
/// Re-send the command reference.
|
||||
Help,
|
||||
/// Admin: issue a token and create a feed for someone else.
|
||||
Admit(String),
|
||||
/// Admin: withdraw someone's token.
|
||||
Block(String),
|
||||
/// Admin: open or close self-service registration.
|
||||
Registration(Mode),
|
||||
}
|
||||
|
||||
/// 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";
|
||||
|
|
@ -54,6 +76,106 @@ 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";
|
||||
#[allow(dead_code)]
|
||||
const F_REGISTER: &str = "twt-register";
|
||||
#[allow(dead_code)]
|
||||
const F_HELP: &str = "twt-help";
|
||||
#[allow(dead_code)]
|
||||
const F_ADMIT: &str = "twt-admit";
|
||||
#[allow(dead_code)]
|
||||
const F_BLOCK: &str = "twt-block";
|
||||
#[allow(dead_code)]
|
||||
const F_REGISTRATION: &str = "twt-registration";
|
||||
|
||||
/// The fields `dispatch` handles, i.e. everything about a feed's contents.
|
||||
const FEED_FIELDS: [&str; 4] = [F_NICK, F_DELETE, F_PRUNE, F_REPOST];
|
||||
/// The fields `request` handles, i.e. everything about the mailbox.
|
||||
#[allow(dead_code)]
|
||||
const REQUEST_FIELDS: [&str; 5] = [F_REGISTER, F_HELP, F_ADMIT, F_BLOCK, F_REGISTRATION];
|
||||
|
||||
/// The address a mail claims to be from: the `smol://` Reply-To it signed,
|
||||
/// accepted only when the key it carries is the key that signed the payload.
|
||||
///
|
||||
/// That check proves the signer signed this URI — nothing more. It does *not*
|
||||
/// prove they hold the mailbox it names, so a caller acting on an unknown
|
||||
/// sender must still resolve the address against its own server. The existing
|
||||
/// rotation path gets away without that only because it additionally requires
|
||||
/// a feed already bound to the address and a signed chain from the key on
|
||||
/// file.
|
||||
pub fn claimed(incoming: &Incoming) -> Option<Address> {
|
||||
let text = std::str::from_utf8(&incoming.body).ok()?;
|
||||
let (fields, _) = fumi_core::message::parse_frontmatter(text);
|
||||
let addr = Address::parse(fields.get(F_REPLY_TO)?).ok()?;
|
||||
(addr.identity? == incoming.sender).then_some(addr)
|
||||
}
|
||||
|
||||
/// A mailbox request, if the mail carries one. `Ok(None)` means it is an
|
||||
/// ordinary post or feed command and belongs to `dispatch`.
|
||||
///
|
||||
/// Pure, like `dispatch`: whether the sender may *do* what they asked — admin
|
||||
/// rights, registration mode, rate budgets — is the caller's decision.
|
||||
#[allow(dead_code)]
|
||||
pub fn request(incoming: &Incoming) -> Result<Option<Request>, Reason> {
|
||||
let Ok(text) = std::str::from_utf8(&incoming.body) else {
|
||||
// Not UTF-8 is a rejection, but `dispatch` already says so and this
|
||||
// layer must not answer for mail that was never a request.
|
||||
return Ok(None);
|
||||
};
|
||||
if !text.starts_with("---\n") {
|
||||
return Ok(None);
|
||||
}
|
||||
let (fields, _) = fumi_core::message::parse_frontmatter(text);
|
||||
let present: Vec<&str> = REQUEST_FIELDS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| fields.contains_key(*f))
|
||||
.collect();
|
||||
let [field] = present[..] else {
|
||||
if present.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Two requests in one mail have no sensible order, and the reply could
|
||||
// only report one of them.
|
||||
return Err(Reason::OneCommandPerMail);
|
||||
};
|
||||
// A request mail is about the mailbox; a feed command is about its
|
||||
// contents. Mixing them would make the reply ambiguous about what did and
|
||||
// did not take effect.
|
||||
if FEED_FIELDS.iter().any(|f| fields.contains_key(*f)) {
|
||||
return Err(Reason::OneCommandPerMail);
|
||||
}
|
||||
let value = fields.get(field).map(String::as_str).unwrap_or_default();
|
||||
let request = match field {
|
||||
F_REGISTER if value.is_empty() => Request::Register { nick: None },
|
||||
F_REGISTER if crate::feedstore::valid_username(value) => Request::Register {
|
||||
nick: Some(value.to_owned()),
|
||||
},
|
||||
F_REGISTER => return Err(Reason::NickInvalid),
|
||||
// Any value is ignored rather than refused: there is nothing an author
|
||||
// could usefully put here, and failing their cry for help on a typo
|
||||
// would be perverse.
|
||||
F_HELP => Request::Help,
|
||||
F_ADMIT | F_BLOCK => {
|
||||
let addr = Address::parse(value)
|
||||
.map_err(|_| Reason::BadAddress { got: value.to_owned() })?;
|
||||
if field == F_ADMIT {
|
||||
Request::Admit(addr.short())
|
||||
} else {
|
||||
Request::Block(addr.short())
|
||||
}
|
||||
}
|
||||
F_REGISTRATION => Request::Registration(match value {
|
||||
"open" => Mode::Open,
|
||||
"closed" => Mode::Closed,
|
||||
"silent" => Mode::Silent,
|
||||
other => {
|
||||
return Err(Reason::RegistrationModeUnknown { got: other.to_owned() })
|
||||
}
|
||||
}),
|
||||
_ => unreachable!("field came from REQUEST_FIELDS"),
|
||||
};
|
||||
Ok(Some(request))
|
||||
}
|
||||
|
||||
/// Fails closed: a block with unreconstructible fields is never published.
|
||||
///
|
||||
|
|
@ -309,6 +431,104 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// The claim check is signature-scoped on purpose: it says the sender
|
||||
/// signed this URI, not that they hold the mailbox it names.
|
||||
#[test]
|
||||
fn a_claim_needs_the_senders_own_key_in_the_uri() {
|
||||
// `incoming` signs as [1; 32]; base32 of that key is what must appear.
|
||||
let key = fumi_core::crypto::b32(&[1u8; 32]);
|
||||
let mine = format!("---\nReply-To: smol://alice@other.org/{key}\n---\n");
|
||||
let addr = claimed(&incoming(&mine)).expect("own key should be claimable");
|
||||
assert_eq!(addr.short(), "alice@other.org");
|
||||
|
||||
// Someone else's key in the URI: the signer did not sign for it.
|
||||
let theirs = format!(
|
||||
"---\nReply-To: smol://alice@other.org/{}\n---\n",
|
||||
fumi_core::crypto::b32(&[2u8; 32])
|
||||
);
|
||||
assert!(claimed(&incoming(&theirs)).is_none());
|
||||
|
||||
// A short-form Reply-To carries no key, so it proves nothing.
|
||||
assert!(claimed(&incoming("---\nReply-To: alice@other.org\n---\n")).is_none());
|
||||
assert!(claimed(&incoming("hello")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mailbox_requests_are_recognised_and_validated() {
|
||||
let req = |body: &str| request(&incoming(body));
|
||||
|
||||
// Ordinary mail is not a request.
|
||||
assert_eq!(req("hello").unwrap(), None);
|
||||
assert_eq!(req("---\nTwt-Nick: toby\n---\n").unwrap(), None);
|
||||
|
||||
assert_eq!(
|
||||
req("---\nTwt-Register:\n---\n").unwrap(),
|
||||
Some(Request::Register { nick: None })
|
||||
);
|
||||
assert_eq!(
|
||||
req("---\nTwt-Register: toby\n---\n").unwrap(),
|
||||
Some(Request::Register { nick: Some("toby".into()) })
|
||||
);
|
||||
// The nick rule, not the path rule: a slug would be wrong in metadata.
|
||||
assert_eq!(req("---\nTwt-Register: Bad Nick\n---\n").unwrap_err(), Reason::NickInvalid);
|
||||
assert_eq!(
|
||||
req("---\nTwt-Register: a@b.org\n---\n").unwrap_err(),
|
||||
Reason::NickInvalid
|
||||
);
|
||||
|
||||
assert_eq!(req("---\nTwt-Help:\n---\n").unwrap(), Some(Request::Help));
|
||||
// A value is ignored rather than refused.
|
||||
assert_eq!(req("---\nTwt-Help: please\n---\n").unwrap(), Some(Request::Help));
|
||||
|
||||
assert_eq!(
|
||||
req("---\nTwt-Admit: alice@other.org\n---\n").unwrap(),
|
||||
Some(Request::Admit("alice@other.org".into()))
|
||||
);
|
||||
// Normalised through short(), so the default port collapses.
|
||||
assert_eq!(
|
||||
req("---\nTwt-Block: alice@other.org:1961\n---\n").unwrap(),
|
||||
Some(Request::Block("alice@other.org".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
req("---\nTwt-Admit: nonsense\n---\n").unwrap_err(),
|
||||
Reason::BadAddress { got: "nonsense".into() }
|
||||
);
|
||||
|
||||
for (value, mode) in [("open", Mode::Open), ("closed", Mode::Closed), ("silent", Mode::Silent)] {
|
||||
assert_eq!(
|
||||
req(&format!("---\nTwt-Registration: {value}\n---\n")).unwrap(),
|
||||
Some(Request::Registration(mode))
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
req("---\nTwt-Registration: maybe\n---\n").unwrap_err(),
|
||||
Reason::RegistrationModeUnknown { got: "maybe".into() }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_command_per_mail() {
|
||||
let req = |body: &str| request(&incoming(body));
|
||||
// Two requests have no sensible order.
|
||||
assert_eq!(
|
||||
req("---\nTwt-Register:\nTwt-Help:\n---\n").unwrap_err(),
|
||||
Reason::OneCommandPerMail
|
||||
);
|
||||
// A request mixed with a feed command would make the reply ambiguous.
|
||||
for body in [
|
||||
"---\nTwt-Register:\nTwt-Nick: toby\n---\n",
|
||||
"---\nTwt-Help:\nTwt-Prune: fifo\n---\n",
|
||||
"---\nTwt-Admit: a@b.org\nTwt-Delete: 2026-10-01T12:47:00Z\n---\n",
|
||||
] {
|
||||
assert_eq!(req(body).unwrap_err(), Reason::OneCommandPerMail, "{body:?}");
|
||||
}
|
||||
// A Reply-To alongside a request is normal, not a conflict.
|
||||
assert_eq!(
|
||||
req("---\nReply-To: smol://a@b.org/k\nTwt-Help:\n---\n").unwrap(),
|
||||
Some(Request::Help)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_mail_shapes_fail_closed_to_ignore() {
|
||||
// Malformed block, empty escape block, only unknown fields with a
|
||||
|
|
|
|||
|
|
@ -86,8 +86,10 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Every id the daemon looks up. A lookup added without a line here still
|
||||
/// works, but loses its compile-adjacent guard — keep them in step.
|
||||
/// The ids looked up by name in code. The `reason-*` ids are deliberately
|
||||
/// absent: `reason::Reason` covers every one of those by walking the enum,
|
||||
/// which cannot drift the way a hand-kept list can. Listing them here too
|
||||
/// would only make this test restate the catalogue back to itself.
|
||||
const IDS: &[&str] = &[
|
||||
"reply-subject",
|
||||
"reply-body",
|
||||
|
|
|
|||
|
|
@ -246,22 +246,12 @@ impl Daemon {
|
|||
incoming: &Incoming,
|
||||
state: &mut MutexGuard<'_, State>,
|
||||
) -> Result<Option<usize>, String> {
|
||||
let Ok(text) = std::str::from_utf8(&incoming.body) else {
|
||||
let Some(addr) = dispatch::claimed(incoming) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (fields, _) = fumi_core::message::parse_frontmatter(text);
|
||||
let Some(reply_to) = fields.get("reply-to") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(addr) = Address::parse(reply_to) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(user_key) = addr.identity else {
|
||||
return Ok(None);
|
||||
};
|
||||
if user_key != incoming.sender {
|
||||
return Ok(None);
|
||||
}
|
||||
// The claim alone proves only that the sender signed this URI. What
|
||||
// makes it safe here is the next two lines: the address must already
|
||||
// own a feed, and the chain below must lead from the key on file.
|
||||
let Some(index) = state.feeds.iter().position(|f| f.address == addr.short()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
use fluent_bundle::FluentArgs;
|
||||
use smolboid_core::rules::RuleError;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Reason {
|
||||
BodyNotUtf8,
|
||||
|
|
@ -29,6 +30,12 @@ pub enum Reason {
|
|||
FeedFull,
|
||||
ReplyToChanged { author: String },
|
||||
NoRecordAt { timestamp: String },
|
||||
/// Two mailbox requests in one mail, or one mixed with a feed command.
|
||||
// Constructed by `dispatch::request`, which the mail loop does not call
|
||||
// yet; the tests cover them meanwhile.
|
||||
OneCommandPerMail,
|
||||
BadAddress { got: String },
|
||||
RegistrationModeUnknown { got: String },
|
||||
}
|
||||
|
||||
impl From<RuleError> for Reason {
|
||||
|
|
@ -60,6 +67,9 @@ impl Reason {
|
|||
Reason::FeedFull => "reason-feed-full",
|
||||
Reason::ReplyToChanged { .. } => "reason-reply-to-changed",
|
||||
Reason::NoRecordAt { .. } => "reason-no-record-at",
|
||||
Reason::OneCommandPerMail => "reason-one-command-per-mail",
|
||||
Reason::BadAddress { .. } => "reason-bad-address",
|
||||
Reason::RegistrationModeUnknown { .. } => "reason-registration-mode-unknown",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +77,9 @@ impl Reason {
|
|||
pub fn args(&self) -> Option<FluentArgs<'static>> {
|
||||
let mut args = FluentArgs::new();
|
||||
match self {
|
||||
Reason::PruneModeUnknown { got } => args.set("got", got.clone()),
|
||||
Reason::PruneModeUnknown { got }
|
||||
| Reason::BadAddress { got }
|
||||
| Reason::RegistrationModeUnknown { got } => args.set("got", got.clone()),
|
||||
Reason::Collision { timestamp } | Reason::NoRecordAt { timestamp } => {
|
||||
args.set("timestamp", timestamp.clone())
|
||||
}
|
||||
|
|
@ -112,6 +124,9 @@ mod tests {
|
|||
Reason::FeedFull,
|
||||
Reason::ReplyToChanged { author: "tobias@smol.place".into() },
|
||||
Reason::NoRecordAt { timestamp: "2026-10-01T12:47:00Z".into() },
|
||||
Reason::OneCommandPerMail,
|
||||
Reason::BadAddress { got: "not an address".into() },
|
||||
Reason::RegistrationModeUnknown { got: "sideways".into() },
|
||||
];
|
||||
for reason in all {
|
||||
let text = reason.text();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue