smolboid/tests/rig.sh

333 lines
14 KiB
Bash
Raw Permalink Normal View History

#!/usr/bin/env bash
# smolboid integration rig (PLAN.md step 7): a local bunshin, the fumi CLI as
# the author, and the daemon under test — covering post, command, repost,
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
# timestamp collision, size-cap warning, both prune modes, an author rotation,
# self-service registration by mail and the admin commands. Real binaries,
# real Noise sessions, real envelopes.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FUMI_DIR="$ROOT/../fumi"
BUNSHIN_DIR="$ROOT/../bunshin"
RIG="$(mktemp -d /tmp/smolboid-rig.XXXXXX)"
HOST=127.0.0.1
SMTP_PORT=19611
HTTP_PORT=18081
DAEMON_ADDR="twtxt@$HOST:$SMTP_PORT"
AUTHOR_ADDR="tobias@$HOST:$SMTP_PORT"
DATA="$RIG/data"
BUNSHIN_PID=""
SMOLBOID_PID=""
cleanup() {
[[ -n "$BUNSHIN_PID" ]] && kill "$BUNSHIN_PID" 2>/dev/null || true
[[ -n "$SMOLBOID_PID" ]] && kill "$SMOLBOID_PID" 2>/dev/null || true
}
trap cleanup EXIT
step() { printf '\n== %s ==\n' "$*"; }
fail() { echo "FAIL: $*" >&2; exit 1; }
wait_for() { # file, text, [tries]
local file=$1 text=$2 tries=${3:-20} i
for ((i = 0; i < tries; i++)); do
[[ -f $file ]] && grep -q "$text" "$file" && return 0
sleep 1
done
fail "timed out waiting for '$text' in $file"
}
wait_gone() { # file, text, [tries]
local file=$1 text=$2 tries=${3:-20} i
for ((i = 0; i < tries; i++)); do
grep -q "$text" "$file" || return 0
sleep 1
done
fail "'$text' is still in $file"
}
step "build bunshin, fumi, smolboid"
cargo build --manifest-path "$BUNSHIN_DIR/Cargo.toml" --quiet
cargo build --manifest-path "$FUMI_DIR/Cargo.toml" --quiet
cargo build --manifest-path "$ROOT/Cargo.toml" --quiet
BUNSHIN="$BUNSHIN_DIR/target/debug/bunshin"
FUMI="$FUMI_DIR/target/debug/fumi"
SMOLBOID="$ROOT/target/debug/smolboid"
step "bunshin on $HOST:$SMTP_PORT"
"$BUNSHIN" keygen --key "$RIG/server.key" >"$RIG/keygen.log" 2>&1
SERVER_KEY="$(grep -oE '[a-z2-7]{52}' "$RIG/keygen.log" | head -1)"
[[ -n "$SERVER_KEY" ]] || { cat "$RIG/keygen.log"; fail "no server key"; }
"$BUNSHIN" serve --key "$RIG/server.key" --db "$RIG/server.db" \
--host "$HOST" --port "$SMTP_PORT" >"$RIG/bunshin.log" 2>&1 &
BUNSHIN_PID=$!
sleep 1
step "identities: author tobias, daemon twtxt"
mkdir -p "$DATA"
fumi() { "$FUMI" --key "$RIG/author.key" --db "$RIG/author.db" --timeout 10 "$@"; }
dfumi() { "$FUMI" --key "$RIG/master.key" --db "$DATA/mail.db" --timeout 10 "$@"; }
fumi keygen --force >/dev/null
fumi trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
fumi register "$AUTHOR_ADDR" >/dev/null
"$SMOLBOID" keygen "$RIG/master.key" >/dev/null
dfumi trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
dfumi register "$DAEMON_ADDR" >/dev/null
step "admit tobias"
cat >"$RIG/smolboid.toml" <<EOF
listen = $HOST:$HTTP_PORT
base_url = http://$HOST:$HTTP_PORT
smolmail_host = $HOST
mailbox = twtxt
master = $RIG/master.key
data = $DATA
poll_secs = 1
timeout = 10
cap_bytes = 1048576
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
registration = open
admins = $AUTHOR_ADDR
admin_notify = on
EOF
"$SMOLBOID" admit --config "$RIG/smolboid.toml" "$AUTHOR_ADDR" >"$RIG/admit.log" 2>&1 \
|| { cat "$RIG/admit.log"; fail "admit"; }
grep -q "welcome mail sent" "$RIG/admit.log" \
|| { cat "$RIG/admit.log"; fail "no welcome mail"; }
# The Accept token's proof is the next step: without it, posts stay in the
# requests tier and nothing ever publishes.
fumi fetch >/dev/null
FEED="$DATA/feeds/tobias.txt"
step "serve"
"$SMOLBOID" serve --config "$RIG/smolboid.toml" >"$RIG/serve.log" 2>&1 &
SMOLBOID_PID=$!
sleep 2
step "post"
fumi send "$DAEMON_ADDR" --body "hello smolverse" >/dev/null
wait_for "$FEED" "hello smolverse"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
grep -q "1 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "the first post did not verify"; }
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" | grep -q "hello smolverse" \
|| fail "the feed is not served over HTTP"
[[ "$(curl -s -o /dev/null -w '%{http_code}' "http://$HOST:$HTTP_PORT/twtxt/nobody.txt")" == 404 ]] \
|| fail "a missing feed must 404"
step "commands: nick, repost"
fumi send "$DAEMON_ADDR" --header "Twt-Nick: toby" --body "" >/dev/null
wait_for "$FEED" "# nick = toby"
fumi send "$DAEMON_ADDR" \
--header "Twt-Repost: @<tobias http://$HOST:$HTTP_PORT/twtxt/tobias.txt> #abc1234" \
--body "" >/dev/null
wait_for "$FEED" "# smol:repost .* #abc1234"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
grep -q "2 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "the repost did not verify"; }
step "delete"
TS="$(grep "hello smolverse" "$FEED" | cut -f1)"
fumi send "$DAEMON_ADDR" --header "Twt-Delete: $TS" --body "" >/dev/null
wait_gone "$FEED" "hello smolverse"
step "timestamp collision"
collided=0
for i in 1 2 3 4 5; do
fumi send "$DAEMON_ADDR" --body "collide A $i" >/dev/null &
A_PID=$!
fumi send "$DAEMON_ADDR" --body "collide B $i" >/dev/null &
B_PID=$!
# Only the two sends, never the servers.
wait "$A_PID" "$B_PID"
sleep 3
if grep -q "collide A $i" "$FEED" && grep -q "collide B $i" "$FEED"; then
continue # different seconds; try again
fi
fumi fetch >/dev/null
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:06:54 +03:00
if fumi list --requests 2>/dev/null | grep -q "already has a post at"; then
collided=1
break
fi
done
[[ "$collided" == 1 ]] || fail "no collision rejection arrived after 5 attempts"
step "retention: hard limit"
kill "$SMOLBOID_PID" 2>/dev/null || true
# Metadata (~190B) plus one record (~253B) must fit under the cap, and land
# above its 90% mark so the warning fires.
sed -i 's/^cap_bytes = .*/cap_bytes = 460/' "$RIG/smolboid.toml"
"$SMOLBOID" serve --config "$RIG/smolboid.toml" >>"$RIG/serve.log" 2>&1 &
SMOLBOID_PID=$!
sleep 2
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
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:06:54 +03:00
fumi list --requests 2>/dev/null | grep -q "Your feed is full" \
|| fail "no cap rejection reply"
step "retention: fifo mode"
fumi send "$DAEMON_ADDR" --header "Twt-Prune: fifo" --body "" >/dev/null
wait_for "$DATA/state.json" '"prune": "Fifo"'
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
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:06:54 +03:00
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" \
|| { cat "$RIG/check.log"; fail "the pruned feed does not verify"; }
step "author rotation"
fumi rotate >/dev/null
fumi send "$DAEMON_ADDR" --body "after rotation" >/dev/null
wait_for "$FEED" "after rotation"
OWNERS="$(python3 -c "import json;print(len(json.load(open('$DATA/state.json'))['feeds'][0]['owner_keys']))")"
[[ "$OWNERS" == 2 ]] || fail "expected 2 owner keys after rotation, got $OWNERS"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
# Under the fifo cap only one record fits, so the rotation post pruned the
# fifo post; what remains must fully verify.
grep -q "1 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "rotation-era records do not verify"; }
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
# ---------------------------------------------------------------------------
# Self-service registration. Nothing below calls `smolboid admit`.
# ---------------------------------------------------------------------------
step "self-service: an unknown sender is told how to register"
dfumi() { "$FUMI" --key "$RIG/master.key" --db "$DATA/mail.db" --timeout 10 "$@"; }
dana() { "$FUMI" --key "$RIG/dana.key" --db "$RIG/dana.db" --timeout 10 "$@"; }
DANA_ADDR="dana@$HOST:$SMTP_PORT"
dana keygen --force >/dev/null
dana trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
dana register "$DANA_ADDR" >/dev/null
dana send "$DAEMON_ADDR" --body "is anyone there" >/dev/null
# The info mail lands in dana's requests tier: she has not accepted the daemon
# yet, so it holds no token of hers.
for ((i = 0; i < 25; i++)); do
dana fetch >/dev/null 2>&1 || true
[[ "$(dana list --requests 2>/dev/null || true)" == *"how to get a feed"* ]] && break
sleep 1
done
[[ "$(dana list --requests 2>/dev/null || true)" == *"how to get a feed"* ]] \
|| { tail -20 "$RIG/serve.log"; fail "no info mail for an unknown sender"; }
# Nothing was published and no feed exists yet.
[[ ! -f "$DATA/feeds/dana.txt" ]] || fail "an unregistered sender got a feed"
step "self-service: Twt-Register creates the feed and hands over the token"
# Deliberately *not* `dana accept "$DAEMON_ADDR"`. Accepting it would make her
# client attach an Accept token to every later mail, and a published record
# cannot carry one and still verify, so her posts would all be refused. Her
# replies arrive in the requests tier instead.
dana send "$DAEMON_ADDR" --header "Twt-Register: dee" --body "" >/dev/null
wait_for "$DATA/feeds/dana.txt" "# nick = dee" 25
curl -fsS "http://$HOST:$HTTP_PORT/twtxt/dana.txt" >"$RIG/dana-feed.txt" \
|| fail "dana's feed is not served"
grep -q "# nick = dee" "$RIG/dana-feed.txt" || fail "nick not in the served feed"
# The confirmation carries the daemon's accept token; fetching it is what lets
# her publish, and her client learns the token from that mail.
for ((i = 0; i < 25; i++)); do
dana fetch >/dev/null 2>&1 || true
[[ "$(dana list --requests 2>/dev/null || true)" == *"your feed is ready"* ]] && break
sleep 1
done
[[ "$(dana list --requests 2>/dev/null || true)" == *"your feed is ready"* ]] \
|| { dana list --requests || true; fail "no registration confirmation"; }
step "self-service: a registered author can post"
dana send "$DAEMON_ADDR" --body "dana registered herself" >/dev/null
wait_for "$DATA/feeds/dana.txt" "dana registered herself" 25
"$SMOLBOID" check "$DATA/feeds/dana.txt" >"$RIG/dana-check.log" \
|| { cat "$RIG/dana-check.log"; fail "dana's records do not verify"; }
step "self-service: the admin was notified"
for ((i = 0; i < 20; i++)); do
fumi fetch >/dev/null 2>&1 || true
[[ "$(fumi list --requests 2>/dev/null || true)" == *"new feed dana"* ]] && break
sleep 1
done
[[ "$(fumi list --requests 2>/dev/null || true)" == *"new feed dana"* ]] \
|| { fumi list --requests || true; fail "no admin notification for the new feed"; }
step "Twt-Help returns the command reference"
dana send "$DAEMON_ADDR" --header "Twt-Help:" --body "" >/dev/null
for ((i = 0; i < 25; i++)); do
dana fetch >/dev/null 2>&1 || true
[[ "$(dana list --requests 2>/dev/null || true)" == *"your commands"* ]] && break
sleep 1
done
[[ "$(dana list --requests 2>/dev/null || true)" == *"your commands"* ]] \
|| fail "no help reply"
step "an accepted mailbox makes posts unpublishable, and says so"
# The trap the info mail warns about: once dana accepts this address her client
# attaches a token to every mail, and such a record could never be verified.
# The daemon must refuse it with that specific reason rather than the generic
# "resend the bare text".
dana accept "$DAEMON_ADDR" >/dev/null
dana send "$DAEMON_ADDR" --body "this carries a token" >/dev/null
sleep 4
grep -q "this carries a token" "$DATA/feeds/dana.txt" \
&& fail "a post carrying an accept token was published"
# Checked in both tiers on purpose: accepting the daemon is what moves its
# replies from her requests tier into her inbox, so this one arrives in the
# main tier precisely because of the step that broke her posting.
#
# Captured into a variable rather than piped into `grep -q`. Under `set -o
# pipefail`, grep -q exits on its first match and SIGPIPEs the producer, so a
# pipeline can report failure for a subject that is plainly there.
dana_mail() { dana list 2>/dev/null || true; dana list --requests 2>/dev/null || true; }
for ((i = 0; i < 20; i++)); do
dana fetch >/dev/null 2>&1 || true
MAIL="$(dana_mail)"
[[ "$MAIL" == *"accept token"* ]] && break
sleep 1
done
MAIL="$(dana_mail)"
[[ "$MAIL" == *"accept token"* ]] \
|| { echo "$MAIL"; fail "no explanation for the refused post"; }
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:06:54 +03:00
# 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"* ]] \
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
|| { echo "$MAIL"; fail "the reason was truncated in the subject"; }
dana block "$DAEMON_ADDR" >/dev/null
step "admin by mail: Twt-Block stops a feed"
fumi send "$DAEMON_ADDR" --header "Twt-Block: $DANA_ADDR" --body "" >/dev/null
sleep 3
dana send "$DAEMON_ADDR" --body "should not publish" >/dev/null
sleep 4
grep -q "should not publish" "$DATA/feeds/dana.txt" \
&& fail "a blocked author still published"
step "admin by mail: Twt-Registration closes the door"
fumi send "$DAEMON_ADDR" --header "Twt-Registration: closed" --body "" >/dev/null
sleep 3
MODE="$(python3 -c "import json;print(json.load(open('$DATA/state.json')).get('registration'))")"
[[ "$MODE" == "closed" ]] || fail "registration mode is $MODE, expected closed"
erin() { "$FUMI" --key "$RIG/erin.key" --db "$RIG/erin.db" --timeout 10 "$@"; }
ERIN_ADDR="erin@$HOST:$SMTP_PORT"
erin keygen --force >/dev/null
erin trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
erin register "$ERIN_ADDR" >/dev/null
erin send "$DAEMON_ADDR" --header "Twt-Register:" --body "" >/dev/null
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
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:06:54 +03:00
[[ "$(erin list --requests 2>/dev/null || true)" == *"not taking new feeds"* ]] && break
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
sleep 1
done
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:06:54 +03:00
[[ "$(erin list --requests 2>/dev/null || true)" == *"not taking new feeds"* ]] \
|| { erin list --requests || true; fail "erin was not told registration is closed"; }
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
step "rig passed"
echo "feeds: $FEED"
echo "logs: $RIG"
cleanup
trap - EXIT