Compare commits

...
Author SHA1 Message Date
beardthelion 2422c6cb42 chore: refresh PR head 2026-11-05 09:41:37 -06:00
beardthelion 8eb62e6fde 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
2026-09-11 14:40:09 -05:00
Kevin Codex bfc44f926d Merge pull request #381 from Gitlawb/fix/gl-whoami-capped-body-read
fix(gl): read whoami error bodies through CappedBody.text
2026-08-26 13:26:50 +08:00
beardthelion 65f5989a1d test(node): pin owner-push off in receive-pack lock tests
These lock-path proofs use non-owner proof DIDs. With enforce_owner_push
defaulting on, they fail before receive-pack runs.
2026-08-25 10:27:48 -05:00
beardthelion 4725146db2 fix(gl): read whoami error bodies through CappedBody.text
#173 changed read_body_capped to return CappedBody; peer.rs and sync.rs
were updated but whoami.rs was not, leaving main red after the #223 merge.
2026-08-25 10:04:20 -05:00
5 changed files with 326 additions and 30 deletions

No files matched your search

+9
View File
@@ -6260,6 +6260,9 @@ mod tests {
// Long enough that the git-service timeout is never what ends this push; the
// disconnect is.
cfg.git_service_timeout_secs = 600;
// Lock-path test, not owner-push authorization: pin off so the default-on gate
// does not reject the proof DID before receive-pack runs.
cfg.enforce_owner_push = false;
state.config = std::sync::Arc::new(cfg);
state
.db
@@ -6430,6 +6433,9 @@ mod tests {
None,
crate::git::repo_store::build_lock_pool(&pool, 4, std::time::Duration::from_secs(5)),
);
let mut cfg = (*state.config).clone();
cfg.enforce_owner_push = false;
state.config = std::sync::Arc::new(cfg);
state
.db
.upsert_mirror_repo(owner, name, "/tmp/z6succ-sc1", None, false)
@@ -6516,6 +6522,9 @@ mod tests {
let owner = "z6lockpool";
let name = "lp1";
let mut state = crate::test_support::test_state(pool.clone()).await;
let mut cfg = (*state.config).clone();
cfg.enforce_owner_push = false;
state.config = std::sync::Arc::new(cfg);
// One lock-pool connection, short checkout timeout so the exhaustion surfaces
// promptly rather than at the handler's own acquire deadline.
state.repo_store = crate::git::repo_store::RepoStore::new(
+1 -1
View File
@@ -60,7 +60,7 @@ pub(crate) async fn run_to_writer(args: WhoamiArgs, w: &mut impl std::io::Write)
}
Ok(resp) => {
let status = resp.status();
let raw = read_body_capped(resp, 8 * 1024).await;
let raw = read_body_capped(resp, 8 * 1024).await.text;
let msg = serde_json::from_str::<Value>(&raw)
.ok()
.and_then(|v| {
+71 -1
View File
@@ -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<String> {
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");
}
}
}
}
}
+111 -28
View File
@@ -30,8 +30,8 @@ fn solve_arithmetic(prompt: &str) -> Option<i64> {
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<i64> {
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<i64> {
fn next_in_sequence(n: &[i64]) -> Option<i64> {
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<i64> {
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<i64> {
.iter()
.enumerate()
.all(|(i, &v)| if i % 2 == 0 { v >= 0 } else { v < 0 });
let mags: Vec<i64> = 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<Vec<i64>> = 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<i64> {
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
);
}
}
@@ -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);
}