diff --git a/crates/icaptcha-client/src/lib.rs b/crates/icaptcha-client/src/lib.rs index 79295932..74ad5978 100644 --- a/crates/icaptcha-client/src/lib.rs +++ b/crates/icaptcha-client/src/lib.rs @@ -355,7 +355,13 @@ fn submit_answer( /// Returns `None` when stdin isn't a usable interactive source (e.g. an agent), /// so the caller surfaces a clear "couldn't auto-solve" error instead. fn interactive_prompt(challenge: &Challenge) -> Option { - use std::io::{stderr, stdin, Write}; + use std::io::{stderr, stdin, IsTerminal, Write}; + // An open but silent stdin (a pipe an orchestrator holds open, an agent + // harness) has no one to answer: `read_line` would block forever. Only a + // terminal can be interactive. + if !stdin().is_terminal() { + return None; + } let mut err = stderr(); let _ = writeln!( err, @@ -761,4 +767,68 @@ mod tests { ); assert_eq!(non_empty.api_key.as_deref(), Some("secret-bearer")); } + + /// #345: an open-but-silent non-TTY stdin must not hang the prompt. The + /// child runs `interactive_prompt` with a held-open, empty pipe as stdin: + /// without the terminal check `read_line` blocks forever; with it the + /// child returns `None` and exits. + #[test] + fn interactive_prompt_skips_open_silent_pipe() { + const CHILD_ENV: &str = "ICAPTCHA_PROMPT_PIPE_CHILD"; + if std::env::var_os(CHILD_ENV).is_some() { + let ch = Challenge { + challenge_id: "c".into(), + kind: "arithmetic".into(), + difficulty: 1, + prompt: "What is 1 + 1?".into(), + token: "t".into(), + pow: None, + }; + assert!(interactive_prompt(&ch).is_none()); + println!("prompt-skipped"); + return; + } + let exe = std::env::current_exe().expect("current test binary"); + let mut child = std::process::Command::new(exe) + .args([ + "--exact", + "tests::interactive_prompt_skips_open_silent_pipe", + "--nocapture", + ]) + .env(CHILD_ENV, "1") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn child test"); + // The parent keeps the write end open with no data, which is exactly + // the shape that hung: an open, silent, non-terminal stdin. + let _held_stdin = child.stdin.take(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + match child.try_wait().expect("poll child") { + Some(status) => { + assert!(status.success(), "child exited with {status}"); + let mut out = String::new(); + std::io::Read::read_to_string( + &mut child.stdout.take().expect("child stdout"), + &mut out, + ) + .expect("read child stdout"); + assert!( + out.contains("prompt-skipped"), + "child test body did not run: {out}" + ); + return; + } + None if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + None => { + let _ = child.kill(); + panic!("interactive_prompt blocked on an open, silent non-TTY stdin"); + } + } + } + } } diff --git a/crates/icaptcha-client/src/solvers.rs b/crates/icaptcha-client/src/solvers.rs index 9dc80ff4..22c67754 100644 --- a/crates/icaptcha-client/src/solvers.rs +++ b/crates/icaptcha-client/src/solvers.rs @@ -30,8 +30,8 @@ fn solve_arithmetic(prompt: &str) -> Option { while let Some(op) = tokens.next() { let n: i64 = tokens.next()?.parse().ok()?; match op { - "+" => acc += n, - "-" => acc -= n, + "+" => acc = acc.checked_add(n)?, + "-" => acc = acc.checked_sub(n)?, _ => return None, } } @@ -45,15 +45,15 @@ fn solve_algebra(prompt: &str) -> Option { let (lhs, rhs) = eq.split_once('=')?; let (al, cl) = parse_linear(lhs.trim())?; let (ar, cr) = parse_linear(rhs.trim())?; - let denom = al - ar; + let denom = al.checked_sub(ar)?; if denom == 0 { return None; } - let num = cr - cl; - if num % denom != 0 { + let num = cr.checked_sub(cl)?; + if num.checked_rem(denom)? != 0 { return None; } - Some(num / denom) + num.checked_div(denom) } /// Parse a linear expression in `x` into `(coeff_of_x, constant)`. @@ -76,12 +76,12 @@ fn parse_linear(s: &str) -> Option<(i64, i64)> { let m: i64 = it.next()?.parse().ok()?; match op { "+" => (1, m), - "-" => (1, -m), + "-" => (1, m.checked_neg()?), _ => return None, } } }; - return Some((a * coeff_inner, a * const_inner)); + return Some((a.checked_mul(coeff_inner)?, a.checked_mul(const_inner)?)); } // Sum of `±`-separated terms. @@ -99,10 +99,10 @@ fn parse_linear(s: &str) -> Option<(i64, i64)> { "-" => -1, _ => cpart.parse().ok()?, }; - coeff += sign * c; + coeff = coeff.checked_add(sign.checked_mul(c)?)?; } else { let n: i64 = t.parse().ok()?; - konst += sign * n; + konst = konst.checked_add(sign.checked_mul(n)?)?; } sign = 1; } @@ -127,23 +127,26 @@ fn solve_sequence(prompt: &str) -> Option { fn next_in_sequence(n: &[i64]) -> Option { let last = *n.last()?; - // Arithmetic: constant first difference. - let d = n[1] - n[0]; - if n.windows(2).all(|w| w[1] - w[0] == d) { - return Some(last + d); + // Arithmetic: constant first difference. An overflowing difference means + // the pattern doesn't fit, not that the answer wraps. + if let Some(d) = n[1].checked_sub(n[0]) { + if n.windows(2).all(|w| w[1].checked_sub(w[0]) == Some(d)) { + return last.checked_add(d); + } } // Geometric: constant integer ratio. - if n.iter().all(|&v| v != 0) && n[0] != 0 && n[1] % n[0] == 0 { - let r = n[1] / n[0]; - if r != 0 && n.windows(2).all(|w| w[1] == w[0] * r) { - return Some(last * r); + if n.iter().all(|&v| v != 0) && n[1].checked_rem(n[0]) == Some(0) { + if let Some(r) = n[1].checked_div(n[0]) { + if r != 0 && n.windows(2).all(|w| w[0].checked_mul(r) == Some(w[1])) { + return last.checked_mul(r); + } } } // Fibonacci-like: each term is the sum of the two before it. - if n.len() >= 3 && (2..n.len()).all(|i| n[i] == n[i - 1] + n[i - 2]) { - return Some(n[n.len() - 1] + n[n.len() - 2]); + if n.len() >= 3 && (2..n.len()).all(|i| n[i - 1].checked_add(n[i - 2]) == Some(n[i])) { + return n[n.len() - 1].checked_add(n[n.len() - 2]); } // Squares: all perfect squares with consecutive roots. @@ -151,7 +154,7 @@ fn next_in_sequence(n: &[i64]) -> Option { if let Some(roots) = roots { if roots.windows(2).all(|w| w[1] == w[0] + 1) { let nr = roots[roots.len() - 1] + 1; - return Some(nr * nr); + return nr.checked_mul(nr); } } @@ -160,12 +163,16 @@ fn next_in_sequence(n: &[i64]) -> Option { .iter() .enumerate() .all(|(i, &v)| if i % 2 == 0 { v >= 0 } else { v < 0 }); - let mags: Vec = n.iter().map(|v| v.abs()).collect(); - let md = mags[1] - mags[0]; - if signs_alternate && mags.windows(2).all(|w| w[1] - w[0] == md) { - let next_mag = mags[mags.len() - 1] + md; - let next_sign = if last >= 0 { -1 } else { 1 }; - return Some(next_sign * next_mag); + let mags: Option> = n.iter().map(|v| v.checked_abs()).collect(); + if signs_alternate { + if let Some(mags) = mags { + if let Some(md) = mags[1].checked_sub(mags[0]) { + if mags.windows(2).all(|w| w[1].checked_sub(w[0]) == Some(md)) { + let next_sign: i64 = if last >= 0 { -1 } else { 1 }; + return next_sign.checked_mul(mags[mags.len() - 1].checked_add(md)?); + } + } + } } None @@ -179,7 +186,7 @@ fn isqrt_exact(v: i64) -> Option { let r = (v as f64).sqrt().round() as i64; [r - 1, r, r + 1] .into_iter() - .find(|&cand| cand >= 0 && cand * cand == v) + .find(|&cand| cand >= 0 && cand.checked_mul(cand) == Some(v)) } #[cfg(test)] @@ -267,4 +274,80 @@ mod tests { assert_eq!(solve("anagram", "Unscramble: tca"), None); assert_eq!(solve("riddle", "What has keys but no locks?"), None); } + + // #345: prompts are attacker/service-controlled and every literal can be a + // valid i64 while the evaluation still overflows. Overflow must return + // None (unsolvable), never a debug panic or a wrapped wrong answer. + + #[test] + fn arithmetic_overflow_returns_none() { + assert_eq!( + solve("arithmetic", "What is 9223372036854775807 + 1?"), + None + ); + assert_eq!( + solve("arithmetic", "What is -9223372036854775808 - 1?"), + None + ); + // Boundary-adjacent values still solve. + assert_eq!( + solve("arithmetic", "What is 9223372036854775806 + 1?").as_deref(), + Some("9223372036854775807") + ); + } + + #[test] + fn algebra_overflow_returns_none() { + // num = i64::MIN, denom = -1: the quotient overflows. + assert_eq!( + solve("algebra", "Solve for x: x + 1 = 2x + -9223372036854775807"), + None + ); + // sign * coefficient overflows at i64::MIN. + assert_eq!( + solve("algebra", "Solve for x: x - -9223372036854775808x = 1"), + None + ); + // A large but solvable equation still solves. + assert_eq!( + solve("algebra", "Solve for x: 4611686018427387904x + 0 = 0").as_deref(), + Some("0") + ); + } + + #[test] + fn sequence_overflow_returns_none() { + // First difference overflows (1 - i64::MIN). + assert_eq!( + solve( + "sequence", + "What is the next number in this sequence? -9223372036854775808, 1, 9223372036854775806, ?" + ), + None + ); + // i64::MIN in an alternating-sign candidate: abs() overflows. + assert_eq!( + solve( + "sequence", + "What is the next number in this sequence? 1, -9223372036854775808, 3, ?" + ), + None + ); + // Perfect-square check near i64::MAX: candidate root squared overflows. + assert_eq!( + solve( + "sequence", + "What is the next number in this sequence? 9223372036854775807, 9223372036854775800, 9223372036854775801, ?" + ), + None + ); + // Geometric next term overflows (r = 2, last * 2 > i64::MAX). + assert_eq!( + solve( + "sequence", + "What is the next number in this sequence? 2305843009213693951, 4611686018427387902, 9223372036854775804, ?" + ), + None + ); + } } diff --git a/crates/icaptcha-client/tests/icaptcha_stdin_e2e.rs b/crates/icaptcha-client/tests/icaptcha_stdin_e2e.rs new file mode 100644 index 00000000..fb2338cc --- /dev/null +++ b/crates/icaptcha-client/tests/icaptcha_stdin_e2e.rs @@ -0,0 +1,134 @@ +//! #345 end-to-end: `obtain_proof` must not block or crash when stdin is an +//! open, silent non-TTY and the challenge can't be solved deterministically. +//! +//! The unit seam can't fake a process stdin, so the parent spawns this test +//! binary with a held-open pipe as stdin and the child runs the real +//! `obtain_proof` against a mocked iCaptcha service over HTTP. Before the fix +//! the child blocked in `read_line` forever (unsolvable type) or panicked / +//! submitted a wrapped answer (overflowing arithmetic). After the fix it +//! exits promptly with the "cannot solve" error. + +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use icaptcha_client::{obtain_proof, IcaptchaCfg}; + +const CHILD_ENV: &str = "ICAPTCHA_STDIN_E2E_CHILD"; +const URL_ENV: &str = "ICAPTCHA_STDIN_E2E_URL"; +const CHILD_TIMEOUT: Duration = Duration::from_secs(15); + +/// Child entry point: fetch the challenge from the mock service and run the +/// full solve loop. Both scenarios must reach the same clean error. +fn run_child() { + let url = std::env::var(URL_ENV).expect("child needs the mock url"); + let cfg = IcaptchaCfg { + url, + did: "did:key:zTEST".to_string(), + level: 1, + api_key: None, + }; + let err = obtain_proof(&cfg, None).expect_err("the challenge is unsolvable"); + assert!( + err.to_string().contains("cannot solve iCaptcha challenge"), + "expected the unsolvable-challenge error, got: {err}" + ); + println!("child-finished-cleanly"); +} + +/// Spawn the child with a held-open, empty pipe as stdin, wait with a +/// deadline, and assert it printed the clean-exit marker. +fn run_scenario(prompt_type: &str, prompt: &str, expect_answer_calls: usize) { + let mut server = mockito::Server::new(); + let challenge = server + .mock("POST", "/v1/challenge") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!( + r#"{{"challengeId":"c1","type":"{prompt_type}","difficulty":1,"prompt":"{prompt}","token":"tok-1"}}"# + )) + .expect(1) + .create(); + // No answer may be submitted: an unsolvable prompt must produce no + // request to /v1/answer (a wrapped wrong answer would still hit it). + let answer = server + .mock("POST", "/v1/answer") + .expect(expect_answer_calls) + .create(); + + let exe = std::env::current_exe().expect("current test binary"); + let mut cmd = Command::new(exe); + cmd.args([ + "--exact", + "obtain_proof_does_not_block_on_silent_non_tty_stdin", + "--nocapture", + ]) + .env(CHILD_ENV, "1") + .env(URL_ENV, server.url()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + // The solver client honors proxy env; force direct connections so the + // loopback mock is always reachable regardless of the runner's env. + for var in [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "REQUEST_METHOD", + ] { + cmd.env_remove(var); + } + cmd.env("NO_PROXY", "*"); + let mut child = cmd.spawn().expect("spawn child"); + + // Hold the write end open with no data: exactly the shape that hung. + let _held_stdin = child.stdin.take(); + + let deadline = Instant::now() + CHILD_TIMEOUT; + loop { + match child.try_wait().expect("poll child") { + Some(_) => break, + None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), + None => { + let _ = child.kill(); + panic!("obtain_proof blocked on an open, silent non-TTY stdin"); + } + } + } + let out = child.wait_with_output().expect("collect child output"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "child failed: status {:?}\nstdout: {stdout}\nstderr: {stderr}", + out.status.code() + ); + assert!( + stdout.contains("child-finished-cleanly"), + "child body did not run to completion: {stdout}" + ); + assert!( + !stderr.contains("panicked"), + "child panicked instead of returning a clean error: {stderr}" + ); + challenge.assert(); + answer.assert(); +} + +#[test] +fn obtain_proof_does_not_block_on_silent_non_tty_stdin() { + if std::env::var_os(CHILD_ENV).is_some() { + run_child(); + return; + } + + // An unsolvable challenge type falls through to the interactive prompt, + // which must decline the non-TTY stdin instead of blocking on read_line. + run_scenario("anagram", "listen", 0); + + // An arithmetic prompt whose evaluation overflows i64 must also end at + // the unsolvable error: no panic, and no wrapped answer submitted. + run_scenario("arithmetic", "What is 9223372036854775807 + 1?", 0); +}