diff --git a/pkg/args/args.go b/pkg/args/args.go new file mode 100644 index 0000000..5436a37 --- /dev/null +++ b/pkg/args/args.go @@ -0,0 +1,206 @@ +// Package args provides the type that represents the Arguments passed to +// a command within an invocation.Token as well as a convenient Add method +// to incrementally build the underlying map. +package args + +import ( + "fmt" + "reflect" + "sync" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/node/bindnode" + "github.com/ipld/go-ipld-prime/schema" + "github.com/ucan-wg/go-ucan/pkg/policy/literal" +) + +const ( + argsSchema = "type Args { String : Any }" + argsName = "Args" +) + +var ( + once sync.Once + ts *schema.TypeSystem + err error +) + +func argsType() schema.Type { + once.Do(func() { + ts, err = ipld.LoadSchemaBytes([]byte(argsSchema)) + }) + if err != nil { + panic(err) + } + + return ts.TypeByName(argsName) +} + +var ErrUnsupported = fmt.Errorf("failure adding unsupported type to meta") + +// Args are the Command's argumennts when an invocation Token is processed +// by the executor. +// +// This type must be compatible with the IPLD type represented by the IPLD +// schema { String : Any }. +type Args struct { + Keys []string + Values map[string]ipld.Node +} + +// New returns a pointer to an initialized Args value. +func New() *Args { + return &Args{ + Values: map[string]ipld.Node{}, + } +} + +// FromIPLD unwraps an Args instance from an ipld.Node. +func FromIPLD(node ipld.Node) (*Args, error) { + var err error + + defer func() { + err = handlePanic(recover()) + }() + + obj := bindnode.Unwrap(node) + + args, ok := obj.(*Args) + if !ok { + err = fmt.Errorf("failed to convert to Args") + } + + return args, err +} + +// Add inserts a key/value pair in the Args set. +// +// Accepted types for val are: bool, string, int, int8, int16, +// int32, int64, uint, uint8, uint16, uint32, float32, float64, []byte, +// []any, map[string]any, ipld.Node and nil. +func (m *Args) Add(key string, val any) error { + if _, ok := m.Values[key]; ok { + return fmt.Errorf("duplicate key %q", key) + } + + node, err := anyNode(val) + if err != nil { + return err + } + + m.Values[key] = node + m.Keys = append(m.Keys, key) + + return nil +} + +// Include merges the provided arguments into the existing arguments. +// +// If duplicate keys are encountered, the new value is silently dropped +// without causing an error. +func (m *Args) Include(other *Args) { + for _, key := range other.Keys { + if _, ok := m.Values[key]; ok { + // don't overwrite + continue + } + m.Values[key] = other.Values[key] + m.Keys = append(m.Keys, key) + } +} + +// ToIPLD wraps an instance of an Args with an ipld.Node. +func (m *Args) ToIPLD() (ipld.Node, error) { + var err error + + defer func() { + err = handlePanic(recover()) + }() + + return bindnode.Wrap(m, argsType()), err +} + +func anyNode(val any) (ipld.Node, error) { + var err error + + defer func() { + err = handlePanic(recover()) + }() + + if val == nil { + return literal.Null(), nil + } + + if cast, ok := val.(ipld.Node); ok { + return cast, nil + } + + if cast, ok := val.(cid.Cid); ok { + return literal.LinkCid(cast), err + } + + var rv reflect.Value + + rv.Kind() + + if cast, ok := val.(reflect.Value); ok { + rv = cast + } else { + rv = reflect.ValueOf(val) + } + + for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface { + rv = rv.Elem() + } + + switch rv.Kind() { + case reflect.Slice: + if rv.Type().Elem().Kind() == reflect.Uint8 { + return literal.Bytes(val.([]byte)), nil + } + + l := make([]reflect.Value, rv.Len()) + + for i := 0; i < rv.Len(); i++ { + l[i] = rv.Index(i) + } + + return literal.List(l) + case reflect.Map: + if rv.Type().Key().Kind() != reflect.String { + return nil, fmt.Errorf("unsupported map key type: %s", rv.Type().Key().Name()) + } + + m := make(map[string]reflect.Value, rv.Len()) + it := rv.MapRange() + + for it.Next() { + m[it.Key().String()] = it.Value() + } + + return literal.Map(m) + case reflect.String: + return literal.String(rv.String()), nil + case reflect.Bool: + return literal.Bool(rv.Bool()), nil + // reflect.Int64 may exceed the safe 53-bit limit of JavaScript + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return literal.Int(rv.Int()), nil + // reflect.Uint64 can't be safely converted to int64 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32: + return literal.Int(int64(rv.Uint())), nil + case reflect.Float32, reflect.Float64: + return literal.Float(rv.Float()), nil + default: + return nil, fmt.Errorf("unsupported Args type: %s", rv.Type().Name()) + } +} + +func handlePanic(rec any) error { + if err, ok := rec.(error); ok { + return err + } + + return fmt.Errorf("%v", rec) +} diff --git a/pkg/args/args_test.go b/pkg/args/args_test.go new file mode 100644 index 0000000..2cccb82 --- /dev/null +++ b/pkg/args/args_test.go @@ -0,0 +1,192 @@ +package args_test + +import ( + "sync" + "testing" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/codec/dagcbor" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/node/bindnode" + "github.com/ipld/go-ipld-prime/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/pkg/args" + "github.com/ucan-wg/go-ucan/pkg/policy/literal" +) + +const ( + argsSchema = "type Args { String : Any }" + argsName = "Args" +) + +var ( + once sync.Once + ts *schema.TypeSystem + err error +) + +func argsType() schema.Type { + once.Do(func() { + ts, err = ipld.LoadSchemaBytes([]byte(argsSchema)) + }) + if err != nil { + panic(err) + } + + return ts.TypeByName(argsName) +} + +func argsPrototype() schema.TypedPrototype { + return bindnode.Prototype((*args.Args)(nil), argsType()) +} + +func TestArgs(t *testing.T) { + t.Parallel() + + const ( + intKey = "intKey" + mapKey = "mapKey" + nilKey = "nilKey" + boolKey = "boolKey" + linkKey = "linkKey" + listKey = "listKey" + nodeKey = "nodeKey" + uintKey = "uintKey" + bytesKey = "bytesKey" + floatKey = "floatKey" + stringKey = "stringKey" + ) + + const ( + expIntVal = int64(-42) + expBoolVal = true + expUintVal = uint(42) + expStringVal = "stringVal" + ) + + var ( + expMapVal = map[string]string{"keyOne": "valOne", "keyTwo": "valTwo"} + // expNilVal = (map[string]string)(nil) + expLinkVal = cid.MustParse("bafzbeigai3eoy2ccc7ybwjfz5r3rdxqrinwi4rwytly24tdbh6yk7zslrm") + expListVal = []string{"elem1", "elem2", "elem3"} + expNodeVal = literal.String("nodeVal") + expBytesVal = []byte{0xde, 0xad, 0xbe, 0xef} + expFloatVal = 42.0 + ) + + argsIn := args.New() + + // WARNING: Do not change the order of these statements as this is the + // order which will be present when decoded from DAG-CBOR ( + // per RFC7049 default canonical ordering?). + for _, a := range []struct { + key string + val any + }{ + {key: intKey, val: expIntVal}, + {key: mapKey, val: expMapVal}, + // {key: nilKey, val: expNilVal}, + {key: boolKey, val: expBoolVal}, + {key: linkKey, val: expLinkVal}, + {key: listKey, val: expListVal}, + {key: nodeKey, val: expNodeVal}, + {key: uintKey, val: expUintVal}, + {key: bytesKey, val: expBytesVal}, + {key: floatKey, val: expFloatVal}, + {key: stringKey, val: expStringVal}, + } { + require.NoError(t, argsIn.Add(a.key, a.val)) + } + + // Round-trip to DAG-CBOR here as ToIPLD/FromIPLD is only a wrapper + argsOut := roundTripThroughDAGCBOR(t, argsIn) + assert.Equal(t, argsIn, argsOut) + + actMapVal := map[string]string{} + mit := argsOut.Values[mapKey].MapIterator() + + es := errorSwallower(t) + + for !mit.Done() { + k, v, err := mit.Next() + require.NoError(t, err) + ks := es(k.AsString()).(string) + vs := es(v.AsString()).(string) + + actMapVal[ks] = vs + } + + actListVal := []string{} + lit := argsOut.Values[listKey].ListIterator() + + for !lit.Done() { + _, v, err := lit.Next() + require.NoError(t, err) + vs := es(v.AsString()).(string) + + actListVal = append(actListVal, vs) + } + + assert.Equal(t, expIntVal, es(argsOut.Values[intKey].AsInt())) + assert.Equal(t, expMapVal, actMapVal) // TODO: special accessor + // TODO: the nil map comes back empty (but the right type) + // assert.Equal(t, expNilVal, actNilVal) + assert.Equal(t, expBoolVal, es(argsOut.Values[boolKey].AsBool())) + assert.Equal(t, expLinkVal.String(), es(argsOut.Values[linkKey].AsLink()).(datamodel.Link).String()) // TODO: special accessor + assert.Equal(t, expListVal, actListVal) // TODO: special accessor + assert.Equal(t, expNodeVal, argsOut.Values[nodeKey]) + assert.Equal(t, expUintVal, uint(es(argsOut.Values[uintKey].AsInt()).(int64))) + assert.Equal(t, expBytesVal, es(argsOut.Values[bytesKey].AsBytes())) + assert.Equal(t, expFloatVal, es(argsOut.Values[floatKey].AsFloat())) + assert.Equal(t, expStringVal, es(argsOut.Values[stringKey].AsString())) +} + +func TestArgs_Include(t *testing.T) { + t.Parallel() + + argsIn := args.New() + require.NoError(t, argsIn.Add("key1", "val1")) + require.NoError(t, argsIn.Add("key2", "val2")) + + argsOther := args.New() + require.NoError(t, argsOther.Add("key2", "valOther")) // This should not overwrite key2 above + require.NoError(t, argsOther.Add("key3", "val3")) + require.NoError(t, argsOther.Add("key4", "val4")) + + argsIn.Include(argsOther) + + es := errorSwallower(t) + + assert.Len(t, argsIn.Values, 4) + assert.Equal(t, "val1", es(argsIn.Values["key1"].AsString())) + assert.Equal(t, "val2", es(argsIn.Values["key2"].AsString())) + assert.Equal(t, "val3", es(argsIn.Values["key3"].AsString())) + assert.Equal(t, "val4", es(argsIn.Values["key4"].AsString())) +} + +func errorSwallower(t *testing.T) func(any, error) any { + return func(val any, err error) any { + require.NoError(t, err) + + return val + } +} + +func roundTripThroughDAGCBOR(t *testing.T, argsIn *args.Args) *args.Args { + t.Helper() + + node, err := argsIn.ToIPLD() + require.NoError(t, err) + + data, err := ipld.Encode(node, dagcbor.Encode) + require.NoError(t, err) + node, err = ipld.DecodeUsingPrototype(data, dagcbor.Decode, argsPrototype()) + require.NoError(t, err) + + argsOut, err := args.FromIPLD(node) + require.NoError(t, err) + + return argsOut +} 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) { diff --git a/pkg/meta/meta.go b/pkg/meta/meta.go index 01baf11..1e6383b 100644 --- a/pkg/meta/meta.go +++ b/pkg/meta/meta.go @@ -1,7 +1,6 @@ package meta import ( - "errors" "fmt" "reflect" "strings" @@ -15,7 +14,9 @@ import ( ) var ErrUnsupported = errors.New("failure adding unsupported type to meta") + var ErrNotFound = errors.New("key-value not found in meta") + var ErrNotEncryptable = errors.New("value of this type cannot be encrypted") // Meta is a container for meta key-value pairs in a UCAN token. @@ -131,6 +132,9 @@ func (m *Meta) GetNode(key string) (ipld.Node, error) { // Accepted types for the value are: bool, string, int, int32, int64, []byte, // and ipld.Node. func (m *Meta) Add(key string, val any) error { + if _, ok := m.Values[key]; ok { + return fmt.Errorf("duplicate key %q", key) + } switch val := val.(type) { case bool: m.Values[key] = basicnode.NewBool(val) @@ -217,6 +221,11 @@ func (m *Meta) String() string { return buf.String() } +// ReadOnly returns a read-only version of Meta. +func (m *Meta) ReadOnly() ReadOnly { + return ReadOnly{m: m} +} + func fqtn(val any) string { var name string diff --git a/pkg/meta/readonly.go b/pkg/meta/readonly.go new file mode 100644 index 0000000..1c8188d --- /dev/null +++ b/pkg/meta/readonly.go @@ -0,0 +1,42 @@ +package meta + +import ( + "github.com/ipld/go-ipld-prime" +) + +// ReadOnly wraps a Meta into a read-only facade. +type ReadOnly struct { + m *Meta +} + +func (r ReadOnly) GetBool(key string) (bool, error) { + return r.m.GetBool(key) +} + +func (r ReadOnly) GetString(key string) (string, error) { + return r.m.GetString(key) +} + +func (r ReadOnly) GetInt64(key string) (int64, error) { + return r.m.GetInt64(key) +} + +func (r ReadOnly) GetFloat64(key string) (float64, error) { + return r.m.GetFloat64(key) +} + +func (r ReadOnly) GetBytes(key string) ([]byte, error) { + return r.m.GetBytes(key) +} + +func (r ReadOnly) GetNode(key string) (ipld.Node, error) { + return r.m.GetNode(key) +} + +func (r ReadOnly) Equals(other ReadOnly) bool { + return r.m.Equals(other.m) +} + +func (r ReadOnly) String() string { + return r.m.String() +} diff --git a/pkg/policy/literal/literal.go b/pkg/policy/literal/literal.go index fa236da..65ef32c 100644 --- a/pkg/policy/literal/literal.go +++ b/pkg/policy/literal/literal.go @@ -2,14 +2,18 @@ package literal import ( + "fmt" + "reflect" + "sort" + "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" ) -// TODO: remove entirely? - var Bool = basicnode.NewBool var Int = basicnode.NewInt var Float = basicnode.NewFloat @@ -26,3 +30,95 @@ func Null() ipld.Node { nb.AssignNull() return nb.Build() } + +// Map creates an IPLD node from a map[string]any +func Map[T any](m map[string]T) (ipld.Node, error) { + return qp.BuildMap(basicnode.Prototype.Any, int64(len(m)), func(ma datamodel.MapAssembler) { + // deterministic iteration + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + qp.MapEntry(ma, key, anyAssemble(m[key])) + } + }) +} + +// 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 + + // 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: + if rt.Elem().Kind() == reflect.Uint8 { + panic("bytes array are not supported yet") + } + return qp.List(int64(rv.Len()), func(la datamodel.ListAssembler) { + for i := range rv.Len() { + qp.ListEntry(la, anyAssemble(rv.Index(i))) + } + }) + case reflect.Slice: + if rt.Elem().Kind() == reflect.Uint8 { + return qp.Bytes(val.([]byte)) + } + 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 + } + // deterministic iteration + keys := rv.MapKeys() + sort.Slice(keys, func(i, j int) bool { + return keys[i].String() < keys[j].String() + }) + return qp.Map(int64(rv.Len()), func(ma datamodel.MapAssembler) { + for _, key := range keys { + qp.MapEntry(ma, key.String(), anyAssemble(rv.MapIndex(key))) + } + }) + 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)) +} diff --git a/pkg/policy/literal/literal_test.go b/pkg/policy/literal/literal_test.go new file mode 100644 index 0000000..8320c85 --- /dev/null +++ b/pkg/policy/literal/literal_test.go @@ -0,0 +1,125 @@ +package literal + +import ( + "testing" + + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/printer" + "github.com/stretchr/testify/require" +) + +func TestList(t *testing.T) { + n, err := List([]int{1, 2, 3}) + require.NoError(t, err) + require.Equal(t, datamodel.Kind_List, n.Kind()) + require.Equal(t, int64(3), n.Length()) + require.Equal(t, `list{ + 0: int{1} + 1: int{2} + 2: int{3} +}`, printer.Sprint(n)) + + n, err = List([][]int{{1, 2, 3}, {4, 5, 6}}) + require.NoError(t, err) + require.Equal(t, datamodel.Kind_List, n.Kind()) + require.Equal(t, int64(2), n.Length()) + require.Equal(t, `list{ + 0: list{ + 0: int{1} + 1: int{2} + 2: int{3} + } + 1: list{ + 0: int{4} + 1: int{5} + 2: int{6} + } +}`, printer.Sprint(n)) +} + +func TestMap(t *testing.T) { + n, err := Map(map[string]any{ + "bool": true, + "string": "foobar", + "bytes": []byte{1, 2, 3, 4}, + "int": 1234, + "uint": uint(12345), + "float": 1.45, + "slice": []int{1, 2, 3}, + "array": [2]int{1, 2}, + "map": map[string]any{ + "foo": "bar", + "foofoo": map[string]string{ + "barbar": "foo", + }, + }, + }) + require.NoError(t, err) + + v, err := n.LookupByString("bool") + require.NoError(t, err) + require.Equal(t, datamodel.Kind_Bool, v.Kind()) + require.Equal(t, true, must(v.AsBool())) + + v, err = n.LookupByString("string") + require.NoError(t, err) + require.Equal(t, datamodel.Kind_String, v.Kind()) + require.Equal(t, "foobar", must(v.AsString())) + + v, err = n.LookupByString("bytes") + require.NoError(t, err) + require.Equal(t, datamodel.Kind_Bytes, v.Kind()) + require.Equal(t, []byte{1, 2, 3, 4}, must(v.AsBytes())) + + v, err = n.LookupByString("int") + require.NoError(t, err) + require.Equal(t, datamodel.Kind_Int, v.Kind()) + require.Equal(t, int64(1234), must(v.AsInt())) + + v, err = n.LookupByString("uint") + require.NoError(t, err) + require.Equal(t, datamodel.Kind_Int, v.Kind()) + require.Equal(t, int64(12345), must(v.AsInt())) + + v, err = n.LookupByString("float") + require.NoError(t, err) + require.Equal(t, datamodel.Kind_Float, v.Kind()) + require.Equal(t, 1.45, must(v.AsFloat())) + + v, err = n.LookupByString("slice") + require.NoError(t, err) + require.Equal(t, datamodel.Kind_List, v.Kind()) + require.Equal(t, int64(3), v.Length()) + require.Equal(t, `list{ + 0: int{1} + 1: int{2} + 2: int{3} +}`, printer.Sprint(v)) + + v, err = n.LookupByString("array") + require.NoError(t, err) + require.Equal(t, datamodel.Kind_List, v.Kind()) + require.Equal(t, int64(2), v.Length()) + require.Equal(t, `list{ + 0: int{1} + 1: int{2} +}`, printer.Sprint(v)) + + v, err = n.LookupByString("map") + require.NoError(t, err) + require.Equal(t, datamodel.Kind_Map, v.Kind()) + require.Equal(t, int64(2), v.Length()) + require.Equal(t, `map{ + string{"foo"}: string{"bar"} + string{"foofoo"}: map{ + string{"barbar"}: string{"foo"} + } +}`, printer.Sprint(v)) +} + +func must[T any](t T, err error) T { + if err != nil { + panic(err) + } + return t +} diff --git a/pkg/policy/match.go b/pkg/policy/match.go index 308229d..59316ed 100644 --- a/pkg/policy/match.go +++ b/pkg/policy/match.go @@ -10,143 +10,243 @@ import ( ) // Match determines if the IPLD node satisfies the policy. -func (p Policy) Match(node datamodel.Node) bool { +// The first Statement failing to match is returned as well. +func (p Policy) Match(node datamodel.Node) (bool, Statement) { for _, stmt := range p { - ok := matchStatement(stmt, node) - if !ok { - return false + res, leaf := matchStatement(stmt, node) + switch res { + case matchResultNoData, matchResultFalse: + return false, leaf + case matchResultOptionalNoData, matchResultTrue: + // continue } } - return true + return true, nil } -func matchStatement(statement Statement, node ipld.Node) bool { - switch statement.Kind() { +// 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 +// (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, matchResultOptionalNoData, matchResultTrue: + // continue + } + } + return true, nil +} + +type matchResult int8 + +const ( + 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: +// - 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 + return matchResultNoData, cur } - return datamodel.DeepEqual(s.value, res) + if res == nil { // optional selector didn't match + return matchResultOptionalNoData, nil + } + 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 + return matchResultNoData, cur } - return isOrdered(s.value, res, gt) + if res == nil { // optional selector didn't match + return matchResultOptionalNoData, nil + } + 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 + return matchResultNoData, cur } - return isOrdered(s.value, res, gte) + if res == nil { // optional selector didn't match + return matchResultOptionalNoData, nil + } + 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 + return matchResultNoData, cur } - return isOrdered(s.value, res, lt) + if res == nil { // optional selector didn't match + return matchResultOptionalNoData, nil + } + 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 + return matchResultNoData, cur } - return isOrdered(s.value, res, lte) + if res == nil { // optional selector didn't match + return matchResultOptionalNoData, nil + } + 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, matchResultOptionalNoData: + return res, leaf + case matchResultTrue: + return matchResultFalse, cur + case matchResultFalse: + return matchResultTrue, nil + } } 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, matchResultOptionalNoData: + return res, 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, matchResultOptionalNoData: + return res, 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 + return matchResultNoData, cur + } + if res == nil { // optional selector didn't match + return matchResultOptionalNoData, nil } 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 + return matchResultNoData, cur + } + if res == nil { + return matchResultOptionalNoData, nil } 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, matchResultOptionalNoData: + return matchRes, 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 + return matchResultNoData, cur + } + if res == nil { + return matchResultOptionalNoData, nil } 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, matchResultOptionalNoData: + return matchRes, 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/match_test.go b/pkg/policy/match_test.go index 9e3de4a..6a85512 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -7,8 +7,11 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" "github.com/ipld/go-ipld-prime/codec/dagjson" + "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" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ucan-wg/go-ucan/pkg/policy/literal" @@ -17,228 +20,252 @@ import ( func TestMatch(t *testing.T) { t.Run("equality", func(t *testing.T) { t.Run("string", func(t *testing.T) { - np := basicnode.Prototype.String - nb := np.NewBuilder() - nb.AssignString("test") - nd := nb.Build() + nd := literal.String("test") pol := MustConstruct(Equal(".", literal.String("test"))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(Equal(".", literal.String("test2"))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) pol = MustConstruct(Equal(".", literal.Int(138))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) }) t.Run("int", func(t *testing.T) { - np := basicnode.Prototype.Int - nb := np.NewBuilder() - nb.AssignInt(138) - nd := nb.Build() + nd := literal.Int(138) pol := MustConstruct(Equal(".", literal.Int(138))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(Equal(".", literal.Int(1138))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) pol = MustConstruct(Equal(".", literal.String("138"))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) }) t.Run("float", func(t *testing.T) { - np := basicnode.Prototype.Float - nb := np.NewBuilder() - nb.AssignFloat(1.138) - nd := nb.Build() + nd := literal.Float(1.138) pol := MustConstruct(Equal(".", literal.Float(1.138))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(Equal(".", literal.Float(11.38))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) pol = MustConstruct(Equal(".", literal.String("138"))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) }) t.Run("IPLD Link", func(t *testing.T) { l0 := cidlink.Link{Cid: cid.MustParse("bafybeif4owy5gno5lwnixqm52rwqfodklf76hsetxdhffuxnplvijskzqq")} l1 := cidlink.Link{Cid: cid.MustParse("bafkreifau35r7vi37tvbvfy3hdwvgb4tlflqf7zcdzeujqcjk3rsphiwte")} - np := basicnode.Prototype.Link - nb := np.NewBuilder() - nb.AssignLink(l0) - nd := nb.Build() + nd := literal.Link(l0) pol := MustConstruct(Equal(".", literal.Link(l0))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(Equal(".", literal.Link(l1))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) pol = MustConstruct(Equal(".", literal.String("bafybeif4owy5gno5lwnixqm52rwqfodklf76hsetxdhffuxnplvijskzqq"))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) }) t.Run("string in map", func(t *testing.T) { - np := basicnode.Prototype.Map - nb := np.NewBuilder() - ma, _ := nb.BeginMap(1) - ma.AssembleKey().AssignString("foo") - ma.AssembleValue().AssignString("bar") - ma.Finish() - nd := nb.Build() + nd, _ := literal.Map(map[string]any{ + "foo": "bar", + }) pol := MustConstruct(Equal(".foo", literal.String("bar"))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(Equal(".[\"foo\"]", literal.String("bar"))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(Equal(".foo", literal.String("baz"))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) pol = MustConstruct(Equal(".foobar", literal.String("bar"))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) }) t.Run("string in list", func(t *testing.T) { - np := basicnode.Prototype.List - nb := np.NewBuilder() - la, _ := nb.BeginList(1) - la.AssembleValue().AssignString("foo") - la.Finish() - nd := nb.Build() + nd, _ := literal.List([]any{"foo"}) pol := MustConstruct(Equal(".[0]", literal.String("foo"))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(Equal(".[1]", literal.String("foo"))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) }) }) t.Run("inequality", func(t *testing.T) { t.Run("gt int", func(t *testing.T) { - np := basicnode.Prototype.Int - nb := np.NewBuilder() - nb.AssignInt(138) - nd := nb.Build() + nd := literal.Int(138) pol := MustConstruct(GreaterThan(".", literal.Int(1))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) + + pol = MustConstruct(GreaterThan(".", literal.Int(138))) + ok, leaf = pol.Match(nd) + require.False(t, ok) + require.Equal(t, pol[0], leaf) + + pol = MustConstruct(GreaterThan(".", literal.Int(140))) + ok, leaf = pol.Match(nd) + require.False(t, ok) + require.Equal(t, pol[0], leaf) }) t.Run("gte int", func(t *testing.T) { - np := basicnode.Prototype.Int - nb := np.NewBuilder() - nb.AssignInt(138) - nd := nb.Build() + nd := literal.Int(138) pol := MustConstruct(GreaterThanOrEqual(".", literal.Int(1))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(GreaterThanOrEqual(".", literal.Int(138))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) + + pol = MustConstruct(GreaterThanOrEqual(".", literal.Int(140))) + ok, leaf = pol.Match(nd) + require.False(t, ok) + require.Equal(t, pol[0], leaf) }) t.Run("gt float", func(t *testing.T) { - np := basicnode.Prototype.Float - nb := np.NewBuilder() - nb.AssignFloat(1.38) - nd := nb.Build() + nd := literal.Float(1.38) pol := MustConstruct(GreaterThan(".", literal.Float(1))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) + + pol = MustConstruct(GreaterThan(".", literal.Float(2))) + ok, leaf = pol.Match(nd) + require.False(t, ok) + require.Equal(t, pol[0], leaf) }) t.Run("gte float", func(t *testing.T) { - np := basicnode.Prototype.Float - nb := np.NewBuilder() - nb.AssignFloat(1.38) - nd := nb.Build() + nd := literal.Float(1.38) pol := MustConstruct(GreaterThanOrEqual(".", literal.Float(1))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(GreaterThanOrEqual(".", literal.Float(1.38))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) + + pol = MustConstruct(GreaterThanOrEqual(".", literal.Float(2))) + ok, leaf = pol.Match(nd) + require.False(t, ok) + require.Equal(t, pol[0], leaf) }) t.Run("lt int", func(t *testing.T) { - np := basicnode.Prototype.Int - nb := np.NewBuilder() - nb.AssignInt(138) - nd := nb.Build() + nd := literal.Int(138) pol := MustConstruct(LessThan(".", literal.Int(1138))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) + + pol = MustConstruct(LessThan(".", literal.Int(138))) + ok, leaf = pol.Match(nd) + require.False(t, ok) + require.Equal(t, pol[0], leaf) + + pol = MustConstruct(LessThan(".", literal.Int(100))) + ok, leaf = pol.Match(nd) + require.False(t, ok) + require.Equal(t, pol[0], leaf) }) t.Run("lte int", func(t *testing.T) { - np := basicnode.Prototype.Int - nb := np.NewBuilder() - nb.AssignInt(138) - nd := nb.Build() + nd := literal.Int(138) pol := MustConstruct(LessThanOrEqual(".", literal.Int(1138))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(LessThanOrEqual(".", literal.Int(138))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) + + pol = MustConstruct(LessThanOrEqual(".", literal.Int(100))) + ok, leaf = pol.Match(nd) + require.False(t, ok) + require.Equal(t, pol[0], leaf) }) }) t.Run("negation", func(t *testing.T) { - np := basicnode.Prototype.Bool - nb := np.NewBuilder() - nb.AssignBool(false) - nd := nb.Build() + nd := literal.Bool(false) pol := MustConstruct(Not(Equal(".", literal.Bool(true)))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(Not(Equal(".", literal.Bool(false)))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) }) t.Run("conjunction", func(t *testing.T) { - np := basicnode.Prototype.Int - nb := np.NewBuilder() - nb.AssignInt(138) - nd := nb.Build() + nd := literal.Int(138) pol := MustConstruct( And( @@ -246,8 +273,9 @@ func TestMatch(t *testing.T) { LessThan(".", literal.Int(1138)), ), ) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct( And( @@ -255,19 +283,18 @@ func TestMatch(t *testing.T) { Equal(".", literal.Int(1138)), ), ) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, MustConstruct(Equal(".", literal.Int(1138)))[0], leaf) pol = MustConstruct(And()) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) }) t.Run("disjunction", func(t *testing.T) { - np := basicnode.Prototype.Int - nb := np.NewBuilder() - nb.AssignInt(138) - nd := nb.Build() + nd := literal.Int(138) pol := MustConstruct( Or( @@ -275,8 +302,9 @@ func TestMatch(t *testing.T) { LessThan(".", literal.Int(1138)), ), ) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct( Or( @@ -284,12 +312,14 @@ func TestMatch(t *testing.T) { Equal(".", literal.Int(1138)), ), ) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) pol = MustConstruct(Or()) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) }) t.Run("wildcard", func(t *testing.T) { @@ -303,14 +333,12 @@ func TestMatch(t *testing.T) { } { func(s string) { t.Run(fmt.Sprintf("pass %s", s), func(t *testing.T) { - np := basicnode.Prototype.String - nb := np.NewBuilder() - nb.AssignString(s) - nd := nb.Build() + nd := literal.String(s) pol := MustConstruct(Like(".", pattern)) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) }) }(s) } @@ -324,70 +352,56 @@ func TestMatch(t *testing.T) { } { func(s string) { t.Run(fmt.Sprintf("fail %s", s), func(t *testing.T) { - np := basicnode.Prototype.String - nb := np.NewBuilder() - nb.AssignString(s) - nd := nb.Build() + nd := literal.String(s) pol := MustConstruct(Like(".", pattern)) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) }) }(s) } }) t.Run("quantification", func(t *testing.T) { - buildValueNode := func(v int64) ipld.Node { - np := basicnode.Prototype.Map - nb := np.NewBuilder() - ma, _ := nb.BeginMap(1) - ma.AssembleKey().AssignString("value") - ma.AssembleValue().AssignInt(v) - ma.Finish() - return nb.Build() - } - t.Run("all", func(t *testing.T) { - np := basicnode.Prototype.List - nb := np.NewBuilder() - la, _ := nb.BeginList(5) - la.AssembleValue().AssignNode(buildValueNode(5)) - la.AssembleValue().AssignNode(buildValueNode(10)) - la.AssembleValue().AssignNode(buildValueNode(20)) - la.AssembleValue().AssignNode(buildValueNode(50)) - la.AssembleValue().AssignNode(buildValueNode(100)) - la.Finish() - nd := nb.Build() + nd, _ := literal.List([]any{ + map[string]int{"value": 5}, + map[string]int{"value": 10}, + map[string]int{"value": 20}, + map[string]int{"value": 50}, + map[string]int{"value": 100}, + }) pol := MustConstruct(All(".[]", GreaterThan(".value", literal.Int(2)))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(All(".[]", GreaterThan(".value", literal.Int(20)))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, MustConstruct(GreaterThan(".value", literal.Int(20)))[0], leaf) }) t.Run("any", func(t *testing.T) { - np := basicnode.Prototype.List - nb := np.NewBuilder() - la, _ := nb.BeginList(5) - la.AssembleValue().AssignNode(buildValueNode(5)) - la.AssembleValue().AssignNode(buildValueNode(10)) - la.AssembleValue().AssignNode(buildValueNode(20)) - la.AssembleValue().AssignNode(buildValueNode(50)) - la.AssembleValue().AssignNode(buildValueNode(100)) - la.Finish() - nd := nb.Build() + nd, _ := literal.List([]any{ + map[string]int{"value": 5}, + map[string]int{"value": 10}, + map[string]int{"value": 20}, + map[string]int{"value": 50}, + map[string]int{"value": 100}, + }) pol := MustConstruct(Any(".[]", GreaterThan(".value", literal.Int(60)))) - ok := pol.Match(nd) + ok, leaf := pol.Match(nd) require.True(t, ok) + require.Nil(t, leaf) pol = MustConstruct(Any(".[]", GreaterThan(".value", literal.Int(100)))) - ok = pol.Match(nd) + ok, leaf = pol.Match(nd) require.False(t, ok) + require.Equal(t, pol[0], leaf) }) }) } @@ -405,7 +419,8 @@ func TestPolicyExamples(t *testing.T) { pol, err := FromDagJson(policy) require.NoError(t, err) - return pol.Match(data) + res, _ := pol.Match(data) + return res } t.Run("And", func(t *testing.T) { @@ -509,6 +524,435 @@ func FuzzMatch(f *testing.F) { t.Skip() } - policy.Match(dataNode) + _, _ = policy.Match(dataNode) + }) +} + +func TestOptionalSelectors(t *testing.T) { + tests := []struct { + name string + policy Policy + data map[string]any + expected bool + }{ + { + name: "missing optional field returns true", + policy: MustConstruct(Equal(".field?", literal.String("value"))), + 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]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]any{"field": "other"}, + expected: false, + }, + { + name: "missing non-optional field returns false", + policy: MustConstruct(Equal(".field", literal.String("value"))), + 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]any{"outer": map[string]any{}}, + expected: false, + }, + { + name: "completely missing nested optional path returns true", + policy: MustConstruct(Equal(".outer?.inner?", literal.String("value"))), + 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]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": []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": []any{}}, + 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) + require.NoError(t, err) + err = nb.AssignNode(n) + require.NoError(t, err) + + result, _ := tt.policy.Match(nb.Build()) + require.Equal(t, tt.expected, result) + }) + } +} + +// 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 map[string]any + expectedMatch bool + expectedStmt Statement + }{ + { + name: "returns true for missing non-optional field", + policy: MustConstruct( + Equal(".field", literal.String("value")), + ), + data: map[string]any{}, + 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]any{ + "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]any{ + "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]any{ + "present": "actual", + }, + expectedMatch: false, + expectedStmt: MustConstruct( + Equal(".present", literal.String("wrong")), + )[0], + }, + + // Optional fields + { + name: "returns false when optional field present but wrong", + policy: MustConstruct( + Equal(".field?", literal.String("value")), + ), + data: map[string]any{ + "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]any{ + "pattern": "testing123", + }, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns false for non-matching like pattern", + policy: MustConstruct( + Like(".pattern", "test*"), + ), + data: map[string]any{ + "pattern": "wrong123", + }, + expectedMatch: false, + expectedStmt: MustConstruct( + Like(".pattern", "test*"), + )[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( + Any(".numbers", Equal(".", literal.Int(4))), + )[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]any{ + "required": "present", + "items": []any{ + map[string]any{ + "name": "wrong", + "id": "ID123", + }, + map[string]any{ + "name": "test", + "id": "ID456", + }, + }, + }, + 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]any{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns true for missing optional like pattern", + policy: MustConstruct( + Like(".pattern?", "test*"), + ), + data: map[string]any{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns true for missing optional greater than", + policy: MustConstruct( + GreaterThan(".number?", literal.Int(5)), + ), + data: map[string]any{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns true for missing optional less than", + policy: MustConstruct( + LessThan(".number?", literal.Int(5)), + ), + data: map[string]any{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns true for missing optional array with all", + policy: MustConstruct( + All(".numbers?", Equal(".", literal.Int(1))), + ), + data: map[string]any{}, + expectedMatch: true, + expectedStmt: nil, + }, + { + name: "returns true for missing optional array with any", + policy: MustConstruct( + Any(".numbers?", Equal(".", literal.Int(1))), + ), + data: map[string]any{}, + 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]any{ + "required": "present", + }, + 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]any{ + "required": "present", + "items": []any{ + map[string]any{ + "name": "test", + // optional_id is missing + }, + }, + }, + expectedMatch: true, + expectedStmt: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + node, err := literal.Map(tt.data) + require.NoError(t, err) + + match, stmt := tt.policy.PartialMatch(node) + require.Equal(t, tt.expectedMatch, match) + if tt.expectedStmt == nil { + require.Nil(t, stmt) + } else { + require.Equal(t, tt.expectedStmt, stmt) + } + }) + } +} + +// TestInvocationValidation applies the example policy to the second +// example arguments as defined in the [Validation] section of the +// invocation specification. +// +// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation +func TestInvocationValidationSpecExamples(t *testing.T) { + t.Parallel() + + pol := MustConstruct( + Equal(".from", literal.String("alice@example.com")), + Any(".to", Like(".", "*@example.com")), + ) + + t.Run("with passing args", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("alice@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@example.com")) + qp.ListEntry(la, qp.String("carol@not.example.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + exec, stmt := pol.Match(argsNode) + assert.True(t, exec) + assert.Nil(t, stmt) + }) + + t.Run("fails on recipients (second statement)", func(t *testing.T) { + t.Parallel() + + argsNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "from", qp.String("alice@example.com")) + qp.MapEntry(ma, "to", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("bob@null.com")) + qp.ListEntry(la, qp.String("carol@elsewhere.example.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + exec, stmt := pol.Match(argsNode) + assert.False(t, exec) + assert.NotNil(t, stmt) }) } 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 diff --git a/pkg/policy/selector/parsing_test.go b/pkg/policy/selector/parsing_test.go index 3edcfd6..3d60d9c 100644 --- a/pkg/policy/selector/parsing_test.go +++ b/pkg/policy/selector/parsing_test.go @@ -1,7 +1,6 @@ package selector import ( - "fmt" "math" "testing" @@ -354,7 +353,6 @@ func TestParse(t *testing.T) { str := `.foo.["bar"].[138]?.baz[1:]` sel, err := Parse(str) require.NoError(t, err) - printSegments(sel) require.Equal(t, str, sel.String()) require.Equal(t, 7, len(sel)) require.False(t, sel[0].Identity()) @@ -404,13 +402,11 @@ func TestParse(t *testing.T) { t.Run("non dotted", func(t *testing.T) { _, err := Parse("foo") require.NotNil(t, err) - fmt.Println(err) }) t.Run("non quoted", func(t *testing.T) { _, err := Parse(".[foo]") require.NotNil(t, err) - fmt.Println(err) }) t.Run("slice with negative start and positive end", func(t *testing.T) { @@ -554,9 +550,3 @@ func TestParse(t *testing.T) { require.Error(t, err) }) } - -func printSegments(s Selector) { - for i, seg := range s { - fmt.Printf("%d: %s\n", i, seg.String()) - } -} diff --git a/pkg/policy/selector/selector_test.go b/pkg/policy/selector/selector_test.go index 5da0231..184b7b3 100644 --- a/pkg/policy/selector/selector_test.go +++ b/pkg/policy/selector/selector_test.go @@ -2,7 +2,6 @@ package selector import ( "errors" - "fmt" "strings" "testing" @@ -13,7 +12,6 @@ import ( "github.com/ipld/go-ipld-prime/must" basicnode "github.com/ipld/go-ipld-prime/node/basic" "github.com/ipld/go-ipld-prime/node/bindnode" - "github.com/ipld/go-ipld-prime/printer" "github.com/stretchr/testify/require" ) @@ -87,8 +85,6 @@ func TestSelect(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, res) - fmt.Println(printer.Sprint(res)) - age := must.Int(must.Node(res.LookupByString("age"))) require.Equal(t, int64(alice.Age), age) }) @@ -101,8 +97,6 @@ func TestSelect(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, res) - fmt.Println(printer.Sprint(res)) - name := must.String(res) require.Equal(t, alice.Name.First, name) @@ -110,8 +104,6 @@ func TestSelect(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, res) - fmt.Println(printer.Sprint(res)) - name = must.String(res) require.Equal(t, bob.Name.First, name) }) @@ -124,8 +116,6 @@ func TestSelect(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, res) - fmt.Println(printer.Sprint(res)) - name := must.String(res) require.Equal(t, *alice.Name.Middle, name) @@ -142,8 +132,6 @@ func TestSelect(t *testing.T) { require.Error(t, err) require.Empty(t, res) - fmt.Println(err) - require.ErrorAs(t, err, &resolutionerr{}, "error should be a resolution error") }) @@ -164,8 +152,6 @@ func TestSelect(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, res) - fmt.Println(printer.Sprint(res)) - iname := must.String(must.Node(must.Node(res.LookupByIndex(0)).LookupByString("name"))) require.Equal(t, alice.Interests[0].Name, iname) diff --git a/pkg/policy/selector/supported_test.go b/pkg/policy/selector/supported_test.go index db22216..44ec241 100644 --- a/pkg/policy/selector/supported_test.go +++ b/pkg/policy/selector/supported_test.go @@ -1,15 +1,12 @@ package selector_test import ( - "bytes" "strings" "testing" "github.com/ipld/go-ipld-prime" "github.com/ipld/go-ipld-prime/codec/dagjson" - "github.com/ipld/go-ipld-prime/datamodel" basicnode "github.com/ipld/go-ipld-prime/node/basic" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ucan-wg/go-ucan/pkg/policy/selector" @@ -55,7 +52,7 @@ func TestSupportedForms(t *testing.T) { require.NotNil(t, res) exp := makeNode(t, tc.Output) - equalIPLD(t, exp, res) + require.True(t, ipld.DeepEqual(exp, res)) }) } @@ -106,23 +103,6 @@ func TestSupportedForms(t *testing.T) { } } -func equalIPLD(t *testing.T, expected datamodel.Node, actual datamodel.Node) bool { - t.Helper() - - exp, act := &bytes.Buffer{}, &bytes.Buffer{} - if err := dagjson.Encode(expected, exp); err != nil { - return assert.Fail(t, "Failed to encode json for expected IPLD node") - } - - if err := dagjson.Encode(actual, act); err != nil { - return assert.Fail(t, "Failed to encode JSON for actual IPLD node") - } - - require.JSONEq(t, exp.String(), act.String()) - - return true -} - func makeNode(t *testing.T, dagJsonInput string) ipld.Node { t.Helper() diff --git a/token/delegation/delegation.go b/token/delegation/delegation.go index 10db932..599f773 100644 --- a/token/delegation/delegation.go +++ b/token/delegation/delegation.go @@ -10,7 +10,6 @@ package delegation // TODO: change the "delegation" link above when the specification is merged import ( - "crypto/rand" "errors" "fmt" "time" @@ -21,6 +20,8 @@ import ( "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" "github.com/ucan-wg/go-ucan/pkg/policy" + "github.com/ucan-wg/go-ucan/token/internal/nonce" + "github.com/ucan-wg/go-ucan/token/internal/parse" ) // Token is an immutable type that holds the fields of a UCAN delegation. @@ -73,16 +74,12 @@ func New(privKey crypto.PrivKey, aud did.DID, cmd command.Command, pol policy.Po } if len(tkn.nonce) == 0 { - tkn.nonce, err = generateNonce() + tkn.nonce, err = nonce.Generate() if err != nil { return nil, err } } - if len(tkn.meta.Keys) < 1 { - tkn.meta = nil - } - if err := tkn.validate(); err != nil { return nil, err } @@ -142,8 +139,8 @@ func (t *Token) Nonce() []byte { } // Meta returns the Token's metadata. -func (t *Token) Meta() *meta.Meta { - return t.meta +func (t *Token) Meta() meta.ReadOnly { + return t.meta.ReadOnly() } // NotBefore returns the time at which the Token becomes "active". @@ -188,27 +185,19 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { return nil, fmt.Errorf("parse iss: %w", err) } - tkn.audience, err = did.Parse(m.Aud) - if err != nil { + if tkn.audience, err = did.Parse(m.Aud); err != nil { return nil, fmt.Errorf("parse audience: %w", err) } - if m.Sub != nil { - tkn.subject, err = did.Parse(*m.Sub) - if err != nil { - return nil, fmt.Errorf("parse subject: %w", err) - } - } else { - tkn.subject = did.Undef + if tkn.subject, err = parse.OptionalDID(m.Sub); err != nil { + return nil, fmt.Errorf("parse subject: %w", err) } - tkn.command, err = command.Parse(m.Cmd) - if err != nil { + if tkn.command, err = command.Parse(m.Cmd); err != nil { return nil, fmt.Errorf("parse command: %w", err) } - tkn.policy, err = policy.FromIPLD(m.Pol) - if err != nil { + if tkn.policy, err = policy.FromIPLD(m.Pol); err != nil { return nil, fmt.Errorf("parse policy: %w", err) } @@ -219,15 +208,8 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { tkn.meta = m.Meta - if m.Nbf != nil { - t := time.Unix(*m.Nbf, 0) - tkn.notBefore = &t - } - - if m.Exp != nil { - t := time.Unix(*m.Exp, 0) - tkn.expiration = &t - } + tkn.notBefore = parse.OptionalTimestamp(m.Nbf) + tkn.expiration = parse.OptionalTimestamp(m.Exp) if err := tkn.validate(); err != nil { return nil, err @@ -235,14 +217,3 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { return &tkn, nil } - -// generateNonce creates a 12-byte random nonce. -// TODO: some crypto scheme require more, is that our case? -func generateNonce() ([]byte, error) { - res := make([]byte, 12) - _, err := rand.Read(res) - if err != nil { - return nil, err - } - return res, nil -} diff --git a/token/delegation/delegation_test.go b/token/delegation/delegation_test.go index 53d7a8f..906f293 100644 --- a/token/delegation/delegation_test.go +++ b/token/delegation/delegation_test.go @@ -103,8 +103,6 @@ func TestConstructors(t *testing.T) { data, err := tkn.ToDagJson(privKey) require.NoError(t, err) - t.Log(string(data)) - golden.Assert(t, string(data), "new.dagjson") }) @@ -122,8 +120,6 @@ func TestConstructors(t *testing.T) { data, err := tkn.ToDagJson(privKey) require.NoError(t, err) - t.Log(string(data)) - golden.Assert(t, string(data), "root.dagjson") }) } diff --git a/token/delegation/ipld.go b/token/delegation/ipld.go index 8508faa..5b67b5e 100644 --- a/token/delegation/ipld.go +++ b/token/delegation/ipld.go @@ -229,5 +229,10 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { Exp: exp, } + // seems like it's a requirement to have a null meta if there are no values? + if len(model.Meta.Keys) == 0 { + model.Meta = nil + } + return envelope.ToIPLD(privKey, model) } diff --git a/token/delegation/schema_test.go b/token/delegation/schema_test.go index 8866cc7..d9a7e8c 100644 --- a/token/delegation/schema_test.go +++ b/token/delegation/schema_test.go @@ -3,7 +3,6 @@ package delegation_test import ( "bytes" _ "embed" - "fmt" "testing" "github.com/ipld/go-ipld-prime" @@ -36,18 +35,13 @@ func TestSchemaRoundTrip(t *testing.T) { cborBytes, id, err := p1.ToSealed(privKey) require.NoError(t, err) assert.Equal(t, newCID, envelope.CIDToBase58BTC(id)) - fmt.Println("cborBytes length", len(cborBytes)) - fmt.Println("cbor", string(cborBytes)) p2, c2, err := delegation.FromSealed(cborBytes) require.NoError(t, err) assert.Equal(t, id, c2) - fmt.Println("read Cbor", p2) readJson, err := p2.ToDagJson(privKey) require.NoError(t, err) - fmt.Println("readJson length", len(readJson)) - fmt.Println("json: ", string(readJson)) assert.JSONEq(t, string(delegationJson), string(readJson)) }) @@ -65,7 +59,6 @@ func TestSchemaRoundTrip(t *testing.T) { cborBytes := &bytes.Buffer{} id, err := p1.ToSealedWriter(cborBytes, privKey) - t.Log(len(id.Bytes()), id.Bytes()) require.NoError(t, err) assert.Equal(t, newCID, envelope.CIDToBase58BTC(id)) diff --git a/token/interface.go b/token/interface.go index 4e8e1c9..3079f56 100644 --- a/token/interface.go +++ b/token/interface.go @@ -17,7 +17,7 @@ type Token interface { // Issuer returns the did.DID representing the Token's issuer. Issuer() did.DID // Meta returns the Token's metadata. - Meta() *meta.Meta + Meta() meta.ReadOnly } type Marshaller interface { diff --git a/token/internal/nonce/nonce.go b/token/internal/nonce/nonce.go new file mode 100644 index 0000000..3bda21b --- /dev/null +++ b/token/internal/nonce/nonce.go @@ -0,0 +1,14 @@ +package nonce + +import "crypto/rand" + +// Generate creates a 12-byte random nonce. +// TODO: some crypto scheme require more, is that our case? +func Generate() ([]byte, error) { + res := make([]byte, 12) + _, err := rand.Read(res) + if err != nil { + return nil, err + } + return res, nil +} diff --git a/token/internal/parse/parse.go b/token/internal/parse/parse.go new file mode 100644 index 0000000..147b308 --- /dev/null +++ b/token/internal/parse/parse.go @@ -0,0 +1,22 @@ +package parse + +import ( + "time" + + "github.com/ucan-wg/go-ucan/did" +) + +func OptionalDID(s *string) (did.DID, error) { + if s == nil { + return did.Undef, nil + } + return did.Parse(*s) +} + +func OptionalTimestamp(sec *int64) *time.Time { + if sec == nil { + return nil + } + t := time.Unix(*sec, 0) + return &t +} diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go new file mode 100644 index 0000000..7ecf349 --- /dev/null +++ b/token/invocation/examples_test.go @@ -0,0 +1,228 @@ +package invocation_test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/codec/dagcbor" + "github.com/ipld/go-ipld-prime/codec/dagjson" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" + "github.com/ipld/go-ipld-prime/node/basicnode" + "github.com/libp2p/go-libp2p/core/crypto" + + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/invocation" +) + +func ExampleNew() { + privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() + if err != nil { + fmt.Println("failed to create setup:", err.Error()) + + return + } + + inv, err := invocation.New(iss, sub, cmd, prf, + invocation.WithArgument("uri", args["uri"]), + invocation.WithArgument("headers", args["headers"]), + invocation.WithArgument("payload", args["payload"]), + invocation.WithMeta("env", "development"), + invocation.WithMeta("tags", meta["tags"]), + invocation.WithExpirationIn(time.Minute), + invocation.WithoutInvokedAt()) + if err != nil { + fmt.Println("failed to create invocation:", err.Error()) + + return + } + + data, cid, err := inv.ToSealed(privKey) + if err != nil { + fmt.Println("failed to seal invocation:", err.Error()) + + return + } + + json, err := prettyDAGJSON(data) + if err != nil { + fmt.Println("failed to pretty DAG-JSON:", err.Error()) + + return + } + + fmt.Println("CID:", cid) + fmt.Println("Token (pretty DAG-JSON):") + fmt.Println(json) + + // Expected CID and DAG-JSON output: + // CID: bafyreid2n5q45vk4osned7k5huocbe3mxbisonh5vujepqftc5ftr543ae + // Token (pretty DAG-JSON): + // [ + // { + // "/": { + // "bytes": "gvyL7kdSkgmaDpDU/Qj9ohRwxYLCHER52HFMSFEqQqEcQC9qr4JCPP1f/WybvGGuVzYiA0Hx4JO+ohNz8BxUAA" + // } + // }, + // { + // "h": { + // "/": { + // "bytes": "NO0BcQ" + // } + // }, + // "ucan/inv@1.0.0-rc.1": { + // "args": { + // "headers": { + // "Content-Type": "application/json" + // }, + // "payload": { + // "body": "UCAN is great", + // "draft": true, + // "title": "UCAN for Fun and Profit", + // "topics": [ + // "authz", + // "journal" + // ] + // }, + // "uri": "https://example.com/blog/posts" + // }, + // "cmd": "/crud/create", + // "exp": 1729788921, + // "iss": "did:key:z6MkhniGGyP88eZrq2dpMvUPdS2RQMhTUAWzcu6kVGUvEtCJ", + // "meta": { + // "env": "development", + // "tags": [ + // "blog", + // "post", + // "pr#123" + // ] + // }, + // "nonce": { + // "/": { + // "bytes": "2xXPoZwWln1TfXIp" + // } + // }, + // "prf": [ + // { + // "/": "bafyreigx3qxd2cndpe66j2mdssj773ecv7tqd7wovcnz5raguw6lj7sjoe" + // }, + // { + // "/": "bafyreib34ira254zdqgehz6f2bhwme2ja2re3ltcalejv4x4tkcveujvpa" + // }, + // { + // "/": "bafyreibkb66tpo2ixqx3fe5hmekkbuasrod6olt5bwm5u5pi726mduuwlq" + // } + // ], + // "sub": "did:key:z6MktWuvPvBe5UyHnDGuEdw8aJ5qrhhwLG6jy7cQYM6ckP6P" + // } + // } + // ] +} + +func prettyDAGJSON(data []byte) (string, error) { + var node ipld.Node + + node, err := ipld.Decode(data, dagcbor.Decode) + if err != nil { + return "", err + } + + jsonData, err := ipld.Encode(node, dagjson.Encode) + if err != nil { + return "", err + } + + var out bytes.Buffer + if err := json.Indent(&out, jsonData, "", " "); err != nil { + return "", err + } + + return out.String(), nil +} + +func setupExampleNew() (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Command, args map[string]datamodel.Node, prf []cid.Cid, meta map[string]datamodel.Node, errs error) { + var err error + + privKey, iss, err = did.GenerateEd25519() + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to generate Issuer identity: %w", err)) + } + + _, sub, err = did.GenerateEd25519() + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to generate Subject identity: %w", err)) + } + + cmd, err = command.Parse("/crud/create") + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to parse command: %w", err)) + } + + headers, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "Content-Type", qp.String("application/json")) + }) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to build headers: %w", err)) + } + + // ***** WARNING - do not change the order of these elements. DAG-CBOR + // will order them alphabetically and the unsealed + // result won't match if the input isn't also created in + // alphabetical order. + payload, err := qp.BuildMap(basicnode.Prototype.Any, 4, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "body", qp.String("UCAN is great")) + qp.MapEntry(ma, "draft", qp.Bool(true)) + qp.MapEntry(ma, "title", qp.String("UCAN for Fun and Profit")) + qp.MapEntry(ma, "topics", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("authz")) + qp.ListEntry(la, qp.String("journal")) + })) + }) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to build payload: %w", err)) + } + + args = map[string]datamodel.Node{ + "uri": basicnode.NewString("https://example.com/blog/posts"), + "headers": headers, + "payload": payload, + } + + prf = make([]cid.Cid, 3) + for i, v := range []string{ + "zdpuAzx4sBrBCabrZZqXgvK3NDzh7Mf5mKbG11aBkkMCdLtCp", + "zdpuApTCXfoKh2sB1KaUaVSGofCBNPUnXoBb6WiCeitXEibZy", + "zdpuAoFdXRPw4n6TLcncoDhq1Mr6FGbpjAiEtqSBrTSaYMKkf", + } { + prf[i], err = cid.Parse(v) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to parse proof cid: %w", err)) + } + } + + // ***** WARNING - do not change the order of these elements. DAG-CBOR + // will order them alphabetically and the unsealed + // result won't match if the input isn't also created in + // alphabetical order. + tags, err := qp.BuildList(basicnode.Prototype.Any, 3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("blog")) + qp.ListEntry(la, qp.String("post")) + qp.ListEntry(la, qp.String("pr#123")) + }) + if err != nil { + errs = errors.Join(errs, fmt.Errorf("failed to build tags: %w", err)) + } + + meta = map[string]datamodel.Node{ + "env": basicnode.NewString("development"), + "tags": tags, + } + + return // WARNING: named return values +} diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index d48fee8..c48121f 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -8,37 +8,94 @@ package invocation import ( - "crypto/rand" "errors" "fmt" "time" + "github.com/ipfs/go-cid" + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/args" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" + "github.com/ucan-wg/go-ucan/token/internal/nonce" + "github.com/ucan-wg/go-ucan/token/internal/parse" ) // Token is an immutable type that holds the fields of a UCAN invocation. type Token struct { - // Issuer DID (invoker) + // The DID of the Invoker issuer did.DID - // Audience DID (receiver/executor) - audience did.DID - // Subject DID (subject being invoked) + // The DID of Subject being invoked subject did.DID - // The Command to invoke + // The DID of the intended Executor if different from the Subject + audience did.DID + + // The Command command command.Command - // TODO: args - // TODO: prf - // A unique, random nonce - nonce []byte + // The Command's Arguments + arguments *args.Args + // Delegations that prove the chain of authority + proof []cid.Cid + // Arbitrary Metadata meta *meta.Meta + + // A unique, random nonce + nonce []byte // The timestamp at which the Invocation becomes invalid expiration *time.Time // The timestamp at which the Invocation was created invokedAt *time.Time - // TODO: cause + + // An optional CID of the Receipt that enqueued the Task + cause *cid.Cid +} + +// New creates an invocation Token with the provided options. +// +// If no nonce is provided, a random 12-byte nonce is generated. Use the +// WithNonce or WithEmptyNonce options to specify provide your own nonce +// or to leave the nonce empty respectively. +// +// If no invokedAt is provided, the current time is used. Use the +// WithInvokedAt or WithInvokedAtIn Options to specify a different time +// or the WithoutInvokedAt Option to clear the Token's invokedAt field. +// +// With the exception of the WithMeta option, all others will overwrite +// the previous contents of their target field. +func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) (*Token, error) { + iat := time.Now() + + tkn := Token{ + issuer: iss, + subject: sub, + command: cmd, + arguments: args.New(), + proof: prf, + meta: meta.NewMeta(), + nonce: nil, + invokedAt: &iat, + } + + for _, opt := range opts { + if err := opt(&tkn); err != nil { + return nil, err + } + } + + if len(tkn.nonce) == 0 { + tkn.nonce, err = nonce.Generate() + if err != nil { + return nil, err + } + } + + if err := tkn.validate(); err != nil { + return nil, err + } + + return &tkn, nil } // Issuer returns the did.DID representing the Token's issuer. @@ -46,40 +103,60 @@ func (t *Token) Issuer() did.DID { return t.issuer } +// Subject returns the did.DID representing the Token's subject. +func (t *Token) Subject() did.DID { + return t.subject +} + // Audience returns the did.DID representing the Token's audience. func (t *Token) Audience() did.DID { return t.audience } -// Subject returns the did.DID representing the Token's subject. -// -// This field may be did.Undef for delegations that are [Powerlined] but -// must be equal to the value returned by the Issuer method for root -// tokens. -func (t *Token) Subject() did.DID { - return t.subject -} - // Command returns the capability's command.Command. func (t *Token) Command() command.Command { return t.command } +// Arguments returns the arguments to be used when the command is +// invoked. +func (t *Token) Arguments() *args.Args { + return t.arguments +} + +// Proof() returns the ordered list of cid.Cid which reference the +// delegation Tokens that authorize this invocation. +func (t *Token) Proof() []cid.Cid { + return t.proof +} + +// Meta returns the Token's metadata. +func (t *Token) Meta() meta.ReadOnly { + return t.meta.ReadOnly() +} + // Nonce returns the random Nonce encapsulated in this Token. func (t *Token) Nonce() []byte { return t.nonce } -// Meta returns the Token's metadata. -func (t *Token) Meta() *meta.Meta { - return t.meta -} - // Expiration returns the time at which the Token expires. func (t *Token) Expiration() *time.Time { return t.expiration } +// InvokedAt returns the time.Time at which the invocation token was +// created. +func (t *Token) InvokedAt() *time.Time { + return t.invokedAt +} + +// Cause returns the Token's (optional) cause field which may specify +// which describes the Receipt that requested the invocation. +func (t *Token) Cause() *cid.Cid { + return t.cause +} + func (t *Token) validate() error { var errs error @@ -90,8 +167,7 @@ func (t *Token) validate() error { } requiredDID(t.issuer, "Issuer") - - // TODO + requiredDID(t.subject, "Subject") if len(t.nonce) < 12 { errs = errors.Join(errs, fmt.Errorf("token nonce too small")) @@ -105,20 +181,42 @@ func (t *Token) validate() error { func tokenFromModel(m tokenPayloadModel) (*Token, error) { var ( tkn Token + err error ) - // TODO + if tkn.issuer, err = did.Parse(m.Iss); err != nil { + return nil, fmt.Errorf("parse iss: %w", err) + } + + if tkn.subject, err = did.Parse(m.Sub); err != nil { + return nil, fmt.Errorf("parse subject: %w", err) + } + + if tkn.audience, err = parse.OptionalDID(m.Aud); err != nil { + return nil, fmt.Errorf("parse audience: %w", err) + } + + if tkn.command, err = command.Parse(m.Cmd); err != nil { + return nil, fmt.Errorf("parse command: %w", err) + } + + if len(m.Nonce) == 0 { + return nil, fmt.Errorf("nonce is required") + } + tkn.nonce = m.Nonce + + tkn.arguments = m.Args + tkn.proof = m.Prf + tkn.meta = m.Meta + + tkn.expiration = parse.OptionalTimestamp(m.Exp) + tkn.invokedAt = parse.OptionalTimestamp(m.Iat) + + tkn.cause = m.Cause + + if err := tkn.validate(); err != nil { + return nil, err + } return &tkn, nil } - -// generateNonce creates a 12-byte random nonce. -// TODO: some crypto scheme require more, is that our case? -func generateNonce() ([]byte, error) { - res := make([]byte, 12) - _, err := rand.Read(res) - if err != nil { - return nil, err - } - return res, nil -} diff --git a/token/invocation/invocation.ipldsch b/token/invocation/invocation.ipldsch index 2acab27..ba2396a 100644 --- a/token/invocation/invocation.ipldsch +++ b/token/invocation/invocation.ipldsch @@ -1,23 +1,32 @@ + type DID string # The Invocation Payload attaches sender, receiver, and provenance to the Task. type Payload struct { - # Issuer DID (sender) + # The DID of the invoker iss DID - # Audience DID (receiver) - aud DID - # Principal that the chain is about (the Subject) - sub optional DID + # The Subject being invoked + sub DID + # The DID of the intended Executor if different from the Subject + aud optional DID - # The Command to eventually invoke + # The Command cmd String - - # A unique, random nonce - nonce Bytes + # The Command's Arguments + args { String : Any} + # Delegations that prove the chain of authority + prf [ Link ] # Arbitrary Metadata - meta {String : Any} + meta optional { String : Any } + # A unique, random nonce + nonce optional Bytes # The timestamp at which the Invocation becomes invalid exp nullable Int + # The Timestamp at which the Invocation was created + iat optional Int + + # An optional CID of the Receipt that enqueued the Task + cause optional Link } diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 897f608..acaaf98 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -193,29 +193,42 @@ func FromIPLD(node datamodel.Node) (*Token, error) { } func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { - var sub *string + var aud *string - if t.subject != did.Undef { - s := t.subject.String() - sub = &s + if t.audience != did.Undef { + a := t.audience.String() + aud = &a } - // TODO - var exp *int64 if t.expiration != nil { u := t.expiration.Unix() exp = &u } + var iat *int64 + if t.invokedAt != nil { + i := t.invokedAt.Unix() + iat = &i + } + model := &tokenPayloadModel{ Iss: t.issuer.String(), - Aud: t.audience.String(), - Sub: sub, + Aud: aud, + Sub: t.subject.String(), Cmd: t.command.String(), + Args: t.arguments, + Prf: t.proof, + Meta: t.meta, Nonce: t.nonce, - Meta: *t.meta, Exp: exp, + Iat: iat, + Cause: t.cause, + } + + // seems like it's a requirement to have a null meta if there are no values? + if len(model.Meta.Keys) == 0 { + model.Meta = nil } return envelope.ToIPLD(privKey, model) diff --git a/token/invocation/ipld_test.go b/token/invocation/ipld_test.go new file mode 100644 index 0000000..9754c16 --- /dev/null +++ b/token/invocation/ipld_test.go @@ -0,0 +1,38 @@ +package invocation_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ucan-wg/go-ucan/token/invocation" +) + +func TestSealUnsealRoundtrip(t *testing.T) { + t.Parallel() + + privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() + require.NoError(t, err) + + tkn1, err := invocation.New(iss, sub, cmd, prf, + invocation.WithArgument("uri", args["uri"]), + invocation.WithArgument("headers", args["headers"]), + invocation.WithArgument("payload", args["payload"]), + invocation.WithMeta("env", "development"), + invocation.WithMeta("tags", meta["tags"]), + invocation.WithExpirationIn(time.Minute), + invocation.WithoutInvokedAt(), + ) + require.NoError(t, err) + + data, cid1, err := tkn1.ToSealed(privKey) + require.NoError(t, err) + + tkn2, cid2, err := invocation.FromSealed(data) + require.NoError(t, err) + + assert.Equal(t, cid1, cid2) + assert.Equal(t, tkn1, tkn2) +} diff --git a/token/invocation/options.go b/token/invocation/options.go new file mode 100644 index 0000000..9322cd7 --- /dev/null +++ b/token/invocation/options.go @@ -0,0 +1,125 @@ +package invocation + +import ( + "time" + + "github.com/ipfs/go-cid" + + "github.com/ucan-wg/go-ucan/did" +) + +// Option is a type that allows optional fields to be set during the +// creation of an invocation Token. +type Option func(*Token) error + +// WithArgument adds a key/value pair to the Token's Arguments field. +func WithArgument(key string, val any) Option { + return func(t *Token) error { + return t.arguments.Add(key, val) + } +} + +// WithAudience sets the Token's audience to the provided did.DID. +// +// If the provided did.DID is the same as the Token's subject, the +// audience is not set. +func WithAudience(aud did.DID) Option { + return func(t *Token) error { + if t.subject.String() != aud.String() { + t.audience = aud + } + + return nil + } +} + +// WithMeta adds a key/value pair in the "meta" field. +// +// WithMeta can be used multiple times in the same call. +// Accepted types for the value are: bool, string, int, int32, int64, []byte, +// and ipld.Node. +func WithMeta(key string, val any) Option { + return func(t *Token) error { + return t.meta.Add(key, val) + } +} + +// WithNonce sets the Token's nonce with the given value. +// +// If this option is not used, a random 12-byte nonce is generated for +// this required field. If you truly want to create an invocation Token +// without a nonce, use the WithEmptyNonce Option which will set the +// nonce to an empty byte array. +func WithNonce(nonce []byte) Option { + return func(t *Token) error { + t.nonce = nonce + + return nil + } +} + +// WithEmptyNonce sets the Token's nonce to an empty byte slice as +// suggested by the UCAN spec for invocation tokens that represent +// idempotent operations. +func WithEmptyNonce() Option { + return func(t *Token) error { + t.nonce = []byte{} + + return nil + } +} + +// WithExpiration set's the Token's optional "expiration" field to the +// value of the provided time.Time. +func WithExpiration(exp time.Time) Option { + return func(t *Token) error { + exp = exp.Round(time.Second) + t.expiration = &exp + + return nil + } +} + +// WithExpirationIn set's the Token's optional "expiration" field to +// Now() plus the given duration. +func WithExpirationIn(after time.Duration) Option { + return WithExpiration(time.Now().Add(after)) +} + +// WithInvokedAt sets the Token's invokedAt field to the provided +// time.Time. +// +// If this Option is not provided, the invocation Token's iat field will +// be set to the value of time.Now(). If you want to create an invocation +// Token without this field being set, use the WithoutInvokedAt Option. +func WithInvokedAt(iat time.Time) Option { + return func(t *Token) error { + t.invokedAt = &iat + + return nil + } +} + +// WithInvokedAtIn sets the Token's invokedAt field to Now() plus the +// given duration. +func WithInvokedAtIn(after time.Duration) Option { + return WithInvokedAt(time.Now().Add(after)) +} + +// WithoutInvokedAt clears the Token's invokedAt field. +func WithoutInvokedAt() Option { + return func(t *Token) error { + t.invokedAt = nil + + return nil + } +} + +// WithCause sets the Token's cause field to the provided cid.Cid. +func WithCause(cause *cid.Cid) Option { + return func(t *Token) error { + t.cause = cause + + return nil + } +} diff --git a/token/invocation/schema.go b/token/invocation/schema.go index e6941a2..d51cf4f 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -5,10 +5,12 @@ import ( "fmt" "sync" + "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" "github.com/ipld/go-ipld-prime/node/bindnode" "github.com/ipld/go-ipld-prime/schema" + "github.com/ucan-wg/go-ucan/pkg/args" "github.com/ucan-wg/go-ucan/pkg/meta" "github.com/ucan-wg/go-ucan/token/internal/envelope" ) @@ -44,28 +46,34 @@ func payloadType() schema.Type { var _ envelope.Tokener = (*tokenPayloadModel)(nil) -// TODO type tokenPayloadModel struct { - // Issuer DID (sender) + // The DID of the Invoker Iss string - // Audience DID (receiver) - Aud string - // Principal that the chain is about (the Subject) - // optional: can be nil - Sub *string + // The DID of Subject being invoked + Sub string + // The DID of the intended Executor if different from the Subject + Aud *string - // The Command to eventually invoke + // The Command Cmd string + // The Command's Arguments + Args *args.Args + // Delegations that prove the chain of authority + Prf []cid.Cid + + // Arbitrary Metadata + Meta *meta.Meta // A unique, random nonce Nonce []byte - - // Arbitrary Metadata - Meta meta.Meta - // The timestamp at which the Invocation becomes invalid // optional: can be nil Exp *int64 + // The timestamp at which the Invocation was created + Iat *int64 + + // An optional CID of the Receipt that enqueued the Task + Cause *cid.Cid } func (e *tokenPayloadModel) Prototype() schema.TypedPrototype { diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go new file mode 100644 index 0000000..77b1afb --- /dev/null +++ b/token/invocation/schema_test.go @@ -0,0 +1,91 @@ +package invocation_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gotest.tools/v3/golden" + + "github.com/ucan-wg/go-ucan/token/internal/envelope" + "github.com/ucan-wg/go-ucan/token/invocation" +) + +const ( + issuerPrivKeyCfg = "CAESQK45xBfqIxRp7ZdRdck3tIJZKocCqvANQc925dCJhFwO7DJNA2j94zkF0TNx5mpXV0s6utfkFdHddWTaPVU6yZc=" + newCID = "zdpuAqY6Zypg4UnpbSUgDvYGneyFaTKaZevzxgSxV4rmv3Fpp" +) + +func TestSchemaRoundTrip(t *testing.T) { + t.Parallel() + + invocationJson := golden.Get(t, "new.dagjson") + privKey := privKey(t, issuerPrivKeyCfg) + + t.Run("via buffers", func(t *testing.T) { + t.Parallel() + + // format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson + // function: DecodeDagJson() Seal() Unseal() EncodeDagJson() + + p1, err := invocation.FromDagJson(invocationJson) + require.NoError(t, err) + + cborBytes, id, err := p1.ToSealed(privKey) + require.NoError(t, err) + assert.Equal(t, newCID, envelope.CIDToBase58BTC(id)) + fmt.Println("cborBytes length", len(cborBytes)) + fmt.Println("cbor", string(cborBytes)) + + p2, c2, err := invocation.FromSealed(cborBytes) + require.NoError(t, err) + assert.Equal(t, id, c2) + fmt.Println("read Cbor", p2) + + readJson, err := p2.ToDagJson(privKey) + require.NoError(t, err) + fmt.Println("readJson length", len(readJson)) + fmt.Println("json: ", string(readJson)) + + assert.JSONEq(t, string(invocationJson), string(readJson)) + }) + + t.Run("via streaming", func(t *testing.T) { + t.Parallel() + + buf := bytes.NewBuffer(invocationJson) + + // format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson + // function: DecodeDagJson() Seal() Unseal() EncodeDagJson() + + p1, err := invocation.FromDagJsonReader(buf) + require.NoError(t, err) + + cborBytes := &bytes.Buffer{} + id, err := p1.ToSealedWriter(cborBytes, privKey) + require.NoError(t, err) + assert.Equal(t, newCID, envelope.CIDToBase58BTC(id)) + + p2, c2, err := invocation.FromSealedReader(cborBytes) + require.NoError(t, err) + assert.Equal(t, envelope.CIDToBase58BTC(id), envelope.CIDToBase58BTC(c2)) + + readJson := &bytes.Buffer{} + require.NoError(t, p2.ToDagJsonWriter(readJson, privKey)) + + assert.JSONEq(t, string(invocationJson), readJson.String()) + }) +} + +func privKey(t require.TestingT, privKeyCfg string) crypto.PrivKey { + privKeyMar, err := crypto.ConfigDecodeKey(privKeyCfg) + require.NoError(t, err) + + privKey, err := crypto.UnmarshalPrivateKey(privKeyMar) + require.NoError(t, err) + + return privKey +} diff --git a/token/invocation/testdata/new.dagjson b/token/invocation/testdata/new.dagjson new file mode 100644 index 0000000..c6a9b3f --- /dev/null +++ b/token/invocation/testdata/new.dagjson @@ -0,0 +1 @@ +[{"/":{"bytes":"o/vTvTs8SEkD9QL/eNhhW0fAng/SGBouywCbUnOfsF2RFHxaV02KTCyzgDxlJLZ2XN/Vk5igLmlKL3QIXMaeCQ"}},{"h":{"/":{"bytes":"NO0BcQ"}},"ucan/inv@1.0.0-rc.1":{"args":{"headers":{"Content-Type":"application/json"},"payload":{"body":"UCAN is great","draft":true,"title":"UCAN for Fun and Profit","topics":["authz","journal"]},"uri":"https://example.com/blog/posts"},"cmd":"/crud/create","exp":1730812145,"iss":"did:key:z6MkvMGkN5nbUQLBVqJhr13Zdqyh9rR1VuF16PuZbfocBxpv","meta":{"env":"development","tags":["blog","post","pr#123"]},"nonce":{"/":{"bytes":"q1AH6MJrqoTH6av7"}},"prf":[{"/":"bafyreigx3qxd2cndpe66j2mdssj773ecv7tqd7wovcnz5raguw6lj7sjoe"},{"/":"bafyreib34ira254zdqgehz6f2bhwme2ja2re3ltcalejv4x4tkcveujvpa"},{"/":"bafyreibkb66tpo2ixqx3fe5hmekkbuasrod6olt5bwm5u5pi726mduuwlq"}],"sub":"did:key:z6MkuFj35aiTL7YQiVMobuSeUQju92g7wZzufS3HAc6NFFcQ"}}] \ No newline at end of file