#!/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, # 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" <"$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: @ #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 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 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 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"; } # --------------------------------------------------------------------------- # 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"; } # 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 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 [[ "$(erin list --requests 2>/dev/null || true)" == *"not taking new feeds"* ]] && break sleep 1 done [[ "$(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" echo "logs: $RIG" cleanup trap - EXIT