Severity: Medium (del/|= through a fold's UPDATE silently discarded — exit 0, document echoed — where jq writes; the read forms emit nothing at exit 0)
Summary
#3133 (PR #3144) carries the path register into a pipe nested under try/if/, and into a catch handler for a pipe stage: resolve_seq_stage puts the carried register on the stage's Frame, and a nested resolve_seq_sink seeds itself from it. A fold's UPDATE/EXTRACT body takes a different route — FoldRegister::resolve → resolve_seq with the fold's own register passed explicitly, under a frame that carries none — so the same shape inside a fold still starts register-less one level down: try ($v | .b) in UPDATE cannot re-establish $v, raises the resolver's own refusal, and try swallows it.
Found by #3133's widened fuzz alphabet (fabricate-baseline/mismatch-baseline rows, i.e. pre-existing on main).
Repro
/usr/bin/jq 1.7.1 vs succinctly (main 833aad9fd and the #3144 branch alike), input {"a":{"b":1}}:
| filter |
jq 1.7.1 |
succinctly |
path(foreach .a as $v (.; try ($v | .b); .)) |
["a","b"] |
(nothing), exit 0 |
del(foreach .a as $v (.; try ($v | .b); .)) |
{"a":{}} |
{"a":{"b":1}}, exit 0 (write discarded) |
path(foreach .a as $v (.; if true then ($v | .b) else . end; .)) |
["a","b"] |
refuses (near attempt to access element "b" of {"b":1}) |
path(foreach .a as $v (.; ($v | .b); .)) (control, no nesting) |
["a","b"] |
["a","b"] |
path(foreach .a as {b:$v} (.; try $v; .)) (control, no navigation in the try) |
["a","b"] |
["a","b"] |
Fuzz-found originals (all reproduce on main):
path(foreach .x as {a:$v0, d:$w0} ?// {c:$v0} (.; try ($v0 | .b?); (try error(1) catch $v0)))
(foreach .x as {x:{c:$v0}} (0; try ($v0 | .b?); ($v0 | .b?))) = 9
(foreach .a as {a:$v0, d:$w0} ?// {c:$v0} (.; ($v0 | .b?)?; ($v0 | reduce (1) as $i (.; getpath(["a"]))))) |= .
Root cause
src/jq/eval.rs: FoldRegister::resolve hands its register to resolve_seq as the explicit register argument, but the Frame it resolves under (fold_frame.extend(&branch.path) / self.frame.unknown()) carries no register, so when UPDATE's stage is try (...)/if ... then (...), the Try/If arm's nested Pipe — which #3133 made seed from frame.register() — finds none.
Why this is separate from #3133
#3133 is the pipe-stage frame; this is the fold register's own frame, built in FoldRegister::enter/advance/relocate, which #3133 deliberately left alone (folds have their own register model, #1440/#1466/#2031). Same mechanism, different owner.
Suggested fix direction
Where FoldRegister::resolve builds the frame it resolves UPDATE/EXTRACT under, attach the register it is about to pass (frame.with_register(Some(®.value)) when the branch is untracked), so the nested-pipe seeding #3133 added applies. The step_may_reestablish trust in resolve_seq_stage already keys on the frame's register, so no second change is needed there. Add the rows above to scripts/jq-bind-origin-oracle-sweep.sh and rerun scripts/jq-bind-origin-fuzz.py (whose USES now draw nested-try bodies inside folds) at zero fabricate.
Refs #3133, #3144, #2979, #2676, #1440.
Severity: Medium (
del/|=through a fold's UPDATE silently discarded — exit 0, document echoed — where jq writes; the read forms emit nothing at exit 0)Summary
#3133 (PR #3144) carries the path register into a pipe nested under
try/if/,and into acatchhandler for a pipe stage:resolve_seq_stageputs the carried register on the stage'sFrame, and a nestedresolve_seq_sinkseeds itself from it. A fold's UPDATE/EXTRACT body takes a different route —FoldRegister::resolve→resolve_seqwith the fold's own register passed explicitly, under a frame that carries none — so the same shape inside a fold still starts register-less one level down:try ($v | .b)in UPDATE cannot re-establish$v, raises the resolver's own refusal, andtryswallows it.Found by #3133's widened fuzz alphabet (
fabricate-baseline/mismatch-baselinerows, i.e. pre-existing onmain).Repro
/usr/bin/jq1.7.1 vs succinctly (main833aad9fdand the #3144 branch alike), input{"a":{"b":1}}:path(foreach .a as $v (.; try ($v | .b); .))["a","b"]del(foreach .a as $v (.; try ($v | .b); .)){"a":{}}{"a":{"b":1}}, exit 0 (write discarded)path(foreach .a as $v (.; if true then ($v | .b) else . end; .))["a","b"]near attempt to access element "b" of {"b":1})path(foreach .a as $v (.; ($v | .b); .))(control, no nesting)["a","b"]["a","b"]path(foreach .a as {b:$v} (.; try $v; .))(control, no navigation in the try)["a","b"]["a","b"]Fuzz-found originals (all reproduce on
main):Root cause
src/jq/eval.rs:FoldRegister::resolvehands its register toresolve_seqas the explicitregisterargument, but theFrameit resolves under (fold_frame.extend(&branch.path)/self.frame.unknown()) carries noregister, so when UPDATE's stage istry (...)/if ... then (...), theTry/Ifarm's nestedPipe— which #3133 made seed fromframe.register()— finds none.Why this is separate from #3133
#3133 is the pipe-stage frame; this is the fold register's own frame, built in
FoldRegister::enter/advance/relocate, which #3133 deliberately left alone (folds have their own register model, #1440/#1466/#2031). Same mechanism, different owner.Suggested fix direction
Where
FoldRegister::resolvebuilds the frame it resolves UPDATE/EXTRACT under, attach the register it is about to pass (frame.with_register(Some(®.value))when the branch is untracked), so the nested-pipe seeding #3133 added applies. Thestep_may_reestablishtrust inresolve_seq_stagealready keys on the frame's register, so no second change is needed there. Add the rows above toscripts/jq-bind-origin-oracle-sweep.shand rerunscripts/jq-bind-origin-fuzz.py(whoseUSESnow draw nested-trybodies inside folds) at zero fabricate.Refs #3133, #3144, #2979, #2676, #1440.