Skip to content

jq: a raw control character in a string is accepted on --slurpfile and primary input; jq rejects it #2878

Description

@newhoggy

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+0000U+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.rsparse_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+0000U+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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    OpusSuitable for an Opus-class model to implementTriagedIssue has been read, planned, and its model-class label verifiedbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions