Skip to content

Fail init's prompts on EOF instead of answering them (#817) - #820

Merged
AcoPiper merged 3 commits into
mainfrom
AcoPiper/issue-817
Aug 12, 2026
Merged

Fail init's prompts on EOF instead of answering them (#817)#820
AcoPiper merged 3 commits into
mainfrom
AcoPiper/issue-817

Conversation

@AcoPiper

@AcoPiper AcoPiper commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

init's prompt reader discarded the byte count read_line returns, so the terminating EOF was indistinguishable from a blank line and every prompt past the end of a piped answer sequence answered itself. The EAB credential prompt — the only one that retries — re-prompted forever (gigabytes of validation errors until the process was killed), and the rest returned an empty string, which silently skipped EAB registration, declined to save freshly generated unseal keys, or accepted a default nobody chose.

  • src/commands/init/steps/prompts.rs now checks the count and bails with the existing error_prompt_eof message on a zero-length read. A blank line keeps its meaning everywhere: the empty answer, the offered default, or n.
  • The three readers (prompt_text, prompt_text_with_default, prompt_yes_no) gained reader-backed helpers an in-memory Cursor can drive. Their names, parameter lists and visibility are unchanged, so every existing call site — including clean and reinit — compiles untouched.
  • prompt_eab_with_validation takes the reader as a parameter, so the unbounded loop itself is under test rather than only the primitive beneath it. maybe_register_eab passes the stdin lock as a temporary, keeping the future Send across the register_eab_secret await.
  • The save-unseal-keys prompt runs outside the rollback envelope on keys held nowhere else, so an unanswerable prompt there emits the cleartext echo before failing — via the same code path the declined branch uses, extracted into a shared helper so the two cannot drift.
  • Two preflight scripts were relying on EOF as an answer (and drove the EAB loop into exactly that spin); both now supply one answer per prompt the run reaches. The init invocations in tests/openbao_stepca_integration.rs were extended the same way, and run_command_with_input says so at its definition.
  • Manual (docs/en/cli.md, docs/ko/cli.md) and the E2E walkthrough now describe the new behaviour, and the walkthrough's sample sequence was corrected.
  • Every piped init line this branch touches now carries BOOTROOT_LANG=en on the right of the pipe. A prefix assignment binds to the command it precedes, so on the left it reached only printf and the run itself took the caller's locale — while the assertions parsing that run's output, and the EOF message quoted in the walkthrough, all assume English.

Closes #817

Test plan

  • prompt_text errors with error_prompt_eof() when read_line returns 0
  • Unit test: an empty in-memory reader errors from the text, with-default and yes/no paths
  • Unit test: "\n" yields "", the default, and false respectively — blank line and EOF stay distinct
  • Unit test: "value\n" yields "value" trimmed; "y\n" yields true
  • All three paths are exercised with an in-memory reader, without touching process stdin
  • Regression test: prompt_eab_with_validation with an empty reader returns the EOF error instead of looping — no OpenBaoClient, no async runtime, no network
  • Companion test: a half-answered kid/hmac pair errors rather than retrying
  • Companion test: a rejected attempt followed by a valid pair still loops once and returns the valid credentials
  • Unit test for the shared cleartext-echo helper: one line per key, in key order
  • End-to-end test: init against an uninitialized OpenBao stub with empty stdin exits nonzero, names EOF on stderr, and still prints every generated unseal key
  • Public signatures and visibility of the three prompts unchanged; clean and reinit call sites compile untouched
  • cargo test passes
  • cargo clippy --all-targets -- -D warnings clean
  • cargo fmt -- --check --config group_imports=StdExternalCrate clean
  • scripts/preflight/ci/check.sh, validate-deploy-compose.sh, validate-compose-instance-names.sh and extra/deploy-no-build-smoke.sh pass
  • scripts/preflight/ci/test-core.sh: both rewritten piped-answer init runs pass, including the PASS: unseal keys displayed in cleartext after declining save assertion, which now exercises the declined path deliberately rather than the EOF path
  • scripts/preflight/extra/cli-scenarios.sh passes end to end; its y\nn\nn\n lines up one-to-one with the db-provision, EAB-registration and save-unseal-keys prompts
  • BOOTROOT_LANG placement verified against the built binary: printf "" | BOOTROOT_LANG=ko bootroot clean prints the Korean EOF message, BOOTROOT_LANG=ko printf "" | bootroot clean prints the English one
  • scripts/preflight/run-all.sh end to end — not reproducible on the macOS Docker host used here, for reasons unrelated to this change and identical on main: test-core.sh cannot docker exec ... >> /etc/hosts into the unprivileged step-ca container, e2e-matrix.sh needs sudo host-file edits plus the Linux docker0 gateway, and e2e-extended.sh fails the same 3 of 7 cases on main as on this branch (runner-timer/runner-cron reject the host's Acos-MacBook-Pro.local as a DNS label; reinit-recovery cannot bind 172.17.0.1). No artefact from any of these runs contains the EOF error. The Linux equivalents are green in CI: Unit & CLI Smoke covers test-core.sh and all ten Docker E2E jobs cover the matrix. e2e-extended.yml is workflow_dispatch/schedule only, so it does not run on pull requests.

`init`'s prompt reader discarded the count `read_line` returns, so the
terminating EOF was indistinguishable from a blank line and every
prompt past the end of a piped answer sequence answered itself. The EAB
credential prompt, the only one that retries, re-prompted forever —
gigabytes of validation errors until the process was killed. The rest
returned an empty string, which silently skipped EAB registration,
declined to save freshly generated unseal keys, or accepted a default
the operator never chose.

The count is now checked and a zero-length read bails with
`error_prompt_eof`. A blank line keeps its meaning everywhere, so the
three readers gain reader-backed helpers an in-memory cursor can drive
and the public signatures stay as they were. The EAB loop takes the
reader as a parameter, which is what makes the unbounded loop itself
testable rather than only the primitive under it.

The save-unseal-keys prompt runs outside the rollback envelope with the
keys held nowhere else, so an unanswerable prompt there emits the
cleartext echo before failing — the same code path the declined branch
uses, extracted so both cannot drift apart.

Two preflight scripts were relying on EOF as an answer and drove the
EAB loop into exactly that spin; both now supply one answer per prompt
the run reaches.

Closes #817
The prompts change is only half the story for anyone driving `init`
over a pipe: the manual still showed a piped answer sequence shorter
than the prompts the run reaches, and said nothing about what a run
does when it runs out of input.  Document both, including the one
prompt that still echoes the unseal keys before it fails.

The save-unseal-keys echo had no test that ran it.  The unit test
covers the shared formatting helper, but nothing proved `init` itself
reaches that prompt, echoes on EOF, and then exits nonzero — the
property that keeps a partial init from leaving freshly generated keys
recorded nowhere.  Stub an OpenBao that `init` has to initialize
itself, which is the only state that reaches the prompt at all, and
drive a real `init` into it with empty stdin.

A half-answered EAB pair gets its own case too: that is the shape a
piped sequence actually runs out in, and reading the `kid` before the
`hmac` goes missing is how the old loop started spinning.

Part of #817
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 1]

  • P2 – apply BOOTROOT_LANG to the command being tested. The new/rewritten pipeline examples still use BOOTROOT_LANG=en printf ... | cargo run ... (for example scripts/preflight/ci/test-core.sh:34, scripts/preflight/extra/cli-scenarios.sh:93, and docs/en/e2e-ci.md:164). In POSIX shells that assignment affects only the left-hand printf process; it does not set BOOTROOT_LANG for cargo run/bootroot. This makes the scripts' English-output assumptions (such as the root-token awk and the cleartext-key grep) depend on the caller's inherited locale, and leaves the documentation's quoted English EOF message unreliable. Put the assignment on the right side of the pipe instead, e.g. printf ... | BOOTROOT_LANG=en cargo run --bin bootroot -- init ... (and likewise for the direct bootroot examples).

Aside from that, the reader-backed helpers preserve the blank-line behavior, the EAB loop now propagates EOF, and the unseal-key fallback is exercised end to end through the shared echo path.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 1: NOT_APPROVED]

A prefix assignment binds to one command, and in a pipeline that is the
command it precedes -- the left-hand printf, which never reads the
variable. The init runs downstream of those pipes were therefore taking
whatever locale the caller happened to export, while the assertions on
their output (the root-token awk, the cleartext unseal-key grep) and the
EOF message quoted in the manual all assume English. Nothing fails today
because en is also the default, so the mistake is invisible until
someone runs the preflight with BOOTROOT_LANG=ko set.

Move the assignment to the right of the pipe, which is where the tree's
other piped init already puts it.

Part of #817
@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Author Round 1]

  • P2 – apply BOOTROOT_LANG to the command being tested. — Fixed. Correct, and confirmed against the built binary rather than reasoned about: BOOTROOT_LANG=ko printf "" | bootroot clean prints the English EOF message, printf "" | BOOTROOT_LANG=ko bootroot clean prints the Korean one. The assignment reached only printf, so every one of these runs was taking whatever locale the caller exported — invisible today because en is also the clap default (src/cli/args.rs:44), and wrong the moment someone runs the preflight with BOOTROOT_LANG=ko set. Moved to the right of the pipe in all five lines this branch touches: scripts/preflight/ci/test-core.sh:34 and :57, scripts/preflight/extra/cli-scenarios.sh:93, and docs/{en,ko}/e2e-ci.md:164/:161. I also moved the second occurrence in each walkthrough (docs/en/e2e-ci.md:307, docs/ko/e2e-ci.md:298) — those lines were not in the diff, but a page that shows the pattern twice with one form fixed and the other not is worse guidance than either form used consistently.

Not changed, and flagged rather than folded in. The same misplacement predates this branch in four files it does not otherwise touch: .github/workflows/ci.yml:241 and :262 (whose grep -q "unseal key" and root-token awk are exactly the English-output assumptions you named), scripts/impl/run-local-lifecycle.sh:331, scripts/impl/run-remote-lifecycle.sh:284, and scripts/impl/run-ca-key-rotation-recovery.sh:559. scripts/impl/run-reinit-recovery.sh:446 already has it on the right, so the correct idiom is the tree's own. I left those alone: none of them is an EOF-answer site, fixing them means editing the CI workflow and three E2E drivers on an issue about read_line returning 0, and the E2E drivers are the ones I cannot run on this macOS host. Happy to fix them here if you would rather have it in one pass than in a follow-up — say the word and it is a one-token move in each.

Verification: bash -n on both scripts, markdownlint-cli2 clean on the two changed pages, and the manual probe above. No Rust changed, so cargo test / clippy / fmt results from Round 1 stand. PR description updated with the change and the probe.

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 2]

Round 1 P2 is resolved: each rewritten pipeline now puts BOOTROOT_LANG=en on the right-hand bootroot/cargo run command, so the output parsed by the scripts and shown in the walkthroughs is deterministically English.

No unresolved items or new findings. read_prompt_text now distinguishes EOF from a blank line, the default and yes/no layers share that reader-backed path, and the EAB retry loop reads from the supplied reader and terminates on EOF. The save-unseal-keys error branch reuses the declined-path echo, and the integration test exercises the real empty-stdin path through initialization. The adjusted preflight inputs also deliberately cover every prompt reached.

[Review Verdict Round 2: APPROVED]

@AcoPiper

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 2: APPROVED]

@AcoPiper

Copy link
Copy Markdown
Contributor Author

Suggested squash commit

Title

Fail init's prompts on EOF rather than answering them

Body

`init`'s prompt reader discarded the byte count `read_line` returns, so
the terminating EOF was indistinguishable from a blank line and every
prompt past the end of a piped answer sequence answered itself. The EAB
credential prompt — the only one that retries — re-prompted forever,
producing gigabytes of validation errors until the process was killed.
The rest returned an empty string, which silently skipped EAB
registration, declined to save freshly generated unseal keys, or
accepted a default nobody chose.

The reader now checks the count and bails with the existing
`error_prompt_eof` message on a zero-length read. A blank line keeps its
meaning everywhere: the empty answer, the offered default, or `n`.

To make that branch testable, the three prompts gained reader-backed
helpers an in-memory cursor can drive. Their names, parameter lists and
visibility are unchanged, so every existing call site — including the
ones in `clean` and `reinit` — compiles untouched. Those two
confirmations now report the EOF error instead of quietly declining;
both still decline to act, and the error names the real reason.

`prompt_eab_with_validation` takes the reader as a parameter so the
unbounded loop itself is under test rather than only the primitive
beneath it. Its caller passes the stdin lock as a temporary, keeping the
future `Send` across the `register_eab_secret` await.

The save-unseal-keys prompt runs outside the rollback envelope on keys
that exist nowhere else yet, so an unanswerable prompt there emits the
cleartext echo *before* failing — through the same code path the
declined branch uses, extracted into a shared helper so the two cannot
drift. The run still exits nonzero, but the operator's last capture
channel stays open instead of recreating the partial-init trap.

Two preflight scripts were relying on EOF as an answer, and drove the
EAB loop into exactly that spin; both now supply one answer per prompt
the run reaches. The `init` invocations in the integration tests were
extended the same way, and their shared helper says so at its
definition. Each piped `init` line touched here also moves
`BOOTROOT_LANG=en` to the right of the pipe: a prefix assignment binds
to the command it precedes, so on the left it reached only `printf`
while the run itself took the caller's locale — and the assertions
parsing that run's output assume English.

Closes #817

@AcoPiper
AcoPiper merged commit 50309dc into main Aug 12, 2026
18 checks passed
@AcoPiper
AcoPiper deleted the AcoPiper/issue-817 branch August 12, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fail init's prompts on EOF instead of looping or silently defaulting

1 participant