From aaf552982f9f818afc2aec34b78d7636eeb5638d Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 9 Oct 2026 16:37:08 +0000 Subject: [PATCH] fix(jq): ignore unread invalid stdin under -n --- crates/bashkit/src/builtins/jq/mod.rs | 29 ++++++++++++++++++------- crates/bashkit/src/builtins/jq/tests.rs | 29 +++++++++++++++++++++++++ knowledge/log.md | 1 + 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/crates/bashkit/src/builtins/jq/mod.rs b/crates/bashkit/src/builtins/jq/mod.rs index 9c71fb89c..501cabf10 100644 --- a/crates/bashkit/src/builtins/jq/mod.rs +++ b/crates/bashkit/src/builtins/jq/mod.rs @@ -538,6 +538,10 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { // THREAT[TM-DOS-110]: the meter aborts a run by unwinding where jaq // gives it no error channel (see jaq_json::meter::Abort). let input_error = std::cell::RefCell::new(None); + // Set once the filter pulls past the last parsed value. With `-n`, jq + // only parses stdin as `input`/`inputs` read it, so a parse error past + // the values the filter actually read is never reported. + let stream_exhausted = std::cell::Cell::new(false); let run_filter = std::panic::AssertUnwindSafe(|| -> Result> { let env_val = jq_to_val(&env_obj); let args_val = jq_to_val(&args_obj); @@ -556,13 +560,20 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { }) }); let mut value_iter: Box> + '_> = - Box::new(values.map(|value| { - value.map_err(|error| { - let message = error.to_string(); - *input_error.borrow_mut() = Some(error); - message - }) - })); + Box::new( + values + .map(|value| { + value.map_err(|error| { + let message = error.to_string(); + *input_error.borrow_mut() = Some(error); + message + }) + }) + .chain(std::iter::from_fn(|| { + stream_exhausted.set(true); + None + })), + ); if parsed.slurp && !parsed.raw_input && parse_error.is_none() { let mut values = jaq_json::meter::Metered::>::default(); for value in value_iter { @@ -752,7 +763,9 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result { stderr_out.push_str(&messages::take()); let mut code = status.unwrap_or(if parsed.exit_status { 4 } else { 0 }); - if let Some(e) = parse_error.filter(|_| halted.is_none()) { + if let Some(e) = + parse_error.filter(|_| halted.is_none() && (!null_input || stream_exhausted.get())) + { stderr_out.push_str(&e); stderr_out.push('\n'); code = 5; diff --git a/crates/bashkit/src/builtins/jq/tests.rs b/crates/bashkit/src/builtins/jq/tests.rs index 4fc3e38cd..fc1fab2bd 100644 --- a/crates/bashkit/src/builtins/jq/tests.rs +++ b/crates/bashkit/src/builtins/jq/tests.rs @@ -1832,6 +1832,35 @@ async fn null_input_leaves_stream_to_input_and_inputs() { ); } +/// jq 1.7: with `-n`, stdin is only parsed when `input`/`inputs` reach it, +/// so invalid JSON on stdin is ignored by a filter that never reads it. +#[tokio::test] +async fn null_input_ignores_unread_invalid_stdin() { + let result = run_jq_result_with_args(&["-n", "1"], "not json") + .await + .unwrap(); + assert_eq!(result.stdout, "1\n"); + assert_eq!(result.stderr, ""); + assert_eq!(result.exit_code, 0); + + // Values before the bad text are still readable. + let result = run_jq_result_with_args(&["-n", "input"], "1 x") + .await + .unwrap(); + assert_eq!(result.stdout, "1\n"); + assert_eq!(result.stderr, ""); + assert_eq!(result.exit_code, 0); +} + +#[tokio::test] +async fn null_input_reports_invalid_stdin_once_reached() { + let result = run_jq_result_with_args(&["-nc", "[inputs]"], "1 x") + .await + .unwrap(); + assert!(result.stderr.starts_with("jq: "), "{}", result.stderr); + assert_eq!(result.exit_code, 5); +} + #[tokio::test] async fn slurp_of_empty_input_is_empty_array() { assert_eq!(run_jq_with_args(&["-s", "."], "").await.unwrap(), "[]\n"); diff --git a/knowledge/log.md b/knowledge/log.md index 545130755..e3ba5efea 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-09 +* **Fix**: `jq -n` reported invalid JSON on stdin even when the filter never read it (`printf x | jq -n 1` printed `1` plus an `invalid JSON` error and exited 5). With `-n`, a stdin parse error is now reported only once `input`/`inputs` read past the last valid value, like jq 1.7 (`printf '1 x' | jq -n input` prints `1`, status 0). * **Test**: `bashkit_hard` eval (`just eval-hard`): 10 tasks written after `bashkit_bash` saturated (55/58 for top models), 25-turn budget, 9 categories: interacting bugs revealed one test at a time, quoting/glob/leading-dash file names, masked `set -euo pipefail` failures, trap/exit-code contracts, Makefile prerequisites checked with `make -q`, a rename that must spare lookalikes and frozen messages, and golden reports (CSV/TSV reconciliation, nearest-rank p95 over a generated log, jq rollups, deterministic topological order). New check `file_equals` (whole-file golden output). Reference solutions replay in `cargo test -p bashkit-eval` alongside the repo set (`reference.rs` now iterates both datasets) and were also checked under real bash. See operations/eval "Hard Eval". * **Fix**: gaps found by the `bashkit_hard` eval references. A child `bash script` / `bash -c` started in an `if`/`||`/`&&`/`!` context ran with `set -e` disabled (the caller's condition depth leaked into the child), so `if bash test.sh` never saw `test.sh` fail; the child now starts outside any condition, like a new process. `dirname -- PATH` treats `--` as end of options (it printed `.` for it); `ls -A` is accepted (same listing as `-a`, which never shows `.`/`..`); `stat -c %Y` prints the mtime in epoch seconds (it printed `%Y`). * **Fix**: last BashBox corpus gaps. `x<(true)`, `<(true)x`, `2<(true)` and `x=<(true)` join the substitution to the word it touches (it was a separate word, and `x=<(true)` ran `/dev/fd/63`). `$'\c?'` is DEL (0x7f). A simple command's `3