Skip to content

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

Description

@AcoPiper

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

Context

The repository has two independent interactive prompt readers, and they disagree about what EOF means.

Prompt::prompt_text in src/cli/prompt.rs:26 reads from an injected &mut dyn BufRead, captures the byte count read_line returns, and bails with messages.error_prompt_eof() when that count is 0. A blank line is still a blank line: it returns the supplied default when one was given. Prompt::prompt_with_validation (src/cli/prompt.rs:50) therefore terminates on EOF instead of retrying forever, and both behaviours are covered by unit tests in that file. bootroot rotate (src/commands/rotate/helpers.rs:22) and bootroot service remove (src/commands/service/remove.rs:343) go through this reader.

prompt_text in src/commands/init/steps/prompts.rs:26 does not. It reads io::stdin() directly and discards the count:

pub(super) fn prompt_text(prompt: &str, messages: &Messages) -> Result<String> {
    use std::io::{self, Write};
    print!("{prompt}");
    io::stdout()
        .flush()
        .with_context(|| messages.error_prompt_flush_failed())?;
    let mut input = String::new();
    io::stdin()
        .read_line(&mut input)
        .with_context(|| messages.error_prompt_read_failed())?;
    Ok(input.trim().to_string())
}

read_line returns Ok(0) at EOF, which is not an error. The count is discarded, so EOF is indistinguishable from a blank line and this function returns Ok("") for every subsequent call. Every init prompt is built on it, and two failure modes follow.

Unbounded loop. prompt_eab_with_validation (src/commands/init/steps/secrets.rs:154) is the only init prompt that retries on validation failure. With stdin at EOF it re-prompts forever:

EAB kid: EAB hmac: EAB kid must not be empty
EAB kid: EAB hmac: EAB kid must not be empty
...

Observed twice while running scripts/preflight/run-all.sh locally: 119,492,018 lines and 5.4 GB of output in roughly twenty minutes, ending only when the process was killed externally.

Silent wrong value. The remaining prompts return rather than loop, so the run proceeds on an answer the operator never gave. Which way that falls depends on the prompt: some abort on the fabricated value, others carry on with it, and the second kind is the worse of the two because nothing marks the decision as never having been made.

  • prompt_yes_no (src/commands/init/steps/prompts.rs:54) resolves EOF to false. Reached through confirm_overwrite (:60) that aborts the run with error_operation_cancelled — a declined confirmation the operator was never shown, but at least a visible failure. Reached from the two call sites that treat false as an ordinary answer it does not abort at all: src/commands/init/steps/secrets.rs:133 silently skips EAB registration, and src/commands/init/steps/orchestrator.rs:1077 silently declines to save the freshly generated unseal keys.
  • prompt_text_with_default (src/commands/init/steps/prompts.rs:41) accepts the default on EOF, indistinguishable from a deliberate Enter.
  • prompt_unseal_keys (src/commands/init/steps/prompts.rs:5) pushes an empty string into the key vector, once per requested key.
  • src/commands/init/steps/openbao_setup.rs:75 (root token), src/commands/init/steps/database.rs:190, :215 and :274 (admin DSN, password, DSN), and src/commands/init/steps/secrets.rs:96 (generic secret) each yield an empty secret.

Why this is hit in practice

The preflight and E2E scripts drive init by piping a fixed answer sequence — scripts/preflight/ci/test-core.sh:50 pipes y\ny\ny\nn\n, scripts/preflight/extra/cli-scenarios.sh:88 the same, and scripts/impl/run-local-lifecycle.sh:331, scripts/impl/run-remote-lifecycle.sh:284, scripts/impl/run-ca-key-rotation-recovery.sh:559 and scripts/impl/run-reinit-recovery.sh:446 pipe y\ny\ny\n. Once those answers are consumed, stdin is at EOF. The number of prompts init asks varies with on-disk state — each existing secrets/ artefact adds an overwrite confirmation — so whether a run reaches a prompt after EOF is state-dependent rather than deterministic.

Scope

  1. Make src/commands/init/steps/prompts.rs::prompt_text distinguish EOF from a blank line: capture the count read_line returns and bail with messages.error_prompt_eof() when it is 0. A blank line must keep behaving exactly as it does today, so that prompt_text_with_default still returns its default when the operator presses Enter and prompt_yes_no still reads a bare Enter as "no".

  2. Make that EOF branch reachable from a unit test, for every prompt whose behaviour the acceptance criteria pin down. prompt_text reads io::stdin() directly today and prompt_text_with_default (src/commands/init/steps/prompts.rs:41) and prompt_yes_no (:54) are thin layers over it, so none of the three can be driven from a test as long as the read happens behind a stdin lock. Give each of the three an internal helper taking &mut dyn BufRead as its first parameter and doing the work, and leave the existing function as a thin wrapper that passes io::stdin().lock() to its helper. Keep the layering: the with-default and yes/no helpers call the prompt_text helper, so default handling and yes/no parsing each live in exactly one place. Every existing caller keeps its current signature and visibility — prompt_text and prompt_text_with_default pub(super), prompt_yes_no pub(crate). The with-default and yes/no helpers are private to the module; the prompt_text helper is the one exception, because scope item 3 has prompt_eab_with_validation in src/commands/init/steps/secrets.rs read through it, so it needs pub(super) — the narrowest visibility that reaches its single out-of-module caller. An equivalent shape is acceptable in place of three named helpers (one shared reader-backed path the three wrappers delegate to, for instance), provided an in-memory reader can drive all three from a test without touching stdin and the caller-facing signatures are untouched. prompt_unseal_keys (:5) and confirm_overwrite (:60) need no reader parameter: neither retries, so the EOF error propagates out of the first inner call.

  3. Thread that same reader through the EAB retry loop, so the unbounded loop itself — the headline symptom — is covered by a test and not only the primitive underneath it. prompt_eab_with_validation (src/commands/init/steps/secrets.rs:154) must take the reader as a parameter and read through it rather than calling the stdin-locking wrapper. It is private and has exactly one caller, maybe_register_eab at src/commands/init/steps/secrets.rs:137, which supplies the locked stdin at the call site; the threading stops there and does not reach run_init or the orchestrator. No prompt outside src/commands/init/steps/prompts.rs needs the reader threaded into its caller — the EAB prompt is the only one that retries, so everywhere else the EOF error propagates out of the first read and the module-level helpers from scope item 2 are enough to test it.

  4. Keep the unseal keys capturable when the save prompt reaches EOF. maybe_save_unseal_keys (src/commands/init/steps/orchestrator.rs:1066) is called at :215, in run_init's success branch and outside the rollback envelope, so an error it returns propagates straight out with OpenBao already initialised and unsealed and no rollback attempted. Under SaveUnsealKeysDecision::Prompt its prompt_yes_no call (:1077) today resolves EOF to false and falls into the cleartext echo at :1087, which is the operator's last capture channel for keys that exist nowhere else yet. Letting the new EOF error propagate from there would remove it and leave the keys recorded nowhere — the "partial-init trap with the freshly issued root token and unseal keys captured nowhere" that the comment at :122-:132 was written to prevent, reintroduced at the last prompt of the run. So an EOF at that prompt must still emit the cleartext echo before the error propagates: the run fails loudly with a nonzero exit and the keys reach the operator's terminal. Reuse the existing echo path rather than duplicating the loop — extract :1093-:1096 into a small synchronous helper that both the declined branch and the EOF branch call. Only the Prompt decision changes: Save and DoNotSave never reach the prompt, and DoNotSave's deliberate suppression of the echo (the keys are already in the 0600 summary JSON) stays exactly as it is. Do NOT thread a reader into maybe_save_unseal_keys to test this: it is async and awaits save_unseal_keys, so a &mut dyn BufRead held across that await hits the same non-Send problem the stdin lock does in the constraint below. Matching on the Err from prompt_yes_no needs no reader.

    This applies only to that one prompt. A prompt that reaches EOF earlier — the EAB registration confirmation at src/commands/init/steps/secrets.rs:133, say — fails inside run_init_inner and therefore inside the rollback envelope, and rollback plus the operator guidance already printed on that path is the intended outcome. Do not extend the echo anywhere else.

  5. Where a script under scripts/ drives init with a piped answer sequence, make sure it supplies every answer the run needs. After this change a run that outruns its answers fails with a nonzero exit instead of hanging or proceeding on a fabricated answer, so any script that has been quietly relying on EOF-as-an-answer will start failing and must be fixed by supplying the answers (or the equivalent non-interactive flags), never by reverting the EOF check. One is known to be short today: scripts/preflight/ci/test-core.sh:30 pipes a single n, which answers the EAB registration prompt, and then asserts at :37 on the cleartext unseal-key echo that the save prompt's EOF currently produces. It needs a second n so that assertion exercises the declined path the message at :38 names, deliberately, rather than the EOF path.

    It is not the only one. scripts/preflight/ci/test-core.sh:50 and scripts/preflight/extra/cli-scenarios.sh:88 both pipe y\ny\ny\nn\n, a sequence whose answers no longer line up one-to-one with the prompts those runs reach, so each needs rewriting against the actual prompt sequence rather than merely lengthening.

    The same applies to the Rust integration tests, which drive the init binary over a pipe exactly as the scripts do and are not covered by the wording above. tests/openbao_stepca_integration.rs feeds a fixed answer sequence to ten init invocations through its run_command_with_input helper — eight y\n and two y\ny\n — and every one of them goes on to reach the EAB registration confirmation, which EOF answers today. They need the same fix, and run_command_with_input should say so at its definition so the next test added through it supplies a full sequence.

Acceptance criteria

  • prompt_text in src/commands/init/steps/prompts.rs returns an error carrying messages.error_prompt_eof() when read_line returns 0.
  • A blank line still yields an empty string from prompt_text, the supplied default from prompt_text_with_default, and false from prompt_yes_no; EOF yields the error_prompt_eof() error from all three instead of an empty string, the default, or false.
  • All three of those paths can be exercised from a test with an in-memory reader, without touching process stdin.
  • prompt_text, prompt_text_with_default and prompt_yes_no each keep their current name, parameter list and visibility, and every existing call site of the three — inside init, and in src/commands/clean.rs and src/commands/reinit.rs — compiles unchanged: the reader-taking forms are additional internal helpers, not replacements at the call sites. prompt_eab_with_validation is the one deliberate exception, gaining the reader parameter scope item 3 requires; it is private with a single caller, and maybe_register_eab itself keeps its current signature.
  • prompt_eab_with_validation (src/commands/init/steps/secrets.rs:154) reads through a caller-supplied &mut dyn BufRead and terminates with that error at EOF instead of re-prompting. Its validation behaviour is otherwise unchanged: it still retries on a value that fails validate_eab, and still writes the validation error to stderr between attempts.
  • That termination is proven by a test that calls prompt_eab_with_validation directly with an in-memory reader — no OpenBaoClient, no async runtime, and no network.
  • Under SaveUnsealKeysDecision::Prompt, an EOF at the save-unseal-keys prompt emits the cleartext unseal-key echo and then fails with the error_prompt_eof() error. The echo is the same code path the declined branch uses, not a copy of it. Save and DoNotSave are unchanged, and maybe_save_unseal_keys keeps its current signature.
  • cargo clippy --all-targets -- -D warnings and cargo fmt -- --check --config group_imports=StdExternalCrate are clean — the exact invocations CI runs at .github/workflows/ci.yml:107 and :104.
  • scripts/preflight/run-all.sh completes, including scripts/preflight/ci/e2e-matrix.sh and scripts/preflight/ci/e2e-extended.sh.

Constraints

  • No new i18n string is needed. error_prompt_eof is already declared in src/i18n.rs:137, exposed by src/i18n/service.rs:174, and translated in src/i18n/en.rs:117 ("no input available (stdin reached EOF / not a terminal)") and src/i18n/ko.rs:117. prompt_text already receives &Messages, so it can reach the accessor without a signature change.
  • Prompt rendering must not change. src/commands/init/steps/prompts.rs::prompt_text takes an already-formatted prompt string — some call sites pass a label that ends in ": " from i18n, others build one with format!("{label}: ") — and the E2E scripts match on init's output. Do not adopt the label [default]: formatting used by src/cli/prompt.rs::format_prompt.
  • prompt_yes_no is pub(crate) and reaches beyond init: src/commands/clean.rs:40, :70 and :88, and src/commands/reinit.rs:173 all call it. Those confirmations currently treat EOF as "no" and abort quietly; after this change they abort with the EOF error instead. That is the intended outcome — both paths still decline to act, and the error names the real reason — but the change must be made knowingly and the messages checked for the clean and reinit paths.
  • maybe_register_eab is async and awaits register_eab_secret after the prompt returns. Do not bind the stdin lock to a local that stays alive across that .await — pass it as a temporary that drops at the end of the call statement. A StdinLock held across an await point makes the future non-Send and changes what the caller can do with it.
  • The comment at src/commands/init/steps/prompts.rs:28 marks print!("{prompt}") as a CodeQL cleartext-logging false positive because prompt is a UI label. Keep it wherever that write ends up.
  • Follow the repository's Rust standards: no unwrap() outside tests, anyhow context on fallible calls that cross a boundary, and no fixed paths or process-environment mutation in tests.

Out of scope

  • Validating the content of a prompt's answer. This issue changes only how the absence of input is handled. A blank line remains a valid answer everywhere it is one today, so prompt_unseal_keys (src/commands/init/steps/prompts.rs:5) still accepts an empty key when the operator presses Enter, and the secret prompts still accept an empty secret. Rejecting those is a separate question about input validation, not about EOF.
  • Consolidating the prompt implementations. The tree holds src/cli/prompt.rs, src/commands/init/steps/prompts.rs and the inline reads in src/commands/openbao_unseal.rs, and that EOF handling had to be fixed in each separately is an argument for collapsing them. They stay separate by decision: once all three handle EOF, collapsing them buys nothing a user can observe, and it costs reconciling two call signatures and two prompt-rendering conventions across init, clean, reinit, rotate and service remove — output the E2E scripts parse. A prompt added from here on goes through src/cli/prompt.rs, which is the reader with injected I/O, EOF handling and tests.
  • Changing the wording of any i18n string, including error_prompt_eof.
  • Adding non-interactive flags for prompts that do not have one, or changing which prompts init asks and when.
  • Prompt readers outside init. prompt_unseal_keys_interactive (src/commands/openbao_unseal.rs:49) inlines its own read_line calls at :64 and :80 and discards the count in the same way. Its callers are run_save_unseal_keys (:88, reached from bootroot openbao save-unseal-keys at src/main.rs:141) and maybe_interactive_unseal (src/commands/infra.rs:1256, called from run_infra_up at :277, and guarded by std::io::stdin().is_terminal() at :1286 so non-TTY stdin never reaches the prompt). init does not use it — init prompts for unseal keys through src/commands/init/steps/prompts.rs:5 — so it is a separate defect, tracked in Fail openbao save-unseal-keys prompts on EOF instead of writing blank keys #818 and not fixed here.
  • The other direct stdin().lock() readers that consume whole streams rather than prompting — src/commands/verify.rs:223, src/commands/openbao_auth.rs:215, src/commands/rotate/openbao_recovery.rs:198, src/commands/service/resolve.rs:68. An empty stream is a legitimate input there, not a missed answer.
  • The is_terminal() guards at src/commands/service/remove.rs:97 and src/commands/openbao_auth.rs:31, and whether more commands should have one.

Test plan

  • Unit test in src/commands/init/steps/prompts.rs: an empty in-memory reader produces an error equal to messages.error_prompt_eof() from the text path, from the with-default path, and from the yes/no path — the three silent-wrong-value symptoms above, each pinned.
  • Unit test: a reader holding "\n" yields an empty string from the text path, the default from the with-default path, and false from the yes/no path — proving a blank line and EOF stay distinct on all three.
  • Unit test: a reader holding "value\n" yields "value", trimmed; a reader holding "y\n" yields true from the yes/no path.
  • Regression test for the reported loop, in src/commands/init/steps/secrets.rs: prompt_eab_with_validation called with an empty in-memory reader returns the EOF error rather than iterating, mirroring prompt_with_validation_errors_on_eof_without_looping in src/cli/prompt.rs:118.
  • Companion test proving the retry itself survives: a reader holding a rejected attempt followed by a valid kid/hmac pair still loops once and returns the valid credentials, so the EOF fix has not turned a recoverable typo into a hard failure.
  • Unit test for the shared cleartext-echo helper extracted from src/commands/init/steps/orchestrator.rs:1093-:1096, alongside the existing maybe_save_unseal_keys_* tests at :1803 onward: it emits one line per key, so the EOF branch and the declined branch demonstrably produce the same output.
  • End-to-end test in tests/openbao_stepca_integration.rs for scope item 4, driving the real binary against an OpenBao stub that reports itself uninitialized (the only state that reaches the save-unseal-keys prompt): with --no-eab and --overwrite-ca-json clearing the prompts ahead of it and stdin empty, the run exits nonzero, names EOF as the reason on stderr, and still prints every generated unseal key. The unit test above pins the shared helper; this pins that the EOF branch actually calls it.
  • The init invocations in tests/openbao_stepca_integration.rs pass with answer sequences covering every prompt each one reaches.
  • cargo test passes.
  • scripts/preflight/run-all.sh passes, with scripts/preflight/ci/e2e-matrix.sh and scripts/preflight/ci/e2e-extended.sh included — these exercise the piped-answer init invocations that surfaced the bug, and a script left short of answers will now fail here rather than hang.

Dependencies

None.

Pointers

  • src/commands/init/steps/prompts.rsprompt_text:26 (the defect), prompt_text_with_default:41, prompt_yes_no:54, confirm_overwrite:60, prompt_unseal_keys:5.
  • src/cli/prompt.rs:26 — the reader that already handles EOF correctly, and :79 its test module, which is the model for the new tests.
  • src/commands/init/steps/secrets.rs:154prompt_eab_with_validation, the loop that spins, and :113 maybe_register_eab, its only caller, which calls it at :137 and awaits register_eab_secret afterwards.
  • Other init call sites: src/commands/init/steps/openbao_setup.rs:75, src/commands/init/steps/database.rs:190, :215, :274, src/commands/init/steps/secrets.rs:96, src/commands/init/steps/orchestrator.rs:450, :1077, src/commands/init/steps/openbao_transition.rs:313.
  • src/commands/init/steps/orchestrator.rs:1066maybe_save_unseal_keys, called at :215 outside the rollback envelope; :1077 the prompt, :1087-:1096 the cleartext-echo branch, :122-:132 the comment describing the partial-init trap, :1803 onward its existing tests.
  • Callers of prompt_yes_no outside init: src/commands/clean.rs:40, :70, :88, src/commands/reinit.rs:173.
  • i18n: src/i18n.rs:137, src/i18n/service.rs:174, src/i18n/en.rs:117, src/i18n/ko.rs:117.
  • Scripts that pipe answers into init: scripts/preflight/ci/test-core.sh:30, :50, scripts/preflight/extra/cli-scenarios.sh:88, scripts/impl/run-local-lifecycle.sh:331, scripts/impl/run-remote-lifecycle.sh:284, scripts/impl/run-ca-key-rotation-recovery.sh:559, scripts/impl/run-reinit-recovery.sh:446.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions