diff --git a/daemon/locales/en-US/main.ftl b/daemon/locales/en-US/main.ftl index 0a78225..0f4594b 100644 --- a/daemon/locales/en-US/main.ftl +++ b/daemon/locales/en-US/main.ftl @@ -40,13 +40,18 @@ welcome-body = ## 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. 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, headers or an Accept - token); resend the bare text +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 @@ -64,22 +69,15 @@ reason-duplicate-timestamp = timestamp already exists in this feed 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-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-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 +reason-registration-mode-unknown = unknown registration mode `{ $got }`, want open, closed or silent ## First contact — an unknown sender who may register @@ -87,18 +85,21 @@ info-subject = twtxt: how to get a feed info-body = You can publish a twtxt feed here by sending mail. - Two steps. First accept this address in your client, so our replies reach - your inbox instead of your requests tier: - - fumi accept { $mailbox } - - Then ask for a feed, optionally naming the nick you want: + Ask for one, optionally naming the nick you want: fumi send { $mailbox } --header "Twt-Register: yournick" We will reply with your feed's address and the commands for managing it. - Nothing is published until you ask for a feed, and this mail is the only - one you get until you do. + Nothing is published until you ask, and this mail is the only one you 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 @@ -152,3 +153,5 @@ 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 diff --git a/daemon/src/dispatch.rs b/daemon/src/dispatch.rs index 8cd685a..cfca5bc 100644 --- a/daemon/src/dispatch.rs +++ b/daemon/src/dispatch.rs @@ -241,6 +241,12 @@ pub fn dispatch(incoming: &Incoming) -> Result { && !fields.contains_key(F_PRUNE) && !fields.contains_key(F_REPOST) { + // An Accept token is the one extra field a client adds by itself, so + // it gets its own answer: the author did nothing wrong and "resend the + // bare text" would not help them. + if beyond_reply_to == ["accept"] { + return Err(Reason::AcceptFieldOnPost); + } return Err(Reason::FieldsBeyondReplyTo); } @@ -350,14 +356,25 @@ mod tests { #[test] fn extra_fields_make_a_rejected_post() { - // A Subject, or the Accept token of an accepted recipient. + // A Subject the author typed: generic answer, resend the bare text. + assert_eq!( + dispatch(&incoming("---\nReply-To: smol://a/b\nSubject: hi\n---\nbody\n")) + .unwrap_err(), + Reason::FieldsBeyondReplyTo + ); + + // An Accept token, which the author's *client* attached because this + // mailbox is in their contacts. Same refusal, different reason: a + // record carrying a token could never be verified, and telling them to + // resend the bare text would not help because the client would attach + // it again. for body in [ - "---\nReply-To: smol://a/b\nSubject: hi\n---\nbody\n", "---\nAccept: token\n---\nbody\n", + "---\nReply-To: smol://a/b\nAccept: token\n---\nbody\n", ] { assert_eq!( dispatch(&incoming(body)).unwrap_err(), - Reason::FieldsBeyondReplyTo, + Reason::AcceptFieldOnPost, "{body:?}" ); } diff --git a/daemon/src/i18n.rs b/daemon/src/i18n.rs index 79c6947..0917b07 100644 --- a/daemon/src/i18n.rs +++ b/daemon/src/i18n.rs @@ -108,6 +108,7 @@ mod tests { "admin-notify-body", "admin-ack-subject", "admin-ack-body", + "reason-accept-field-on-post", ]; #[test] diff --git a/daemon/src/reason.rs b/daemon/src/reason.rs index 05f4c13..8c9bbe2 100644 --- a/daemon/src/reason.rs +++ b/daemon/src/reason.rs @@ -36,6 +36,13 @@ pub enum Reason { OneCommandPerMail, BadAddress { got: String }, RegistrationModeUnknown { got: String }, + /// The post carried an `Accept:` token, which its client attached because + /// the author accepted this mailbox. A published record is verified by + /// rebuilding the exact body that was signed, and that body cannot be + /// rebuilt without the token — which must not be published. So this is + /// not a mistake the author made, it is one their client made for them, + /// and the fix is to stop accepting this address. + AcceptFieldOnPost, } impl From for Reason { @@ -70,6 +77,7 @@ impl Reason { Reason::OneCommandPerMail => "reason-one-command-per-mail", Reason::BadAddress { .. } => "reason-bad-address", Reason::RegistrationModeUnknown { .. } => "reason-registration-mode-unknown", + Reason::AcceptFieldOnPost => "reason-accept-field-on-post", } } @@ -103,9 +111,8 @@ mod tests { /// Every variant must resolve to real copy. `i18n`'s own test covers the /// ids it knows about; this one walks the enum, so a variant added without /// a catalogue entry fails here. - #[test] - fn every_variant_resolves_to_copy() { - let all = [ + fn all_reasons() -> Vec { + vec![ Reason::BodyNotUtf8, Reason::PostNotCanonical, Reason::RepostNotCanonical, @@ -127,8 +134,15 @@ mod tests { Reason::OneCommandPerMail, Reason::BadAddress { got: "not an address".into() }, Reason::RegistrationModeUnknown { got: "sideways".into() }, - ]; - for reason in all { + Reason::AcceptFieldOnPost, + ] + } + + /// Every variant must resolve to real copy. Walking the enum cannot drift + /// the way a hand-kept id list can. + #[test] + fn every_variant_resolves_to_copy() { + for reason in all_reasons() { let text = reason.text(); assert!( !text.starts_with("???"), @@ -140,6 +154,24 @@ mod tests { } } + /// A reason is interpolated into the reply's Subject, and a frontmatter + /// value containing a line break makes the entire block malformed — the + /// recipient then sees no subject at all, because smolmail fails closed + /// toward display. Caught in the rig once already: a wrapped catalogue + /// entry silently stripped the subject from every refusal. + #[test] + fn every_reason_is_a_single_line() { + for reason in all_reasons() { + let text = reason.text(); + assert!( + !text.contains('\n') && !text.contains('\r'), + "`{}` spans lines, which would strip the reply's subject:\n{text}", + reason.id() + ); + assert!(!text.is_empty(), "{reason:?} resolves to empty copy"); + } + } + #[test] fn interpolated_reasons_carry_their_value() { assert!(Reason::PruneModeUnknown { got: "sideways".into() } diff --git a/tests/pentest.sh b/tests/pentest.sh index a568477..f24d2d5 100755 --- a/tests/pentest.sh +++ b/tests/pentest.sh @@ -94,6 +94,11 @@ timeout = 10 cap_bytes = 1048576 posts_per_day = 50 reposts_per_day = 50 +# Open, so the probes below exercise the path an attacker would actually find. +# Silent would make most of them pass for the wrong reason. +registration = open +admins = tobias@$HOST:$SMTP_PORT +admin_notify = off EOF "$SMOLBOID" admit --config "$RIG/smolboid.toml" "tobias@$HOST:$SMTP_PORT" >"$RIG/admit.log" 2>&1 \ || { cat "$RIG/admit.log"; fail "admit"; } @@ -237,7 +242,11 @@ author send "$DAEMON_ADDR" \ sleep 4 [[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "a noncanonical repost published" -step "mail: unadmitted senders publish nothing" +step "mail: an unadmitted sender publishes nothing and gets no token" +# Rewritten for self-service registration. The old assertion was that an +# unadmitted sender draws no reply at all; under `registration = open` she +# draws an info mail. What must still hold is that writing in publishes +# nothing, creates no feed, and hands over no accept token. mallory keygen --force >/dev/null mallory trust "$HOST:$SMTP_PORT" "$SERVER_KEY" mallory register "mallory@$HOST:$SMTP_PORT" >/dev/null @@ -245,6 +254,107 @@ mallory send "$DAEMON_ADDR" --body "mallory was here" >/dev/null sleep 4 [[ "$(grep -c "mallory was here" "$FEED")" == 0 ]] || fail "unadmitted mail published" [[ ! -f "$DATA/feeds/mallory.txt" ]] || fail "an unadmitted sender got a feed" +mallory fetch >/dev/null 2>&1 || true +# The info mail must not carry an Accept token: the token is what moves her +# into the main tier, and it is only issued once she actually registers. +if mallory list --requests 2>/dev/null | grep -q "how to get a feed"; then + mallory read 1 >"$RIG/mallory-info.txt" 2>/dev/null || true + grep -qi "^Accept:" "$RIG/mallory-info.txt" \ + && fail "the info mail carried an accept token" +fi + +step "mail: no signed Reply-To draws no reply at all" +# --anonymous omits the Reply-To, so there is no address to verify and nothing +# to answer. This is the anti-reflector case: a freshly minted keypair must not +# be able to make the daemon send mail anywhere. +BEFORE_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l)" +mallory send "$DAEMON_ADDR" --anonymous --body "no reply-to here" >/dev/null +sleep 4 +AFTER_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l)" +[[ "$BEFORE_SENT" == "$AFTER_SENT" ]] \ + || fail "an anonymous sender drew a reply ($BEFORE_SENT -> $AFTER_SENT)" + +step "mail: claiming someone else's address takes nothing and poisons nothing" +# The squatting and contact-poisoning probe, and the most important assertion +# in this file. Mallory signs a Reply-To naming tobias's address but carrying +# her own key. The key matches the signer, so the claim is self-consistent — +# which is exactly why the daemon must resolve the address and find tobias's +# key there instead. +# +# Two things must hold: she gets no feed at tobias's slug, and tobias's +# contact row must still hold tobias's key. If the daemon replied to the +# claimed smol:// URI it would pin her key as verified under his address, and +# his own registration would fail on KeyChanged forever after. +MALLORY_KEY="$(mallory whoami 2>/dev/null | grep -oE '[a-z2-7]{52}' | head -1)" +[[ -n "$MALLORY_KEY" ]] || fail "could not read mallory's key" +mallory send "$DAEMON_ADDR" --anonymous \ + --header "Reply-To: smol://tobias@$HOST:$SMTP_PORT/$MALLORY_KEY" \ + --header "Twt-Register:" --body "" >/dev/null +sleep 5 +# tobias's feed is his own and still bound to his address. +grep -q "# smol:author = tobias@$HOST:$SMTP_PORT" "$FEED" \ + || fail "tobias's feed lost its author binding" +# And the daemon's contact for tobias must not have become mallory's key. +CONTACT="$(daemon_fumi contacts 2>/dev/null | grep "tobias@$HOST:$SMTP_PORT" || true)" +if [[ -n "$CONTACT" ]]; then + echo "$CONTACT" | grep -q "$MALLORY_KEY" \ + && fail "mallory's key got pinned under tobias's address" +fi + +step "mail: a claim for an address nobody holds draws nothing" +BEFORE_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l)" +mallory send "$DAEMON_ADDR" --anonymous \ + --header "Reply-To: smol://nobody@$HOST:$SMTP_PORT/$MALLORY_KEY" \ + --header "Twt-Register:" --body "" >/dev/null +sleep 5 +AFTER_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l)" +[[ "$BEFORE_SENT" == "$AFTER_SENT" ]] \ + || fail "a claim for a nonexistent address drew a reply" +[[ ! -f "$DATA/feeds/nobody.txt" ]] || fail "a nonexistent address got a feed" + +step "mail: admin commands from a non-admin do nothing" +# And must not hint that admin commands exist: mallory gets the ordinary +# unknown-sender answer, never an ack. +mallory send "$DAEMON_ADDR" --header "Twt-Admit: mallory@$HOST:$SMTP_PORT" \ + --body "" >/dev/null +sleep 4 +[[ ! -f "$DATA/feeds/mallory.txt" ]] || fail "Twt-Admit from a non-admin created a feed" +mallory fetch >/dev/null 2>&1 || true +mallory list --requests 2>/dev/null | grep -qi "admitted" \ + && fail "a non-admin was told an admin command ran" + +step "mail: a blocked address stays blocked" +# Order matters, and the first attempt at this probe got it wrong. Blocking an +# address that was never accepted is a no-op in the store — there is no row to +# deactivate — so `accepted_state` stays None and nothing is being tested. The +# sequence that matters is the real one: register, get blocked, come back. +# +# store.block only clears the active flag, so the check has to run before any +# network call or Twt-Block is a one-poll inconvenience. +mallory send "$DAEMON_ADDR" --header "Twt-Register:" --body "" >/dev/null +for ((i = 0; i < 20; i++)); do + [[ -f "$DATA/feeds/mallory.txt" ]] && break + sleep 1 +done +[[ -f "$DATA/feeds/mallory.txt" ]] || fail "mallory could not register at all" + +author send "$DAEMON_ADDR" --header "Twt-Block: mallory@$HOST:$SMTP_PORT" \ + --body "" >/dev/null +sleep 5 + +# Blocked: her posts no longer publish. +mallory send "$DAEMON_ADDR" --body "after being blocked" >/dev/null +sleep 5 +grep -q "after being blocked" "$DATA/feeds/mallory.txt" \ + && fail "a blocked author still published" + +# And re-registering draws nothing at all, not even a reply. +BEFORE_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l || true)" +mallory send "$DAEMON_ADDR" --header "Twt-Register:" --body "" >/dev/null +sleep 5 +AFTER_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l || true)" +[[ "$BEFORE_SENT" == "$AFTER_SENT" ]] \ + || fail "a blocked address drew a reply when re-registering" step "mail: forged signing keys verify as invalid, never as verified" # Tamper the published feed's own signature line: the record must flip to diff --git a/tests/rig.sh b/tests/rig.sh index b98d804..46c07ce 100755 --- a/tests/rig.sh +++ b/tests/rig.sh @@ -1,8 +1,9 @@ #!/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 and an author -# rotation. Real binaries, real Noise sessions, real envelopes. +# 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)" @@ -84,6 +85,9 @@ data = $DATA poll_secs = 1 timeout = 10 cap_bytes = 1048576 +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"; } @@ -187,6 +191,140 @@ OWNERS="$(python3 -c "import json;print(len(json.load(open('$DATA/state.json'))[ 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 frontmatter +# value makes the block malformed and strips the subject entirely. +[[ "$MAIL" == *"withdraw the acceptance and resend"* ]] \ + || { 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)" == *"registration is closed"* ]] && break + sleep 1 +done +[[ "$(erin list --requests 2>/dev/null || true)" == *"registration is closed"* ]] \ + || fail "erin was not told registration is closed" + step "rig passed" echo "feeds: $FEED" echo "logs: $RIG"