Skip to content

Commit 8eb62e6

Browse files
committed
fix(icaptcha): return unsolved instead of blocking or overflowing
interactive_prompt waited on stdin().read_line even when stdin was an open, silent non-TTY (an orchestrator holding the pipe open), which blocked obtain_proof forever. Check IsTerminal first and return None so the caller surfaces the "couldn't auto-solve" error. The deterministic solvers evaluated prompts with unchecked i64 arithmetic; service-controlled literals could push add/sub/mul/div/abs past the range, panicking in debug builds or wrapping to a wrong answer in release. Use checked arithmetic throughout and return None on overflow. Fixes #345
1 parent bfc44f9 commit 8eb62e6

3 files changed

Lines changed: 316 additions & 29 deletions

File tree

‎crates/icaptcha-client/src/lib.rs‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,13 @@ fn submit_answer(
355355
/// Returns `None` when stdin isn't a usable interactive source (e.g. an agent),
356356
/// so the caller surfaces a clear "couldn't auto-solve" error instead.
357357
fn interactive_prompt(challenge: &Challenge) -> Option<String> {
358-
use std::io::{stderr, stdin, Write};
358+
use std::io::{stderr, stdin, IsTerminal, Write};
359+
// An open but silent stdin (a pipe an orchestrator holds open, an agent
360+
// harness) has no one to answer: `read_line` would block forever. Only a
361+
// terminal can be interactive.
362+
if !stdin().is_terminal() {
363+
return None;
364+
}
359365
let mut err = stderr();
360366
let _ = writeln!(
361367
err,
@@ -761,4 +767,68 @@ mod tests {
761767
);
762768
assert_eq!(non_empty.api_key.as_deref(), Some("secret-bearer"));
763769
}
770+
771+
/// #345: an open-but-silent non-TTY stdin must not hang the prompt. The
772+
/// child runs `interactive_prompt` with a held-open, empty pipe as stdin:
773+
/// without the terminal check `read_line` blocks forever; with it the
774+
/// child returns `None` and exits.
775+
#[test]
776+
fn interactive_prompt_skips_open_silent_pipe() {
777+
const CHILD_ENV: &str = "ICAPTCHA_PROMPT_PIPE_CHILD";
778+
if std::env::var_os(CHILD_ENV).is_some() {
779+
let ch = Challenge {
780+
challenge_id: "c".into(),
781+
kind: "arithmetic".into(),
782+
difficulty: 1,
783+
prompt: "What is 1 + 1?".into(),
784+
token: "t".into(),
785+
pow: None,
786+
};
787+
assert!(interactive_prompt(&ch).is_none());
788+
println!("prompt-skipped");
789+
return;
790+
}
791+
let exe = std::env::current_exe().expect("current test binary");
792+
let mut child = std::process::Command::new(exe)
793+
.args([
794+
"--exact",
795+
"tests::interactive_prompt_skips_open_silent_pipe",
796+
"--nocapture",
797+
])
798+
.env(CHILD_ENV, "1")
799+
.stdin(std::process::Stdio::piped())
800+
.stdout(std::process::Stdio::piped())
801+
.stderr(std::process::Stdio::null())
802+
.spawn()
803+
.expect("spawn child test");
804+
// The parent keeps the write end open with no data, which is exactly
805+
// the shape that hung: an open, silent, non-terminal stdin.
806+
let _held_stdin = child.stdin.take();
807+
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
808+
loop {
809+
match child.try_wait().expect("poll child") {
810+
Some(status) => {
811+
assert!(status.success(), "child exited with {status}");
812+
let mut out = String::new();
813+
std::io::Read::read_to_string(
814+
&mut child.stdout.take().expect("child stdout"),
815+
&mut out,
816+
)
817+
.expect("read child stdout");
818+
assert!(
819+
out.contains("prompt-skipped"),
820+
"child test body did not run: {out}"
821+
);
822+
return;
823+
}
824+
None if std::time::Instant::now() < deadline => {
825+
std::thread::sleep(std::time::Duration::from_millis(50));
826+
}
827+
None => {
828+
let _ = child.kill();
829+
panic!("interactive_prompt blocked on an open, silent non-TTY stdin");
830+
}
831+
}
832+
}
833+
}
764834
}

‎crates/icaptcha-client/src/solvers.rs‎

Lines changed: 111 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ fn solve_arithmetic(prompt: &str) -> Option<i64> {
3030
while let Some(op) = tokens.next() {
3131
let n: i64 = tokens.next()?.parse().ok()?;
3232
match op {
33-
"+" => acc += n,
34-
"-" => acc -= n,
33+
"+" => acc = acc.checked_add(n)?,
34+
"-" => acc = acc.checked_sub(n)?,
3535
_ => return None,
3636
}
3737
}
@@ -45,15 +45,15 @@ fn solve_algebra(prompt: &str) -> Option<i64> {
4545
let (lhs, rhs) = eq.split_once('=')?;
4646
let (al, cl) = parse_linear(lhs.trim())?;
4747
let (ar, cr) = parse_linear(rhs.trim())?;
48-
let denom = al - ar;
48+
let denom = al.checked_sub(ar)?;
4949
if denom == 0 {
5050
return None;
5151
}
52-
let num = cr - cl;
53-
if num % denom != 0 {
52+
let num = cr.checked_sub(cl)?;
53+
if num.checked_rem(denom)? != 0 {
5454
return None;
5555
}
56-
Some(num / denom)
56+
num.checked_div(denom)
5757
}
5858

5959
/// Parse a linear expression in `x` into `(coeff_of_x, constant)`.
@@ -76,12 +76,12 @@ fn parse_linear(s: &str) -> Option<(i64, i64)> {
7676
let m: i64 = it.next()?.parse().ok()?;
7777
match op {
7878
"+" => (1, m),
79-
"-" => (1, -m),
79+
"-" => (1, m.checked_neg()?),
8080
_ => return None,
8181
}
8282
}
8383
};
84-
return Some((a * coeff_inner, a * const_inner));
84+
return Some((a.checked_mul(coeff_inner)?, a.checked_mul(const_inner)?));
8585
}
8686

8787
// Sum of `±`-separated terms.
@@ -99,10 +99,10 @@ fn parse_linear(s: &str) -> Option<(i64, i64)> {
9999
"-" => -1,
100100
_ => cpart.parse().ok()?,
101101
};
102-
coeff += sign * c;
102+
coeff = coeff.checked_add(sign.checked_mul(c)?)?;
103103
} else {
104104
let n: i64 = t.parse().ok()?;
105-
konst += sign * n;
105+
konst = konst.checked_add(sign.checked_mul(n)?)?;
106106
}
107107
sign = 1;
108108
}
@@ -127,31 +127,34 @@ fn solve_sequence(prompt: &str) -> Option<i64> {
127127
fn next_in_sequence(n: &[i64]) -> Option<i64> {
128128
let last = *n.last()?;
129129

130-
// Arithmetic: constant first difference.
131-
let d = n[1] - n[0];
132-
if n.windows(2).all(|w| w[1] - w[0] == d) {
133-
return Some(last + d);
130+
// Arithmetic: constant first difference. An overflowing difference means
131+
// the pattern doesn't fit, not that the answer wraps.
132+
if let Some(d) = n[1].checked_sub(n[0]) {
133+
if n.windows(2).all(|w| w[1].checked_sub(w[0]) == Some(d)) {
134+
return last.checked_add(d);
135+
}
134136
}
135137

136138
// Geometric: constant integer ratio.
137-
if n.iter().all(|&v| v != 0) && n[0] != 0 && n[1] % n[0] == 0 {
138-
let r = n[1] / n[0];
139-
if r != 0 && n.windows(2).all(|w| w[1] == w[0] * r) {
140-
return Some(last * r);
139+
if n.iter().all(|&v| v != 0) && n[1].checked_rem(n[0]) == Some(0) {
140+
if let Some(r) = n[1].checked_div(n[0]) {
141+
if r != 0 && n.windows(2).all(|w| w[0].checked_mul(r) == Some(w[1])) {
142+
return last.checked_mul(r);
143+
}
141144
}
142145
}
143146

144147
// Fibonacci-like: each term is the sum of the two before it.
145-
if n.len() >= 3 && (2..n.len()).all(|i| n[i] == n[i - 1] + n[i - 2]) {
146-
return Some(n[n.len() - 1] + n[n.len() - 2]);
148+
if n.len() >= 3 && (2..n.len()).all(|i| n[i - 1].checked_add(n[i - 2]) == Some(n[i])) {
149+
return n[n.len() - 1].checked_add(n[n.len() - 2]);
147150
}
148151

149152
// Squares: all perfect squares with consecutive roots.
150153
let roots: Option<Vec<i64>> = n.iter().map(|&v| isqrt_exact(v)).collect();
151154
if let Some(roots) = roots {
152155
if roots.windows(2).all(|w| w[1] == w[0] + 1) {
153156
let nr = roots[roots.len() - 1] + 1;
154-
return Some(nr * nr);
157+
return nr.checked_mul(nr);
155158
}
156159
}
157160

@@ -160,12 +163,16 @@ fn next_in_sequence(n: &[i64]) -> Option<i64> {
160163
.iter()
161164
.enumerate()
162165
.all(|(i, &v)| if i % 2 == 0 { v >= 0 } else { v < 0 });
163-
let mags: Vec<i64> = n.iter().map(|v| v.abs()).collect();
164-
let md = mags[1] - mags[0];
165-
if signs_alternate && mags.windows(2).all(|w| w[1] - w[0] == md) {
166-
let next_mag = mags[mags.len() - 1] + md;
167-
let next_sign = if last >= 0 { -1 } else { 1 };
168-
return Some(next_sign * next_mag);
166+
let mags: Option<Vec<i64>> = n.iter().map(|v| v.checked_abs()).collect();
167+
if signs_alternate {
168+
if let Some(mags) = mags {
169+
if let Some(md) = mags[1].checked_sub(mags[0]) {
170+
if mags.windows(2).all(|w| w[1].checked_sub(w[0]) == Some(md)) {
171+
let next_sign: i64 = if last >= 0 { -1 } else { 1 };
172+
return next_sign.checked_mul(mags[mags.len() - 1].checked_add(md)?);
173+
}
174+
}
175+
}
169176
}
170177

171178
None
@@ -179,7 +186,7 @@ fn isqrt_exact(v: i64) -> Option<i64> {
179186
let r = (v as f64).sqrt().round() as i64;
180187
[r - 1, r, r + 1]
181188
.into_iter()
182-
.find(|&cand| cand >= 0 && cand * cand == v)
189+
.find(|&cand| cand >= 0 && cand.checked_mul(cand) == Some(v))
183190
}
184191

185192
#[cfg(test)]
@@ -267,4 +274,80 @@ mod tests {
267274
assert_eq!(solve("anagram", "Unscramble: tca"), None);
268275
assert_eq!(solve("riddle", "What has keys but no locks?"), None);
269276
}
277+
278+
// #345: prompts are attacker/service-controlled and every literal can be a
279+
// valid i64 while the evaluation still overflows. Overflow must return
280+
// None (unsolvable), never a debug panic or a wrapped wrong answer.
281+
282+
#[test]
283+
fn arithmetic_overflow_returns_none() {
284+
assert_eq!(
285+
solve("arithmetic", "What is 9223372036854775807 + 1?"),
286+
None
287+
);
288+
assert_eq!(
289+
solve("arithmetic", "What is -9223372036854775808 - 1?"),
290+
None
291+
);
292+
// Boundary-adjacent values still solve.
293+
assert_eq!(
294+
solve("arithmetic", "What is 9223372036854775806 + 1?").as_deref(),
295+
Some("9223372036854775807")
296+
);
297+
}
298+
299+
#[test]
300+
fn algebra_overflow_returns_none() {
301+
// num = i64::MIN, denom = -1: the quotient overflows.
302+
assert_eq!(
303+
solve("algebra", "Solve for x: x + 1 = 2x + -9223372036854775807"),
304+
None
305+
);
306+
// sign * coefficient overflows at i64::MIN.
307+
assert_eq!(
308+
solve("algebra", "Solve for x: x - -9223372036854775808x = 1"),
309+
None
310+
);
311+
// A large but solvable equation still solves.
312+
assert_eq!(
313+
solve("algebra", "Solve for x: 4611686018427387904x + 0 = 0").as_deref(),
314+
Some("0")
315+
);
316+
}
317+
318+
#[test]
319+
fn sequence_overflow_returns_none() {
320+
// First difference overflows (1 - i64::MIN).
321+
assert_eq!(
322+
solve(
323+
"sequence",
324+
"What is the next number in this sequence? -9223372036854775808, 1, 9223372036854775806, ?"
325+
),
326+
None
327+
);
328+
// i64::MIN in an alternating-sign candidate: abs() overflows.
329+
assert_eq!(
330+
solve(
331+
"sequence",
332+
"What is the next number in this sequence? 1, -9223372036854775808, 3, ?"
333+
),
334+
None
335+
);
336+
// Perfect-square check near i64::MAX: candidate root squared overflows.
337+
assert_eq!(
338+
solve(
339+
"sequence",
340+
"What is the next number in this sequence? 9223372036854775807, 9223372036854775800, 9223372036854775801, ?"
341+
),
342+
None
343+
);
344+
// Geometric next term overflows (r = 2, last * 2 > i64::MAX).
345+
assert_eq!(
346+
solve(
347+
"sequence",
348+
"What is the next number in this sequence? 2305843009213693951, 4611686018427387902, 9223372036854775804, ?"
349+
),
350+
None
351+
);
352+
}
270353
}

0 commit comments

Comments
 (0)