From 7662fe34db8d05879dd4f4a0079d45698193693c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Thu, 24 Oct 2024 13:50:56 +0200 Subject: [PATCH 01/16] policy: implement partial matching, to evaluate in multiple steps with fail early --- pkg/policy/literal/literal.go | 2 - pkg/policy/match.go | 196 +++++++++++++++++++++++---------- pkg/policy/selector/parsing.go | 3 +- 3 files changed, 136 insertions(+), 65 deletions(-) diff --git a/pkg/policy/literal/literal.go b/pkg/policy/literal/literal.go index fa236da..16bc04b 100644 --- a/pkg/policy/literal/literal.go +++ b/pkg/policy/literal/literal.go @@ -8,8 +8,6 @@ import ( "github.com/ipld/go-ipld-prime/node/basicnode" ) -// TODO: remove entirely? - var Bool = basicnode.NewBool var Int = basicnode.NewInt var Float = basicnode.NewFloat diff --git a/pkg/policy/match.go b/pkg/policy/match.go index 308229d..4b0d414 100644 --- a/pkg/policy/match.go +++ b/pkg/policy/match.go @@ -12,141 +12,215 @@ import ( // Match determines if the IPLD node satisfies the policy. func (p Policy) Match(node datamodel.Node) bool { for _, stmt := range p { - ok := matchStatement(stmt, node) - if !ok { + res, _ := matchStatement(stmt, node) + switch res { + case matchResultNoData, matchResultFalse: return false + case matchResultTrue: + // continue } } return true } -func matchStatement(statement Statement, node ipld.Node) bool { - switch statement.Kind() { +// PartialMatch returns false IIF one of the Statement has the corresponding data and doesn't match. +// If the data is missing or the Statement is matching, true is returned. +// +// This allows performing the policy checking in multiple steps, and find immediately if a Statement already failed. +// A final call to Match is necessary to make sure that the policy is fully matched, with no missing data +// (apart from optional values). +// +// The first Statement failing to match is returned as well. +func (p Policy) PartialMatch(node datamodel.Node) (bool, Statement) { + for _, stmt := range p { + res, leaf := matchStatement(stmt, node) + switch res { + case matchResultFalse: + return false, leaf + case matchResultNoData, matchResultTrue: + // continue + } + } + return true, nil +} + +type matchResult int8 + +const ( + matchResultTrue matchResult = iota + matchResultFalse + matchResultNoData +) + +// matchStatement evaluate the policy against the given ipld.Node and returns: +// - matchResultTrue: if the selector matched and the statement evaluated to true. +// - matchResultFalse: if the selector matched and the statement evaluated to false. +// - matchResultNoData: if the selector didn't match the expected data. +// For matchResultTrue and matchResultNoData, the leaf-most (innermost) statement failing to be true is returned, +// as well as the corresponding root-most encompassing statement. +func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Statement) { + var boolToRes = func(v bool) (matchResult, Statement) { + if v { + return matchResultTrue, nil + } else { + return matchResultFalse, cur + } + } + + switch cur.Kind() { case KindEqual: - if s, ok := statement.(equality); ok { + if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) - if err != nil { - return false + if err != nil || res == nil { + return matchResultNoData, cur } - return datamodel.DeepEqual(s.value, res) + return boolToRes(datamodel.DeepEqual(s.value, res)) } case KindGreaterThan: - if s, ok := statement.(equality); ok { + if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) - if err != nil { - return false + if err != nil || res == nil { + return matchResultNoData, cur } - return isOrdered(s.value, res, gt) + return boolToRes(isOrdered(s.value, res, gt)) } case KindGreaterThanOrEqual: - if s, ok := statement.(equality); ok { + if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) - if err != nil { - return false + if err != nil || res == nil { + return matchResultNoData, cur } - return isOrdered(s.value, res, gte) + return boolToRes(isOrdered(s.value, res, gte)) } case KindLessThan: - if s, ok := statement.(equality); ok { + if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) - if err != nil { - return false + if err != nil || res == nil { + return matchResultNoData, cur } - return isOrdered(s.value, res, lt) + return boolToRes(isOrdered(s.value, res, lt)) } case KindLessThanOrEqual: - if s, ok := statement.(equality); ok { + if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) - if err != nil { - return false + if err != nil || res == nil { + return matchResultNoData, cur } - return isOrdered(s.value, res, lte) + return boolToRes(isOrdered(s.value, res, lte)) } case KindNot: - if s, ok := statement.(negation); ok { - return !matchStatement(s.statement, node) + if s, ok := cur.(negation); ok { + res, leaf := matchStatement(s.statement, node) + switch res { + case matchResultNoData: + return matchResultNoData, leaf + case matchResultTrue: + return matchResultFalse, leaf + case matchResultFalse: + return matchResultTrue, leaf + } } case KindAnd: - if s, ok := statement.(connective); ok { + if s, ok := cur.(connective); ok { for _, cs := range s.statements { - r := matchStatement(cs, node) - if !r { - return false + res, leaf := matchStatement(cs, node) + switch res { + case matchResultNoData: + return matchResultNoData, leaf + case matchResultTrue: + // continue + case matchResultFalse: + return matchResultFalse, leaf } } - return true + return matchResultTrue, nil } case KindOr: - if s, ok := statement.(connective); ok { + if s, ok := cur.(connective); ok { if len(s.statements) == 0 { - return true + return matchResultTrue, nil } for _, cs := range s.statements { - r := matchStatement(cs, node) - if r { - return true + res, leaf := matchStatement(cs, node) + switch res { + case matchResultNoData: + return matchResultNoData, leaf + case matchResultTrue: + return matchResultTrue, leaf + case matchResultFalse: + // continue } } - return false + return matchResultFalse, cur } case KindLike: - if s, ok := statement.(wildcard); ok { + if s, ok := cur.(wildcard); ok { res, err := s.selector.Select(node) - if err != nil { - return false + if err != nil || res == nil { + return matchResultNoData, cur } v, err := res.AsString() if err != nil { - return false // not a string + return matchResultFalse, cur // not a string } - return s.pattern.Match(v) + return boolToRes(s.pattern.Match(v)) } case KindAll: - if s, ok := statement.(quantifier); ok { + if s, ok := cur.(quantifier); ok { res, err := s.selector.Select(node) - if err != nil { - return false + if err != nil || res == nil { + return matchResultNoData, cur } it := res.ListIterator() if it == nil { - return false // not a list + return matchResultFalse, cur // not a list } for !it.Done() { _, v, err := it.Next() if err != nil { - return false + panic("should never happen") } - ok := matchStatement(s.statement, v) - if !ok { - return false + matchRes, leaf := matchStatement(s.statement, v) + switch matchRes { + case matchResultNoData: + return matchResultNoData, leaf + case matchResultTrue: + // continue + case matchResultFalse: + return matchResultFalse, leaf } } - return true + return matchResultTrue, nil } case KindAny: - if s, ok := statement.(quantifier); ok { + if s, ok := cur.(quantifier); ok { res, err := s.selector.Select(node) - if err != nil { - return false + if err != nil || res == nil { + return matchResultNoData, cur } it := res.ListIterator() if it == nil { - return false // not a list + return matchResultFalse, cur // not a list } for !it.Done() { _, v, err := it.Next() if err != nil { - return false + panic("should never happen") } - ok := matchStatement(s.statement, v) - if ok { - return true + matchRes, leaf := matchStatement(s.statement, v) + switch matchRes { + case matchResultNoData: + return matchResultNoData, leaf + case matchResultTrue: + return matchResultTrue, nil + case matchResultFalse: + // continue } } - return false + return matchResultFalse, cur } } - panic(fmt.Errorf("unimplemented statement kind: %s", statement.Kind())) + panic(fmt.Errorf("unimplemented statement kind: %s", cur.Kind())) } func isOrdered(expected ipld.Node, actual ipld.Node, satisfies func(order int) bool) bool { diff --git a/pkg/policy/selector/parsing.go b/pkg/policy/selector/parsing.go index a432ec0..507ef77 100644 --- a/pkg/policy/selector/parsing.go +++ b/pkg/policy/selector/parsing.go @@ -9,7 +9,6 @@ import ( ) var ( - identity = Selector{segment{str: ".", identity: true}} indexRegex = regexp.MustCompile(`^-?\d+$`) sliceRegex = regexp.MustCompile(`^((\-?\d+:\-?\d*)|(\-?\d*:\-?\d+))$`) fieldRegex = regexp.MustCompile(`^\.[a-zA-Z_]*?$`) @@ -23,7 +22,7 @@ func Parse(str string) (Selector, error) { return nil, newParseError("selector must start with identity segment '.'", str, 0, string(str[0])) } if str == "." { - return identity, nil + return Selector{segment{str: ".", identity: true}}, nil } if str == ".?" { return Selector{segment{str: ".?", identity: true, optional: true}}, nil From 6d85b2ba3c53be03d99c0b3c628297bbf7bba937 Mon Sep 17 00:00:00 2001 From: Fabio Bozzo Date: Fri, 1 Nov 2024 13:07:46 +0100 Subject: [PATCH 02/16] additional tests for optional selectors --- pkg/policy/literal/literal.go | 57 +++++++++++++++++++++++ pkg/policy/match.go | 15 +++++++ pkg/policy/match_test.go | 80 ++++++++++++++++++++++++++++++++- pkg/policy/selector/selector.go | 8 ++++ 4 files changed, 159 insertions(+), 1 deletion(-) diff --git a/pkg/policy/literal/literal.go b/pkg/policy/literal/literal.go index 16bc04b..3e6dd8e 100644 --- a/pkg/policy/literal/literal.go +++ b/pkg/policy/literal/literal.go @@ -2,6 +2,8 @@ package literal import ( + "fmt" + "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" cidlink "github.com/ipld/go-ipld-prime/linking/cid" @@ -24,3 +26,58 @@ func Null() ipld.Node { nb.AssignNull() return nb.Build() } + +// Map creates an IPLD node from a map[string]interface{} +func Map(v interface{}) (ipld.Node, error) { + m, ok := v.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("expected map[string]interface{}, got %T", v) + } + + nb := basicnode.Prototype.Map.NewBuilder() + ma, err := nb.BeginMap(int64(len(m))) + if err != nil { + return nil, err + } + + for k, v := range m { + if err := ma.AssembleKey().AssignString(k); err != nil { + return nil, err + } + + switch x := v.(type) { + case string: + if err := ma.AssembleValue().AssignString(x); err != nil { + return nil, err + } + case []interface{}: + lb := basicnode.Prototype.List.NewBuilder() + la, err := lb.BeginList(int64(len(x))) + if err != nil { + return nil, err + } + if err := la.Finish(); err != nil { + return nil, err + } + if err := ma.AssembleValue().AssignNode(lb.Build()); err != nil { + return nil, err + } + case map[string]interface{}: + nestedNode, err := Map(x) // recursive call for nested maps + if err != nil { + return nil, err + } + if err := ma.AssembleValue().AssignNode(nestedNode); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unsupported value type: %T", v) + } + } + + if err := ma.Finish(); err != nil { + return nil, err + } + + return nb.Build(), nil +} diff --git a/pkg/policy/match.go b/pkg/policy/match.go index 4b0d414..bdf66e4 100644 --- a/pkg/policy/match.go +++ b/pkg/policy/match.go @@ -72,6 +72,9 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) if err != nil || res == nil { + if s.selector.IsOptional() { + return matchResultTrue, nil + } return matchResultNoData, cur } return boolToRes(datamodel.DeepEqual(s.value, res)) @@ -80,6 +83,9 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) if err != nil || res == nil { + if s.selector.IsOptional() { + return matchResultTrue, nil + } return matchResultNoData, cur } return boolToRes(isOrdered(s.value, res, gt)) @@ -88,6 +94,9 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) if err != nil || res == nil { + if s.selector.IsOptional() { + return matchResultTrue, nil + } return matchResultNoData, cur } return boolToRes(isOrdered(s.value, res, gte)) @@ -96,6 +105,9 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) if err != nil || res == nil { + if s.selector.IsOptional() { + return matchResultTrue, nil + } return matchResultNoData, cur } return boolToRes(isOrdered(s.value, res, lt)) @@ -104,6 +116,9 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) if err != nil || res == nil { + if s.selector.IsOptional() { + return matchResultTrue, nil + } return matchResultNoData, cur } return boolToRes(isOrdered(s.value, res, lte)) diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 9e3de4a..46a4a4f 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -9,6 +9,7 @@ import ( "github.com/ipld/go-ipld-prime/codec/dagjson" cidlink "github.com/ipld/go-ipld-prime/linking/cid" "github.com/ipld/go-ipld-prime/node/basicnode" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ucan-wg/go-ucan/pkg/policy/literal" @@ -457,7 +458,7 @@ func TestPolicyExamples(t *testing.T) { require.False(t, evaluate(`["all", ".a", [">", ".b", 0]]`, data)) }) - t.Run("Any", func(t *testing.T) { + t.Run("Map", func(t *testing.T) { data := makeNode(`{"a": [{"b": 1}, {"b": 2}, {"z": [7, 8, 9]}]}`) require.True(t, evaluate(`["any", ".a", ["==", ".b", 2]]`, data)) @@ -512,3 +513,80 @@ func FuzzMatch(f *testing.F) { policy.Match(dataNode) }) } + +func TestOptionalSelectors(t *testing.T) { + tests := []struct { + name string + policy Policy + data interface{} + expected bool + }{ + { + name: "missing optional field returns true", + policy: MustConstruct(Equal(".field?", literal.String("value"))), + data: map[string]interface{}{}, + expected: true, + }, + { + name: "present optional field with matching value returns true", + policy: MustConstruct(Equal(".field?", literal.String("value"))), + data: map[string]interface{}{"field": "value"}, + expected: true, + }, + { + name: "present optional field with non-matching value returns false", + policy: MustConstruct(Equal(".field?", literal.String("value"))), + data: map[string]interface{}{"field": "other"}, + expected: false, + }, + { + name: "missing non-optional field returns false", + policy: MustConstruct(Equal(".field", literal.String("value"))), + data: map[string]interface{}{}, + expected: false, + }, + { + name: "nested missing non-optional field returns false", + policy: MustConstruct(Equal(".outer?.inner", literal.String("value"))), + data: map[string]interface{}{"outer": map[string]interface{}{}}, + expected: false, + }, + { + name: "completely missing nested optional path returns true", + policy: MustConstruct(Equal(".outer?.inner?", literal.String("value"))), + data: map[string]interface{}{}, + expected: true, + }, + { + name: "partially present nested optional path with missing end returns true", + policy: MustConstruct(Equal(".outer?.inner?", literal.String("value"))), + data: map[string]interface{}{"outer": map[string]interface{}{}}, + expected: true, + }, + { + name: "optional array index returns true when array is empty", + policy: MustConstruct(Equal(".array[0]?", literal.String("value"))), + data: map[string]interface{}{"array": []interface{}{}}, + expected: true, + }, + { + name: "non-optional array index returns false when array is empty", + policy: MustConstruct(Equal(".array[0]", literal.String("value"))), + data: map[string]interface{}{"array": []interface{}{}}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + nb := basicnode.Prototype.Map.NewBuilder() + n, err := literal.Map(tt.data) + assert.NoError(t, err) + err = nb.AssignNode(n) + assert.NoError(t, err) + + result := tt.policy.Match(nb.Build()) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/policy/selector/selector.go b/pkg/policy/selector/selector.go index 149078d..0d13a47 100644 --- a/pkg/policy/selector/selector.go +++ b/pkg/policy/selector/selector.go @@ -33,6 +33,14 @@ func (s Selector) String() string { return res.String() } +func (s Selector) IsOptional() bool { + if len(s) == 0 { + return false + } + + return s[len(s)-1].optional +} + type segment struct { str string identity bool From b210c6917366a0c2f49bd25ea08710aab324da16 Mon Sep 17 00:00:00 2001 From: Fabio Bozzo Date: Fri, 1 Nov 2024 17:43:55 +0100 Subject: [PATCH 03/16] tests for partial match --- pkg/policy/match_test.go | 77 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 46a4a4f..df3459f 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -590,3 +590,80 @@ func TestOptionalSelectors(t *testing.T) { }) } } + +// The unique behaviour of PartialMatch is that it should return true for missing non-optional data (unlike Match). +func TestPartialMatch(t *testing.T) { + tests := []struct { + name string + policy Policy + data interface{} + expectedMatch bool + expectedStmt Statement + }{ + { + name: "returns true for missing non-optional field", + policy: MustConstruct( + Equal(".field", literal.String("value")), + ), + data: map[string]interface{}{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns true when present data matches", + policy: MustConstruct( + Equal(".foo", literal.String("correct")), + Equal(".missing", literal.String("whatever")), + ), + data: map[string]interface{}{ + "foo": "correct", + }, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns false with failing statement for present but non-matching value", + policy: MustConstruct( + Equal(".foo", literal.String("value1")), + Equal(".bar", literal.String("value2")), + ), + data: map[string]interface{}{ + "foo": "wrong", + "bar": "value2", + }, + expectedMatch: false, + expectedStmt: MustConstruct( + Equal(".foo", literal.String("value1")), + )[0], + }, + { + name: "continues past missing data until finding actual mismatch", + policy: MustConstruct( + Equal(".missing", literal.String("value")), + Equal(".present", literal.String("wrong")), + ), + data: map[string]interface{}{ + "present": "actual", + }, + expectedMatch: false, + expectedStmt: MustConstruct( + Equal(".present", literal.String("wrong")), + )[0], + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + node, err := literal.Map(tt.data) + assert.NoError(t, err) + + match, stmt := tt.policy.PartialMatch(node) + assert.Equal(t, tt.expectedMatch, match) + if tt.expectedStmt == nil { + assert.Nil(t, stmt) + } else { + assert.Equal(t, tt.expectedStmt.Kind(), stmt.Kind()) + } + }) + } +} From 9e9c632ded8cf7b7de6ec1c2fd127ad0c557b94f Mon Sep 17 00:00:00 2001 From: Fabio Bozzo Date: Fri, 1 Nov 2024 17:47:47 +0100 Subject: [PATCH 04/16] revert typo --- pkg/policy/match_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index df3459f..559789a 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -458,7 +458,7 @@ func TestPolicyExamples(t *testing.T) { require.False(t, evaluate(`["all", ".a", [">", ".b", 0]]`, data)) }) - t.Run("Map", func(t *testing.T) { + t.Run("Any", func(t *testing.T) { data := makeNode(`{"a": [{"b": 1}, {"b": 2}, {"z": [7, 8, 9]}]}`) require.True(t, evaluate(`["any", ".a", ["==", ".b", 2]]`, data)) From 6717a3a89c68541fcd616776d01a66f72d66a511 Mon Sep 17 00:00:00 2001 From: Fabio Bozzo Date: Mon, 4 Nov 2024 10:56:06 +0100 Subject: [PATCH 05/16] refactor: simplify optional selector handling Let Select() handle optional selectors by checking its nil return value, rather than explicitly checking IsOptional() Applied this pattern consistently across all statement kinds (Equal, Like, All, Any, etc) --- pkg/policy/match.go | 55 ++++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/pkg/policy/match.go b/pkg/policy/match.go index bdf66e4..480cedb 100644 --- a/pkg/policy/match.go +++ b/pkg/policy/match.go @@ -71,56 +71,56 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat case KindEqual: if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) - if err != nil || res == nil { - if s.selector.IsOptional() { - return matchResultTrue, nil - } + if err != nil { return matchResultNoData, cur } + if res == nil { // Optional selector that didn't match + return matchResultTrue, nil + } return boolToRes(datamodel.DeepEqual(s.value, res)) } case KindGreaterThan: if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) - if err != nil || res == nil { - if s.selector.IsOptional() { - return matchResultTrue, nil - } + if err != nil { return matchResultNoData, cur } + if res == nil { + return matchResultTrue, nil + } return boolToRes(isOrdered(s.value, res, gt)) } case KindGreaterThanOrEqual: if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) - if err != nil || res == nil { - if s.selector.IsOptional() { - return matchResultTrue, nil - } + if err != nil { return matchResultNoData, cur } + if res == nil { + return matchResultTrue, nil + } return boolToRes(isOrdered(s.value, res, gte)) } case KindLessThan: if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) - if err != nil || res == nil { - if s.selector.IsOptional() { - return matchResultTrue, nil - } + if err != nil { return matchResultNoData, cur } + if res == nil { + return matchResultTrue, nil + } return boolToRes(isOrdered(s.value, res, lt)) } case KindLessThanOrEqual: if s, ok := cur.(equality); ok { res, err := s.selector.Select(node) - if err != nil || res == nil { - if s.selector.IsOptional() { - return matchResultTrue, nil - } + if err != nil { return matchResultNoData, cur } + if res == nil { + return matchResultTrue, nil + } return boolToRes(isOrdered(s.value, res, lte)) } case KindNot: @@ -171,9 +171,12 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat case KindLike: if s, ok := cur.(wildcard); ok { res, err := s.selector.Select(node) - if err != nil || res == nil { + if err != nil { return matchResultNoData, cur } + if res == nil { + return matchResultTrue, nil + } v, err := res.AsString() if err != nil { return matchResultFalse, cur // not a string @@ -183,9 +186,12 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat case KindAll: if s, ok := cur.(quantifier); ok { res, err := s.selector.Select(node) - if err != nil || res == nil { + if err != nil { return matchResultNoData, cur } + if res == nil { + return matchResultTrue, nil + } it := res.ListIterator() if it == nil { return matchResultFalse, cur // not a list @@ -210,9 +216,12 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat case KindAny: if s, ok := cur.(quantifier); ok { res, err := s.selector.Select(node) - if err != nil || res == nil { + if err != nil { return matchResultNoData, cur } + if res == nil { + return matchResultTrue, nil + } it := res.ListIterator() if it == nil { return matchResultFalse, cur // not a list From 400f689a859ef58f3d3f13b4475a78639a416df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Mon, 4 Nov 2024 11:15:12 +0100 Subject: [PATCH 06/16] literal: some better typing --- pkg/policy/literal/literal.go | 13 ++++--------- pkg/policy/match_test.go | 30 +++++++++++++++--------------- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/pkg/policy/literal/literal.go b/pkg/policy/literal/literal.go index 3e6dd8e..b9041cc 100644 --- a/pkg/policy/literal/literal.go +++ b/pkg/policy/literal/literal.go @@ -27,13 +27,8 @@ func Null() ipld.Node { return nb.Build() } -// Map creates an IPLD node from a map[string]interface{} -func Map(v interface{}) (ipld.Node, error) { - m, ok := v.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("expected map[string]interface{}, got %T", v) - } - +// Map creates an IPLD node from a map[string]any +func Map(m map[string]any) (ipld.Node, error) { nb := basicnode.Prototype.Map.NewBuilder() ma, err := nb.BeginMap(int64(len(m))) if err != nil { @@ -50,7 +45,7 @@ func Map(v interface{}) (ipld.Node, error) { if err := ma.AssembleValue().AssignString(x); err != nil { return nil, err } - case []interface{}: + case []any: lb := basicnode.Prototype.List.NewBuilder() la, err := lb.BeginList(int64(len(x))) if err != nil { @@ -62,7 +57,7 @@ func Map(v interface{}) (ipld.Node, error) { if err := ma.AssembleValue().AssignNode(lb.Build()); err != nil { return nil, err } - case map[string]interface{}: + case map[string]any: nestedNode, err := Map(x) // recursive call for nested maps if err != nil { return nil, err diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 559789a..5baab7e 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -518,61 +518,61 @@ func TestOptionalSelectors(t *testing.T) { tests := []struct { name string policy Policy - data interface{} + data map[string]any expected bool }{ { name: "missing optional field returns true", policy: MustConstruct(Equal(".field?", literal.String("value"))), - data: map[string]interface{}{}, + data: map[string]any{}, expected: true, }, { name: "present optional field with matching value returns true", policy: MustConstruct(Equal(".field?", literal.String("value"))), - data: map[string]interface{}{"field": "value"}, + data: map[string]any{"field": "value"}, expected: true, }, { name: "present optional field with non-matching value returns false", policy: MustConstruct(Equal(".field?", literal.String("value"))), - data: map[string]interface{}{"field": "other"}, + data: map[string]any{"field": "other"}, expected: false, }, { name: "missing non-optional field returns false", policy: MustConstruct(Equal(".field", literal.String("value"))), - data: map[string]interface{}{}, + data: map[string]any{}, expected: false, }, { name: "nested missing non-optional field returns false", policy: MustConstruct(Equal(".outer?.inner", literal.String("value"))), - data: map[string]interface{}{"outer": map[string]interface{}{}}, + data: map[string]any{"outer": map[string]interface{}{}}, expected: false, }, { name: "completely missing nested optional path returns true", policy: MustConstruct(Equal(".outer?.inner?", literal.String("value"))), - data: map[string]interface{}{}, + data: map[string]any{}, expected: true, }, { name: "partially present nested optional path with missing end returns true", policy: MustConstruct(Equal(".outer?.inner?", literal.String("value"))), - data: map[string]interface{}{"outer": map[string]interface{}{}}, + data: map[string]any{"outer": map[string]interface{}{}}, expected: true, }, { name: "optional array index returns true when array is empty", policy: MustConstruct(Equal(".array[0]?", literal.String("value"))), - data: map[string]interface{}{"array": []interface{}{}}, + data: map[string]any{"array": []interface{}{}}, expected: true, }, { name: "non-optional array index returns false when array is empty", policy: MustConstruct(Equal(".array[0]", literal.String("value"))), - data: map[string]interface{}{"array": []interface{}{}}, + data: map[string]any{"array": []interface{}{}}, expected: false, }, } @@ -596,7 +596,7 @@ func TestPartialMatch(t *testing.T) { tests := []struct { name string policy Policy - data interface{} + data map[string]any expectedMatch bool expectedStmt Statement }{ @@ -605,7 +605,7 @@ func TestPartialMatch(t *testing.T) { policy: MustConstruct( Equal(".field", literal.String("value")), ), - data: map[string]interface{}{}, + data: map[string]any{}, expectedMatch: true, expectedStmt: nil, }, @@ -615,7 +615,7 @@ func TestPartialMatch(t *testing.T) { Equal(".foo", literal.String("correct")), Equal(".missing", literal.String("whatever")), ), - data: map[string]interface{}{ + data: map[string]any{ "foo": "correct", }, expectedMatch: true, @@ -627,7 +627,7 @@ func TestPartialMatch(t *testing.T) { Equal(".foo", literal.String("value1")), Equal(".bar", literal.String("value2")), ), - data: map[string]interface{}{ + data: map[string]any{ "foo": "wrong", "bar": "value2", }, @@ -642,7 +642,7 @@ func TestPartialMatch(t *testing.T) { Equal(".missing", literal.String("value")), Equal(".present", literal.String("wrong")), ), - data: map[string]interface{}{ + data: map[string]any{ "present": "actual", }, expectedMatch: false, From 3cf1de6b671d090ae771106f57fcec43fda7d730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Mon, 4 Nov 2024 11:15:32 +0100 Subject: [PATCH 07/16] policy: fix distrinction between "no data" and "optional not data" --- pkg/policy/match.go | 63 +++++++++++++++++---------------- pkg/policy/selector/selector.go | 8 ----- 2 files changed, 32 insertions(+), 39 deletions(-) diff --git a/pkg/policy/match.go b/pkg/policy/match.go index 480cedb..c3862d5 100644 --- a/pkg/policy/match.go +++ b/pkg/policy/match.go @@ -16,15 +16,15 @@ func (p Policy) Match(node datamodel.Node) bool { switch res { case matchResultNoData, matchResultFalse: return false - case matchResultTrue: + case matchResultOptionalNoData, matchResultTrue: // continue } } return true } -// PartialMatch returns false IIF one of the Statement has the corresponding data and doesn't match. -// If the data is missing or the Statement is matching, true is returned. +// PartialMatch returns false IIF one non-optional Statement has the corresponding data and doesn't match. +// If the data is missing or the non-optional Statement is matching, true is returned. // // This allows performing the policy checking in multiple steps, and find immediately if a Statement already failed. // A final call to Match is necessary to make sure that the policy is fully matched, with no missing data @@ -37,7 +37,7 @@ func (p Policy) PartialMatch(node datamodel.Node) (bool, Statement) { switch res { case matchResultFalse: return false, leaf - case matchResultNoData, matchResultTrue: + case matchResultNoData, matchResultOptionalNoData, matchResultTrue: // continue } } @@ -47,9 +47,10 @@ func (p Policy) PartialMatch(node datamodel.Node) (bool, Statement) { type matchResult int8 const ( - matchResultTrue matchResult = iota - matchResultFalse - matchResultNoData + matchResultTrue matchResult = iota // statement has data and resolve to true + matchResultFalse // statement has data and resolve to false + matchResultNoData // statement has no data + matchResultOptionalNoData // statement has no data and is optional ) // matchStatement evaluate the policy against the given ipld.Node and returns: @@ -74,8 +75,8 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat if err != nil { return matchResultNoData, cur } - if res == nil { // Optional selector that didn't match - return matchResultTrue, nil + if res == nil { // optional selector didn't match + return matchResultOptionalNoData, nil } return boolToRes(datamodel.DeepEqual(s.value, res)) } @@ -85,8 +86,8 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat if err != nil { return matchResultNoData, cur } - if res == nil { - return matchResultTrue, nil + if res == nil { // optional selector didn't match + return matchResultOptionalNoData, nil } return boolToRes(isOrdered(s.value, res, gt)) } @@ -96,8 +97,8 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat if err != nil { return matchResultNoData, cur } - if res == nil { - return matchResultTrue, nil + if res == nil { // optional selector didn't match + return matchResultOptionalNoData, nil } return boolToRes(isOrdered(s.value, res, gte)) } @@ -107,8 +108,8 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat if err != nil { return matchResultNoData, cur } - if res == nil { - return matchResultTrue, nil + if res == nil { // optional selector didn't match + return matchResultOptionalNoData, nil } return boolToRes(isOrdered(s.value, res, lt)) } @@ -118,8 +119,8 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat if err != nil { return matchResultNoData, cur } - if res == nil { - return matchResultTrue, nil + if res == nil { // optional selector didn't match + return matchResultOptionalNoData, nil } return boolToRes(isOrdered(s.value, res, lte)) } @@ -127,8 +128,8 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat if s, ok := cur.(negation); ok { res, leaf := matchStatement(s.statement, node) switch res { - case matchResultNoData: - return matchResultNoData, leaf + case matchResultNoData, matchResultOptionalNoData: + return res, leaf case matchResultTrue: return matchResultFalse, leaf case matchResultFalse: @@ -140,8 +141,8 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat for _, cs := range s.statements { res, leaf := matchStatement(cs, node) switch res { - case matchResultNoData: - return matchResultNoData, leaf + case matchResultNoData, matchResultOptionalNoData: + return res, leaf case matchResultTrue: // continue case matchResultFalse: @@ -158,8 +159,8 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat for _, cs := range s.statements { res, leaf := matchStatement(cs, node) switch res { - case matchResultNoData: - return matchResultNoData, leaf + case matchResultNoData, matchResultOptionalNoData: + return res, leaf case matchResultTrue: return matchResultTrue, leaf case matchResultFalse: @@ -174,8 +175,8 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat if err != nil { return matchResultNoData, cur } - if res == nil { - return matchResultTrue, nil + if res == nil { // optional selector didn't match + return matchResultOptionalNoData, nil } v, err := res.AsString() if err != nil { @@ -190,7 +191,7 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat return matchResultNoData, cur } if res == nil { - return matchResultTrue, nil + return matchResultOptionalNoData, nil } it := res.ListIterator() if it == nil { @@ -203,8 +204,8 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat } matchRes, leaf := matchStatement(s.statement, v) switch matchRes { - case matchResultNoData: - return matchResultNoData, leaf + case matchResultNoData, matchResultOptionalNoData: + return matchRes, leaf case matchResultTrue: // continue case matchResultFalse: @@ -220,7 +221,7 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat return matchResultNoData, cur } if res == nil { - return matchResultTrue, nil + return matchResultOptionalNoData, nil } it := res.ListIterator() if it == nil { @@ -233,8 +234,8 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat } matchRes, leaf := matchStatement(s.statement, v) switch matchRes { - case matchResultNoData: - return matchResultNoData, leaf + case matchResultNoData, matchResultOptionalNoData: + return matchRes, leaf case matchResultTrue: return matchResultTrue, nil case matchResultFalse: diff --git a/pkg/policy/selector/selector.go b/pkg/policy/selector/selector.go index 0d13a47..149078d 100644 --- a/pkg/policy/selector/selector.go +++ b/pkg/policy/selector/selector.go @@ -33,14 +33,6 @@ func (s Selector) String() string { return res.String() } -func (s Selector) IsOptional() bool { - if len(s) == 0 { - return false - } - - return s[len(s)-1].optional -} - type segment struct { str string identity bool From 10b5e1e603c37c486e505f48378f06503585d8a4 Mon Sep 17 00:00:00 2001 From: Fabio Bozzo Date: Mon, 4 Nov 2024 13:04:54 +0100 Subject: [PATCH 08/16] add test cases for optional, like pattern, nested policy --- pkg/policy/match_test.go | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 5baab7e..8af5ba1 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -650,6 +650,88 @@ func TestPartialMatch(t *testing.T) { Equal(".present", literal.String("wrong")), )[0], }, + + // Optional fields + { + name: "returns true for missing optional field", + policy: MustConstruct( + Equal(".field?", literal.String("value")), + ), + data: map[string]interface{}{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns false when optional field present but wrong", + policy: MustConstruct( + Equal(".field?", literal.String("value")), + ), + data: map[string]interface{}{ + "field": "wrong", + }, + expectedMatch: false, + expectedStmt: MustConstruct( + Equal(".field?", literal.String("value")), + )[0], + }, + + // Like pattern matching + { + name: "returns true for matching like pattern", + policy: MustConstruct( + Like(".pattern", "test*"), + ), + data: map[string]interface{}{ + "pattern": "testing123", + }, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns false for non-matching like pattern", + policy: MustConstruct( + Like(".pattern", "test*"), + ), + data: map[string]interface{}{ + "pattern": "wrong123", + }, + expectedMatch: false, + expectedStmt: MustConstruct( + Like(".pattern", "test*"), + )[0], + }, + + // Complex nested case + { + name: "complex nested policy", + policy: MustConstruct( + And( + Equal(".required", literal.String("present")), + Equal(".optional?", literal.String("value")), + Any(".items", + And( + Equal(".name", literal.String("test")), + Like(".id", "ID*"), + ), + ), + ), + ), + data: map[string]interface{}{ + "required": "present", + "items": []interface{}{ + map[string]interface{}{ + "name": "wrong", + "id": "ID123", + }, + map[string]interface{}{ + "name": "test", + "id": "ID456", + }, + }, + }, + expectedMatch: true, + expectedStmt: nil, + }, } for _, tt := range tests { From 5bfe430934c462f1c3f53ec76a0c7fea25bceb30 Mon Sep 17 00:00:00 2001 From: Fabio Bozzo Date: Mon, 4 Nov 2024 17:07:32 +0100 Subject: [PATCH 09/16] add test cases for missing optional values for all operators --- pkg/policy/match_test.go | 84 +++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 8af5ba1..3d21fad 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -652,15 +652,6 @@ func TestPartialMatch(t *testing.T) { }, // Optional fields - { - name: "returns true for missing optional field", - policy: MustConstruct( - Equal(".field?", literal.String("value")), - ), - data: map[string]interface{}{}, - expectedMatch: true, - expectedStmt: nil, - }, { name: "returns false when optional field present but wrong", policy: MustConstruct( @@ -732,6 +723,81 @@ func TestPartialMatch(t *testing.T) { expectedMatch: true, expectedStmt: nil, }, + + // missing optional values for all the operators + { + name: "returns true for missing optional equal", + policy: MustConstruct( + Equal(".field?", literal.String("value")), + ), + data: map[string]interface{}{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns true for missing optional like pattern", + policy: MustConstruct( + Like(".pattern?", "test*"), + ), + data: map[string]interface{}{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns true for missing optional greater than", + policy: MustConstruct( + GreaterThan(".number?", literal.Int(5)), + ), + data: map[string]interface{}{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns true for missing optional less than", + policy: MustConstruct( + LessThan(".number?", literal.Int(5)), + ), + data: map[string]interface{}{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns true for missing optional array with all", + policy: MustConstruct( + All(".numbers?", Equal(".", literal.Int(1))), + ), + data: map[string]interface{}{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns true for missing optional array with any", + policy: MustConstruct( + Any(".numbers?", Equal(".", literal.Int(1))), + ), + data: map[string]interface{}{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns true for complex nested optional paths", + policy: MustConstruct( + And( + Equal(".required", literal.String("present")), + Any(".optional_array?", + And( + Equal(".name?", literal.String("test")), + Like(".id?", "ID*"), + ), + ), + ), + ), + data: map[string]interface{}{ + "required": "present", + }, + expectedMatch: true, + expectedStmt: nil, + }, } for _, tt := range tests { From bc847ee02749930a2b2e3184fa17ae1af18edd11 Mon Sep 17 00:00:00 2001 From: Fabio Bozzo Date: Mon, 4 Nov 2024 17:10:57 +0100 Subject: [PATCH 10/16] fix literal.Map to handle list values too --- pkg/policy/literal/literal.go | 18 ++++++++++++++++++ pkg/policy/match_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/pkg/policy/literal/literal.go b/pkg/policy/literal/literal.go index b9041cc..872f6a1 100644 --- a/pkg/policy/literal/literal.go +++ b/pkg/policy/literal/literal.go @@ -51,6 +51,24 @@ func Map(m map[string]any) (ipld.Node, error) { if err != nil { return nil, err } + for _, elem := range x { + switch e := elem.(type) { + case string: + if err := la.AssembleValue().AssignString(e); err != nil { + return nil, err + } + case map[string]any: + nestedNode, err := Map(e) + if err != nil { + return nil, err + } + if err := la.AssembleValue().AssignNode(nestedNode); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unsupported array element type: %T", elem) + } + } if err := la.Finish(); err != nil { return nil, err } diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 3d21fad..da03c46 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -798,6 +798,31 @@ func TestPartialMatch(t *testing.T) { expectedMatch: true, expectedStmt: nil, }, + { + name: "returns true for partially present nested optional paths", + policy: MustConstruct( + And( + Equal(".required", literal.String("present")), + Any(".items", + And( + Equal(".name", literal.String("test")), + Like(".optional_id?", "ID*"), + ), + ), + ), + ), + data: map[string]interface{}{ + "required": "present", + "items": []interface{}{ + map[string]interface{}{ + "name": "test", + // optional_id is missing + }, + }, + }, + expectedMatch: true, + expectedStmt: nil, + }, } for _, tt := range tests { From 19721027e4ba3fb5cda144d7c3694eda7ab0186e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Mon, 4 Nov 2024 18:27:38 +0100 Subject: [PATCH 11/16] literal: rewrite Map() to cover more types --- pkg/policy/literal/literal.go | 122 ++++++++++++++++------------------ 1 file changed, 59 insertions(+), 63 deletions(-) diff --git a/pkg/policy/literal/literal.go b/pkg/policy/literal/literal.go index 872f6a1..64baa1d 100644 --- a/pkg/policy/literal/literal.go +++ b/pkg/policy/literal/literal.go @@ -3,9 +3,12 @@ package literal import ( "fmt" + "reflect" "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" cidlink "github.com/ipld/go-ipld-prime/linking/cid" "github.com/ipld/go-ipld-prime/node/basicnode" ) @@ -29,68 +32,61 @@ func Null() ipld.Node { // Map creates an IPLD node from a map[string]any func Map(m map[string]any) (ipld.Node, error) { - nb := basicnode.Prototype.Map.NewBuilder() - ma, err := nb.BeginMap(int64(len(m))) - if err != nil { - return nil, err - } - - for k, v := range m { - if err := ma.AssembleKey().AssignString(k); err != nil { - return nil, err + return qp.BuildMap(basicnode.Prototype.Any, int64(len(m)), func(ma datamodel.MapAssembler) { + for k, v := range m { + qp.MapEntry(ma, k, anyAssemble(v)) } - - switch x := v.(type) { - case string: - if err := ma.AssembleValue().AssignString(x); err != nil { - return nil, err - } - case []any: - lb := basicnode.Prototype.List.NewBuilder() - la, err := lb.BeginList(int64(len(x))) - if err != nil { - return nil, err - } - for _, elem := range x { - switch e := elem.(type) { - case string: - if err := la.AssembleValue().AssignString(e); err != nil { - return nil, err - } - case map[string]any: - nestedNode, err := Map(e) - if err != nil { - return nil, err - } - if err := la.AssembleValue().AssignNode(nestedNode); err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("unsupported array element type: %T", elem) - } - } - if err := la.Finish(); err != nil { - return nil, err - } - if err := ma.AssembleValue().AssignNode(lb.Build()); err != nil { - return nil, err - } - case map[string]any: - nestedNode, err := Map(x) // recursive call for nested maps - if err != nil { - return nil, err - } - if err := ma.AssembleValue().AssignNode(nestedNode); err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("unsupported value type: %T", v) - } - } - - if err := ma.Finish(); err != nil { - return nil, err - } - - return nb.Build(), nil + }) +} + +func anyAssemble(val any) qp.Assemble { + var rt reflect.Type + var rv reflect.Value + + // support for recursive calls, staying in reflection land + if cast, ok := val.(reflect.Value); ok { + rt = cast.Type() + rv = cast + } else { + rt = reflect.TypeOf(val) + rv = reflect.ValueOf(val) + } + + // we need to dereference in some cases, to get the real value type + if rt.Kind() == reflect.Ptr || rt.Kind() == reflect.Interface { + rv = rv.Elem() + rt = rv.Type() + } + + switch rt.Kind() { + case reflect.Array, reflect.Slice: + return qp.List(int64(rv.Len()), func(la datamodel.ListAssembler) { + for i := range rv.Len() { + qp.ListEntry(la, anyAssemble(rv.Index(i))) + } + }) + case reflect.Map: + if rt.Key().Kind() != reflect.String { + break + } + it := rv.MapRange() + return qp.Map(int64(rv.Len()), func(ma datamodel.MapAssembler) { + for it.Next() { + qp.MapEntry(ma, it.Key().String(), anyAssemble(it.Value())) + } + }) + case reflect.Bool: + return qp.Bool(rv.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return qp.Int(rv.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return qp.Int(int64(rv.Uint())) + case reflect.Float32, reflect.Float64: + return qp.Float(rv.Float()) + case reflect.String: + return qp.String(rv.String()) + default: + } + + panic(fmt.Sprintf("unsupported type %T", val)) } From 61e031529f98f498dd271865feac7fa6a812cdd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Mon, 4 Nov 2024 18:41:18 +0100 Subject: [PATCH 12/16] policy: use "any" --- pkg/policy/match_test.go | 42 ++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index da03c46..83e0d08 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -548,7 +548,7 @@ func TestOptionalSelectors(t *testing.T) { { name: "nested missing non-optional field returns false", policy: MustConstruct(Equal(".outer?.inner", literal.String("value"))), - data: map[string]any{"outer": map[string]interface{}{}}, + data: map[string]any{"outer": map[string]any{}}, expected: false, }, { @@ -560,19 +560,19 @@ func TestOptionalSelectors(t *testing.T) { { name: "partially present nested optional path with missing end returns true", policy: MustConstruct(Equal(".outer?.inner?", literal.String("value"))), - data: map[string]any{"outer": map[string]interface{}{}}, + data: map[string]any{"outer": map[string]any{}}, expected: true, }, { name: "optional array index returns true when array is empty", policy: MustConstruct(Equal(".array[0]?", literal.String("value"))), - data: map[string]any{"array": []interface{}{}}, + data: map[string]any{"array": []any{}}, expected: true, }, { name: "non-optional array index returns false when array is empty", policy: MustConstruct(Equal(".array[0]", literal.String("value"))), - data: map[string]any{"array": []interface{}{}}, + data: map[string]any{"array": []any{}}, expected: false, }, } @@ -657,7 +657,7 @@ func TestPartialMatch(t *testing.T) { policy: MustConstruct( Equal(".field?", literal.String("value")), ), - data: map[string]interface{}{ + data: map[string]any{ "field": "wrong", }, expectedMatch: false, @@ -672,7 +672,7 @@ func TestPartialMatch(t *testing.T) { policy: MustConstruct( Like(".pattern", "test*"), ), - data: map[string]interface{}{ + data: map[string]any{ "pattern": "testing123", }, expectedMatch: true, @@ -683,7 +683,7 @@ func TestPartialMatch(t *testing.T) { policy: MustConstruct( Like(".pattern", "test*"), ), - data: map[string]interface{}{ + data: map[string]any{ "pattern": "wrong123", }, expectedMatch: false, @@ -707,14 +707,14 @@ func TestPartialMatch(t *testing.T) { ), ), ), - data: map[string]interface{}{ + data: map[string]any{ "required": "present", - "items": []interface{}{ - map[string]interface{}{ + "items": []any{ + map[string]any{ "name": "wrong", "id": "ID123", }, - map[string]interface{}{ + map[string]any{ "name": "test", "id": "ID456", }, @@ -730,7 +730,7 @@ func TestPartialMatch(t *testing.T) { policy: MustConstruct( Equal(".field?", literal.String("value")), ), - data: map[string]interface{}{}, + data: map[string]any{}, expectedMatch: true, expectedStmt: nil, }, @@ -739,7 +739,7 @@ func TestPartialMatch(t *testing.T) { policy: MustConstruct( Like(".pattern?", "test*"), ), - data: map[string]interface{}{}, + data: map[string]any{}, expectedMatch: true, expectedStmt: nil, }, @@ -748,7 +748,7 @@ func TestPartialMatch(t *testing.T) { policy: MustConstruct( GreaterThan(".number?", literal.Int(5)), ), - data: map[string]interface{}{}, + data: map[string]any{}, expectedMatch: true, expectedStmt: nil, }, @@ -757,7 +757,7 @@ func TestPartialMatch(t *testing.T) { policy: MustConstruct( LessThan(".number?", literal.Int(5)), ), - data: map[string]interface{}{}, + data: map[string]any{}, expectedMatch: true, expectedStmt: nil, }, @@ -766,7 +766,7 @@ func TestPartialMatch(t *testing.T) { policy: MustConstruct( All(".numbers?", Equal(".", literal.Int(1))), ), - data: map[string]interface{}{}, + data: map[string]any{}, expectedMatch: true, expectedStmt: nil, }, @@ -775,7 +775,7 @@ func TestPartialMatch(t *testing.T) { policy: MustConstruct( Any(".numbers?", Equal(".", literal.Int(1))), ), - data: map[string]interface{}{}, + data: map[string]any{}, expectedMatch: true, expectedStmt: nil, }, @@ -792,7 +792,7 @@ func TestPartialMatch(t *testing.T) { ), ), ), - data: map[string]interface{}{ + data: map[string]any{ "required": "present", }, expectedMatch: true, @@ -811,10 +811,10 @@ func TestPartialMatch(t *testing.T) { ), ), ), - data: map[string]interface{}{ + data: map[string]any{ "required": "present", - "items": []interface{}{ - map[string]interface{}{ + "items": []any{ + map[string]any{ "name": "test", // optional_id is missing }, From 02be4010d6f09a188a83e6a3a07580608fe27ed5 Mon Sep 17 00:00:00 2001 From: Fabio Bozzo Date: Mon, 4 Nov 2024 18:50:30 +0100 Subject: [PATCH 13/16] add array quantifiers tests and tiny fix --- pkg/policy/match.go | 4 +++- pkg/policy/match_test.go | 50 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/pkg/policy/match.go b/pkg/policy/match.go index c3862d5..2a586f5 100644 --- a/pkg/policy/match.go +++ b/pkg/policy/match.go @@ -242,7 +242,9 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat // continue } } - return matchResultFalse, cur + + // when no elements match, return the leaf statement instead of 'cur' + return matchResultFalse, s.statement } } panic(fmt.Errorf("unimplemented statement kind: %s", cur.Kind())) diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 83e0d08..56a2814 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -692,6 +692,56 @@ func TestPartialMatch(t *testing.T) { )[0], }, + // Array quantifiers + { + name: "all matches when every element satisfies condition", + policy: MustConstruct( + All(".numbers", Equal(".", literal.Int(1))), + ), + data: map[string]interface{}{ + "numbers": []interface{}{1, 1, 1}, + }, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "all fails when any element doesn't satisfy", + policy: MustConstruct( + All(".numbers", Equal(".", literal.Int(1))), + ), + data: map[string]interface{}{ + "numbers": []interface{}{1, 2, 1}, + }, + expectedMatch: false, + expectedStmt: MustConstruct( + Equal(".", literal.Int(1)), + )[0], + }, + { + name: "any succeeds when one element matches", + policy: MustConstruct( + Any(".numbers", Equal(".", literal.Int(2))), + ), + data: map[string]interface{}{ + "numbers": []interface{}{1, 2, 3}, + }, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "any fails when no elements match", + policy: MustConstruct( + Any(".numbers", Equal(".", literal.Int(4))), + ), + data: map[string]interface{}{ + "numbers": []interface{}{1, 2, 3}, + }, + expectedMatch: false, + expectedStmt: MustConstruct( + Equal(".", literal.Int(4)), + )[0], + }, + // Complex nested case { name: "complex nested policy", From 72f4ef7b5eb3d42ca51a86899e9b5f5b51f7541e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Mon, 4 Nov 2024 19:07:36 +0100 Subject: [PATCH 14/16] policy: fix incorrect test for PartialMatch --- pkg/policy/match.go | 4 +--- pkg/policy/match_test.go | 17 ++++++++--------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/pkg/policy/match.go b/pkg/policy/match.go index 2a586f5..c3862d5 100644 --- a/pkg/policy/match.go +++ b/pkg/policy/match.go @@ -242,9 +242,7 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat // continue } } - - // when no elements match, return the leaf statement instead of 'cur' - return matchResultFalse, s.statement + return matchResultFalse, cur } } panic(fmt.Errorf("unimplemented statement kind: %s", cur.Kind())) diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 56a2814..7d10d43 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -9,7 +9,6 @@ import ( "github.com/ipld/go-ipld-prime/codec/dagjson" cidlink "github.com/ipld/go-ipld-prime/linking/cid" "github.com/ipld/go-ipld-prime/node/basicnode" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ucan-wg/go-ucan/pkg/policy/literal" @@ -581,12 +580,12 @@ func TestOptionalSelectors(t *testing.T) { t.Run(tt.name, func(t *testing.T) { nb := basicnode.Prototype.Map.NewBuilder() n, err := literal.Map(tt.data) - assert.NoError(t, err) + require.NoError(t, err) err = nb.AssignNode(n) - assert.NoError(t, err) + require.NoError(t, err) result := tt.policy.Match(nb.Build()) - assert.Equal(t, tt.expected, result) + require.Equal(t, tt.expected, result) }) } } @@ -738,7 +737,7 @@ func TestPartialMatch(t *testing.T) { }, expectedMatch: false, expectedStmt: MustConstruct( - Equal(".", literal.Int(4)), + Any(".numbers", Equal(".", literal.Int(4))), )[0], }, @@ -878,14 +877,14 @@ func TestPartialMatch(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { node, err := literal.Map(tt.data) - assert.NoError(t, err) + require.NoError(t, err) match, stmt := tt.policy.PartialMatch(node) - assert.Equal(t, tt.expectedMatch, match) + require.Equal(t, tt.expectedMatch, match) if tt.expectedStmt == nil { - assert.Nil(t, stmt) + require.Nil(t, stmt) } else { - assert.Equal(t, tt.expectedStmt.Kind(), stmt.Kind()) + require.Equal(t, tt.expectedStmt, stmt) } }) } From 6f9a6fa5c14b5892d51ccd0198e5bd4e3890feaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Tue, 5 Nov 2024 16:26:14 +0100 Subject: [PATCH 15/16] literal: make Map and List generic, to avoid requiring conversions --- pkg/policy/literal/literal.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/policy/literal/literal.go b/pkg/policy/literal/literal.go index 64baa1d..b3e6aa3 100644 --- a/pkg/policy/literal/literal.go +++ b/pkg/policy/literal/literal.go @@ -31,7 +31,7 @@ func Null() ipld.Node { } // Map creates an IPLD node from a map[string]any -func Map(m map[string]any) (ipld.Node, error) { +func Map[T any](m map[string]T) (ipld.Node, error) { return qp.BuildMap(basicnode.Prototype.Any, int64(len(m)), func(ma datamodel.MapAssembler) { for k, v := range m { qp.MapEntry(ma, k, anyAssemble(v)) @@ -39,6 +39,15 @@ func Map(m map[string]any) (ipld.Node, error) { }) } +// List creates an IPLD node from a []any +func List[T any](l []T) (ipld.Node, error) { + return qp.BuildList(basicnode.Prototype.Any, int64(len(l)), func(la datamodel.ListAssembler) { + for _, val := range l { + qp.ListEntry(la, anyAssemble(val)) + } + }) +} + func anyAssemble(val any) qp.Assemble { var rt reflect.Type var rv reflect.Value From 06a72868a54cad4e2bd92b885134782a5df056e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Tue, 5 Nov 2024 16:26:53 +0100 Subject: [PATCH 16/16] container: add a delegation iterator --- pkg/container/reader.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/container/reader.go b/pkg/container/reader.go index 61402e4..db1e145 100644 --- a/pkg/container/reader.go +++ b/pkg/container/reader.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "fmt" "io" + "iter" "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" @@ -42,6 +43,19 @@ func (ctn Reader) GetDelegation(cid cid.Cid) (*delegation.Token, error) { return nil, fmt.Errorf("not a delegation token") } +// GetAllDelegations returns all the delegation.Token in the container. +func (ctn Reader) GetAllDelegations() iter.Seq2[cid.Cid, *delegation.Token] { + return func(yield func(cid.Cid, *delegation.Token) bool) { + for c, t := range ctn { + if t, ok := t.(*delegation.Token); ok { + if !yield(c, t) { + return + } + } + } + } +} + // GetInvocation returns the first found invocation.Token. // If none are found, ErrNotFound is returned. func (ctn Reader) GetInvocation() (*invocation.Token, error) {