Severity: Low (accept-where-jq-refuses, on malformed JSON only — no wrong value for well-formed input)
Summary
A raw, unescaped control character inside a JSON string is rejected by jq on every path and accepted by succinctly on --slurpfile and on the primary document input. RFC 8259 requires U+0000–U+001F to be escaped; json::validate::Validator enforces that (ValidationErrorKind::ControlCharacter), but neither of these paths runs it — they delimit values with find_json_values/serde_json::Deserializer::byte_offset() and then materialize each span with this crate's own decoder, which tolerates the raw byte.
Found while resolving #2052 (which moved --argjson/--jsonargs/--seq onto the crate's own validator; those three reject it correctly).
Repro
printf '["tab\tin"]' — a literal TAB inside the string — captured live against /usr/bin/jq 1.7.1:
$ jq -c . f.json
jq: parse error: Invalid string: control characters from U+0000 through U+001F must be escaped at line 1, column 9
$ succinctly jq -c . f.json
["tab in"] # accepted, raw TAB round-tripped
$ jq -nc --slurpfile x f.json '$x'
jq: Bad JSON in --slurpfile x f.json: Invalid string: control characters ...
$ succinctly jq -nc --slurpfile x f.json '$x'
[["tab\tin"]] # accepted, and re-escaped on output
$ succinctly jq -nc --argjson x '["tab<TAB>in"]' '$x' # correctly rejected (#2052)
The --slurpfile output is the more surprising half: the raw byte is silently normalized to \t on the way out, so a malformed document becomes a well-formed one with no diagnostic.
Root cause
src/bin/succinctly/jq_runner.rs — parse_json_stream (backing --slurpfile and, per its own doc comment, the primary input path) validates the stream shape with serde_json::Deserializer only to find each value's byte span, then hands the span to json_bytes_to_owned_value_checked. serde_json's span walk is not consulted for string content at that granularity, and the decoder has no control-character rule. find_json_values, the non-serde fallback, is explicitly "boundary-only" and has none either.
Why this is separate from #2052
#2052 replaced the --argjson family's validation gate; parse_json_stream's gate is a different mechanism (stream splitting, not value validation) and its fix has a much wider blast radius — the primary document-input path is the crate's hottest route, so adding a full-content validation pass there is a performance question as well as a correctness one, and the semi-index already walks those bytes once.
Suggested fix direction
Prefer making the decoder (or the semi-index scan already walking the bytes) reject an unescaped U+0000–U+001F inside a string span, rather than adding a second full pass — json::validate's own rule is the reference for what to reject. Measure the primary-input path before and after (succinctly bench run jq_bench), since that is where the cost would land. Check whether succinctly json validate (strict mode) already covers the same input, and add a docs/compliance/jq/limitations.md row if any acceptance is deliberately kept.
Severity: Low (accept-where-jq-refuses, on malformed JSON only — no wrong value for well-formed input)
Summary
A raw, unescaped control character inside a JSON string is rejected by jq on every path and accepted by
succinctlyon--slurpfileand on the primary document input. RFC 8259 requiresU+0000–U+001Fto be escaped;json::validate::Validatorenforces that (ValidationErrorKind::ControlCharacter), but neither of these paths runs it — they delimit values withfind_json_values/serde_json::Deserializer::byte_offset()and then materialize each span with this crate's own decoder, which tolerates the raw byte.Found while resolving #2052 (which moved
--argjson/--jsonargs/--seqonto the crate's own validator; those three reject it correctly).Repro
printf '["tab\tin"]'— a literal TAB inside the string — captured live against/usr/bin/jq1.7.1:The
--slurpfileoutput is the more surprising half: the raw byte is silently normalized to\ton the way out, so a malformed document becomes a well-formed one with no diagnostic.Root cause
src/bin/succinctly/jq_runner.rs—parse_json_stream(backing--slurpfileand, per its own doc comment, the primary input path) validates the stream shape withserde_json::Deserializeronly to find each value's byte span, then hands the span tojson_bytes_to_owned_value_checked.serde_json's span walk is not consulted for string content at that granularity, and the decoder has no control-character rule.find_json_values, the non-serde fallback, is explicitly "boundary-only" and has none either.Why this is separate from #2052
#2052 replaced the
--argjsonfamily's validation gate;parse_json_stream's gate is a different mechanism (stream splitting, not value validation) and its fix has a much wider blast radius — the primary document-input path is the crate's hottest route, so adding a full-content validation pass there is a performance question as well as a correctness one, and the semi-index already walks those bytes once.Suggested fix direction
Prefer making the decoder (or the semi-index scan already walking the bytes) reject an unescaped
U+0000–U+001Finside a string span, rather than adding a second full pass —json::validate's own rule is the reference for what to reject. Measure the primary-input path before and after (succinctly bench run jq_bench), since that is where the cost would land. Check whethersuccinctly json validate(strict mode) already covers the same input, and add adocs/compliance/jq/limitations.mdrow if any acceptance is deliberately kept.