From 6f288535f999ec906f28cb2dbb9064567a9cdd45 Mon Sep 17 00:00:00 2001 From: quobix Date: Sat, 24 Oct 2026 09:51:49 -0400 Subject: [PATCH] fix: support backtick member escapes in JSONPath Plus --- README.md | 22 ++++++ pkg/jsonpath/backtick_test.go | 109 ++++++++++++++++++++++++++++ pkg/jsonpath/parser.go | 7 +- pkg/jsonpath/token/backtick_test.go | 40 ++++++++++ pkg/jsonpath/token/token.go | 28 +++++++ 5 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 pkg/jsonpath/backtick_test.go create mode 100644 pkg/jsonpath/token/backtick_test.go diff --git a/README.md b/README.md index 1d56010..1945f7e 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,28 @@ store: ## JSONPath Plus Extensions +### Backtick member escapes + +A single leading backtick selects a literal member name in dot notation or an +unquoted bracket selector. For example: + +~~~text +$.schema.`$ref +$.content.`application/json.schema +$..`* +$[`$ref,`*] +~~~ + +There is no closing backtick: a trailing backtick is part of the key. Path +separators still delimit segments. Use quoted bracket notation for keys that +contain separators or whitespace, and inside filters: `@['$ref']`. + +Quoted bracket names retain their RFC 9535 meaning, including literal backticks; +unlike the JavaScript implementation, `$['*']` selects the key `*`, and +`` $['`*'] `` selects the key `` `* ``. `String()` normalizes backtick escapes +to quoted bracket notation. `config.WithStrictRFC9535()` rejects unquoted +backtick escapes. + ### Context Variables Context variables provide information about the current evaluation context within filter expressions. They are prefixed with `@` and can be used in comparisons. diff --git a/pkg/jsonpath/backtick_test.go b/pkg/jsonpath/backtick_test.go new file mode 100644 index 0000000..0731655 --- /dev/null +++ b/pkg/jsonpath/backtick_test.go @@ -0,0 +1,109 @@ +package jsonpath + +import ( + "testing" + + "github.com/pb33f/go-yaml" + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "github.com/pb33f/testify/require" +) + +func TestBacktickMemberSelection(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("{\"$ref\":\"target\",\"$ref`\":\"trailing\",\"*\":\"star\",\"\":\"empty\",\"`*\":\"literal\",\"application/json\":{\"$ref\":\"nested\"},\"@number()\":\"type\",\"true\":\"boolean-name\",\"123\":\"numeric-name\",\"日本語\":\"unicode\",\"a-b\":\"hyphen\"}"), &root)) + for _, tc := range []struct { + name, path, normalized string + values []string + }{ + {"ref", "$.`$ref", "$['$ref']", []string{"target"}}, + {"trailing backtick is literal", "$.`$ref`", "$['$ref`']", []string{"trailing"}}, + {"wildcard is literal", "$.`*", "$['*']", []string{"star"}}, + {"empty key", "$.`", "$['']", []string{"empty"}}, + {"escaped backtick", "$.``*", "$['`*']", []string{"literal"}}, + {"nested media type", "$.`application/json.`$ref", "$['application/json']['$ref']", []string{"nested"}}, + {"recursive", "$..`$ref", "$..['$ref']", []string{"target", "nested"}}, + {"bracket union", "$[`$ref,`*]", "$['$ref', '*']", []string{"target", "star"}}, + {"mixed union", "$[`$ref,'*']", "$['$ref', '*']", []string{"target", "star"}}, + {"type selector is literal", "$.`@number()", "$['@number()']", []string{"type"}}, + {"boolean name", "$.`true", "$['true']", []string{"boolean-name"}}, + {"numeric name", "$.`123", "$['123']", []string{"numeric-name"}}, + {"unicode", "$.`日本語", "$['日本語']", []string{"unicode"}}, + {"hyphen", "$.`a-b", "$['a-b']", []string{"hyphen"}}, + {"missing", "$.`absent", "$['absent']", nil}, + {"quoted backtick stays literal", "$['`*']", "$['`*']", []string{"literal"}}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, options := range [][]config.Option{nil, {config.WithSpectralCompatibility(), config.WithLazyContextTracking()}} { + path, err := NewPath(tc.path, options...) + require.NoError(t, err) + require.Equal(t, tc.normalized, path.String()) + var values []string + for _, node := range path.Query(&root) { + values = append(values, node.Value) + } + require.Equal(t, tc.values, values) + normalized, err := NewPath(path.String(), config.WithStrictRFC9535()) + require.NoError(t, err) + require.Equal(t, path.Query(&root), normalized.Query(&root)) + } + }) + } +} + +func TestBacktickStrictAndInvalidContexts(t *testing.T) { + for _, expression := range []string{"$.`$ref", "$..`*", "$[`$ref]"} { + _, err := NewPath(expression, config.WithStrictRFC9535()) + require.ErrorContains(t, err, "backtick member escapes require JSONPath Plus mode") + } + for _, expression := range []string{"`$ref", "$`$ref", "$.name`other", "$[?@.`$ref]", "$[`$ref", "$.`name]"} { + t.Run(expression, func(t *testing.T) { + _, err := NewPath(expression) + require.Error(t, err) + }) + } + // Quoted keys remain RFC-compatible even when the key contains a backtick. + _, err := NewPath("$['`*']", config.WithStrictRFC9535()) + require.NoError(t, err) +} + +func TestBacktickNavigationAndMetadata(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`{"$ref":[{"name":"first"},{"name":"second"}]}`), &root)) + path, err := NewPath("$.`$ref[1].name") + require.NoError(t, err) + require.True(t, path.IsSingular()) + segments, err := path.GetSegmentInfo() + require.NoError(t, err) + require.Len(t, segments, 3) + require.Equal(t, "$ref", segments[0].Key) + require.Equal(t, int64(1), segments[1].Index) + require.Equal(t, "name", segments[2].Key) + nodes := path.Query(&root) + require.Len(t, nodes, 1) + require.Equal(t, "second", nodes[0].Value) + + path, err = NewPath("$.`$ref~", config.WithPropertyNameExtension()) + require.NoError(t, err) + nodes = path.Query(&root) + require.Len(t, nodes, 1) + require.Equal(t, "$ref", nodes[0].Value) + + path, err = NewPath("$.`$ref^") + require.NoError(t, err) + nodes = path.Query(&root) + require.Len(t, nodes, 1) + require.Equal(t, root.Content[0], nodes[0]) +} + +func BenchmarkBacktickCompile(b *testing.B) { + for _, expression := range []string{"$.paths.*.*.requestBody.content['application/json'].schema['$ref']", "$.paths.*.*.requestBody.content.`application/json.schema.`$ref"} { + b.Run(expression, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := NewPath(expression); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/pkg/jsonpath/parser.go b/pkg/jsonpath/parser.go index f321abd..dc6c44c 100644 --- a/pkg/jsonpath/parser.go +++ b/pkg/jsonpath/parser.go @@ -156,7 +156,10 @@ func (p *JSONPath) parseInnerSegment() (retValue *innerSegment, err error) { return nil, p.parseFailure(nil, "unexpected end of input") } firstToken := p.tokens[p.current] - if firstToken.Token == token.WILDCARD { + if firstToken.Token == token.ESCAPED_NAME { + p.current++ + return &innerSegment{kind: segmentLongHand, selectors: []*selector{{kind: selectorSubKindName, name: firstToken.Literal}}}, nil + } else if firstToken.Token == token.WILDCARD { p.current += 1 return &innerSegment{segmentDotWildcard, "", nil}, nil } else if firstToken.Token == token.STRING { @@ -219,7 +222,7 @@ func (p *JSONPath) parseSelector() (retSelector *selector, err error) { }() // name-selector = string-literal - if p.tokens[p.current].Token == token.STRING_LITERAL { + if p.tokens[p.current].Token == token.STRING_LITERAL || p.tokens[p.current].Token == token.ESCAPED_NAME { name := p.tokens[p.current].Literal p.current++ return &selector{kind: selectorSubKindName, name: name}, nil diff --git a/pkg/jsonpath/token/backtick_test.go b/pkg/jsonpath/token/backtick_test.go new file mode 100644 index 0000000..d92e07d --- /dev/null +++ b/pkg/jsonpath/token/backtick_test.go @@ -0,0 +1,40 @@ +package token + +import ( + "testing" + + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "github.com/pb33f/testify/require" +) + +func TestBacktickTokens(t *testing.T) { + for _, tc := range []struct { + expression string + tokens []TokenInfo + }{ + {"$.`$ref.name", []TokenInfo{ + {Token: ROOT, Line: 1, Column: 0, Len: 1}, + {Token: CHILD, Line: 1, Column: 1, Len: 1}, + {Token: ESCAPED_NAME, Line: 1, Column: 2, Len: 5, Literal: "$ref"}, + {Token: CHILD, Line: 1, Column: 7, Len: 1}, + {Token: STRING, Line: 1, Column: 8, Len: 4, Literal: "name"}, + }}, + {"$[`*,`$ref]", []TokenInfo{ + {Token: ROOT, Line: 1, Column: 0, Len: 1}, + {Token: BRACKET_LEFT, Line: 1, Column: 1, Len: 1}, + {Token: ESCAPED_NAME, Line: 1, Column: 2, Len: 2, Literal: "*"}, + {Token: COMMA, Line: 1, Column: 4, Len: 1}, + {Token: ESCAPED_NAME, Line: 1, Column: 5, Len: 5, Literal: "$ref"}, + {Token: BRACKET_RIGHT, Line: 1, Column: 10, Len: 1}, + }}, + } { + t.Run(tc.expression, func(t *testing.T) { + tokens := NewTokenizer(tc.expression).Tokenize() + require.Equal(t, tc.tokens, []TokenInfo(tokens)) + require.False(t, tokens.IsSimple()) + }) + } + require.Equal(t, "ESCAPED_NAME", ESCAPED_NAME.String()) + tokens := NewTokenizer("$.`$ref", config.WithStrictRFC9535()).Tokenize() + require.Equal(t, ILLEGAL, tokens[2].Token) +} diff --git a/pkg/jsonpath/token/token.go b/pkg/jsonpath/token/token.go index ef7a1fe..7226599 100644 --- a/pkg/jsonpath/token/token.go +++ b/pkg/jsonpath/token/token.go @@ -212,6 +212,9 @@ const ( REGEX STRICT_EQ STRICT_NE + + // ESCAPED_NAME is a JSONPath Plus member name prefixed with a backtick. + ESCAPED_NAME ) var SimpleTokens = [...]Token{ @@ -225,6 +228,7 @@ var SimpleTokens = [...]Token{ } var tokens = [...]string{ + ESCAPED_NAME: "ESCAPED_NAME", ILLEGAL: "ILLEGAL", STRING: "STRING", INTEGER: "INTEGER", @@ -459,6 +463,12 @@ func (t *Tokenizer) Tokenize() Tokens { } switch ch := t.input[t.pos]; { + case ch == '`': + if t.config.JSONPathPlusEnabled() && !t.isInFilterContext() { + t.scanEscapedName() + } else { + t.addToken(ILLEGAL, 1, "backtick member escapes require JSONPath Plus mode outside filters; use quoted bracket notation") + } case ch == '$': if config.SpectralCompatibilityEnabled(t.config) && t.spectralDollarMemberCanStartHere() { t.addToken(STRING, 4, "$ref") @@ -667,6 +677,24 @@ func (t *Tokenizer) Tokenize() Tokens { return t.tokens } +// scanEscapedName consumes one prefix backtick and the following member name. +// Path separators still delimit segments; a second backtick is literal, not a +// closing quote. Quoted bracket names use scanString and remain unchanged. +func (t *Tokenizer) scanEscapedName() { + start := t.pos + end := start + 1 + for end < len(t.input) { + ch := t.input[end] + if ch == '.' || ch == '[' || ch == ']' || ch == ',' || ch == '^' || ch == '~' || ch == '\'' || ch == '"' || ch == '\n' || isSpace(ch) { + break + } + end++ + } + t.addToken(ESCAPED_NAME, end-start, t.input[start+1:end]) + t.pos = end - 1 + t.column += end - start - 1 +} + func (t *Tokenizer) spectralDollarMemberCanStartHere() bool { if len(t.tokens) == 0 || t.tokens[len(t.tokens)-1].Token != CHILD || !strings.HasPrefix(t.input[t.pos:], "$ref") { return false