diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9728ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Build output +/target +.devbox/ +result +result-* + +# The release job sources FORGEJO_TOKEN from here. Never commit it. +.env + +# Identity and feed state the daemon writes at runtime, and whatever the +# test rigs leave in a checkout. +*.key +*.db +*.db-wal +*.db-shm +state.json +smolboid.toml diff --git a/Cargo.lock b/Cargo.lock index 989eccb..3244c30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -316,7 +316,7 @@ dependencies = [ [[package]] name = "fumi-core" -version = "0.1.0" +version = "0.1.1" dependencies = [ "chacha20poly1305", "data-encoding", @@ -652,7 +652,7 @@ checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "smolboid" -version = "0.2.0" +version = "0.2.2" dependencies = [ "fluent-bundle", "fumi-core", diff --git a/README.md b/README.md index 8880866..c36bf98 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ poll_secs = 60 # fetch interval timeout = 30 # smolmail network timeout, seconds posts_per_day = 50 # per-feed daily quota reposts_per_day = 50 +max_post_chars = 288 # longest post, in characters (code points) cap_bytes = 1048576 # per-feed size cap, default 1 MiB registration = silent # open | closed | silent — see Admission registrations_per_hour = 10 # global cap on attempts that reach the network @@ -162,7 +163,7 @@ One command per mail. Two of them together, or one mixed with another, is refuse fumi send twtxt@smol.place --header Twt-Delete:2026-10-01T12:47:00Z --body "" ``` -The rules the daemon enforces: text is at most 160 Unicode code points and 640 bytes and may not contain TAB, LF, CR or U+2028; the timestamp is the mail's own time, pinned by the signature, so a same-second resend is rejected with a reply — wait a second and resend; at 90% of the size cap the daemon mails a warning naming the cap and both prune modes. Error replies are subject to the address's rules: at most ten per sender per hour, never twice for the same rejected payload. +The rules the daemon enforces: text is at most `max_post_chars` characters (288 by default; twtxt itself sets no hard limit) and may not contain control characters such as TAB, LF or CR, as twtxt requires, while U+2028 is allowed as the Multiline extension's line break; the timestamp is the mail's own time, pinned by the signature, so a same-second resend is rejected with a reply — wait a second and resend; at 90% of the size cap the daemon mails a warning naming the cap and both prune modes. Error replies are subject to the address's rules: at most ten per sender per hour, never twice for the same rejected payload. ## What a feed looks like diff --git a/audit.md b/audit.md index bf21529..b12b58a 100644 --- a/audit.md +++ b/audit.md @@ -19,7 +19,7 @@ The reader of a feed verifies the author's own §5.3 payload signature, rebuilt ## Security properties, each with its enforcement point and proof -- **Feed-line injection is impossible.** Post and repost text is rejected on TAB, LF, CR or U+2028 (post rule 4, `rules.rs`), so a record can never introduce a second line; a text posing as `# smol:mail` or `# smol:repost` publishes only as timestamped post text. Proven by the pentest's injection and poser probes. +- **Feed-line injection is impossible.** Post and repost text is rejected on any control character, TAB, LF and CR included (post rule 3, `rules.rs`), so a record can never introduce a second line; a text posing as `# smol:mail` or `# smol:repost` publishes only as timestamped post text. Proven by the pentest's injection and poser probes. - **The feed path cannot escape its directory.** A slug is validated before any filesystem access (`feedstore::valid_slug`), and the join is then checked to have stayed inside `feeds/` — so traversal, encoding, null-byte, unicode and dotfile forms all 404 without touching a file outside it. Proven by nine pentest path probes, a reject table and an HTTP property test. The grammar widened when remote authors got host-namespaced slugs (`alice@other.org`), and that changed the guard's character rather than just its range: a path could not previously contain a dot, which is *why* traversal was unreachable. Dots are legal now, so `..`, adjacent separators, uppercase, `%`, `/` and empty DNS labels are each refused explicitly. The nick rule (`valid_username`, 1-30 bytes of `[a-z0-9-]`) is deliberately a separate function and was left untouched, because a nick reaches `# nick =` metadata that other twtxt clients parse; a test asserts the two have not been conflated. diff --git a/core/src/feed.rs b/core/src/feed.rs index 571a13d..96cd2c6 100644 --- a/core/src/feed.rs +++ b/core/src/feed.rs @@ -228,7 +228,7 @@ impl Feed { }) } - /// Appends a record and its signature line, post rule 5's append-only + /// Appends a record and its signature line, post rule 4's append-only /// half. The caller has validated the record and checked uniqueness. pub fn append(&mut self, record: Record, mail: Option) { let mail_item = mail.map(Item::Mail); diff --git a/core/src/lib.rs b/core/src/lib.rs index b2e9928..320b851 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -13,6 +13,6 @@ pub mod verify; pub use envelope::{open, Opened}; pub use feed::{Entry, Feed, MailLine, Record}; -pub use rules::{validate_text, validate_timestamp, RuleError, MAX_BYTES, MAX_CODEPOINTS}; +pub use rules::{validate_text, validate_timestamp, RuleError}; pub use time::{parse as parse_timestamp, render as render_timestamp}; pub use verify::{reply_to_body, repost_body, signed_bytes, verify, Report, State}; diff --git a/core/src/rules.rs b/core/src/rules.rs index c37a251..b39d3f2 100644 --- a/core/src/rules.rs +++ b/core/src/rules.rs @@ -1,13 +1,8 @@ -//! Post rules 1, 3 and 4 from PLAN.md, enforced at receipt by the daemon and +//! Post rules 1 and 3 from PLAN.md, enforced at receipt by the daemon and //! usable by any author-side tool. -pub const MAX_CODEPOINTS: usize = 160; -pub const MAX_BYTES: usize = 640; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RuleError { - TooManyCodepoints, - TooManyBytes, ForbiddenChar, BadTimestamp, DuplicateTimestamp, @@ -16,9 +11,7 @@ pub enum RuleError { impl std::fmt::Display for RuleError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let msg = match self { - RuleError::TooManyCodepoints => "more than 160 Unicode code points", - RuleError::TooManyBytes => "more than 640 bytes", - RuleError::ForbiddenChar => "contains TAB, LF, CR or U+2028", + RuleError::ForbiddenChar => "contains a control character", RuleError::BadTimestamp => "not an RFC 3339 UTC timestamp at second precision", RuleError::DuplicateTimestamp => "timestamp already exists in this feed", }; @@ -28,18 +21,13 @@ impl std::fmt::Display for RuleError { impl std::error::Error for RuleError {} -/// Rules 3 and 4: at most 160 code points and 640 bytes, and no TAB, LF, CR -/// or U+2028 — any of those would break the one-record-per-line invariant. -/// The byte cap is mathematically implied by the codepoint cap over UTF-8 but -/// is kept as an explicit guard. +/// Rule 3: no control characters, as twtxt requires. TAB, LF and CR are among +/// them, so a record can never break the one-record-per-line invariant. Length +/// is an instance policy, so the daemon caps it, not this rule. U+2028 +/// is not a control character and stays allowed: it is the twtxt Multiline +/// extension's line break. pub fn validate_text(text: &str) -> Result<(), RuleError> { - if text.chars().count() > MAX_CODEPOINTS { - return Err(RuleError::TooManyCodepoints); - } - if text.len() > MAX_BYTES { - return Err(RuleError::TooManyBytes); - } - if text.contains(['\t', '\n', '\r', '\u{2028}']) { + if text.chars().any(char::is_control) { return Err(RuleError::ForbiddenChar); } Ok(()) @@ -64,24 +52,14 @@ mod tests { } #[test] - fn rejects_over_length_text() { - let chars: String = std::iter::repeat('a').take(161).collect(); - assert_eq!(validate_text(&chars), Err(RuleError::TooManyCodepoints)); - - // 160 four-byte code points sit exactly on both caps. - let four_byte: String = "🙂".repeat(160); - assert_eq!(validate_text(&four_byte), Ok(())); - let over: String = "🙂".repeat(161); - assert_eq!( - validate_text(&over), - Err(RuleError::TooManyCodepoints), - "codepoints are checked before bytes" - ); + fn accepts_long_and_multiline_text() { + assert_eq!(validate_text(&"🙂".repeat(1000)), Ok(())); + assert_eq!(validate_text("first\u{2028}second"), Ok(())); } #[test] - fn rejects_record_breaking_characters() { - for bad in ["a\tb", "a\nb", "a\rb", "a\u{2028}b"] { + fn rejects_control_characters() { + for bad in ["a\tb", "a\nb", "a\rb", "a\u{0}b", "a\u{1b}[31mb", "a\u{85}b"] { assert_eq!(validate_text(bad), Err(RuleError::ForbiddenChar)); } } diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index 9101d1d..9dbf20b 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smolboid" -version = "0.2.0" +version = "0.2.2" edition = "2021" description = "smolboid daemon: twtxt feeds published by smolmail" license = "Apache-2.0" diff --git a/daemon/locales/en-US/main.ftl b/daemon/locales/en-US/main.ftl index 0f4594b..36f018b 100644 --- a/daemon/locales/en-US/main.ftl +++ b/daemon/locales/en-US/main.ftl @@ -1,157 +1,200 @@ -## Loaded once by `src/i18n.rs`; every string the daemon mails to an author -## lives here, grouped by the mail it belongs to. Operator-facing CLI output is -## deliberately not here — see the module header. +## Every string the daemon mails to an author. Operator CLI output is not +## here, deliberately: see the i18n module header. ## -## Keep the `-32` style placeables out: these bodies are read in plain-text -## mail clients and on phones, so they stay short lines and no markup. +## House style, in short. Write to "you" and use contractions. Sentence case. +## Start with a verb where you can. Say what happened and what to do next, +## without blaming anyone and without the words invalid, illegal or fatal. +## Use the words the clients use: "header", not frontmatter field; "Requests +## folder", not requests tier; "post", not record, twt or entry. No em dashes. +## +## These are read in plain-text mail clients and on phones, so keep lines +## short and leave markup out. -## Error replies — a mail the daemon could not apply +## Replies about a message we couldn't publish reply-subject = twtxt: { $reason } reply-body = - Your mail could not be applied. + { $reason } - Reason: { $reason } - - Quoted mail: + Here's the message: { $quoted } -## Retention — the one-time warning as a feed approaches its size cap +## Reasons. Each one is a whole sentence: it becomes the subject line, and +## the body opens with it. +## +## Each MUST be a single line, however long. A reason goes into a mail header, +## and a line break there breaks the whole header block, so the reply arrives +## with no subject at all. Fluent keeps the newlines in a wrapped value, so +## there's no safe way to wrap these. `every_reason_is_a_single_line` checks +## it. When the fix needs more room, put it in a detail paragraph below. -cap-warning-subject = twtxt: feed at 90% of its size cap +reason-body-not-utf8 = We couldn't read the text in this message. Try sending it again. +reason-post-not-canonical = We couldn't rebuild this message exactly as you sent it, so we can't prove it's yours. Send the text on its own. +reason-repost-not-canonical = We couldn't rebuild this repost exactly as you sent it. Send it again with only the Twt-Repost header. +reason-fields-beyond-reply-to = This post carries extra headers. Send it again with just the text. +reason-nick-invalid = A nick can use a-z, 0-9 and hyphens, up to 30 characters. +reason-delete-needs-timestamp = Twt-Delete needs the time of the post to remove, like 2026-10-14T19:39:39Z. +reason-prune-mode-unknown = Twt-Prune takes fifo or off, not "{ $got }". + +## Limits on what a post can contain + +reason-too-long = A post can be up to { $max } characters. Shorten it and send it again. +reason-forbidden-char = A post has to fit on one line, without tabs or other control characters. Take them out and send it again. +reason-bad-timestamp = That isn't a time we can read. Use the form 2026-10-14T19:39:39Z. +reason-duplicate-timestamp = Your feed already has a post at that time. + +## Quota and retention + +reason-over-quota-post = You've reached today's limit for posts. Try again tomorrow. +reason-over-quota-repost = You've reached today's limit for reposts. Try again tomorrow. +reason-collision = Your feed already has a post at { $timestamp }. Wait a second, then send it again. +reason-feed-full = Your feed is full. Remove a post with Twt-Delete, or send Twt-Prune: fifo to drop the oldest ones from now on. +reason-reply-to-changed = This feed belongs to { $author }. Send your posts from that address. +reason-no-record-at = Your feed has no post at { $timestamp }. + +## Commands + +reason-one-command-per-mail = Send one command per message. Twt-Register, Twt-Help, Twt-Admit, Twt-Block and Twt-Registration each need their own, and feed commands need another. +reason-bad-address = "{ $got }" isn't a smolmail address. +reason-registration-mode-unknown = Twt-Registration takes open, closed or silent, not "{ $got }". + +## An accept token in a post. The author didn't do anything wrong: their +## client added the token because they accepted this address. The fix depends +## on which client they use, so it goes in a detail paragraph. + +reason-accept-field-on-post = A post can't carry the accept token your client attached. +reason-accept-field-on-first-post = Your first post carried an accept token, which we can't publish. + +## Detail paragraphs, added to the end of the reply when one line isn't +## enough room for the fix. Most reasons don't need one. + +reason-accept-field-on-post-detail = + + We publish your post by rebuilding exactly what you signed, so anyone can + check it was you. A token can't be part of that. + + We've got your token now, so send the same post again and it should go up. + + If it's refused again, your client is adding the token to every message + instead of sending it once. Block this address in your client and send + the post again. Blocking only moves our replies back to your Requests + folder. + +reason-accept-field-on-first-post-detail = + + Your feed is fine. It's there and it's waiting for its first post. + + We publish your post by rebuilding exactly what you signed, so anyone can + check it was you. A token can't be part of that. + + We've got your token now, so send that post again and it should go up. + + If it's refused again, your client is adding the token to every message + instead of sending it once. Block this address in your client and send + the post again. Blocking only moves our replies back to your Requests + folder. + +## Warning as a feed fills up + +cap-warning-subject = twtxt: your feed is nearly full cap-warning-body = - Your feed has reached 90% of the { $cap }-byte cap. + Your feed has reached 90% of its { $cap } limit. - In hard-limit mode (the default) a full feed rejects new records; delete - some with Twt-Delete, or choose FIFO pruning with Twt-Prune: fifo, which - drops the oldest records to make room. + When it's full, new posts are refused. Remove some with Twt-Delete, or + send Twt-Prune: fifo and we'll drop the oldest ones to make room. -## Admission — the mail that hands over the accept token +## The mail that hands over the accept token welcome-subject = twtxt: your feed is ready welcome-body = - Your smolboid feed is live: { $url } + Your feed is live: { $url } - Post by sending mail to this address with the twt as the body. Manage it - with the Twt-Nick, Twt-Delete, Twt-Repost and Twt-Prune frontmatter - fields, and send Twt-Help to get this list again. + Send a message to this address and it becomes a post. -## Rejection reasons — one per `reason::Reason` variant, rendered into the -## reply subject and body above. Each is a sentence fragment completing -## "your mail could not be applied because …", so they start lowercase and -## carry the fix where there is one. -## -## Each MUST be a single line, however long. These are interpolated into the -## reply's Subject, and a frontmatter value carrying a line break makes the -## whole block malformed — smolmail then fails closed toward display (SPEC.md -## sec 5.5) and the reply arrives with no subject at all. Fluent multiline -## values keep their newlines, so there is no safe way to wrap them here. -## `reason::tests::every_reason_is_a_single_line` enforces this. + To manage your feed, add one of these headers: Twt-Nick, Twt-Delete, + Twt-Repost or Twt-Prune. Send Twt-Help for what each one does. -reason-body-not-utf8 = body is not UTF-8 -reason-post-not-canonical = post mail is not in canonical form -reason-repost-not-canonical = repost mail is not in canonical form -reason-fields-beyond-reply-to = post mail carries fields beyond Reply-To (Subject or headers); resend the bare text -reason-nick-invalid = nick must be 1-30 bytes of a-z, 0-9 or hyphen -reason-delete-needs-timestamp = Twt-Delete needs an RFC 3339 UTC timestamp -reason-prune-mode-unknown = unknown prune mode `{ $got }`, want fifo or off - -## Post rules, from core::rules::RuleError - -reason-too-many-codepoints = more than 160 Unicode code points -reason-too-many-bytes = more than 640 bytes -reason-forbidden-char = contains TAB, LF, CR or U+2028 -reason-bad-timestamp = not an RFC 3339 UTC timestamp at second precision -reason-duplicate-timestamp = timestamp already exists in this feed - -## Retention and quota, from the apply step - -reason-over-quota-post = daily post quota exceeded; try tomorrow -reason-over-quota-repost = daily repost quota exceeded; try tomorrow -reason-collision = timestamp { $timestamp } already exists; wait a second and resend -reason-feed-full = feed is at its size cap; delete records with Twt-Delete or choose Twt-Prune: fifo -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 - -## First contact — an unknown sender who may register +## First contact from someone who doesn't have a feed info-subject = twtxt: how to get a feed info-body = You can publish a twtxt feed here by sending mail. - Ask for one, optionally naming the nick you want: + To get one, send a message to this address with this header: - fumi send { $mailbox } --header "Twt-Register: yournick" + Twt-Register: yournick - We will reply with your feed's address and the commands for managing it. - Nothing is published until you ask, and this mail is the only one you get + Headers sit next to the subject in most clients, often behind a button + marked "+ header". Leave the value empty and we'll use your username. + + Our reply arrives in your Requests folder, not your inbox, because you + haven't accepted this address. We recommend leaving it that way. + + We publish nothing until you ask, and this is the only mail you'll get until you do. - Our replies arrive in your requests tier, so look for them there: - - fumi fetch && fumi list --requests - - Do not accept this address in your client. If you do, it will attach an - accept token to everything you send, and a published record cannot carry - one and still be verifiable — so your posts would be refused. - ## First contact while registration is closed -info-closed-subject = twtxt: registration is closed +info-closed-subject = twtxt: we're not taking new feeds info-closed-body = - Thanks for writing. This instance is not taking new feeds at the moment, - so there is nothing for you to do here yet. + Thanks for writing. We're not taking new feeds right now, so there's + nothing to do here yet. Nothing was published and nothing was kept. -## Twt-Help — the command reference +## Twt-Help help-subject = twtxt: your commands help-body = Your feed: { $url } - Post by sending mail to this address with the twt as the body. The body is - published byte for byte, at most 160 characters. + Send a message to this address and it becomes a post. We publish exactly + what you write, on one line. - Management fields, one per mail: + To manage your feed, add one of these headers. One per message. - Twt-Nick: yournick set the displayed nick - Twt-Delete: remove one post or repost - Twt-Repost: <#id> publish a repost - Twt-Prune: fifo | off what a full feed does at its size cap + Twt-Nick: yournick change the name shown on your feed + Twt-Delete: