From 78691dba0669247fa59cfdd3382a06e9bffbb5ee Mon Sep 17 00:00:00 2001 From: wenovus Date: Thu, 22 Oct 2020 14:56:20 -0700 Subject: [PATCH] Add PathElemsMatchQuery helper for checking path membership to a telemetry path This helper checks whether a particular concrete path matches a query path per [gNMI path specifications](https://github.com/openconfig/reference/blob/master/rpc/gnmi/gnmi-path-conventions.md) NOTE: the new pathstrings.go is **COPIED** from the ygot package, as otherwise we have a circular dependency. In the future, the copied helpers should move to the util package. --- util/gnmi.go | 35 +++++ util/gnmi_test.go | 105 ++++++++++++++ util/pathstrings.go | 131 ++++++++++++++++++ util/pathstrings_test.go | 290 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 561 insertions(+) create mode 100644 util/pathstrings.go create mode 100644 util/pathstrings_test.go diff --git a/util/gnmi.go b/util/gnmi.go index cedc6bae9..ced0393cc 100644 --- a/util/gnmi.go +++ b/util/gnmi.go @@ -134,6 +134,41 @@ func FindPathElemPrefix(paths []*gpb.Path) *gpb.Path { } } +// pathElemMatchesQuery returns true if the given concrete (no wildcard) +// PathElem is a possible gNMI telemetry name match for the reference PathElem. +// In particular, it matches wildcards for names and list keys, but *not* "...". +// See https://github.com/openconfig/reference/blob/master/rpc/gnmi/gnmi-path-conventions.md +func pathElemMatchesQuery(elem, refElem *gpb.PathElem) bool { + if elem == nil || refElem == nil { + return elem == nil && refElem == nil + } + + if refElem.Name != "*" && elem.Name != refElem.Name { + return false + } + + for k, ref := range refElem.Key { + if v, ok := elem.Key[k]; !ok || (ref != "*" && v != ref) { + return false + } + } + return true +} + +// PathElemsMatchQuery returns true if the given concrete (no wildcard) +// PathElem path slice is a possible gNMI telemetry path match for the +// reference PathElem path slice. +// In particular, it matches wildcards for names and list keys, but *not* "...". +// See https://github.com/openconfig/reference/blob/master/rpc/gnmi/gnmi-path-conventions.md +func PathElemsMatchQuery(elems, refElems []*gpb.PathElem) bool { + for i := 0; i != len(elems) && i != len(refElems); i++ { + if !pathElemMatchesQuery(elems[i], refElems[i]) { + return false + } + } + return len(refElems) <= len(elems) +} + // PopGNMIPath returns the supplied GNMI path with the first path element // removed. If the path is empty, it returns an empty path. func PopGNMIPath(path *gpb.Path) *gpb.Path { diff --git a/util/gnmi_test.go b/util/gnmi_test.go index d570e0e74..1f5d789cd 100644 --- a/util/gnmi_test.go +++ b/util/gnmi_test.go @@ -551,6 +551,111 @@ func TestFindPathElemPrefix(t *testing.T) { } } +func mustPathElem(s string) []*gpb.PathElem { + p, err := stringToStructuredPath(s) + if err != nil { + panic(err) + } + return p.Elem +} + +func TestPathElemsMatchQuery(t *testing.T) { + tests := []struct { + desc string + inRefElems []*gpb.PathElem + inMatchingElems [][]*gpb.PathElem + inNonMatchingElems [][]*gpb.PathElem + }{{ + desc: "no-wildcard, non-list path", + inRefElems: mustPathElem("/alpha/bravo/charlie"), + inMatchingElems: [][]*gpb.PathElem{ + mustPathElem("/alpha/bravo/charlie"), + mustPathElem("/alpha/bravo/charlie/delta"), + mustPathElem("/alpha/bravo/charlie/echo"), + }, + inNonMatchingElems: [][]*gpb.PathElem{ + mustPathElem("/alpha/bravo/delta"), + mustPathElem("/alpha/bravo/delta/charlie"), + mustPathElem("/alpha/bravo/delta/echo"), + }, + }, { + desc: "wildcard, non-list path", + inRefElems: mustPathElem("/alpha/*/charlie"), + inMatchingElems: [][]*gpb.PathElem{ + mustPathElem("/alpha/bravo/charlie"), + mustPathElem("/alpha/zulu/charlie/delta"), + mustPathElem("/alpha/yankee/charlie/echo"), + }, + inNonMatchingElems: [][]*gpb.PathElem{ + mustPathElem("/alpha/bravo/delta"), + mustPathElem("/alpha/zulu/delta/charlie"), + mustPathElem("/bravo/yankee/charlie/echo"), + }, + }, { + desc: "no-wildcard, list path", + inRefElems: mustPathElem("/alpha/bravo[key=value]/charlie"), + inMatchingElems: [][]*gpb.PathElem{ + mustPathElem("/alpha/bravo[key=value]/charlie"), + mustPathElem("/alpha/bravo[key=value]/charlie/delta"), + }, + inNonMatchingElems: [][]*gpb.PathElem{ + mustPathElem("/alpha/bravo[key=value2]/charlie"), + mustPathElem("/alpha/bravo[key=value2]/charlie/echo"), + mustPathElem("/alpha/bravo/charlie"), + mustPathElem("/alpha/bravo/charlie/echo"), + }, + }, { + desc: "wildcard, list path", + inRefElems: mustPathElem("/alpha/bravo[key=*]/charlie"), + inMatchingElems: [][]*gpb.PathElem{ + mustPathElem("/alpha/bravo[key=value]/charlie"), + mustPathElem("/alpha/bravo[key=value]/charlie/delta"), + mustPathElem("/alpha/bravo[key=value2]/charlie"), + mustPathElem("/alpha/bravo[key=value2]/charlie/echo"), + }, + inNonMatchingElems: [][]*gpb.PathElem{ + mustPathElem("/alpha/bravo/charlie"), + mustPathElem("/alpha/bravo/charlie/foxtrot"), + mustPathElem("/alpha/bravo/charlie"), + mustPathElem("/alpha/bravo/charlie/echo"), + }, + }, { + desc: "multi-wildcard, list path", + inRefElems: mustPathElem("/alpha[asn=15169]/bravo[key=*]/*/delta[name=*]/echo"), + inMatchingElems: [][]*gpb.PathElem{ + mustPathElem("/alpha[asn=15169]/bravo[key=tincan][key2=kale]/charlie[k=v]/delta[name=lamp]/echo[a=b]/"), + mustPathElem("/alpha[asn=15169]/bravo[key=tincan]/charlie/delta[name=lamp]/echo/"), + mustPathElem("/alpha[asn=15169]/bravo[key=tincan]/whiskey/delta[name=lamp]/echo/"), + mustPathElem("/alpha[asn=15169]/bravo[key=tincan]/charlie/delta[name=lamp]/echo/a[name=bulb]/b/c"), + mustPathElem("/alpha[asn=15169]/bravo[key=tincan]/charlie/delta[name=lamp]/echo/f[name=bulb]"), + }, + inNonMatchingElems: [][]*gpb.PathElem{ + mustPathElem("/alpha[asn=30]/bravo[key=tincan]/charlie/delta[name=lamp]/echo/b/c[name=bulb]/d"), + mustPathElem("/alpha[asn=15169]/bravo/charlie/delta[name=lamp]/echo/f[name=bulb]"), + mustPathElem("/quebec[asn=15169]/bravo/charlie/delta[name=lamp]/echo/f[name=bulb]"), + mustPathElem("/alpha[password=15169]/bravo[key=tincan]/charlie/delta[name=lamp]/echo/"), + mustPathElem("/alpha/bravo[key=tincan]/charlie/delta[name=lamp]/echo/f[name=bulb]"), + mustPathElem("/alpha/bravo[key=tincan]/charlie/delta[name=lamp]/echo/f[name=bulb]"), + mustPathElem("/alpha[asn=15169]/bravo[key=tincan]/charlie/delta/echo/f[name=bulb]"), + }, + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + for _, matchElems := range tt.inMatchingElems { + if !PathElemsMatchQuery(matchElems, tt.inRefElems) { + t.Errorf("unexpected non-matching result for %v\nreference path elems: %v", matchElems, tt.inRefElems) + } + } + for _, nonMatchElems := range tt.inNonMatchingElems { + if PathElemsMatchQuery(nonMatchElems, tt.inRefElems) { + t.Errorf("unexpected matching result for %v\nreference path elems: %v", nonMatchElems, tt.inRefElems) + } + } + }) + } +} + func TestFindModelData(t *testing.T) { tests := []struct { name string diff --git a/util/pathstrings.go b/util/pathstrings.go new file mode 100644 index 000000000..e7111f843 --- /dev/null +++ b/util/pathstrings.go @@ -0,0 +1,131 @@ +// Copyright 2020 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "bytes" + "errors" + "fmt" + "strings" + + gnmipb "github.com/openconfig/gnmi/proto/gnmi" +) + +// stringToStructuredPath takes a string representing a path, and converts it to +// a gnmi.Path, using the PathElem element message that is defined in gNMI 0.4.0. +// XXX: This is copied code from ygot package. ygot's code should probably +// live in this package instead. +func stringToStructuredPath(path string) (*gnmipb.Path, error) { + parts := PathStringToElements(path) + + gpath := &gnmipb.Path{} + for _, p := range parts { + name, kv, err := extractKV(p) + if err != nil { + return nil, fmt.Errorf("error parsing path %s: %v", path, err) + } + gpath.Elem = append(gpath.Elem, &gnmipb.PathElem{ + Name: name, + Key: kv, + }) + } + return gpath, nil +} + +// extractKV extracts key value predicates from the input string in. It returns +// the name of the element, a map keyed by key name with values of the predicates +// specified. It removes escape characters from keys and values where they are +// specified. +// XXX: This is copied code from ygot package. ygot's code should probably +// live in this package instead. +func extractKV(in string) (string, map[string]string, error) { + var inEscape, inKey, inValue bool + var name, currentKey string + var buf bytes.Buffer + keys := map[string]string{} + + for _, ch := range in { + switch { + case ch == '[' && !inEscape && !inValue && inKey: + return "", nil, fmt.Errorf("received an unescaped [ in key of element %s", name) + case ch == '[' && !inEscape && !inKey: + inKey = true + if len(keys) == 0 { + if buf.Len() == 0 { + return "", nil, errors.New("received a value when the element name was null") + } + name = buf.String() + buf.Reset() + } + continue + case ch == ']' && !inEscape && !inKey: + return "", nil, fmt.Errorf("received an unescaped ] when not in a key for element %s", buf.String()) + case ch == ']' && !inEscape: + inKey = false + inValue = false + if err := addKey(keys, name, currentKey, buf.String()); err != nil { + return "", nil, err + } + buf.Reset() + currentKey = "" + continue + case ch == '\\' && !inEscape: + inEscape = true + continue + case ch == '=' && inKey && !inEscape && !inValue: + currentKey = buf.String() + buf.Reset() + inValue = true + continue + } + + buf.WriteRune(ch) + inEscape = false + } + + if len(keys) == 0 { + name = buf.String() + } + + if len(keys) != 0 && buf.Len() != 0 { + // In this case, we have trailing garbage following the key. + return "", nil, fmt.Errorf("trailing garbage following keys in element %s, got: %v", name, buf.String()) + } + + if strings.Contains(name, " ") { + return "", nil, fmt.Errorf("invalid space character included in element name '%s'", name) + } + + return name, keys, nil +} + +// addKey adds key k with value v to the key's map. The key, value pair is specified +// to be for an element named e. +// XXX: This is copied code from ygot package. ygot's code should probably +// live in this package instead. +func addKey(keys map[string]string, e, k, v string) error { + switch { + case strings.Contains(k, " "): + return fmt.Errorf("received an invalid space in element %s key name '%s'", e, k) + case e == "": + return fmt.Errorf("received null element value with key and value %s=%s", k, v) + case k == "": + return fmt.Errorf("received null key name for element %s", e) + case v == "": + return fmt.Errorf("received null value for key %s of element %s", k, e) + } + keys[k] = v + return nil +} diff --git a/util/pathstrings_test.go b/util/pathstrings_test.go new file mode 100644 index 000000000..2bd162718 --- /dev/null +++ b/util/pathstrings_test.go @@ -0,0 +1,290 @@ +// Copyright 2020 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "strings" + "testing" + + "google.golang.org/protobuf/encoding/prototext" + "google.golang.org/protobuf/proto" + + gnmipb "github.com/openconfig/gnmi/proto/gnmi" +) + +// XXX: This is copied code from ygot package. ygot's code should probably +// live in this package instead. +func TestStringToPath(t *testing.T) { + tests := []struct { + name string + in string + wantStructuredPath *gnmipb.Path + wantStructuredErr string + }{{ + name: "simple path", + in: "/a/b/c/d", + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "a"}, + {Name: "b"}, + {Name: "c"}, + {Name: "d"}, + }, + }, + }, { + name: "path with simple key", + in: "/a/b[c=d]/e", + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "a"}, + {Name: "b", Key: map[string]string{"c": "d"}}, + {Name: "e"}, + }, + }, + }, { + name: "path with multiple keys", + in: "/a/b[c=d][e=f]/g", + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "a"}, + {Name: "b", Key: map[string]string{ + "c": "d", + "e": "f", + }}, + {Name: "g"}, + }, + }, + }, { + name: "path with a key missing an equals sign", + in: "/a/b[cd]/e", + wantStructuredErr: "received null key name for element b", + }, { + name: "path with slashes in the key", + in: `/interfaces/interface[name=Ethernet1/2/3]`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "interfaces"}, + {Name: "interface", Key: map[string]string{"name": "Ethernet1/2/3"}}, + }, + }, + }, { + name: "path with escaped equals in the key", + in: `/interfaces/interface[name=Ethernet\=bar]`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "interfaces"}, + {Name: "interface", Key: map[string]string{"name": `Ethernet=bar`}}, + }, + }, + }, { + name: "open square bracket in the key", + in: `/interfaces/interface[name=[foo]/state`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "interfaces"}, + {Name: "interface", Key: map[string]string{"name": "[foo"}}, + {Name: "state"}, + }, + }, + }, { + name: `name [name=[\\\]] example from specification`, + in: `/interfaces/interface[name=[\\\]]`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "interfaces"}, + {Name: "interface", Key: map[string]string{"name": `[\]`}}, + }, + }, + }, { + name: "forward slash in key which does not need to be escaped ", + in: `/interfaces/interface[name=\/foo]/state`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "interfaces"}, + {Name: "interface", Key: map[string]string{"name": `/foo`}}, + {Name: "state"}, + }, + }, + }, { + name: "escaped forward slash in an element name", + in: `/interfaces/inter\/face[name=foo]`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "interfaces"}, + {Name: "inter/face", Key: map[string]string{"name": "foo"}}, + }, + }, + }, { + name: "escaped forward slash in an attribute", + in: `/interfaces/interface[name=foo\/bar]`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "interfaces"}, + {Name: "interface", Key: map[string]string{"name": "foo/bar"}}, + }, + }, + }, { + name: `single-level wildcard`, + in: `/interfaces/interface/*/state`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "interfaces"}, + {Name: "interface"}, + {Name: "*"}, + {Name: "state"}, + }, + }, + }, { + name: "multi-level wildcard", + in: `/interfaces/.../state`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "interfaces"}, + {Name: "..."}, + {Name: "state"}, + }, + }, + }, { + name: "path with escaped backslash in an element", + in: `/foo/bar\\\/baz/hat`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "foo"}, + {Name: `bar/baz`}, + {Name: "hat"}, + }, + }, + }, { + name: "path with escaped backslash in a key", + in: `/foo/bar[baz\\foo=hat]`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "foo"}, + {Name: "bar", Key: map[string]string{`baz\foo`: "hat"}}, + }, + }, + }, { + name: "additional equals within the key, unescaped", + in: `/foo/bar[baz==bat]`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "foo"}, + {Name: "bar", Key: map[string]string{"baz": "=bat"}}, + }, + }, + }, { + name: "error - unescaped ] within a key value", + in: `/foo/bar[baz=]bat]`, + wantStructuredErr: "received null value for key baz of element bar", + }, { + name: "escaped ] within key value", + in: `/foo/bar[baz=\]bat]`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "foo"}, + {Name: "bar", Key: map[string]string{"baz": "]bat"}}, + }, + }, + }, { + name: "trailing garbage outside of kv name", + in: `/foo/bar[baz=bat]hat`, + wantStructuredErr: "trailing garbage following keys in element bar, got: hat", + }, { + name: "relative path", + in: `../foo/bar`, + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: ".."}, + {Name: "foo"}, + {Name: "bar"}, + }, + }, + }, { + name: "key with null value", + in: `/foo/bar[baz=]/hat`, + wantStructuredErr: "received null value for key baz of element bar", + }, { + name: "key with unescaped [ within key", + in: `/foo/bar[[bar=baz]`, + wantStructuredErr: "received an unescaped [ in key of element bar", + }, { + name: "element with unescaped ]", + in: `/foo/bar]`, + wantStructuredErr: "received an unescaped ] when not in a key for element bar", + }, { + name: "empty string", + in: "", + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{}, + }, + }, { + name: "root element", + in: "/", + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{}, + }, + }, { + name: "trailing /", + in: "/foo/bar/", + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "foo"}, + {Name: "bar"}, + }, + }, + }, { + name: "whitespace in key", + in: "foo[bar =baz]", + wantStructuredErr: "received an invalid space in element foo key name 'bar '", + }, { + name: "whitespace in value", + in: "foo[bar= baz]", + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{ + {Name: "foo", Key: map[string]string{"bar": " baz"}}, + }, + }, + }, { + name: "whitespace in element name", + in: "foo bar/baz", + wantStructuredErr: "invalid space character included in element name 'foo bar'", + }, { + name: "bgp example", + in: "neighbors/neighbor[neighbor-address=192.0.2.1]/config/neighbor-address", + wantStructuredPath: &gnmipb.Path{ + Elem: []*gnmipb.PathElem{{ + Name: "neighbors", + }, { + Name: "neighbor", + Key: map[string]string{"neighbor-address": "192.0.2.1"}, + }, { + Name: "config", + }, { + Name: "neighbor-address", + }}, + }, + }} + + for _, tt := range tests { + gotStructuredPath, strErr := stringToStructuredPath(tt.in) + if strErr != nil && !strings.Contains(strErr.Error(), tt.wantStructuredErr) { + t.Errorf("%s: stringToStructuredPath(%v): did not get expected error, got: %v, want: %v", tt.name, tt.in, strErr, tt.wantStructuredErr) + } + + if strErr == nil && !proto.Equal(gotStructuredPath, tt.wantStructuredPath) { + t.Errorf("%s: stringToStructuredPath(%v): did not get expected structured path, got: %v, want: %v", tt.name, tt.in, prototext.Format(gotStructuredPath), prototext.Format(tt.wantStructuredPath)) + } + } +}