feat: follow upstream twtxt post rules: reject control characters, allow U+2028, make the length cap configurable

This commit is contained in:
randogoth 2026-10-15 13:59:39 +03:00
parent dd6865782a
commit 801c66d3b0
12 changed files with 63 additions and 58 deletions

View file

@ -38,9 +38,8 @@ reason-prune-mode-unknown = Twt-Prune takes fifo or off, not "{ $got }".
## Limits on what a post can contain
reason-too-many-codepoints = A post can be up to 160 characters. Shorten it and send it again.
reason-too-many-bytes = A post can be up to 640 bytes, and accents and emoji use several each. Shorten it and send it again.
reason-forbidden-char = A post has to fit on one line. Take out the line breaks and tabs, then send it again.
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.
@ -150,7 +149,7 @@ help-body =
Your feed: { $url }
Send a message to this address and it becomes a post. We publish exactly
what you write, up to 160 characters, on one line.
what you write, on one line.
To manage your feed, add one of these headers. One per message.

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

@ -94,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,
@ -102,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(
@ -112,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)
@ -929,6 +933,7 @@ impl Daemon {
}
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,
@ -1068,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();
@ -1136,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,
@ -67,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",
@ -97,6 +98,7 @@ 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)
@ -154,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 },