From 33d65e157bb747d56772c934a4d5e8a2ec407cef Mon Sep 17 00:00:00 2001 From: Aco Piper Date: Wed, 12 Aug 2026 23:10:58 +0900 Subject: [PATCH 1/3] Fail init's prompts on EOF instead of answering them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- CHANGELOG.md | 16 +++ scripts/preflight/ci/test-core.sh | 11 +- scripts/preflight/extra/cli-scenarios.sh | 7 +- src/commands/init/steps/orchestrator.rs | 61 ++++++++++- src/commands/init/steps/prompts.rs | 126 +++++++++++++++++++++-- src/commands/init/steps/secrets.rs | 54 +++++++++- tests/openbao_stepca_integration.rs | 42 ++++---- 7 files changed, 275 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fb7b5b..25c4f193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,22 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed +- Fixed `bootroot init` treating a closed stdin as an answer. Every + `init` prompt read the terminating EOF as an empty line, so a run + whose piped answer sequence ran out answered the rest of its prompts + itself: the EAB credential prompt re-prompted forever (over five + gigabytes of output in one observed run, ending only when the process + was killed), and the remaining prompts took an empty string — + silently skipping EAB registration, declining to save freshly + generated unseal keys, or accepting a default nobody chose. A prompt + with no input left now fails the run with "no input available (stdin + reached EOF / not a terminal)". Pressing Enter is unchanged: a blank + line still means the empty answer, the offered default, or "no". + Where the unseal-key save prompt is the one that runs out, the keys + are still echoed in cleartext before the run fails, so a partial init + cannot leave them recorded nowhere. The confirmations in + `bootroot clean` and `bootroot reinit` now report the same error + instead of quietly declining; both still decline to act. - Fixed the published manual having no styling at all. The theme was installed into a dot-prefixed directory under `docs/`, and MkDocs excludes every dot-prefixed path inside `docs_dir` from the build, so diff --git a/scripts/preflight/ci/test-core.sh b/scripts/preflight/ci/test-core.sh index 6337125c..23a9ab79 100755 --- a/scripts/preflight/ci/test-core.sh +++ b/scripts/preflight/ci/test-core.sh @@ -26,8 +26,12 @@ echo "[test-core] installing infrastructure" cargo run --bin bootroot -- infra install # --- Zero-config Init (answer n, no show-secrets) --- +# Two answers, one per prompt this run reaches: decline EAB registration, +# then decline saving the unseal keys. The second `n` is what makes the +# assertion below exercise the declined path deliberately — `init` fails +# on EOF rather than reading an unanswered prompt as "no". echo "[test-core] zero-config init (answer n, no show-secrets)" -BOOTROOT_LANG=en printf "n\n" | cargo run --bin bootroot -- init \ +BOOTROOT_LANG=en printf "n\nn\n" | cargo run --bin bootroot -- init \ --enable auto-generate \ --http-hmac "dev-hmac" \ --secrets-dir "$BOOTROOT_SECRETS_DIR" \ @@ -46,8 +50,11 @@ cargo run --bin bootroot -- clean -y cargo run --bin bootroot -- infra install # --- CLI Init --- +# `clean -y` above removed password.txt, ca.json and state.json, so no +# overwrite confirmation fires here: the two prompts this run reaches +# are EAB registration and saving the unseal keys, both declined. echo "[test-core] CLI init (smoke)" -BOOTROOT_LANG=en printf "y\ny\ny\nn\n" | cargo run --bin bootroot -- init \ +BOOTROOT_LANG=en printf "n\nn\n" | cargo run --bin bootroot -- init \ --enable auto-generate,show-secrets \ --http-hmac "dev-hmac" \ --secrets-dir "$BOOTROOT_SECRETS_DIR" \ diff --git a/scripts/preflight/extra/cli-scenarios.sh b/scripts/preflight/extra/cli-scenarios.sh index 394de0c6..3139d69f 100755 --- a/scripts/preflight/extra/cli-scenarios.sh +++ b/scripts/preflight/extra/cli-scenarios.sh @@ -85,7 +85,12 @@ run_init_scenario() { wait_for_postgres_admin log "Running bootroot init" - BOOTROOT_LANG=en printf "y\ny\ny\nn\n" | cargo run --bin bootroot -- init \ + # One answer per prompt this run reaches. `secrets/` and `state.json` + # were removed above, so no overwrite confirmation fires: `y` confirms + # the `db-provision` feature, then EAB registration and saving the + # unseal keys are both declined. An answer short of the last prompt + # aborts the run — init fails on EOF rather than answering itself. + BOOTROOT_LANG=en printf "y\nn\nn\n" | cargo run --bin bootroot -- init \ --enable auto-generate,show-secrets,db-provision \ --summary-json "$INIT_SUMMARY_JSON" \ --http-hmac "$responder_hmac" \ diff --git a/src/commands/init/steps/orchestrator.rs b/src/commands/init/steps/orchestrator.rs index 5e2788cf..a1f0d045 100644 --- a/src/commands/init/steps/orchestrator.rs +++ b/src/commands/init/steps/orchestrator.rs @@ -1074,7 +1074,21 @@ async fn maybe_save_unseal_keys( SaveUnsealKeysDecision::Save => true, SaveUnsealKeysDecision::DoNotSave => false, SaveUnsealKeysDecision::Prompt => { - prompt_yes_no(messages.prompt_save_unseal_keys(), messages)? + match prompt_yes_no(messages.prompt_save_unseal_keys(), messages) { + Ok(answer) => answer, + Err(err) => { + // The prompt could not be answered — stdin is at EOF, or + // the read failed. This runs outside the rollback + // envelope with OpenBao already initialised and unsealed, + // and the keys exist nowhere else yet, so echo them + // before failing: the run still exits nonzero, but the + // operator's last capture channel stays open instead of + // recreating the partial-init trap the comment in + // `run_init` describes. + echo_unseal_keys_cleartext(keys, messages); + return Err(err); + } + } } }; if save { @@ -1090,14 +1104,31 @@ async fn maybe_save_unseal_keys( // `--no-save-unseal-keys` the keys are already captured in the // 0600 summary JSON (clap enforces `requires = "summary_json"`), // so echoing them here would leak into CI logs — skip it. - eprintln!("{}", messages.openbao_unseal_keys_not_saved_warning()); - for (idx, key) in keys.iter().enumerate() { - println!("{}", messages.summary_unseal_key(idx + 1, key)); - } + echo_unseal_keys_cleartext(keys, messages); } Ok(()) } +/// Displays the unseal keys in cleartext, one line per key, so the +/// operator can copy them for manual safekeeping. +/// +/// Shared by the declined branch and the unanswerable-prompt branch of +/// `maybe_save_unseal_keys` so both emit exactly the same thing. +fn echo_unseal_keys_cleartext(keys: &[String], messages: &Messages) { + eprintln!("{}", messages.openbao_unseal_keys_not_saved_warning()); + for line in unseal_key_echo_lines(keys, messages) { + println!("{line}"); + } +} + +/// Formats one cleartext line per unseal key, in key order. +fn unseal_key_echo_lines(keys: &[String], messages: &Messages) -> Vec { + keys.iter() + .enumerate() + .map(|(idx, key)| messages.summary_unseal_key(idx + 1, key)) + .collect() +} + /// Rotates the temporary `POSTGRES_PASSWORD` from `.env` and returns /// the new DSN on success, or `None` if rotation was skipped. #[allow(clippy::too_many_lines)] @@ -1868,6 +1899,26 @@ mod tests { ); } + /// The cleartext echo is one shared path, so the declined branch and + /// the branch that fails on an unanswerable prompt hand the operator + /// the same thing: one line per key, in key order. + #[test] + fn unseal_key_echo_lines_emits_one_line_per_key() { + let messages = test_messages(); + let keys = vec!["key-1".to_string(), "key-2".to_string()]; + let lines = unseal_key_echo_lines(&keys, &messages); + assert_eq!(lines.len(), keys.len()); + for (idx, key) in keys.iter().enumerate() { + let line = lines.get(idx).expect("one line per key"); + assert!(line.contains(key), "line {idx} must carry its key: {line}"); + assert!( + line.contains(&(idx + 1).to_string()), + "line {idx} must be numbered from 1: {line}" + ); + } + assert!(unseal_key_echo_lines(&[], &messages).is_empty()); + } + /// `write_root_token_file` persists the token with mode `0600`. /// Reinit's `--root-token-output` reaches the operator via this /// helper; tightening the permission contract here guards against diff --git a/src/commands/init/steps/prompts.rs b/src/commands/init/steps/prompts.rs index 7cd735cf..d3e235e6 100644 --- a/src/commands/init/steps/prompts.rs +++ b/src/commands/init/steps/prompts.rs @@ -1,3 +1,5 @@ +use std::io::{self, BufRead, Write}; + use anyhow::{Context, Result}; use crate::i18n::Messages; @@ -24,18 +26,38 @@ pub(super) fn prompt_unseal_keys( } pub(super) fn prompt_text(prompt: &str, messages: &Messages) -> Result { - use std::io::{self, Write}; + read_prompt_text(&mut io::stdin().lock(), prompt, messages) +} + +/// Reads one answer from `input`, failing rather than fabricating one +/// when there is nothing left to read. +/// +/// # Errors +/// +/// Returns `messages.error_prompt_eof()` when `read_line` reports zero +/// bytes, which happens only at EOF: a blank line still carries its +/// newline. Without the distinction a closed stdin answers every +/// remaining prompt with an empty string, and the one prompt that +/// retries on a rejected answer re-prompts forever. +pub(super) fn read_prompt_text( + input: &mut dyn BufRead, + prompt: &str, + messages: &Messages, +) -> Result { // CodeQL flags this as cleartext-logging, but `prompt` is a UI label // (e.g. "PostgreSQL password: "), not a secret value. Dismiss as false positive. print!("{prompt}"); io::stdout() .flush() .with_context(|| messages.error_prompt_flush_failed())?; - let mut input = String::new(); - io::stdin() - .read_line(&mut input) + let mut line = String::new(); + let read = input + .read_line(&mut line) .with_context(|| messages.error_prompt_read_failed())?; - Ok(input.trim().to_string()) + if read == 0 { + anyhow::bail!(messages.error_prompt_eof()); + } + Ok(line.trim().to_string()) } pub(super) fn prompt_text_with_default( @@ -43,17 +65,30 @@ pub(super) fn prompt_text_with_default( default: &str, messages: &Messages, ) -> Result { - let input = prompt_text(prompt, messages)?; - if input.trim().is_empty() { + read_prompt_text_with_default(&mut io::stdin().lock(), prompt, default, messages) +} + +fn read_prompt_text_with_default( + input: &mut dyn BufRead, + prompt: &str, + default: &str, + messages: &Messages, +) -> Result { + let answer = read_prompt_text(input, prompt, messages)?; + if answer.trim().is_empty() { Ok(default.to_string()) } else { - Ok(input) + Ok(answer) } } pub(crate) fn prompt_yes_no(prompt: &str, messages: &Messages) -> Result { - let input = prompt_text(prompt, messages)?; - let trimmed = input.trim().to_ascii_lowercase(); + read_prompt_yes_no(&mut io::stdin().lock(), prompt, messages) +} + +fn read_prompt_yes_no(input: &mut dyn BufRead, prompt: &str, messages: &Messages) -> Result { + let answer = read_prompt_text(input, prompt, messages)?; + let trimmed = answer.trim().to_ascii_lowercase(); Ok(trimmed == "y" || trimmed == "yes") } @@ -82,7 +117,76 @@ pub(super) const fn should_confirm(condition: bool, confirmed: bool, reinit_mode #[cfg(test)] mod tests { - use super::should_confirm; + use std::io::Cursor; + + use super::super::test_support::test_messages; + use super::{ + read_prompt_text, read_prompt_text_with_default, read_prompt_yes_no, should_confirm, + }; + + /// EOF is not an answer. Each of the three readers must surface it + /// as the dedicated error rather than as an empty string, the + /// supplied default, or a declined confirmation — the three silent + /// wrong values a closed stdin used to produce. + #[test] + fn prompts_error_on_eof() { + let messages = test_messages(); + + let err = read_prompt_text(&mut Cursor::new(""), "Label: ", &messages) + .expect_err("EOF must error"); + assert_eq!(err.to_string(), messages.error_prompt_eof()); + + let err = + read_prompt_text_with_default(&mut Cursor::new(""), "Label: ", "fallback", &messages) + .expect_err("EOF must error instead of returning the default"); + assert_eq!(err.to_string(), messages.error_prompt_eof()); + + let err = read_prompt_yes_no(&mut Cursor::new(""), "Label: ", &messages) + .expect_err("EOF must error instead of answering no"); + assert_eq!(err.to_string(), messages.error_prompt_eof()); + } + + /// A blank line remains a deliberate answer everywhere it is one + /// today, so the EOF check must not swallow a bare Enter. + #[test] + fn prompts_treat_blank_line_as_an_answer() { + let messages = test_messages(); + + let value = read_prompt_text(&mut Cursor::new("\n"), "Label: ", &messages) + .expect("a blank line is an answer"); + assert_eq!(value, ""); + + let value = + read_prompt_text_with_default(&mut Cursor::new("\n"), "Label: ", "fallback", &messages) + .expect("a blank line is an answer"); + assert_eq!(value, "fallback"); + + let answer = read_prompt_yes_no(&mut Cursor::new("\n"), "Label: ", &messages) + .expect("a blank line is an answer"); + assert!(!answer, "a bare Enter still reads as no"); + } + + #[test] + fn prompts_read_and_trim_a_typed_answer() { + let messages = test_messages(); + + let value = read_prompt_text(&mut Cursor::new(" value \n"), "Label: ", &messages) + .expect("typed answer"); + assert_eq!(value, "value"); + + let value = read_prompt_text_with_default( + &mut Cursor::new("value\n"), + "Label: ", + "fallback", + &messages, + ) + .expect("typed answer wins over the default"); + assert_eq!(value, "value"); + + let answer = read_prompt_yes_no(&mut Cursor::new("y\n"), "Label: ", &messages) + .expect("typed answer"); + assert!(answer); + } /// A prompt whose condition does not hold never fires, whatever the /// flag says — passing a flag for an absent file is a silent no-op. diff --git a/src/commands/init/steps/secrets.rs b/src/commands/init/steps/secrets.rs index a51fb6c2..dd84b3e4 100644 --- a/src/commands/init/steps/secrets.rs +++ b/src/commands/init/steps/secrets.rs @@ -1,3 +1,5 @@ +use std::io::{self, BufRead}; + use anyhow::{Context, Result}; use base64::Engine; use bootroot::openbao::OpenBaoClient; @@ -5,7 +7,7 @@ use bootroot::openbao::OpenBaoClient; use super::super::constants::SECRET_BYTES; use super::super::constants::openbao_constants::PATH_AGENT_EAB; use super::super::types::EabCredentials; -use super::prompts::{prompt_text, prompt_yes_no}; +use super::prompts::{prompt_text, prompt_yes_no, read_prompt_text}; use super::{InitRollback, InitSecrets}; use crate::cli::args::{InitArgs, InitFeature}; use crate::i18n::Messages; @@ -134,7 +136,10 @@ pub(super) async fn maybe_register_eab( return Ok(None); } println!("{}", messages.eab_prompt_instructions()); - let credentials = prompt_eab_with_validation(messages)?; + // The lock is a temporary that dies with this statement: holding it + // in a local across the `register_eab_secret` await below would make + // this future non-`Send`. + let credentials = prompt_eab_with_validation(&mut io::stdin().lock(), messages)?; register_eab_secret( client, &args.openbao.kv_mount, @@ -151,10 +156,18 @@ pub(super) async fn maybe_register_eab( /// aborts with Ctrl-C and re-runs `init` (eventually with `--no-eab`). /// Coercing blank-to-"no EAB" silently here would leak the same garbage /// (kid="", hmac="") into KV that issue #588 §3 closes. -fn prompt_eab_with_validation(messages: &Messages) -> Result { +/// +/// This is the only `init` prompt that retries, so it is the only one +/// that needs the reader threaded in: an EOF that the primitive turns +/// into an error terminates the loop here instead of spinning on an +/// answer that will never arrive. +fn prompt_eab_with_validation( + input: &mut dyn BufRead, + messages: &Messages, +) -> Result { loop { - let kid = prompt_text(messages.prompt_eab_kid(), messages)?; - let hmac = prompt_text(messages.prompt_eab_hmac(), messages)?; + let kid = read_prompt_text(input, messages.prompt_eab_kid(), messages)?; + let hmac = read_prompt_text(input, messages.prompt_eab_hmac(), messages)?; match validate_eab(&kid, &hmac) { Ok(creds) => return Ok(creds), Err(err) => { @@ -391,6 +404,37 @@ mod tests { ); } + /// Regression for the reported symptom: with stdin at EOF the EAB + /// prompt used to read `""` for both fields forever, printing the + /// validation error once per iteration (gigabytes of it) until the + /// process was killed. It must terminate on the first read instead. + #[test] + fn prompt_eab_with_validation_errors_on_eof_without_looping() { + use std::io::Cursor; + + let messages = test_messages(); + let err = prompt_eab_with_validation(&mut Cursor::new(""), &messages) + .expect_err("EOF must error instead of looping"); + assert_eq!(err.to_string(), messages.error_prompt_eof()); + } + + /// The EOF fix must not turn a recoverable typo into a hard failure: + /// a rejected attempt followed by a valid pair still loops once and + /// returns the valid credentials. + #[test] + fn prompt_eab_with_validation_retries_after_a_rejected_attempt() { + use std::io::Cursor; + + let messages = test_messages(); + let hmac = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0xCD; 32]); + // First attempt: a one-character HMAC, the #588 §3a symptom. + let script = format!("kid-1\ny\nkid-2\n{hmac}\n"); + let creds = prompt_eab_with_validation(&mut Cursor::new(script), &messages) + .expect("a rejected attempt must be retried, not fatal"); + assert_eq!(creds.kid, "kid-2"); + assert_eq!(creds.hmac, hmac); + } + #[test] fn validate_eab_accepts_32_byte_base64url() { // 32 bytes → 43 base64url-no-pad chars. diff --git a/tests/openbao_stepca_integration.rs b/tests/openbao_stepca_integration.rs index 07ae1122..dc97572c 100644 --- a/tests/openbao_stepca_integration.rs +++ b/tests/openbao_stepca_integration.rs @@ -20,6 +20,12 @@ mod unix_integration { write_fake_docker, write_fake_docker_with_status, write_password_file, }; + /// Drives `command` with a fixed answer sequence. + /// + /// The sequence must cover every prompt the run reaches: `init` + /// fails on EOF rather than answering the remainder itself, so a + /// short sequence aborts the run with "no input available" instead + /// of proceeding on an answer nobody gave. fn run_command_with_input(command: &mut Command, input: &str) -> Result { let mut child = command .stdin(Stdio::piped()) @@ -74,8 +80,8 @@ mod unix_integration { compose_file.to_string_lossy().as_ref(), ]) .env("PATH", combined_path); - let output = - run_command_with_input(&mut command, "y\n").context("Failed to run bootroot init")?; + let output = run_command_with_input(&mut command, "y\nn\n") + .context("Failed to run bootroot init")?; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -124,8 +130,8 @@ mod unix_integration { compose_file.to_string_lossy().as_ref(), ]) .env("PATH", combined_path); - let output = - run_command_with_input(&mut command, "y\n").context("Failed to run bootroot init")?; + let output = run_command_with_input(&mut command, "y\nn\n") + .context("Failed to run bootroot init")?; let stderr = String::from_utf8_lossy(&output.stderr); if !output.status.success() { @@ -170,8 +176,8 @@ mod unix_integration { compose_file.to_string_lossy().as_ref(), ]) .env("PATH", combined_path); - let output = - run_command_with_input(&mut command, "y\n").context("Failed to run bootroot init")?; + let output = run_command_with_input(&mut command, "y\nn\n") + .context("Failed to run bootroot init")?; assert!( !output.status.success(), @@ -229,8 +235,8 @@ mod unix_integration { &responder.uri(), ]) .env("PATH", combined_path); - let output = - run_command_with_input(&mut command, "y\n").context("Failed to run bootroot init")?; + let output = run_command_with_input(&mut command, "y\nn\n") + .context("Failed to run bootroot init")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -283,8 +289,8 @@ mod unix_integration { &responder.uri(), ]) .env("PATH", combined_path); - let output = - run_command_with_input(&mut command, "y\n").context("Failed to run bootroot init")?; + let output = run_command_with_input(&mut command, "y\nn\n") + .context("Failed to run bootroot init")?; assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); @@ -338,8 +344,8 @@ mod unix_integration { "responder-check", ]) .env("PATH", combined_path); - let output = - run_command_with_input(&mut command, "y\n").context("Failed to run bootroot init")?; + let output = run_command_with_input(&mut command, "y\nn\n") + .context("Failed to run bootroot init")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -378,8 +384,8 @@ mod unix_integration { compose_file.to_string_lossy().as_ref(), ]) .env("PATH", combined_path); - let output = - run_command_with_input(&mut command, "y\n").context("Failed to run bootroot init")?; + let output = run_command_with_input(&mut command, "y\nn\n") + .context("Failed to run bootroot init")?; assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); @@ -422,8 +428,8 @@ mod unix_integration { compose_file.to_string_lossy().as_ref(), ]) .env("PATH", combined_path); - let output = - run_command_with_input(&mut command, "y\n").context("Failed to run bootroot init")?; + let output = run_command_with_input(&mut command, "y\nn\n") + .context("Failed to run bootroot init")?; assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); @@ -472,7 +478,7 @@ mod unix_integration { "responder-check", ]) .env("PATH", combined_path); - let output = run_command_with_input(&mut command, "y\ny\n") + let output = run_command_with_input(&mut command, "y\ny\nn\n") .context("Failed to run bootroot init")?; let stdout = String::from_utf8_lossy(&output.stdout); @@ -525,7 +531,7 @@ mod unix_integration { compose_file.to_string_lossy().as_ref(), ]) .env("PATH", combined_path); - let output = run_command_with_input(&mut command, "y\ny\n") + let output = run_command_with_input(&mut command, "y\ny\nn\n") .context("Failed to run bootroot init")?; assert!(!output.status.success()); From 862f344d6fb2345b4c163c55f10c037c853e0f17 Mon Sep 17 00:00:00 2001 From: Aco Piper Date: Thu, 13 Aug 2026 00:05:15 +0900 Subject: [PATCH 2/3] Cover the EOF paths in docs and an end-to-end test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/en/cli.md | 11 ++++ docs/en/e2e-ci.md | 5 +- docs/ko/cli.md | 12 +++++ docs/ko/e2e-ci.md | 5 +- src/commands/init/steps/secrets.rs | 14 +++++ tests/openbao_stepca_integration.rs | 79 +++++++++++++++++++++++++++-- tests/support/mod.rs | 35 +++++++++++++ 7 files changed, 155 insertions(+), 6 deletions(-) diff --git a/docs/en/cli.md b/docs/en/cli.md index cb725501..c983e8cc 100644 --- a/docs/en/cli.md +++ b/docs/en/cli.md @@ -610,6 +610,17 @@ prompts on the run still need their own flags (`--no-eab` or `--no-save-unseal-keys`, and `--root-token`/`--unseal-key` where the OpenBao state requires them). +A prompt that is reached with nothing left to read — stdin closed, or a +piped answer sequence that ran out — aborts the run with `no input +available (stdin reached EOF / not a terminal)` instead of being +answered. Pressing Enter is unaffected: a blank line is still the empty +answer, the offered default, or `n`. So a script driving `init` over a +pipe must supply one answer per prompt the run reaches, or set the flag +that suppresses it. The one exception is the "Save unseal keys to file +for automatic unseal?" prompt: running out of input there still echoes +the freshly generated keys in cleartext before the run fails, so a +partial init cannot leave them recorded nowhere. + If a previous `init` failed mid-flight and rolled back, OpenBao may remain initialised in its volume while bootroot has no usable root token. `init` detects this state on startup and emits an actionable diff --git a/docs/en/e2e-ci.md b/docs/en/e2e-ci.md index bcf2efab..57e25c5a 100644 --- a/docs/en/e2e-ci.md +++ b/docs/en/e2e-ci.md @@ -159,11 +159,14 @@ bootroot infra install --compose-file "$COMPOSE_FILE" # DB credentials are read from .env created by infra install. # POSTGRES_HOST and POSTGRES_PORT are set by the script so that # build_admin_dsn_from_env() connects via the host-mapped port. -BOOTROOT_LANG=en printf "y\n" | bootroot init \ +# The piped sequence must answer every prompt the run reaches: init +# fails on EOF rather than answering an unanswered prompt itself. +BOOTROOT_LANG=en printf "y\ny\ny\n" | bootroot init \ --compose-file "$COMPOSE_FILE" \ --secrets-dir "$SECRETS_DIR" \ --summary-json "$INIT_SUMMARY_JSON" \ --enable auto-generate,show-secrets,db-provision \ + --no-eab \ --db-user "step" \ --db-name "stepca" \ --responder-url "$RESPONDER_URL" diff --git a/docs/ko/cli.md b/docs/ko/cli.md index e21e8e70..509a37b3 100644 --- a/docs/ko/cli.md +++ b/docs/ko/cli.md @@ -600,6 +600,18 @@ OpenBao 초기화/언실/정책/AppRole 구성, step-ca 초기화, 시크릿 등 `--no-save-unseal-keys`, OpenBao 상태에 따라 `--root-token`/`--unseal-key`). +읽을 입력이 남아 있지 않은 상태에서 프롬프트에 도달하면(stdin이 +닫혔거나 파이프로 넘긴 답변이 모자란 경우) 그 프롬프트는 답변으로 +처리되지 않고 `no input available (stdin reached EOF / not a +terminal)` 오류로 실행이 중단됩니다. Enter 입력은 그대로입니다. 빈 +줄은 여전히 빈 답변이거나 제시된 기본값이거나 `n`입니다. 따라서 +파이프로 `init`을 구동하는 스크립트는 실행이 도달하는 프롬프트마다 +답변을 하나씩 공급하거나 해당 프롬프트를 억제하는 플래그를 지정해야 +합니다. 예외는 "Save unseal keys to file for automatic unseal?" +프롬프트 하나로, 이 지점에서 입력이 떨어지면 실행이 실패하기 전에 +새로 생성된 unseal key를 평문으로 출력합니다. 부분 초기화가 키를 +어디에도 남기지 않는 상황을 막기 위해서입니다. + 이전 `init`이 중간에 실패하고 롤백되었다면 OpenBao는 볼륨에 초기화된 상태로 남아 있는 반면 bootroot에는 사용 가능한 root token이 없을 수 있습니다. `init`은 시작 시 이 상태를 감지하고 불투명한 diff --git a/docs/ko/e2e-ci.md b/docs/ko/e2e-ci.md index 586ac760..52c68989 100644 --- a/docs/ko/e2e-ci.md +++ b/docs/ko/e2e-ci.md @@ -156,11 +156,14 @@ bootroot infra install --compose-file "$COMPOSE_FILE" # DB 자격 증명은 infra install이 생성한 .env에서 자동으로 읽힙니다. # POSTGRES_HOST와 POSTGRES_PORT는 스크립트에서 설정하여 # host-mapped 포트를 통해 연결합니다. -BOOTROOT_LANG=en printf "y\n" | bootroot init \ +# 파이프로 넘기는 답변은 해당 실행이 도달하는 모든 프롬프트를 채워야 +# 합니다. init은 EOF를 답으로 읽지 않고 실행을 실패시킵니다. +BOOTROOT_LANG=en printf "y\ny\ny\n" | bootroot init \ --compose-file "$COMPOSE_FILE" \ --secrets-dir "$SECRETS_DIR" \ --summary-json "$INIT_SUMMARY_JSON" \ --enable auto-generate,show-secrets,db-provision \ + --no-eab \ --db-user "step" \ --db-name "stepca" \ --responder-url "$RESPONDER_URL" diff --git a/src/commands/init/steps/secrets.rs b/src/commands/init/steps/secrets.rs index dd84b3e4..06926649 100644 --- a/src/commands/init/steps/secrets.rs +++ b/src/commands/init/steps/secrets.rs @@ -418,6 +418,20 @@ mod tests { assert_eq!(err.to_string(), messages.error_prompt_eof()); } + /// The shape a piped answer sequence actually runs out in: the + /// `kid` is answered and the `hmac` is not. A partially answered + /// attempt must fail on the missing read rather than complete the + /// pair with `""` and retry, which is how the loop used to spin. + #[test] + fn prompt_eab_with_validation_errors_on_eof_midway_through_a_pair() { + use std::io::Cursor; + + let messages = test_messages(); + let err = prompt_eab_with_validation(&mut Cursor::new("kid-1\n"), &messages) + .expect_err("a half-answered pair must error, not retry"); + assert_eq!(err.to_string(), messages.error_prompt_eof()); + } + /// The EOF fix must not turn a recoverable typo into a hard failure: /// a rejected attempt followed by a valid pair still loops once and /// returns the valid credentials. diff --git a/tests/openbao_stepca_integration.rs b/tests/openbao_stepca_integration.rs index dc97572c..7718e6e0 100644 --- a/tests/openbao_stepca_integration.rs +++ b/tests/openbao_stepca_integration.rs @@ -14,10 +14,10 @@ mod unix_integration { use wiremock::{Mock, MockServer, ResponseTemplate}; use super::support::{ - ROOT_TOKEN, create_secrets_dir, expect_rollback_deletes, stub_openbao, - stub_openbao_audit_failure, stub_openbao_expect_audit, stub_openbao_sealed, - stub_openbao_unseal_failure, stub_openbao_with_write_failure, write_dotenv_file, - write_fake_docker, write_fake_docker_with_status, write_password_file, + GENERATED_UNSEAL_KEYS, ROOT_TOKEN, create_secrets_dir, expect_rollback_deletes, + stub_openbao, stub_openbao_audit_failure, stub_openbao_expect_audit, stub_openbao_sealed, + stub_openbao_uninitialized, stub_openbao_unseal_failure, stub_openbao_with_write_failure, + write_dotenv_file, write_fake_docker, write_fake_docker_with_status, write_password_file, }; /// Drives `command` with a fixed answer sequence. @@ -95,6 +95,77 @@ mod unix_integration { Ok(()) } + /// The save-unseal-keys prompt runs after `run_init_inner` has + /// returned, outside the rollback envelope, on keys that exist + /// nowhere else yet. Running out of input there must still hand + /// them to the operator in cleartext and only then fail, so a run + /// that outlives its answers cannot leave the vault initialized with + /// its keys recorded nowhere. + #[tokio::test] + async fn init_echoes_unseal_keys_when_the_save_prompt_runs_out_of_input() -> Result<()> { + let temp_dir = tempdir().context("Failed to create temp dir")?; + let secrets_dir = create_secrets_dir(temp_dir.path())?; + let compose_file = temp_dir.path().join("docker-compose.yml"); + fs::write(&compose_file, "services: {}").context("Failed to write compose file")?; + write_dotenv_file(temp_dir.path())?; + + let bin_dir = temp_dir.path().join("bin"); + fs::create_dir_all(&bin_dir).context("Failed to create bin dir")?; + write_fake_docker(&bin_dir)?; + + let server = MockServer::start().await; + stub_openbao_uninitialized(&server).await; + + let path = env::var("PATH").unwrap_or_default(); + let combined_path = format!("{}:{}", bin_dir.display(), path); + + // `--no-eab` and `--overwrite-ca-json` answer the two prompts + // that would otherwise fire first, so the save-unseal-keys + // prompt is the one the empty stdin lands on. + let mut command = Command::new(env!("CARGO_BIN_EXE_bootroot")); + command + .current_dir(temp_dir.path()) + .args([ + "init", + "--openbao-url", + &server.uri(), + "--enable", + "auto-generate", + "--no-eab", + "--overwrite-ca-json", + "--secrets-dir", + secrets_dir.to_string_lossy().as_ref(), + "--compose-file", + compose_file.to_string_lossy().as_ref(), + ]) + .env("PATH", combined_path) + .env("BOOTROOT_LANG", "en"); + let output = + run_command_with_input(&mut command, "").context("Failed to run bootroot init")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.status.success(), + "an unanswerable prompt must fail the run: {stdout}" + ); + for key in GENERATED_UNSEAL_KEYS { + assert!( + stdout.contains(key), + "unseal key {key} must still reach the operator: {stdout}" + ); + } + assert!( + stderr.contains("Unseal keys were NOT saved to a file"), + "the echo must be the declined branch's, warning included: {stderr}" + ); + assert!( + stderr.contains("no input available"), + "the run must name EOF as the reason: {stderr}" + ); + Ok(()) + } + #[tokio::test] async fn init_enables_audit_backend() -> Result<()> { let temp_dir = tempdir().context("Failed to create temp dir")?; diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 969b4444..c538a882 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -16,6 +16,11 @@ use wiremock::matchers::{header, header_exists, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; pub(crate) const ROOT_TOKEN: &str = "root-token"; +/// The unseal keys [`stub_openbao_uninitialized`] hands back from +/// `POST /v1/sys/init`. Distinctive enough that a test can assert they +/// reached the operator's terminal. +pub(crate) const GENERATED_UNSEAL_KEYS: &[&str] = + &["generated-unseal-key-one", "generated-unseal-key-two"]; const POLICY_NAMES: &[&str] = &[ "bootroot-agent", "bootroot-responder", @@ -238,6 +243,25 @@ pub(crate) async fn stub_openbao(server: &MockServer) { stub_kv_secrets(server).await; } +/// Stubs an `OpenBao` that `init` has to initialize itself, handing back +/// [`GENERATED_UNSEAL_KEYS`] and leaving the vault unsealed. +/// +/// This is the only path that reaches the save-unseal-keys prompt: the +/// prompt fires only when `init` generated the keys during this run, so +/// a stub reporting an already-initialized vault never gets there. +pub(crate) async fn stub_openbao_uninitialized(server: &MockServer) { + stub_health(server).await; + stub_init_status_uninitialized(server).await; + stub_seal_status(server).await; + stub_sys_init(server).await; + stub_kv_mount(server).await; + stub_auth_backends(server).await; + stub_audit_backend(server).await; + stub_policies(server).await; + stub_approles(server).await; + stub_kv_secrets(server).await; +} + pub(crate) async fn stub_openbao_expect_audit(server: &MockServer) { stub_health(server).await; stub_init_status(server).await; @@ -368,6 +392,17 @@ async fn stub_init_status(server: &MockServer) { .await; } +async fn stub_sys_init(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/v1/sys/init")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "keys": GENERATED_UNSEAL_KEYS, + "root_token": ROOT_TOKEN + }))) + .mount(server) + .await; +} + async fn stub_init_status_uninitialized(server: &MockServer) { Mock::given(method("GET")) .and(path("/v1/sys/init")) From 382eb04774be06e8bb3985e6418fb1beb6d2755d Mon Sep 17 00:00:00 2001 From: Aco Piper Date: Thu, 13 Aug 2026 02:27:00 +0900 Subject: [PATCH 3/3] Pin English on the command whose output is parsed 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 --- docs/en/e2e-ci.md | 4 ++-- docs/ko/e2e-ci.md | 4 ++-- scripts/preflight/ci/test-core.sh | 4 ++-- scripts/preflight/extra/cli-scenarios.sh | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/en/e2e-ci.md b/docs/en/e2e-ci.md index 57e25c5a..3a5e1dbc 100644 --- a/docs/en/e2e-ci.md +++ b/docs/en/e2e-ci.md @@ -161,7 +161,7 @@ bootroot infra install --compose-file "$COMPOSE_FILE" # build_admin_dsn_from_env() connects via the host-mapped port. # The piped sequence must answer every prompt the run reaches: init # fails on EOF rather than answering an unanswered prompt itself. -BOOTROOT_LANG=en printf "y\ny\ny\n" | bootroot init \ +printf "y\ny\ny\n" | BOOTROOT_LANG=en bootroot init \ --compose-file "$COMPOSE_FILE" \ --secrets-dir "$SECRETS_DIR" \ --summary-json "$INIT_SUMMARY_JSON" \ @@ -304,7 +304,7 @@ Actual commands (script excerpt): ```bash # control node: infra-install / init / service-add bootroot infra install --compose-file "$COMPOSE_FILE" -BOOTROOT_LANG=en printf "y\ny\nn\n" | bootroot init \ +printf "y\ny\nn\n" | BOOTROOT_LANG=en bootroot init \ --compose-file "$COMPOSE_FILE" --summary-json "$INIT_SUMMARY_JSON" \ --enable auto-generate,show-secrets --eab-kid "$INIT_EAB_KID" \ --eab-hmac "$INIT_EAB_HMAC" diff --git a/docs/ko/e2e-ci.md b/docs/ko/e2e-ci.md index 52c68989..a56f0f62 100644 --- a/docs/ko/e2e-ci.md +++ b/docs/ko/e2e-ci.md @@ -158,7 +158,7 @@ bootroot infra install --compose-file "$COMPOSE_FILE" # host-mapped 포트를 통해 연결합니다. # 파이프로 넘기는 답변은 해당 실행이 도달하는 모든 프롬프트를 채워야 # 합니다. init은 EOF를 답으로 읽지 않고 실행을 실패시킵니다. -BOOTROOT_LANG=en printf "y\ny\ny\n" | bootroot init \ +printf "y\ny\ny\n" | BOOTROOT_LANG=en bootroot init \ --compose-file "$COMPOSE_FILE" \ --secrets-dir "$SECRETS_DIR" \ --summary-json "$INIT_SUMMARY_JSON" \ @@ -295,7 +295,7 @@ sudo -n cp "$tmp_file" /etc/hosts ```bash # control node: infra-install / init / service-add bootroot infra install --compose-file "$COMPOSE_FILE" -BOOTROOT_LANG=en printf "y\ny\nn\n" | bootroot init \ +printf "y\ny\nn\n" | BOOTROOT_LANG=en bootroot init \ --compose-file "$COMPOSE_FILE" --summary-json "$INIT_SUMMARY_JSON" \ --enable auto-generate,show-secrets --eab-kid "$INIT_EAB_KID" \ --eab-hmac "$INIT_EAB_HMAC" diff --git a/scripts/preflight/ci/test-core.sh b/scripts/preflight/ci/test-core.sh index 23a9ab79..f3365f1b 100755 --- a/scripts/preflight/ci/test-core.sh +++ b/scripts/preflight/ci/test-core.sh @@ -31,7 +31,7 @@ cargo run --bin bootroot -- infra install # assertion below exercise the declined path deliberately — `init` fails # on EOF rather than reading an unanswered prompt as "no". echo "[test-core] zero-config init (answer n, no show-secrets)" -BOOTROOT_LANG=en printf "n\nn\n" | cargo run --bin bootroot -- init \ +printf "n\nn\n" | BOOTROOT_LANG=en cargo run --bin bootroot -- init \ --enable auto-generate \ --http-hmac "dev-hmac" \ --secrets-dir "$BOOTROOT_SECRETS_DIR" \ @@ -54,7 +54,7 @@ cargo run --bin bootroot -- infra install # overwrite confirmation fires here: the two prompts this run reaches # are EAB registration and saving the unseal keys, both declined. echo "[test-core] CLI init (smoke)" -BOOTROOT_LANG=en printf "n\nn\n" | cargo run --bin bootroot -- init \ +printf "n\nn\n" | BOOTROOT_LANG=en cargo run --bin bootroot -- init \ --enable auto-generate,show-secrets \ --http-hmac "dev-hmac" \ --secrets-dir "$BOOTROOT_SECRETS_DIR" \ diff --git a/scripts/preflight/extra/cli-scenarios.sh b/scripts/preflight/extra/cli-scenarios.sh index 3139d69f..f0a1f201 100755 --- a/scripts/preflight/extra/cli-scenarios.sh +++ b/scripts/preflight/extra/cli-scenarios.sh @@ -90,7 +90,7 @@ run_init_scenario() { # the `db-provision` feature, then EAB registration and saving the # unseal keys are both declined. An answer short of the last prompt # aborts the run — init fails on EOF rather than answering itself. - BOOTROOT_LANG=en printf "y\nn\nn\n" | cargo run --bin bootroot -- init \ + printf "y\nn\nn\n" | BOOTROOT_LANG=en cargo run --bin bootroot -- init \ --enable auto-generate,show-secrets,db-provision \ --summary-json "$INIT_SUMMARY_JSON" \ --http-hmac "$responder_hmac" \