Compare commits

...

6 commits
v0.2.0 ... main

Author SHA1 Message Date
randogoth
3d9ec1e773 chore: release 0.2.2
Post rules follow upstream twtxt: control characters are rejected, U+2028 multiline is allowed, and the length cap is configurable as max_post_chars, default 288.
2026-10-15 14:05:38 +03:00
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
dd6865782a chore: add a .gitignore, so .env cannot be committed
The release job sources FORGEJO_TOKEN from a .env beside the flake, and
nothing was holding that file out of a commit. target/ and .devbox/ were
only excluded by .git/info/exclude, which is local to one clone and never
travels, so a fresh checkout had no protection for either.

Also covers what the daemon and the test rigs write into a checkout:
master.key, mail.db, state.json and an operator's own smolboid.toml. No
tracked file becomes ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-15 09:58:55 +03:00
randogoth
83125a7d3f chore: release 0.2.1
Author-facing copy in plain language, and a line per mail saying what the
daemon did with it.
2026-10-15 09:24:54 +03:00
randogoth
db8aee2949 feat: say what happened to each mail
poll printed only "{applied} applied, {dropped} dropped", and dropped meant
every outcome that wasn't a published post. Answering a newcomer and refusing
a post both read "0 applied, 1 dropped", as did a mail with no Reply-To, a
blocked sender, a host outside register_hosts, silent mode, and a spent rate
limit. Eleven ways out of the stranger path, all indistinguishable.

That cost real time. Twice I read a successful info reply as a failure and had
to open state.json to tell, and once I had to ask the author to forward the
refusal, because the reason is nowhere in the log and the daemon's sent copies
are sealed to itself.

Outcome now carries its own line and poll prints it, so the phrase is written
where the context is and emitted in one place. A new branch can't return
Dropped without saying why, because it won't compile. Answered splits from
dropped in the counters too: a reply sent is not a mail ignored.

The silence toward senders is deliberate and unchanged — a reply would confirm
whether an address exists. The operator's journal is a different audience and
reaches nobody else. The enum says so, so nobody later "fixes" a quiet drop by
answering the sender.
2026-10-15 09:15:51 +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
17 changed files with 452 additions and 222 deletions

17
.gitignore vendored Normal file
View file

@ -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

4
Cargo.lock generated
View file

@ -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",

View file

@ -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

View file

@ -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.

View file

@ -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<MailLine>) {
let mail_item = mail.map(Item::Mail);

View file

@ -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};

View file

@ -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));
}
}

View file

@ -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"

View file

@ -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: <timestamp> remove one post or repost
Twt-Repost: <mention> <#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: <time> remove one post or repost
Twt-Repost: <mention> <#id> share someone else's post
Twt-Prune: fifo | off what happens when your feed fills up
Twt-Help: send this list again
help-admin-section =
Admin, from your address:
You're an admin here, so you can also send:
Twt-Admit: user@host give someone a feed
Twt-Block: user@host withdraw someone's token
Twt-Block: user@host stop someone posting
Twt-Registration: open | closed | silent
## Admin notifications
admin-notify-subject = twtxt: new feed { $slug }
admin-notify-body =
A feed was created.
Someone registered a feed.
Address: { $address }
Feed: { $url }
Fingerprint: { $fingerprint }
Verify that fingerprint out of band if you do not already know this
author. Twt-Block withdraws their token.
If you don't know this person, check that fingerprint with them some
other way. Send Twt-Block to stop them posting.
## What an admin command did. These are sentences on their own: they become
## the subject and open the body.
admin-admitted = Gave { $address } a feed at { $url }.
admin-admit-failed = Couldn't give { $address } a feed. { $detail }
admin-blocked = Blocked { $address }. Their posts won't go up from now on.
admin-block-unknown = { $address } wasn't accepted here, so there was nothing to block.
admin-block-failed = Couldn't block { $address }. { $detail }
admin-block-half-done = Blocked { $address } here, but the server still has their token, so their posts may still go up. { $detail }
admin-registration-open = Anyone can register a feed now.
admin-registration-closed = Registration is closed. People who write in are told so.
admin-registration-silent = Registration is closed and people who write in get no reply.
admin-ack-subject = twtxt: { $what }
admin-ack-body =
{ $what }
Sent because your address is listed as an admin of this instance.
reason-accept-field-on-post = your client attached an accept token because this address is in your contacts; a published record is verified by rebuilding the exact bytes you signed and cannot include a token, so withdraw the acceptance and resend
You're getting this because you're an admin here.

View file

@ -24,6 +24,9 @@ pub struct Config {
pub data: PathBuf,
pub posts_per_day: u32,
pub reposts_per_day: u32,
/// Longest post or repost text, in Unicode code points. twtxt itself sets
/// no hard limit, so this is instance policy.
pub max_post_chars: usize,
/// Per-feed size cap in bytes (PLAN.md, Retention).
pub cap_bytes: usize,
pub poll_secs: u64,
@ -60,6 +63,7 @@ impl Default for Config {
data: PathBuf::from("data"),
posts_per_day: 50,
reposts_per_day: 50,
max_post_chars: 288,
cap_bytes: 1_048_576,
poll_secs: 60,
timeout: 30,
@ -100,6 +104,7 @@ impl Config {
"data" => cfg.data = PathBuf::from(value),
"posts_per_day" => cfg.posts_per_day = value.parse().map_err(&bad)?,
"reposts_per_day" => cfg.reposts_per_day = value.parse().map_err(&bad)?,
"max_post_chars" => cfg.max_post_chars = value.parse().map_err(&bad)?,
"cap_bytes" => cfg.cap_bytes = value.parse().map_err(&bad)?,
"poll_secs" => cfg.poll_secs = value.parse().map_err(&bad)?,
"timeout" => cfg.timeout = value.parse().map_err(&bad)?,

View file

@ -342,10 +342,6 @@ mod tests {
#[test]
fn post_rules_are_enforced_with_reasons() {
use smolboid_core::rules::RuleError;
assert_eq!(
dispatch(&incoming(&"a".repeat(161))).unwrap_err(),
Reason::Rule(RuleError::TooManyCodepoints)
);
assert_eq!(
dispatch(&incoming("a\tb")).unwrap_err(),
Reason::Rule(RuleError::ForbiddenChar)

View file

@ -108,7 +108,15 @@ mod tests {
"admin-notify-body",
"admin-ack-subject",
"admin-ack-body",
"reason-accept-field-on-post",
"admin-admitted",
"admin-admit-failed",
"admin-blocked",
"admin-block-unknown",
"admin-block-failed",
"admin-block-half-done",
"admin-registration-open",
"admin-registration-closed",
"admin-registration-silent",
];
#[test]

View file

@ -29,17 +29,48 @@ use std::sync::{Mutex, MutexGuard};
/// the next cycle rather than dropped.
const DIALS_PER_POLL: usize = 4;
/// What became of one mail.
/// What became of one mail, and the line the operator reads about it.
///
/// Every branch carries its own phrase so that `poll` can log all of them in
/// one place: a new branch cannot be added without saying what it did.
///
/// The daemon stays silent toward *senders* deliberately — a reply would
/// confirm whether an address exists, and that silence is a security
/// property. The operator's journal is a different audience and reaches
/// nobody else. Do not "fix" a quiet drop by answering the sender.
#[derive(Debug, PartialEq, Eq)]
enum Outcome {
/// The feed or state changed.
Applied,
/// Handled, with nothing to change — including every silent drop.
Dropped,
Applied(String),
/// A reply was queued, but nothing changed here.
Answered(String),
/// Nothing happened and the sender was told nothing.
Dropped(String),
/// Needed a dial the cycle could not afford. Left in the store.
Deferred,
}
impl Outcome {
fn line(&self) -> String {
match self {
Outcome::Applied(what) => format!("applied: {what}"),
Outcome::Answered(what) => format!("answered: {what}"),
Outcome::Dropped(why) => format!("dropped: {why}"),
Outcome::Deferred => "deferred: no dials left this cycle".to_string(),
}
}
}
/// A byte count as something a reader can picture. Deliberately coarse: the
/// exact figure helps nobody deciding whether to prune their feed.
fn human_size(bytes: usize) -> String {
match bytes {
b if b >= 1 << 20 => format!("{} MB", b / (1 << 20)),
b if b >= 1 << 10 => format!("{} KB", b / (1 << 10)),
b => format!("{b} bytes"),
}
}
/// Whether a request is one only an admin may run. Register and Help are not:
/// an admin sending either means it literally, and routing them through the
/// admin branch would answer with silence.
@ -63,6 +94,7 @@ pub struct PendingRecord {
/// Failures that draw an error reply (PLAN.md, Management mail).
#[derive(Debug)]
pub enum ApplyError {
TooLong,
OverQuota(bool),
Collision(String),
FeedFull,
@ -71,7 +103,7 @@ pub enum ApplyError {
ReplyToChanged,
}
/// Applies a published record to the feed: quota, uniqueness, retention
/// Applies a published record to the feed: length, quota, uniqueness, retention
/// (pruning in Fifo mode, rejecting in Off mode), then the append.
/// Returns the rendered size after appending, for the 90% warning.
pub fn apply_record(
@ -81,6 +113,9 @@ pub fn apply_record(
record: PendingRecord,
now_unix: i64,
) -> Result<(), ApplyError> {
if record.text.chars().count() > cfg.max_post_chars {
return Err(ApplyError::TooLong);
}
State::roll_counters(fs, now_unix);
let (counter, limit) = if record.repost {
(&mut fs.reposts, cfg.reposts_per_day)
@ -226,6 +261,7 @@ impl Daemon {
}
let mut applied = 0;
let mut answered = 0;
let mut dropped = 0;
let mut replies: Vec<(String, String, String)> = Vec::new(); // (address, subject, body)
@ -233,6 +269,11 @@ impl Daemon {
let mut deferred = 0;
for stored in &mail {
let outcome = self.process(stored.id, &stored.envelope, &mut replies, &mut dials);
// One line per mail, so a silent drop is still visible to whoever
// runs this. Without it every outcome looked the same in the log.
if let Ok(outcome) = &outcome {
println!("{}", outcome.line());
}
// A deferred mail keeps its row, so the next cycle sees it again.
if outcome.as_ref().is_ok_and(|o| *o == Outcome::Deferred) {
deferred += 1;
@ -240,14 +281,15 @@ impl Daemon {
}
let _ = self.store.delete_local("inbox", &stored.id);
match outcome {
Ok(Outcome::Applied) => applied += 1,
Ok(Outcome::Applied(_)) => applied += 1,
Ok(Outcome::Answered(_)) => answered += 1,
Ok(_) => dropped += 1,
Err(e) => eprintln!("mail: {e}"),
}
}
let failed = self.send_replies(replies);
Ok(format!(
"{applied} applied, {dropped} dropped{}{}{}",
"{applied} applied, {answered} answered, {dropped} dropped{}{}{}",
match drained {
Some(n) => format!(", {n} backlog discarded"),
None => String::new(),
@ -336,7 +378,7 @@ impl Daemon {
// Blocked: their token is withdrawn, so nothing they send applies and
// they get no reply either.
if !self.accepted(&address)? {
return Ok(Outcome::Dropped);
return Ok(Outcome::Dropped(format!("{address} is blocked")));
}
match dispatch::request(incoming) {
// An owner's admin command needs no resolve: the feed's address
@ -350,14 +392,13 @@ impl Daemon {
// section for a non-admin.
Ok(Some(_)) => {
self.queue_help(state, incoming, &address, index, replies);
Ok(Outcome::Dropped)
Ok(Outcome::Answered(format!("help for {address}")))
}
Ok(None) => self
.apply(incoming, index, state, replies)
.map(|changed| if changed { Outcome::Applied } else { Outcome::Dropped }),
Ok(None) => self.apply(incoming, index, state, replies),
Err(reason) => {
let line = format!("{address}: {}", reason.text());
self.queue_reply(state, incoming, &address, &reason, replies);
Ok(Outcome::Dropped)
Ok(Outcome::Answered(format!("refused {line}")))
}
}
}
@ -376,14 +417,16 @@ impl Daemon {
// replying would make this daemon a mail reflector for anyone holding
// a freshly minted keypair.
let Some(claim) = dispatch::claimed(incoming) else {
return Ok(Outcome::Dropped);
return Ok(Outcome::Dropped(
"no signed Reply-To, so there is no address to verify".into(),
));
};
let address = claim.short();
// Before any network call, so a blocked author cannot spend our
// budget by re-registering.
if self.store.accepted_state(&address).unwrap_or(None) == Some(false) {
return Ok(Outcome::Dropped);
return Ok(Outcome::Dropped(format!("{address} is blocked")));
}
let request = dispatch::request(incoming);
@ -394,10 +437,15 @@ impl Daemon {
// even for a well-formed request — except for an admin, who would
// otherwise have no way to turn registration back on.
if mode == Mode::Silent && !is_admin_address {
return Ok(Outcome::Dropped);
return Ok(Outcome::Dropped(format!(
"{address} wrote in, and registration is silent"
)));
}
if !self.host_allowed(&claim) {
return Ok(Outcome::Dropped);
return Ok(Outcome::Dropped(format!(
"{} is not in register_hosts, so {address} cannot register",
claim.host
)));
}
if *dials >= DIALS_PER_POLL {
return Ok(Outcome::Deferred);
@ -410,14 +458,18 @@ impl Daemon {
Ok(Some(ref request)) if is_admin_address && is_admin_command(request) => {
*dials += 1;
if !self.holds_address(&claim, &incoming.sender)? {
return Ok(Outcome::Dropped);
return Ok(Outcome::Dropped(format!(
"{address} does not resolve to the key that signed an admin command"
)));
}
self.run_admin(request, &address, state, replies, dials)
}
Ok(Some(Request::Register { nick })) => {
if mode == Mode::Closed {
self.queue_closed(state, incoming, &address, replies);
return Ok(Outcome::Dropped);
return Ok(Outcome::Answered(format!(
"told {address} registration is closed"
)));
}
// Counted before the dial, not after: a registration that
// fails its key check costs the same outbound connection as
@ -428,7 +480,10 @@ impl Daemon {
self.cfg.registrations_per_hour,
now(),
) {
return Ok(Outcome::Dropped);
return Ok(Outcome::Dropped(format!(
"{address} tried to register, and this hour's limit of {} is spent",
self.cfg.registrations_per_hour
)));
}
*dials += 1;
self.register(&claim, nick.as_deref(), incoming, state, replies)
@ -439,10 +494,15 @@ impl Daemon {
_ => {
if mode == Mode::Closed {
self.queue_closed(state, incoming, &address, replies);
return Ok(Outcome::Dropped);
return Ok(Outcome::Answered(format!(
"told {address} registration is closed"
)));
}
if !global_allowed(state, Global::Informed, self.cfg.info_replies_per_hour, now()) {
return Ok(Outcome::Dropped);
return Ok(Outcome::Dropped(format!(
"{address} wrote in, and this hour's limit of {} info replies is spent",
self.cfg.info_replies_per_hour
)));
}
*dials += 1;
// Resolving first is what makes the reply safe. Sending to the
@ -452,10 +512,12 @@ impl Daemon {
// without this check would mail the real owner on a stranger's
// behalf.
if !self.holds_address(&claim, &incoming.sender)? {
return Ok(Outcome::Dropped);
return Ok(Outcome::Dropped(format!(
"{address} does not resolve to the key that signed this mail"
)));
}
self.queue_info(state, incoming, &address, replies);
Ok(Outcome::Dropped)
Ok(Outcome::Answered(format!("told {address} how to register")))
}
}
}
@ -523,8 +585,13 @@ impl Daemon {
// Includes the key-mismatch refusal, which must stay quiet:
// the sender is not the address holder, so there is nobody
// here to answer.
eprintln!("register {}: {e}", claim.short());
return Ok(Outcome::Dropped);
// Includes the key-mismatch refusal. Quiet toward the
// sender, loud here: this is the line an operator needs when
// a registration does not appear.
return Ok(Outcome::Dropped(format!(
"could not register {}: {e}",
claim.short()
)));
}
};
@ -543,7 +610,10 @@ impl Daemon {
if self.cfg.admin_notify {
self.notify_admins(&admitted, claim, &url, replies);
}
Ok(Outcome::Applied)
Ok(Outcome::Applied(format!(
"registered {} as {url}",
claim.short()
)))
}
fn notify_admins(
@ -605,37 +675,64 @@ impl Daemon {
crate::i18n::t1("welcome-body", "url", url.clone()),
));
}
format!("admitted {target}: {url}")
let mut args = fluent_bundle::FluentArgs::new();
args.set("address", target.clone());
args.set("url", url);
crate::i18n::t_args("admin-admitted", Some(&args))
}
Err(e) => {
let mut args = fluent_bundle::FluentArgs::new();
args.set("address", target.clone());
args.set("detail", e);
crate::i18n::t_args("admin-admit-failed", Some(&args))
}
Err(e) => format!("could not admit {target}: {e}"),
}
}
Request::Block(target) => match self.store.block(target) {
Ok(true) => {
match client::push_tokens(&self.store, &self.account, None, self.cfg.timeout) {
Ok(_) => format!("blocked {target}"),
Err(e) => format!("blocked {target} locally, but the server still holds their token: {e}"),
Request::Block(target) => {
let said = |id: &str, detail: Option<String>| {
let mut args = fluent_bundle::FluentArgs::new();
args.set("address", target.clone());
if let Some(detail) = detail {
args.set("detail", detail);
}
crate::i18n::t_args(id, Some(&args))
};
match self.store.block(target) {
Ok(true) => {
match client::push_tokens(&self.store, &self.account, None, self.cfg.timeout)
{
Ok(_) => said("admin-blocked", None),
Err(e) => said("admin-block-half-done", Some(e.to_string())),
}
}
Ok(false) => said("admin-block-unknown", None),
Err(e) => said("admin-block-failed", Some(e.to_string())),
}
Ok(false) => format!("{target} was never accepted"),
Err(e) => format!("could not block {target}: {e}"),
},
}
Request::Registration(mode) => {
state.set_registration(*mode);
state
.save(&self.cfg.data.join("state.json"))
.map_err(|e| format!("state: {e}"))?;
format!("registration is now {mode:?}").to_lowercase()
crate::i18n::t(match mode {
Mode::Open => "admin-registration-open",
Mode::Closed => "admin-registration-closed",
Mode::Silent => "admin-registration-silent",
})
}
// Not an admin command; the caller routes these elsewhere.
Request::Register { .. } | Request::Help => return Ok(Outcome::Dropped),
Request::Register { .. } | Request::Help => {
return Ok(Outcome::Dropped(
"an admin command that is not one; the caller routes these".into(),
))
}
};
replies.push((
address.to_owned(),
crate::i18n::t1("admin-ack-subject", "what", what.clone()),
crate::i18n::t1("admin-ack-body", "what", what),
crate::i18n::t1("admin-ack-body", "what", what.clone()),
));
Ok(Outcome::Applied)
Ok(Outcome::Applied(format!("admin {address}: {what}")))
}
/// The command reference, with the admin section only for an admin.
@ -751,7 +848,7 @@ impl Daemon {
index: usize,
state: &mut MutexGuard<'_, State>,
replies: &mut Vec<(String, String, String)>,
) -> Result<bool, String> {
) -> Result<Outcome, String> {
let username = state.feeds[index].username.clone();
let address = state.feeds[index].address.clone();
let mut feed = self.feeds.load(&username)?;
@ -772,17 +869,29 @@ impl Daemon {
}
Ok(Action::Commands(commands)) => {
match apply_commands(&mut feed, &mut state.feeds[index], &commands) {
Ok(n) => n > 0,
Ok(n) => Outcome::Applied(format!("{n} command(s) from {address}")),
Err(reason) => {
let line = reason.text();
self.queue_reply(state, incoming, &address, &reason, replies);
false
Outcome::Answered(format!("refused {address}: {line}"))
}
}
}
Ok(Action::Ignore) => false,
Ok(Action::Ignore) => {
Outcome::Dropped(format!("nothing to do in {address}'s mail"))
}
Err(reason) => {
// Someone watching their first post be refused needs to hear
// that their feed is fine, not just what went wrong.
let reason = match reason {
Reason::AcceptFieldOnPost if feed.entries().is_empty() => {
Reason::AcceptFieldOnFirstPost
}
other => other,
};
let line = reason.text();
self.queue_reply(state, incoming, &address, &reason, replies);
false
Outcome::Answered(format!("refused {address}: {line}"))
}
};
@ -807,7 +916,7 @@ impl Daemon {
incoming: &Incoming,
replies: &mut Vec<(String, String, String)>,
record: PendingRecord,
) -> Result<bool, String> {
) -> Result<Outcome, String> {
let was_warned = state.feeds[index].warned;
match apply_record(feed, &mut state.feeds[index], &self.cfg, record, incoming.time) {
Ok(()) => {
@ -817,17 +926,14 @@ impl Daemon {
replies.push((
address.to_owned(),
crate::i18n::t("cap-warning-subject"),
crate::i18n::t1(
"cap-warning-body",
"cap",
self.cfg.cap_bytes.to_string(),
),
crate::i18n::t1("cap-warning-body", "cap", human_size(self.cfg.cap_bytes)),
));
}
Ok(true)
Ok(Outcome::Applied(format!("post by {address}")))
}
Err(e) => {
let reason = match e {
ApplyError::TooLong => Reason::TooLong { max: self.cfg.max_post_chars },
ApplyError::OverQuota(repost) => Reason::OverQuota { repost },
ApplyError::Collision(timestamp) => Reason::Collision { timestamp },
ApplyError::FeedFull => Reason::FeedFull,
@ -835,8 +941,9 @@ impl Daemon {
author: feed.author().unwrap_or_default().to_owned(),
},
};
let line = reason.text();
self.queue_reply(state, incoming, address, &reason, replies);
Ok(false)
Ok(Outcome::Answered(format!("refused {address}: {line}")))
}
}
}
@ -857,14 +964,10 @@ impl Daemon {
let text = std::str::from_utf8(&incoming.body)
.map(|t| t.chars().take(200).collect::<String>())
.unwrap_or_default();
let sentence = reason.text();
let mut args = fluent_bundle::FluentArgs::new();
args.set("reason", sentence.clone());
args.set("quoted", text);
replies.push((
address.to_owned(),
crate::i18n::t1("reply-subject", "reason", sentence),
crate::i18n::t_args("reply-body", Some(&args)),
crate::i18n::t1("reply-subject", "reason", reason.text()),
reason.reply_body(&text),
));
}
@ -950,6 +1053,15 @@ mod tests {
}
}
#[test]
fn sizes_read_as_sizes() {
assert_eq!(human_size(1_048_576), "1 MB");
assert_eq!(human_size(10 * 1_048_576), "10 MB");
assert_eq!(human_size(2048), "2 KB");
assert_eq!(human_size(1023), "1023 bytes");
assert_eq!(human_size(0), "0 bytes");
}
#[test]
fn posts_publish_and_verify() {
let mut feed = smolboid_core::Feed::new();
@ -961,6 +1073,18 @@ mod tests {
assert_eq!(feed.entries().len(), 1);
}
#[test]
fn length_cap_is_configurable() {
let mut feed = smolboid_core::Feed::new();
let mut fs = feed_state();
let cfg = Config { max_post_chars: 5, ..config(1 << 20) };
let five = record("2026-10-01T12:47:00Z", "🙂🙂🙂🙂🙂");
apply_record(&mut feed, &mut fs, &cfg, five, 0).unwrap();
let six = record("2026-10-01T12:47:01Z", "abcdef");
assert!(matches!(apply_record(&mut feed, &mut fs, &cfg, six, 0), Err(ApplyError::TooLong)));
assert_eq!(fs.posts, 1, "a refused post spends no quota");
}
#[test]
fn reposts_publish_as_repost_records() {
let mut feed = smolboid_core::Feed::new();
@ -1029,7 +1153,7 @@ mod tests {
fn warning_flag_flips_at_ninety_percent() {
let mut feed = smolboid_core::Feed::new();
let mut fs = feed_state();
let cfg = config(1300);
let cfg = Config { max_post_chars: 700, ..config(1300) };
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:00Z", "x"), 0).unwrap();
assert!(!fs.warned);
apply_record(&mut feed, &mut fs, &cfg, record("2026-10-01T12:47:01Z", &"y".repeat(700)), 0).unwrap();

View file

@ -25,6 +25,8 @@ pub enum Reason {
/// maps it to copy and the library keeps its own `Display` for
/// author-side tools.
Rule(RuleError),
/// Longer than the instance's `max_post_chars`.
TooLong { max: usize },
OverQuota { repost: bool },
Collision { timestamp: String },
FeedFull,
@ -43,6 +45,10 @@ pub enum Reason {
/// not a mistake the author made, it is one their client made for them,
/// and the fix is to stop accepting this address.
AcceptFieldOnPost,
/// The same, on a feed that has published nothing yet — so the author is
/// watching their very first post be refused, and deserves to be told
/// that nothing is wrong with their feed.
AcceptFieldOnFirstPost,
}
impl From<RuleError> for Reason {
@ -63,9 +69,8 @@ impl Reason {
Reason::NickInvalid => "reason-nick-invalid",
Reason::DeleteNeedsTimestamp => "reason-delete-needs-timestamp",
Reason::PruneModeUnknown { .. } => "reason-prune-mode-unknown",
Reason::Rule(RuleError::TooManyCodepoints) => "reason-too-many-codepoints",
Reason::Rule(RuleError::TooManyBytes) => "reason-too-many-bytes",
Reason::Rule(RuleError::ForbiddenChar) => "reason-forbidden-char",
Reason::TooLong { .. } => "reason-too-long",
Reason::Rule(RuleError::BadTimestamp) => "reason-bad-timestamp",
Reason::Rule(RuleError::DuplicateTimestamp) => "reason-duplicate-timestamp",
Reason::OverQuota { repost: false } => "reason-over-quota-post",
@ -78,6 +83,7 @@ impl Reason {
Reason::BadAddress { .. } => "reason-bad-address",
Reason::RegistrationModeUnknown { .. } => "reason-registration-mode-unknown",
Reason::AcceptFieldOnPost => "reason-accept-field-on-post",
Reason::AcceptFieldOnFirstPost => "reason-accept-field-on-first-post",
}
}
@ -92,16 +98,46 @@ impl Reason {
args.set("timestamp", timestamp.clone())
}
Reason::ReplyToChanged { author } => args.set("author", author.clone()),
Reason::TooLong { max } => args.set("max", max.to_string()),
_ => return None,
}
Some(args)
}
/// The author-facing sentence, used both as the reply's subject suffix and
/// in its body.
/// in its body. One line, always: it goes into a frontmatter value, and a
/// line break there makes the whole block malformed.
pub fn text(&self) -> String {
crate::i18n::t_args(self.id(), self.args().as_ref())
}
/// The whole reply body: the template, the quoted mail, and the detail
/// paragraph if this reason has one.
///
/// Assembled here so there is one place that knows how the pieces join.
/// Rebuilding it at a call site is how the detail ended up glued to the
/// last line of the quoted mail.
pub fn reply_body(&self, quoted: &str) -> String {
let mut args = fluent_bundle::FluentArgs::new();
args.set("reason", self.text());
args.set("quoted", quoted.to_owned());
let mut body = crate::i18n::t_args("reply-body", Some(&args));
if let Some(detail) = self.detail() {
// The template ends on the quoted mail with no trailing newline.
body.push_str("\n\n");
body.push_str(&detail);
}
body
}
/// An optional paragraph for the reply body, when one sentence cannot
/// carry the fix. Most reasons have none, and the subject says it all.
pub fn detail(&self) -> Option<String> {
let rendered = crate::i18n::t_args(&format!("{}-detail", self.id()), self.args().as_ref());
// The catalogue marks a missing id rather than failing, which is
// exactly how an absent detail reads.
(!rendered.starts_with("???")).then_some(rendered)
}
}
#[cfg(test)]
@ -120,9 +156,8 @@ mod tests {
Reason::NickInvalid,
Reason::DeleteNeedsTimestamp,
Reason::PruneModeUnknown { got: "sideways".into() },
Reason::Rule(RuleError::TooManyCodepoints),
Reason::Rule(RuleError::TooManyBytes),
Reason::Rule(RuleError::ForbiddenChar),
Reason::TooLong { max: 288 },
Reason::Rule(RuleError::BadTimestamp),
Reason::Rule(RuleError::DuplicateTimestamp),
Reason::OverQuota { repost: false },
@ -135,6 +170,7 @@ mod tests {
Reason::BadAddress { got: "not an address".into() },
Reason::RegistrationModeUnknown { got: "sideways".into() },
Reason::AcceptFieldOnPost,
Reason::AcceptFieldOnFirstPost,
]
}
@ -172,6 +208,20 @@ mod tests {
}
}
/// A detail is optional, but one that exists must be reachable — a typo
/// in its id would otherwise silently drop the explanation.
#[test]
fn details_are_optional_but_never_half_present() {
assert!(Reason::AcceptFieldOnPost.detail().is_some());
assert!(Reason::AcceptFieldOnFirstPost.detail().is_some());
assert!(Reason::FeedFull.detail().is_none());
for reason in all_reasons() {
if let Some(detail) = reason.detail() {
assert!(!detail.is_empty(), "{reason:?} has an empty detail");
}
}
}
#[test]
fn interpolated_reasons_carry_their_value() {
assert!(Reason::PruneModeUnknown { got: "sideways".into() }

View file

@ -148,6 +148,7 @@
data = ${cfg.dataDir}
posts_per_day = ${toString cfg.postsPerDay}
reposts_per_day = ${toString cfg.repostsPerDay}
max_post_chars = ${toString cfg.maxPostChars}
cap_bytes = ${toString cfg.capBytes}
poll_secs = ${toString cfg.pollSecs}
timeout = ${toString cfg.timeout}
@ -254,6 +255,12 @@
description = "Per-feed daily repost quota.";
};
maxPostChars = mkOption {
type = types.ints.positive;
default = 288;
description = "Longest post or repost text, in Unicode code points.";
};
capBytes = mkOption {
type = types.ints.positive;
default = 1048576;

View file

@ -218,13 +218,14 @@ sleep 4
[[ "$(grep -c "return" "$FEED")" == 0 ]] || fail "a CR-carrying post published"
[[ "$(grep -cE '^[0-9]{4}-' "$FEED")" == 1 ]] || fail "the feed grew beyond the real post"
step "mail: oversized and hostile payloads are rejected, not published"
author send "$DAEMON_ADDR" --body "$(printf 'x%.0s' $(seq 1 161))" >/dev/null
step "mail: control-character and hostile payloads are rejected, not published"
author send "$DAEMON_ADDR" --body "$(printf 'escape\033[31mred')" >/dev/null
author send "$DAEMON_ADDR" --body "# smol:mail forged" >/dev/null
author send "$DAEMON_ADDR" --body "# smol:repost 2020-01-01T00:00:00Z @<x y> #fake" >/dev/null
sleep 4
# Poser text may publish as ordinary post text (tab-prefixed, harmless);
# what must never happen is a line that IS a smol record.
[[ "$(grep -c "escape" "$FEED")" == 0 ]] || fail "a post with a control character published"
[[ "$(grep -cE '^# smol:mail .*forged' "$FEED")" == 0 ]] || fail "a fake smol record line published"
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "text posing as a repost published as a repost"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
@ -378,13 +379,13 @@ fi
step "mail: reply-flood is capped at 10 per hour"
for i in $(seq 1 15); do
author send "$DAEMON_ADDR" --body "$(printf 'y%.0s' $(seq 1 $((200 + i))))" >/dev/null
author send "$DAEMON_ADDR" --body "$(printf 'y%.0s' $(seq 1 "$i"))$(printf '\tz')" >/dev/null
done
sleep 5
author fetch >/dev/null
# Fifteen distinct oversized payloads; at most ten answers, the rest silent.
# Fifteen distinct payloads carrying a TAB; at most ten answers, the rest silent.
# (Earlier probes may have drawn their own replies for other reasons.)
author list --requests 2>/dev/null | grep -c "twtxt: more than 160" >"$RIG/replies.count" || true
author list --requests 2>/dev/null | grep -c "twtxt: A post has to fit on one line" >"$RIG/replies.count" || true
REPLIES=$(grep -oE '^[0-9]+' "$RIG/replies.count" | head -1 || echo 0)
echo "flood answers: $REPLIES of 15"
[[ "$REPLIES" -le 10 ]] || fail "reply flood drew $REPLIES answers, over the hourly cap"

View file

@ -144,7 +144,7 @@ for i in 1 2 3 4 5; do
continue # different seconds; try again
fi
fumi fetch >/dev/null
if fumi list --requests 2>/dev/null | grep -q "timestamp .* already exists"; then
if fumi list --requests 2>/dev/null | grep -q "already has a post at"; then
collided=1
break
fi
@ -163,7 +163,7 @@ fumi send "$DAEMON_ADDR" --body "over the cap" >/dev/null
sleep 3
grep -q "over the cap" "$FEED" && fail "a capped feed published a record"
fumi fetch >/dev/null
fumi list --requests 2>/dev/null | grep -q "feed is at its size cap" \
fumi list --requests 2>/dev/null | grep -q "Your feed is full" \
|| fail "no cap rejection reply"
step "retention: fifo mode"
@ -173,7 +173,7 @@ fumi send "$DAEMON_ADDR" --body "fifo post" >/dev/null
wait_for "$FEED" "fifo post"
[[ "$(grep -cE '^[0-9]' "$FEED")" == 1 ]] || fail "fifo did not prune down to one record"
fumi fetch >/dev/null
fumi list --requests 2>/dev/null | grep -q "90% of its size cap" \
fumi list --requests 2>/dev/null | grep -q "your feed is nearly full" \
|| fail "no 90% warning reply"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
grep -q "1 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
@ -289,9 +289,9 @@ done
MAIL="$(dana_mail)"
[[ "$MAIL" == *"accept token"* ]] \
|| { echo "$MAIL"; fail "no explanation for the refused post"; }
# The whole sentence must survive as a subject: a line break in a frontmatter
# value makes the block malformed and strips the subject entirely.
[[ "$MAIL" == *"withdraw the acceptance and resend"* ]] \
# The whole sentence must survive as a subject: a line break in a mail header
# makes the block malformed and strips the subject entirely.
[[ "$MAIL" == *"your client attached"* ]] \
|| { echo "$MAIL"; fail "the reason was truncated in the subject"; }
dana block "$DAEMON_ADDR" >/dev/null
@ -319,11 +319,11 @@ sleep 4
[[ ! -f "$DATA/feeds/erin.txt" ]] || fail "registration is closed but erin got a feed"
for ((i = 0; i < 20; i++)); do
erin fetch >/dev/null 2>&1 || true
[[ "$(erin list --requests 2>/dev/null || true)" == *"registration is closed"* ]] && break
[[ "$(erin list --requests 2>/dev/null || true)" == *"not taking new feeds"* ]] && break
sleep 1
done
[[ "$(erin list --requests 2>/dev/null || true)" == *"registration is closed"* ]] \
|| fail "erin was not told registration is closed"
[[ "$(erin list --requests 2>/dev/null || true)" == *"not taking new feeds"* ]] \
|| { erin list --requests || true; fail "erin was not told registration is closed"; }
step "rig passed"
echo "feeds: $FEED"