From 68a1bc653276d2e997d25b2c69c04b0aac622603 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sun, 11 Oct 2026 23:12:49 -0500 Subject: [PATCH] fix(jq): bound object-key/index error previews with capped rendering --- .../bashkit/src/builtins/jq/jaq_json/jqmsg.rs | 75 ++++++++++++++++--- knowledge/runtimes/jaq-json-vendor.md | 2 +- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/crates/bashkit/src/builtins/jq/jaq_json/jqmsg.rs b/crates/bashkit/src/builtins/jq/jaq_json/jqmsg.rs index eefac0ec3..b1e853489 100644 --- a/crates/bashkit/src/builtins/jq/jaq_json/jqmsg.rs +++ b/crates/bashkit/src/builtins/jq/jaq_json/jqmsg.rs @@ -17,17 +17,47 @@ fn type_name(v: &Val) -> &'static str { } } -/// jq dumps an operand into a 15-byte buffer: longer dumps keep 11 bytes -/// and end in `...`. +/// Preview of a value for error messages, mirroring jq's 15-byte dump +/// buffer: longer dumps keep 11 bytes and end in `...`. +/// +/// The cut is applied *while* rendering through a capped sink, so a huge +/// value never materializes its full dump as an intermediate string. +/// (Truncating after `v.to_string()` would still transiently allocate the +/// whole rendering outside the memory meter and the 64 KiB diagnostic cap.) fn dump_trunc(v: &Val) -> String { - let mut s = String::new(); - // Render at most a little past the cut, whatever the value's size. - for c in v.to_string().chars() { - s.push(c); - if s.len() > 14 { - break; + /// `fmt::Write` sink that keeps at most `cap` bytes; excess is dropped. + struct Capped { + buf: String, + cap: usize, + over: bool, + } + impl std::fmt::Write for Capped { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + if self.buf.len() >= self.cap { + self.over = true; + return Ok(()); + } + let room = self.cap - self.buf.len(); + let mut end = s.len().min(room); + while !s.is_char_boundary(end) { + end -= 1; + } + self.buf.push_str(&s[..end]); + if end < s.len() { + self.over = true; + } + Ok(()) } } + use std::fmt::Write as _; + // One byte past the 14-byte cut: enough to tell "fits" from "truncate", + // so small-value output is byte-identical to the old truncation. + let mut out = Capped { buf: String::new(), cap: 15, over: false }; + let _ = write!(out, "{v}"); + let mut s = out.buf; + if !out.over && s.len() <= 14 { + return s; + } if s.len() > 14 { let mut end = 11; while !s.is_char_boundary(end) { @@ -63,7 +93,9 @@ pub(crate) fn math(l: Val, op: ops::Math, r: Val, zero: bool) -> Error { /// `l[r]` is not defined. pub(crate) fn index(l: Val, r: Val) -> Error { let right = match &r { - Val::BStr(_) | Val::TStr(_) => std::format!("string {}", r), + // Bound the key preview: interpolating the raw string would embed + // arbitrarily large input in the error text. + Val::BStr(_) | Val::TStr(_) => std::format!("string {}", dump_trunc(&r)), other => type_name(other).to_string(), }; Error::str(std::format!("Cannot index {} with {right}", type_name(&l))) @@ -95,3 +127,28 @@ pub(crate) fn trunc_int(n: &Num) -> Num { } Num::Int(f.trunc() as i64 as isize) } + +#[cfg(test)] +mod tests { + use super::super::metered; + use super::*; + + fn huge_array_key() -> Val { + let body: Vec = (0..200_000).map(|i| Val::Num(Num::Int(i as isize))).collect(); + Val::Arr(metered(body)) + } + + #[test] + fn object_key_error_stays_small_for_huge_key() { + let err = object_key(&huge_array_key()).to_string(); + assert!(err.len() < 4096, "object-key error materialized {} bytes", err.len()); + } + + #[test] + fn index_error_stays_small_for_huge_string() { + let l = Val::Arr(metered(Vec::new())); + let r = Val::utf8_str("y".repeat(200_000)); + let err = index(l, r).to_string(); + assert!(err.len() < 4096, "index error materialized {} bytes", err.len()); + } +} diff --git a/knowledge/runtimes/jaq-json-vendor.md b/knowledge/runtimes/jaq-json-vendor.md index 86391d97a..3ddb89d3d 100644 --- a/knowledge/runtimes/jaq-json-vendor.md +++ b/knowledge/runtimes/jaq-json-vendor.md @@ -116,7 +116,7 @@ same rewrite applies to every release: timeout (`jq: execution timed out`). - Output conversion is capped by `max_stdout_bytes`, so a value built from shared parts (`reduce range(40) as $i (1; [., .])`) cannot expand when - rendered. Runtime error messages render at most 64 KiB of a value. + rendered. Runtime error messages render at most 64 KiB of a value. Short error previews (`jqmsg::dump_trunc`, object-key and cannot-index messages) render through a capped sink, so even the intermediate preview never materializes a huge value's full dump. Builds with `panic = "abort"` cannot unwind. There the fallible checks still apply, over-limit infallible constructions are cut short with the meter