yq/pkg/yqlib/operator_traverse_path_test.go
StressTestor 0cc5c19843 fix: apply per-candidate index when traversing arrays/maps by a streamed index
`$o[.]` over a streamed context (e.g. `keys[] | $o[.]`) only returned the
first match. The index expression yields one index set per incoming
candidate, but traverseArrayOperator used only the first set
(rhs.MatchingNodes.Front()), dropping the rest.

Pair each index set with its candidate: when the LHS has one node per
candidate (e.g. `.[] | .[idx]`) each node is traversed with its own index
set; when the LHS collapses to a single node (a variable) it is traversed
against every index set. Covers both arrays and maps.

Fixes #2593.
2026-06-15 16:00:44 -06:00

771 lines
19 KiB
Go

package yqlib
import (
"testing"
)
var mergeDocSample = `foo: &foo
a: foo_a
thing: foo_thing
c: foo_c
bar: &bar
b: bar_b
thing: bar_thing
c: bar_c
foobarList:
b: foobarList_b
<<: [*foo,*bar]
c: foobarList_c
foobar:
c: foobar_c
<<: *foo
thing: foobar_thing
`
var fixedTraversePathOperatorScenarios = []expressionScenario{
{
description: "FIXED: Traversing merge anchors with override",
subdescription: "Set `--yaml-fix-merge-anchor-to-spec=true` to get this correct merge behaviour.",
document: mergeDocSample,
expression: `.foobar.c`,
expected: []string{
"D0, P[foobar c], (!!str)::foobar_c\n",
},
},
{
description: "FIXED: Traversing merge anchor lists",
subdescription: "Set `--yaml-fix-merge-anchor-to-spec=true` to get this correct merge behaviour. Note that the keys earlier in the merge anchors sequence override later ones",
document: mergeDocSample,
expression: `.foobarList.thing`,
expected: []string{
"D0, P[foo thing], (!!str)::foo_thing\n",
},
},
{
description: "FIXED: Splatting merge anchors",
subdescription: "Set `--yaml-fix-merge-anchor-to-spec=true` to get this correct merge behaviour. Note that the keys earlier in the merge anchors sequence override later ones",
document: mergeDocSample,
expression: `.foobar[]`,
expected: []string{
"D0, P[foo a], (!!str)::foo_a\n",
"D0, P[foobar thing], (!!str)::foobar_thing\n",
"D0, P[foobar c], (!!str)::foobar_c\n",
},
},
{
description: "FIXED: Splatting merge anchor lists",
subdescription: "Set `--yaml-fix-merge-anchor-to-spec=true` to get this correct merge behaviour. Note that the keys earlier in the merge anchors sequence override later ones",
document: mergeDocSample,
expression: `.foobarList[]`,
expected: []string{
"D0, P[foobarList b], (!!str)::foobarList_b\n",
"D0, P[foo thing], (!!str)::foo_thing\n",
"D0, P[foobarList c], (!!str)::foobarList_c\n",
"D0, P[foo a], (!!str)::foo_a\n",
},
},
{
skipDoc: true,
document: mergeDocSample,
expression: `.foobarList.b`,
expected: []string{
"D0, P[foobarList b], (!!str)::foobarList_b\n",
},
},
}
var badTraversePathOperatorScenarios = []expressionScenario{
{
description: "LEGACY: Traversing merge anchors with override",
subdescription: "This is legacy behaviour, see --yaml-fix-merge-anchor-to-spec",
document: mergeDocSample,
expression: `.foobar.c`,
expected: []string{
"D0, P[foo c], (!!str)::foo_c\n",
},
},
{
description: "LEGACY: Traversing merge anchor lists",
subdescription: "Note that the later merge anchors override previous, " +
"but this is legacy behaviour, see --yaml-fix-merge-anchor-to-spec",
document: mergeDocSample,
expression: `.foobarList.thing`,
expected: []string{
"D0, P[bar thing], (!!str)::bar_thing\n",
},
},
{
description: "LEGACY: Splatting merge anchors",
subdescription: "With legacy override behaviour, see --yaml-fix-merge-anchor-to-spec",
document: mergeDocSample,
expression: `.foobar[]`,
expected: []string{
"D0, P[foo c], (!!str)::foo_c\n",
"D0, P[foo a], (!!str)::foo_a\n",
"D0, P[foobar thing], (!!str)::foobar_thing\n",
},
},
{
description: "LEGACY: Splatting merge anchor lists",
subdescription: "With legacy override behaviour, see --yaml-fix-merge-anchor-to-spec",
document: mergeDocSample,
expression: `.foobarList[]`,
expected: []string{
"D0, P[bar b], (!!str)::bar_b\n",
"D0, P[foo a], (!!str)::foo_a\n",
"D0, P[bar thing], (!!str)::bar_thing\n",
"D0, P[foobarList c], (!!str)::foobarList_c\n",
},
},
{
skipDoc: true,
subdescription: "This is legacy behaviour, see --yaml-fix-merge-anchor-to-spec",
document: mergeDocSample,
expression: `.foobarList.b`,
expected: []string{
"D0, P[bar b], (!!str)::bar_b\n",
},
},
}
var traversePathOperatorScenarios = []expressionScenario{
{
skipDoc: true,
description: "strange map with key but no value",
document: "!!null\n-",
expression: ".x",
expected: []string{
"D0, P[x], (!!null)::null\n",
},
skipForGoccy: true, // throws an error instead, that's fine
},
{
skipDoc: true,
description: "access merge anchors",
document: "foo: &foo {x: y}\nbar:\n <<: *foo\n",
expression: `.bar["<<"] | alias`,
expected: []string{
"D0, P[bar <<], (!!str)::foo\n",
},
},
{
skipDoc: true,
description: "dynamically set parent and key",
expression: `.a.b.c = 3 | .a.b.c`,
expected: []string{
"D0, P[a b c], (!!int)::3\n",
},
},
{
skipDoc: true,
description: "dynamically set parent and key in array",
expression: `.a.b[0] = 3 | .a.b[0]`,
expected: []string{
"D0, P[a b 0], (!!int)::3\n",
},
},
{
skipDoc: true,
description: "dynamically set parent and key",
expression: `.a.b = ["x","y"] | .a.b[1]`,
expected: []string{
"D0, P[a b 1], (!!str)::y\n",
},
},
{
skipDoc: true,
description: "splat empty map",
document: "{}",
expression: ".[]",
expected: []string{},
},
{
skipDoc: true,
document: `[[1]]`,
expression: `.[0][0]`,
expected: []string{
"D0, P[0 0], (!!int)::1\n",
},
},
{
skipDoc: true,
expression: `.cat["12"] = "things"`,
expected: []string{
"D0, P[], ()::cat:\n \"12\": things\n",
},
},
{
skipDoc: true,
document: `blah: {}`,
expression: `.blah.cat = "cool"`,
expected: []string{
"D0, P[], (!!map)::blah:\n cat: cool\n",
},
},
{
skipDoc: true,
document: `blah: []`,
expression: `.blah.0 = "cool"`,
expected: []string{
"D0, P[], (!!map)::blah:\n - cool\n",
},
},
{
skipDoc: true,
document: `b: cat`,
expression: ".b\n",
expected: []string{
"D0, P[b], (!!str)::cat\n",
},
},
{
skipDoc: true,
document: `[[[1]]]`,
expression: `.[0][0][0]`,
expected: []string{
"D0, P[0 0 0], (!!int)::1\n",
},
},
{
skipDoc: true,
expression: `.["cat"] = "thing"`,
expected: []string{
"D0, P[], ()::cat: thing\n",
},
},
{
description: "Simple map navigation",
document: `{a: {b: apple}}`,
expression: `.a`,
expected: []string{
"D0, P[a], (!!map)::{b: apple}\n",
},
},
{
description: "Splat",
subdescription: "Often used to pipe children into other operators",
document: `[{b: apple}, {c: banana}]`,
expression: `.[]`,
expected: []string{
"D0, P[0], (!!map)::{b: apple}\n",
"D0, P[1], (!!map)::{c: banana}\n",
},
},
{
description: "Optional Splat",
subdescription: "Just like splat, but won't error if you run it against scalars",
document: `"cat"`,
expression: `.[]`,
expected: []string{},
},
{
description: "Special characters",
subdescription: "Use quotes with square brackets around path elements with special characters",
document: `{"{}": frog}`,
expression: `.["{}"]`,
expected: []string{
"D0, P[{}], (!!str)::frog\n",
},
},
{
description: "Nested special characters",
document: `a: {"key.withdots": {"another.key": apple}}`,
expression: `.a["key.withdots"]["another.key"]`,
expected: []string{
"D0, P[a key.withdots another.key], (!!str)::apple\n",
},
},
{
description: "Keys with spaces",
subdescription: "Use quotes with square brackets around path elements with special characters",
document: `{"red rabbit": frog}`,
expression: `.["red rabbit"]`,
expected: []string{
"D0, P[red rabbit], (!!str)::frog\n",
},
},
{
skipDoc: true,
document: `{"flying fox": frog}`,
expression: `.["flying fox"]`,
expected: []string{
"D0, P[flying fox], (!!str)::frog\n",
},
},
{
skipDoc: true,
document: `c: dog`,
expression: `.[.a.b] as $x | .`,
expected: []string{
"D0, P[], (!!map)::c: dog\n",
},
},
{
description: "Dynamic keys",
subdescription: `Expressions within [] can be used to dynamically lookup / calculate keys`,
document: `{b: apple, apple: crispy yum, banana: soft yum}`,
expression: `.[.b]`,
expected: []string{
"D0, P[apple], (!!str)::crispy yum\n",
},
},
{
skipDoc: true,
document: `{b: apple, fruit: {apple: yum, banana: smooth}}`,
expression: `.fruit[.b]`,
expected: []string{
"D0, P[fruit apple], (!!str)::yum\n",
},
},
{
description: "Children don't exist",
subdescription: "Nodes are added dynamically while traversing",
document: `{c: banana}`,
expression: `.a.b`,
expected: []string{
"D0, P[a b], (!!null)::null\n",
},
},
{
description: "Optional identifier",
subdescription: "Like jq, does not output an error when the yaml is not an array or object as expected",
document: `[1,2,3]`,
expression: `.a?`,
expected: []string{},
},
{
skipDoc: true,
document: `[[1,2,3], {a: frog}]`,
expression: `.[] | .["a"]?`,
expected: []string{"D0, P[1 a], (!!str)::frog\n"},
},
{
skipDoc: true,
document: ``,
expression: `.[1].a`,
expected: []string{
"D0, P[1 a], (!!null)::null\n",
},
},
{
skipDoc: true,
document: `{}`,
expression: `.a[1]`,
expected: []string{
"D0, P[a 1], (!!null)::null\n",
},
},
{
description: "Wildcard matching",
document: `{a: {cat: apple, mad: things}}`,
expression: `.a."*a*"`,
expected: []string{
"D0, P[a cat], (!!str)::apple\n",
"D0, P[a mad], (!!str)::things\n",
},
},
{
skipDoc: true,
document: `{a: {cat: {b: 3}, mad: {b: 4}, fad: {c: t}}}`,
expression: `.a."*a*".b`,
expected: []string{
"D0, P[a cat b], (!!int)::3\n",
"D0, P[a mad b], (!!int)::4\n",
"D0, P[a fad b], (!!null)::null\n",
},
},
{
skipDoc: true,
document: `{a: {cat: apple, mad: things}}`,
expression: `.a | (.cat, .mad)`,
expected: []string{
"D0, P[a cat], (!!str)::apple\n",
"D0, P[a mad], (!!str)::things\n",
},
},
{
skipDoc: true,
document: `{a: {cat: apple, mad: things}}`,
expression: `.a | (.cat, .mad, .fad)`,
expected: []string{
"D0, P[a cat], (!!str)::apple\n",
"D0, P[a mad], (!!str)::things\n",
"D0, P[a fad], (!!null)::null\n",
},
},
{
skipDoc: true,
document: `{a: {cat: apple, mad: things}}`,
expression: `.a | (.cat, .mad, .fad) | select( (. == null) | not)`,
expected: []string{
"D0, P[a cat], (!!str)::apple\n",
"D0, P[a mad], (!!str)::things\n",
},
},
{
description: "Aliases",
document: `{a: &cat {c: frog}, b: *cat}`,
expression: `.b`,
expected: []string{
"D0, P[b], (alias)::*cat\n",
},
},
{
description: "Traversing aliases with splat",
document: `{a: &cat {c: frog}, b: *cat}`,
expression: `.b[]`,
expected: []string{
"D0, P[a c], (!!str)::frog\n",
},
},
{
description: "Traversing aliases explicitly",
document: `{a: &cat {c: frog}, b: *cat}`,
expression: `.b.c`,
expected: []string{
"D0, P[a c], (!!str)::frog\n",
},
},
{
description: "Traversing arrays by index",
document: `[1,2,3]`,
expression: `.[0]`,
expected: []string{
"D0, P[0], (!!int)::1\n",
},
},
{
description: "Traversing nested arrays by index",
dontFormatInputForDoc: true,
document: `[[], [cat]]`,
expression: `.[1][0]`,
expected: []string{
"D0, P[1 0], (!!str)::cat\n",
},
},
{
description: "Maps with numeric keys",
document: `{2: cat}`,
expression: `.[2]`,
expected: []string{
"D0, P[2], (!!str)::cat\n",
},
},
{
description: "Maps with non existing numeric keys",
document: `{a: b}`,
expression: `.[0]`,
expected: []string{
"D0, P[0], (!!null)::null\n",
},
},
{
skipDoc: true,
description: "Merge anchor with inline map",
document: `{<<: {a: 42}}`,
expression: `.a`,
expected: []string{
"D0, P[<< a], (!!int)::42\n",
},
},
{
skipDoc: true,
description: "Merge anchor with sequence with inline map",
document: `{<<: [{a: 42}]}`,
expression: `.a`,
expected: []string{
"D0, P[<< 0 a], (!!int)::42\n",
},
},
{
skipDoc: true,
description: "Merge anchor with aliased sequence with inline map",
document: `{s: &s [{a: 42}], m: {<<: *s}}`,
expression: `.m.a`,
expected: []string{
"D0, P[s 0 a], (!!int)::42\n",
},
},
{
skipDoc: true,
document: mergeDocSample,
expression: `.foobar`,
expected: []string{
"D0, P[foobar], (!!map)::c: foobar_c\n<<: *foo\nthing: foobar_thing\n",
},
},
{
description: "Traversing merge anchors",
document: mergeDocSample,
expression: `.foobar.a`,
expected: []string{
"D0, P[foo a], (!!str)::foo_a\n",
},
},
{
description: "Traversing merge anchors with local override",
document: mergeDocSample,
expression: `.foobar.thing`,
expected: []string{
"D0, P[foobar thing], (!!str)::foobar_thing\n",
},
},
{
skipDoc: true,
document: mergeDocSample,
expression: `.foobarList`,
expected: []string{
"D0, P[foobarList], (!!map)::b: foobarList_b\n<<: [*foo, *bar]\nc: foobarList_c\n",
},
},
{
skipDoc: true,
document: mergeDocSample,
expression: `.foobarList.a`,
expected: []string{
"D0, P[foo a], (!!str)::foo_a\n",
},
},
{
skipDoc: true,
document: mergeDocSample,
expression: `.foobarList.c`,
expected: []string{
"D0, P[foobarList c], (!!str)::foobarList_c\n",
},
},
{
skipDoc: true,
document: `[a,b,c]`,
expression: `.[]`,
expected: []string{
"D0, P[0], (!!str)::a\n",
"D0, P[1], (!!str)::b\n",
"D0, P[2], (!!str)::c\n",
},
},
{
skipDoc: true,
document: `[a,b,c]`,
expression: `[]`,
expected: []string{
"D0, P[], (!!seq)::[]\n",
},
},
{
skipDoc: true,
document: `{a: [a,b,c]}`,
expression: `.a[0]`,
expected: []string{
"D0, P[a 0], (!!str)::a\n",
},
},
{
description: "Select multiple indices",
document: `{a: [a,b,c]}`,
expression: `.a[0, 2]`,
expected: []string{
"D0, P[a 0], (!!str)::a\n",
"D0, P[a 2], (!!str)::c\n",
},
},
{
skipDoc: true,
document: `{a: [a,b,c]}`,
expression: `.a[0, 2]`,
expected: []string{
"D0, P[a 0], (!!str)::a\n",
"D0, P[a 2], (!!str)::c\n",
},
},
{
skipDoc: true,
document: `{a: [a,b,c]}`,
expression: `.a[-1]`,
expected: []string{
"D0, P[a 2], (!!str)::c\n",
},
},
{
skipDoc: true,
document: `{a: [a,b,c]}`,
expression: `.a[-2]`,
expected: []string{
"D0, P[a 1], (!!str)::b\n",
},
},
{
skipDoc: true,
document: `{a: [a,b,c]}`,
expression: `.a[]`,
expected: []string{
"D0, P[a 0], (!!str)::a\n",
"D0, P[a 1], (!!str)::b\n",
"D0, P[a 2], (!!str)::c\n",
},
},
{
skipDoc: true,
document: `{a: [a,b,c]}`,
expression: `.a[]`,
expected: []string{
"D0, P[a 0], (!!str)::a\n",
"D0, P[a 1], (!!str)::b\n",
"D0, P[a 2], (!!str)::c\n",
},
},
{
skipDoc: true,
document: `{a: [a,b,c]}`,
expression: `.a | .[]`,
expected: []string{
"D0, P[a 0], (!!str)::a\n",
"D0, P[a 1], (!!str)::b\n",
"D0, P[a 2], (!!str)::c\n",
},
},
{
skipDoc: true,
description: "Duplicate keys",
subdescription: "outside merge anchor",
document: `{a: 1, a: 2}`,
expression: `.a`,
expected: []string{
"D0, P[a], (!!int)::2\n",
},
},
{
skipDoc: true,
description: "Traversing map with invalid merge anchor should not fail",
subdescription: "Otherwise code cannot do anything with it",
document: `{a: 42, <<: 37}`,
expression: `.a`,
expected: []string{
"D0, P[a], (!!int)::42\n",
},
},
{
skipDoc: true,
description: "Directly accessing invalid merge anchor should not fail",
document: `{<<: 37}`,
expression: `.<<`,
expected: []string{
"D0, P[<<], (!!int)::37\n",
},
},
{
skipDoc: true,
description: "!!str << should not be treated as merge anchor",
document: `{!!str <<: {a: 37}}`,
expression: `.a`,
expected: []string{
"D0, P[a], (!!null)::null\n",
},
},
{
// Regression test for https://issues.oss-fuzz.com/issues/390467412
// go-yaml accepts cross-document alias references (invalid per
// YAML spec). A nested assignment on such an alias can create a
// circular alias node, which must not cause a stack overflow.
skipDoc: true,
document: "&-- a\n---\n*--",
expression: ". = (.x = 1)",
expectedError: "alias cycle detected",
},
{
// Regression test for https://github.com/mikefarah/yq/issues/2593
// A context-dependent index (here the streamed key) must be applied
// per candidate; previously only the first index was used.
skipDoc: true,
document: `["a","b"]`,
expression: `. as $o | keys[] | $o[.]`,
expected: []string{
"D0, P[0], (!!str)::a\n",
"D0, P[1], (!!str)::b\n",
},
},
{
skipDoc: true,
document: `{"x": 1, "y": 2}`,
expression: `. as $o | keys[] | $o[.]`,
expected: []string{
"D0, P[x], (!!int)::1\n",
"D0, P[y], (!!int)::2\n",
},
},
}
func TestTraversePathOperatorScenarios(t *testing.T) {
for _, tt := range append(traversePathOperatorScenarios, badTraversePathOperatorScenarios...) {
testScenario(t, &tt)
}
documentOperatorScenarios(t, "traverse-read", append(traversePathOperatorScenarios, badTraversePathOperatorScenarios...))
}
func TestTraversePathOperatorAlignedToSpecScenarios(t *testing.T) {
ConfiguredYamlPreferences.FixMergeAnchorToSpec = true
for _, tt := range append(fixedTraversePathOperatorScenarios, traversePathOperatorScenarios...) {
testScenario(t, &tt)
}
appendOperatorDocumentScenario(t, "traverse-read", fixedTraversePathOperatorScenarios)
ConfiguredYamlPreferences.FixMergeAnchorToSpec = false
}
// Regression test for https://issues.oss-fuzz.com/issues/390467412
// A circular alias (alias pointing back to itself) must not cause a
// stack overflow. resolveAliasChain should detect the cycle and return
// an error; both traverse() and traverseArrayIndices() use it.
func TestTraverseAliasCycle(t *testing.T) {
aliasNode := &CandidateNode{
Kind: AliasNode,
}
aliasNode.Alias = aliasNode // A -> A
op := &Operation{
OperationType: traversePathOpType,
Value: "key",
StringValue: "key",
Preferences: traversePreferences{},
}
_, err := traverse(Context{}, aliasNode, op)
if err == nil {
t.Fatal("expected error for alias cycle, got nil")
}
if err.Error() != "alias cycle detected" {
t.Fatalf("expected 'alias cycle detected', got %q", err.Error())
}
// Same cycle must be caught through the array traversal path.
_, err = traverseArrayIndices(Context{}, aliasNode, nil, traversePreferences{})
if err == nil {
t.Fatal("expected error for alias cycle via traverseArrayIndices, got nil")
}
if err.Error() != "alias cycle detected" {
t.Fatalf("expected 'alias cycle detected', got %q", err.Error())
}
}
func TestTraverseAliasCycleChain(t *testing.T) {
nodeA := &CandidateNode{Kind: AliasNode}
nodeB := &CandidateNode{Kind: AliasNode}
nodeA.Alias = nodeB
nodeB.Alias = nodeA // A -> B -> A
op := &Operation{
OperationType: traversePathOpType,
Value: "key",
StringValue: "key",
Preferences: traversePreferences{},
}
_, err := traverse(Context{}, nodeA, op)
if err == nil {
t.Fatal("expected error for alias cycle chain, got nil")
}
if err.Error() != "alias cycle detected" {
t.Fatalf("expected 'alias cycle detected', got %q", err.Error())
}
}