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/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..3a5e1dbc 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. +printf "y\ny\ny\n" | BOOTROOT_LANG=en 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" @@ -301,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/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..a56f0f62 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를 답으로 읽지 않고 실행을 실패시킵니다. +printf "y\ny\ny\n" | BOOTROOT_LANG=en 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" @@ -292,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 6337125c..f3365f1b 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 \ +printf "n\nn\n" | BOOTROOT_LANG=en 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 \ +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 394de0c6..f0a1f201 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. + 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" \ 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..06926649 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,51 @@ 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 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. + #[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..7718e6e0 100644 --- a/tests/openbao_stepca_integration.rs +++ b/tests/openbao_stepca_integration.rs @@ -14,12 +14,18 @@ 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. + /// + /// 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); @@ -89,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")?; @@ -124,8 +201,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 +247,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 +306,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 +360,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 +415,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 +455,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 +499,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 +549,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 +602,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()); 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"))