diff --git a/apps/zerocode/src/main.rs b/apps/zerocode/src/main.rs index 4ac528f2bcf..5eff84f1773 100644 --- a/apps/zerocode/src/main.rs +++ b/apps/zerocode/src/main.rs @@ -184,20 +184,34 @@ enum InsecureTlsChoice { Abort, } -fn confirm_insecure_tls(url: &str) -> anyhow::Result { - use std::io::Write as _; - eprintln!( +/// Prompt the operator to accept an insecure-TLS connection to `url`. +/// +/// Returns the operator's [`InsecureTlsChoice`]: +/// - [`InsecureTlsChoice::Once`] for `y` / `yes` (connect once, do not persist) +/// - [`InsecureTlsChoice::Always`] for `a` / `always` (connect and remember this route) +/// - [`InsecureTlsChoice::Abort`] for everything else (default, empty, `n`, junk) +/// +/// Reads the operator's answer from `reader` and writes the prompt to +/// `writer` so tests can inject deterministic input without touching +/// `stdin` / `stderr`. +fn confirm_insecure_tls_with( + mut reader: R, + writer: &mut W, + url: &str, +) -> anyhow::Result { + writeln!( + writer, "\nWARNING: --tls-skip-verify DISABLES TLS certificate verification for\n\ {url}\nThis connection is UNSAFE on untrusted networks (susceptible to\n\ man-in-the-middle). Only continue on a trusted network against a\n\ self-signed cert you control.\n\n\ You are accepting an UNVERIFIED route, not a trusted peer.\n\ [y] yes, connect once [a] always (remember this route) [N] no, abort" - ); - eprint!("Continue with verification disabled? [y/a/N] "); - std::io::stderr().flush().ok(); + )?; + write!(writer, "Continue with verification disabled? [y/a/N] ")?; + writer.flush().ok(); let mut answer = String::new(); - std::io::stdin().read_line(&mut answer)?; + reader.read_line(&mut answer)?; match answer.trim().to_ascii_lowercase().as_str() { "y" | "yes" => Ok(InsecureTlsChoice::Once), "a" | "always" => Ok(InsecureTlsChoice::Always), @@ -205,6 +219,16 @@ fn confirm_insecure_tls(url: &str) -> anyhow::Result { } } +/// Production entry point: locks `stdin` and writes the prompt to `stderr`, +/// delegating to [`confirm_insecure_tls_with`]. Behaviour is identical to +/// the previous inline implementation — the refactor only adds the +/// `BufRead` / `Write` seam so the prompt logic can be unit-tested. +fn confirm_insecure_tls(url: &str) -> anyhow::Result { + let stdin = std::io::stdin(); + let mut stderr = std::io::stderr(); + confirm_insecure_tls_with(stdin.lock(), &mut stderr, url) +} + async fn run() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -467,3 +491,206 @@ mod connection_tests { ); } } + +#[cfg(test)] +mod confirm_insecure_tls_tests { + //! Tests for [`crate::confirm_insecure_tls_with`], the test-seam + //! extracted from the original `confirm_insecure_tls(url)` so the + //! input → choice mapping and prompt content can be asserted + //! deterministically without touching `stdin` / `stderr`. + //! + //! Acceptance criterion coverage for issue #7693: + //! 1. "Insecure TLS cannot be accepted without explicit confirmation" + //! — the empty / `n` / junk / uppercase-`N` / default branches all + //! return [`InsecureTlsChoice::Abort`]. + //! 2. "Decline/abort paths leave no persisted insecure-TLS choice" + //! — the static-source test + //! [`abort_arm_of_confirm_match_must_not_call_persist`] enforces + //! the structural invariant that the `Abort` arm of the production + //! match in `run()` does not invoke `persist_wss_route_ack`. + //! 3. "Mode transition tests cover the quickstart/chat handoff" is + //! covered by the existing `connection_tests::flag_connect_*` / + //! `config_uri_*` / `skip_verify_*` tests; this issue does not + //! change `resolve_wss_target`'s contract. + //! 4. "prompt persistence behavior needed to test those transitions + //! deterministically" is covered by the existing + //! `route_acked_membership` / `persist_wss_route_ack_dedups` / + //! `persist_wss_route_ack_preserves_other_sections` tests in + //! `crate::config` — this issue does not duplicate that coverage. + + use super::InsecureTlsChoice::{Abort, Always, Once}; + use super::*; + use std::io::Cursor; + + /// Drive [`confirm_insecure_tls_with`] with a deterministic stdin + /// buffer and a fresh output buffer, returning the operator's + /// choice and the captured prompt text. + fn run(input: &str, url: &str) -> (InsecureTlsChoice, String) { + let mut output = Vec::new(); + let choice = confirm_insecure_tls_with(Cursor::new(input), &mut output, url) + .expect("confirm_insecure_tls_with must succeed on plain stdin read"); + let stderr = String::from_utf8(output).expect("prompt must be valid UTF-8"); + (choice, stderr) + } + + #[test] + fn confirm_input_y_returns_once() { + assert!(matches!(run("y\n", "wss://example.test:1").0, Once)); + } + + #[test] + fn confirm_input_yes_returns_once() { + assert!(matches!(run("yes\n", "wss://example.test:1").0, Once)); + } + + #[test] + fn confirm_input_a_returns_always() { + assert!(matches!(run("a\n", "wss://example.test:1").0, Always)); + } + + #[test] + fn confirm_input_always_returns_always() { + assert!(matches!(run("always\n", "wss://example.test:1").0, Always)); + } + + #[test] + fn confirm_input_n_returns_abort() { + assert!(matches!(run("n\n", "wss://example.test:1").0, Abort)); + } + + #[test] + fn confirm_input_empty_returns_abort() { + // Acceptance: insecure TLS cannot be accepted without explicit + // confirmation. An empty stdin (e.g. operator hits enter without + // typing) must default-decline. + assert!(matches!(run("\n", "wss://example.test:1").0, Abort)); + } + + #[test] + fn confirm_input_junk_returns_abort() { + // Acceptance: unknown input must default to the safe Abort + // branch — only `y` / `yes` / `a` / `always` may opt into + // verification-disabled transport. + assert!(matches!(run("xyz\n", "wss://example.test:1").0, Abort)); + } + + #[test] + fn confirm_input_uppercase_lowercases_before_match() { + // The match arm uses `to_ascii_lowercase()` so case variations + // resolve identically. This is the seam's contract; pin both + // "Once" and "Always" branches to defend against an + // accidental case-sensitive refactor. + assert!(matches!(run("Y\n", "wss://example.test:1").0, Once)); + assert!(matches!(run("YES\n", "wss://example.test:1").0, Once)); + assert!(matches!(run("ALWAYS\n", "wss://example.test:1").0, Always)); + // Uppercase `N` and `NO` must still resolve to Abort — they + // are not in the affirmative set. + assert!(matches!(run("N\n", "wss://example.test:1").0, Abort)); + assert!(matches!(run("NO\n", "wss://example.test:1").0, Abort)); + } + + #[test] + fn confirm_prompt_writes_url_and_choice_menu_to_writer() { + // The operator must see (a) which URL they are accepting + // insecure-TLS for, and (b) the `[y/a/N]` choice menu, before + // any answer is read. Capture the prompt text and pin both + // invariants so a future refactor cannot silently truncate the + // warning or the menu. + let url = "wss://insecure-host.example:8443"; + let (_, stderr) = run("n\n", url); + assert!( + stderr.contains(url), + "stderr prompt must contain the URL being confirmed; got: {stderr}" + ); + assert!( + stderr.contains("[y/a/N]"), + "stderr prompt must show the y/a/N choice menu; got: {stderr}" + ); + assert!( + stderr.contains("WARNING"), + "stderr prompt must lead with a WARNING banner so the \ + operator does not skim past an insecure-TLS confirmation; \ + got: {stderr}" + ); + } + + /// Static invariant from issue #7693 acceptance criterion 2: + /// "Decline/abort paths leave no persisted insecure-TLS choice." + /// + /// `confirm_insecure_tls` is called from `run()` in a `match` that + /// decides whether to invoke `persist_wss_route_ack`. Persisting on + /// the `Abort` branch would silently store an insecure-TLS choice + /// the operator explicitly declined — a security-sensitive + /// regression that no other test in the suite catches. + /// + /// Rather than spawn the full CLI / daemon / config-dir stack to + /// exercise the abort path end-to-end, this test inspects the + /// production source of `main.rs` and asserts the `Abort` arm does + /// not contain the persist call. This is a structural guard: any + /// future move of `persist_wss_route_ack(...)` into the abort arm + /// trips this test loudly. + #[test] + fn abort_arm_of_confirm_match_must_not_call_persist() { + const MAIN_SRC: &str = include_str!("main.rs"); + const MATCH_OPEN: &str = "match confirm_insecure_tls(url)? {"; + const ABORT_ARM_LABEL: &str = "InsecureTlsChoice::Abort"; + const PERSIST_CALL: &str = "persist_wss_route_ack(&local_config_dir, url)?"; + + let match_open_idx = MAIN_SRC + .find(MATCH_OPEN) + .unwrap_or_else(|| panic!("main.rs must contain a `{MATCH_OPEN}` block")); + // Locate the matching closing brace by scanning for the first + // `}\n` after the open that is preceded by another `}` at the + // same indentation depth. The match block in `run()` is + // followed by code at lower indentation, so we use a simple + // brace-pair scan: every `{` increments depth, every `}` + // decrements, and depth 0 is the close. + let after_open = match_open_idx + MATCH_OPEN.len(); + let mut depth: usize = 1; + let mut idx = after_open; + let bytes = MAIN_SRC.as_bytes(); + while idx < bytes.len() { + match bytes[idx] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + idx += 1; + } + assert!( + depth == 0, + "match block in main.rs does not close cleanly (depth={depth} at idx={idx})" + ); + let match_block = &MAIN_SRC[match_open_idx..=idx]; + + // Slice just the Abort arm: from `InsecureTlsChoice::Abort` to + // the next `=>` (the arm label terminator) or the end of the + // block. + let abort_label_idx = match_block.find(ABORT_ARM_LABEL).unwrap_or_else(|| { + panic!( + "main.rs match block must include `{ABORT_ARM_LABEL}` arm; \ + got block:\n{match_block}" + ) + }); + let arm_tail_start = match_block[abort_label_idx..] + .find("=>") + .map(|i| abort_label_idx + i + "=>".len()) + .unwrap_or(match_block.len()); + // The arm body extends to the end of the match block (we slice + // up to the closing brace which was at `idx`). Subtract 1 to + // exclude the `}` itself. + let abort_arm_body = &match_block[arm_tail_start..match_block.len() - 1]; + assert!( + !abort_arm_body.contains(PERSIST_CALL), + "Abort arm of `match confirm_insecure_tls(url)?` MUST NOT call \ + `{PERSIST_CALL}` — persisting on Abort would silently store an \ + insecure-TLS choice the operator declined. Found in arm body:\n\ + {abort_arm_body}" + ); + } +}