From a7ed5ccbe9c0c163dee4cac7aeab88d22e101299 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 08:20:57 +0200 Subject: [PATCH] fix: release the allocator when an updated value escapes to a query The allocator of _modify tracks the values it created so that a later update can modify them in place instead of copying them. When a target path is a proper prefix of an earlier one, getpath hands such a value to the update function, which may store it in more than one place; updating through one of those places then changes all of them. $ gojq -cn '{"a":{"b":1}} | (.a.b, .a, .a.p.z) |= {p: ., q: .}' .a.p and .a.q hold separate values in jq, so only .a.p gains the "z" key, while gojq adds it to both. Release the allocator whenever a value it created leaves the update, so that the values are copied on update from then on. The same applies to an object used as a path element, which stays reachable through the path after the update. --- cli/test.yaml | 8 ++++++++ compiler.go | 11 ++++++----- func.go | 22 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/cli/test.yaml b/cli/test.yaml index 4818048..3b77647 100644 --- a/cli/test.yaml +++ b/cli/test.yaml @@ -5502,6 +5502,14 @@ expected: | [1,3,6,5,5] +- name: update-assignment operator against object with overlapping paths + args: + - -c + - '(.a.b,.a,.a.p.z) |= {p: ., q: .}' + input: '{"a":{"b":1}}' + expected: | + {"a":{"p":{"b":{"p":1,"q":1},"z":{"p":null,"q":null}},"q":{"b":{"p":1,"q":1}}}} + - name: update-assignment operator with optional operator args: - -c diff --git a/compiler.go b/compiler.go index 6b51e0e..050951b 100644 --- a/compiler.go +++ b/compiler.go @@ -1093,7 +1093,7 @@ func (c *compiler) compileModify() { &code{op: opcall, v: [3]any{funcAllocator, 0, "_allocator"}}, &code{op: opstore, v: a}, &code{op: opload, v: v}, - &code{op: opfork, v: len(c.codes) + 39}, // reduce [L1] + &code{op: opfork, v: len(c.codes) + 40}, // reduce [L1] &code{op: oppathbegin}, // path(p) &code{op: opload, v: p}, &code{op: opcallpc}, @@ -1102,12 +1102,13 @@ func (c *compiler) compileModify() { &code{op: opstore, v: p}, // as $p (.; &code{op: opforklabel, v: l}, // label $l | &code{op: opload, v: v}, // - &code{op: opfork, v: len(c.codes) + 36}, // [L2] + &code{op: opfork, v: len(c.codes) + 37}, // [L2] &code{op: oppop}, // (getpath($p) | &code{op: opload, v: a}, + &code{op: opload, v: a}, &code{op: opload, v: p}, &code{op: opload, v: v}, - &code{op: opcall, v: [3]any{internalFuncs["getpath"].callback, 1, "getpath"}}, + &code{op: opcall, v: [3]any{funcGetpathWithAllocator, 2, "getpath"}}, &code{op: opload, v: f}, // f) &code{op: opcallpc}, &code{op: opload, v: p}, // setpath($p; ...) @@ -1115,8 +1116,8 @@ func (c *compiler) compileModify() { &code{op: opcall, v: [3]any{funcSetpathWithAllocator, 3, "_setpath"}}, &code{op: opstore, v: v}, &code{op: opload, v: v}, // ., break $l - &code{op: opfork, v: len(c.codes) + 34}, // [L4] - &code{op: opjump, v: len(c.codes) + 38}, // [L3] + &code{op: opfork, v: len(c.codes) + 35}, // [L4] + &code{op: opjump, v: len(c.codes) + 39}, // [L3] &code{op: opload, v: l}, // [L4] &code{op: opcall, v: [3]any{funcBreak(""), 0, "_break"}}, &code{op: opload, v: p}, // append $p to $d [L2] diff --git a/func.go b/func.go index 81dbe31..35c99fd 100644 --- a/func.go +++ b/func.go @@ -1488,6 +1488,17 @@ func (a allocator) allocated(v any) bool { return ok } +// release forgets every allocated address when the value is one of them, so +// that a value handed to a query is copied on update again. +func (a allocator) release(v any) { + switch v.(type) { + case map[string]any, []any: + if a.allocated(v) { + clear(a) + } + } +} + func (a allocator) makeObject(l int) map[string]any { v := make(map[string]any, l) if a != nil { @@ -1514,6 +1525,15 @@ func funcSetpathWithAllocator(v any, args []any) any { return setpath(v, args[0], args[1], args[2].(allocator)) } +// Used in compiler#compileModify: the update function may store the value +// anywhere, so it must not be modified in place afterwards. Registered as +// getpath to keep tracking paths through the call. +func funcGetpathWithAllocator(v any, args []any) any { + w := funcGetpath(v, args[0]) + args[1].(allocator).release(w) + return w +} + func setpath(v, p, n any, a allocator) any { path, ok := p.([]any) if !ok { @@ -1591,6 +1611,8 @@ func update(v any, path []any, n any, a allocator) (any, error) { return nil, &expectedArrayError{v} } case map[string]any: + // A slice bound stays reachable through the path. + a.release(p) switch v := v.(type) { case nil: return updateArraySlice(nil, p, path[1:], n, a)