Commit graph

7 commits

Author SHA1 Message Date
randogoth
801c66d3b0 feat: follow upstream twtxt post rules: reject control characters, allow U+2028, make the length cap configurable 2026-10-15 14:05:33 +03:00
randogoth
72c353cb5a docs: rewrite the author-facing copy in plain language
The copy was written for someone who had read the spec. It named UTF-8, RFC
3339, canonical form, code points and frontmatter fields, and the one place it
gave instructions it gave them as fumi command lines, which say nothing to
anyone using carrier or gsmol.

Now it uses the words the clients put on screen: "header", with the "+ header"
button both of them have, and "Requests folder", which is what both call it.
One word per idea throughout — post, feed, header, Requests folder — and
reasons are whole sentences that say what happened and what to do, so the
reply no longer opens "Reason:" followed by a lowercase fragment.

The first-contact mail is the one a stranger reads before anything else, so it
now covers accepting this address: a recommendation, not the mechanism behind
it. The mechanism would also have aged badly, since a client that delivers its
token once makes accepting cost a single refused post rather than all of them.

A refused post gets a detail paragraph where one line cannot hold the fix. It
tells the author their token arrived and to send again, and what to do if it
is refused a second time, which is what happens on a client that attaches the
token to everything. A first post on an empty feed says so differently: the
author is watching their first attempt fail and needs to hear the feed is
fine.

Seven admin strings were built with format! and never reached the catalogue,
so an ack opened its body in lowercase and could not be translated. They are
entries now, and say what blocking does rather than naming the mechanism.

The cap warning said "90% of its 1048576 byte limit". It says 1 MB.

Four rig assertions matched the old wording and now match the new. They have
to read rendered mail, so they stay coupled to it in a way the unit tests,
which assert on Reason variants, do not.
2026-10-15 09:07:24 +03:00
randogoth
1171d2157d test: cover self-service registration, and fix what it found
Rig legs for the whole author path with no CLI admit anywhere: an unknown
sender drawing the info mail, Twt-Register creating the feed, the token
arriving so she can publish, Twt-Help, the admin notification, Twt-Block
stopping a feed and Twt-Registration closing the door. Pentest probes for the
attacks: no Reply-To, a claim on someone else's address, a claim on an address
nobody holds, an admin command from a non-admin, and a blocked author coming
back.

Two real bugs, both found by asserting on what the author receives rather than
on what the daemon meant to send.

Five catalogue entries were wrapped across lines, and a reason is interpolated
into the reply's Subject. A frontmatter value carrying a line break makes the
whole block malformed, so smolmail fails closed toward display (SPEC.md sec
5.5) and the reply arrives with no subject at all — every rejection, silently.
The same bug class the parent commit fixed sender-side; step 2 reintroduced it
from the catalogue, because the Rust prose it replaced used backslash
continuations that join without a newline. Reasons are single-line now, with a
test over the enum that fails if one ever wraps again.

And the flow had a step that could not work. Telling an author to accept this
mailbox makes their client attach an Accept token to every later mail, and a
published record is verified by rebuilding the exact bytes that were signed —
which cannot include a token without publishing it. So every post from an
author who followed that advice was refused forever. The info mail now says
not to accept the address and where to find replies instead, and that refusal
has its own reason, since "resend the bare text" is useless advice when the
client adds the field by itself.

Three of my own probes were wrong too, kept here as comments so they are not
rewritten the same way: grep -q under `set -o pipefail` SIGPIPEs its producer
and can report failure for a subject that is plainly present; the refusal
reply lands in the main tier precisely because accepting the mailbox is what
moves replies there; and blocking an address that was never accepted is a
store no-op, so the probe has to register first.
2026-10-14 20:51:21 +03:00
randogoth
f1084c1056 feat: answer unknown senders, gated on proving they hold their address
process now splits by whether the sender owns a feed. The owner path runs
first, so a rotated owner is never mistaken for a registration, and behaves
exactly as before for ordinary mail.

The stranger path is the new one, and every branch of it is gated. No signed
Reply-To means no address to verify or answer, so it stays a silent drop. The
blocked check runs before anything touches the network, so Twt-Block cannot be
sidestepped by re-registering. Silent mode stays silent, which is how every
version before this behaved — except for an admin, who would otherwise have no
way to turn registration back on.

Then the gate the whole change rests on: before any outbound mail, the claimed
address is resolved on its own server and the resolved key must equal the key
that signed the request. Without it a signed Reply-To naming someone else's
address would be enough to take their feed, and replying to the claimed
smol:// URI would pin the sender's key as verified under that address — a
permanent denial of service against whoever really owns it. Every reply is
queued against the short form for the same reason; nothing hands a
self-certifying URI to send.

Registration attempts are counted before the dial rather than after, since one
that fails its key check costs the same connection. A per-cycle dial budget
bounds how long the state lock is held, and a mail over budget keeps its row
instead of being dropped.

Admin commands are recognised only from an admin address, and only Admit,
Block and Registration count as admin commands — an admin's Twt-Help means
Twt-Help. A non-admin sending one gets the ordinary answer and is never told
admin commands exist.

Rig still passes, so the owner path is unchanged in practice as well as in
intent.
2026-10-14 20:21:11 +03:00
randogoth
03e54fea49 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.
2026-10-14 19:37:17 +03:00
randogoth
bf6e2eaa15 refactor: reject mail with a reason identifier, not a sentence
dispatch and apply_commands returned English prose that became the reply
subject, so the pure layers owned author-facing copy. They now return
reason::Reason and the mail-building edge resolves it through the catalogue.

core::rules needed no change: RuleError was already a typed enum, and its
Display stays for author-side tools. The daemon maps the variants itself
rather than borrowing that prose.

Error-path tests now assert on variants, so rewording main.ftl cannot break
them, and PruneModeUnknown carries the value the author actually sent.

Reason::every_variant_resolves_to_copy walks the enum, so a variant added
without a catalogue entry fails in tests rather than mailing someone
"???reason-…???".
2026-10-14 18:57:57 +03:00
randogoth
3279fd3a2d refactor: move author-facing mail copy into a Fluent catalogue
Ports carrier's src/i18n.rs and its locales/en-US/main.ftl layout, same
fluent-bundle and unic-langid versions, so the two catalogues stay portable
between them. Only mail the daemon sends an author moves; operator CLI output
stays inline, since the operator runs the binary and reads English.

The error-reply reasons from dispatch and ApplyError are deliberately left
behind: dispatch is pure by contract, so those become reason identifiers
resolved at the mail-building edge in a separate change rather than having the
pure layer reach for a bundle.

Adds a test carrier lacks. FluentResource::try_new and add_resource both panic
on a malformed or duplicate-id catalogue, and nothing touches the bundle until
the daemon first mails someone, so without it a bad catalogue surfaces inside
poll() on a live instance.

Sentences are verbatim; the bodies are now hard-wrapped where they were single
long lines, which reads better in a plain-text client.
2026-10-14 18:48:31 +03:00