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/54] 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 d784c92c293ce5a67e14c919bdb5c8f8fa9a7244 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 10:44:38 -0400 Subject: [PATCH 02/54] feat(invocation): provide New constructor and encoding to wire-format --- token/invocation/invocation.go | 103 ++++++++++++++++----- token/invocation/invocation.ipldsch | 29 +++--- token/invocation/ipld.go | 47 ++++++++-- token/invocation/options.go | 134 ++++++++++++++++++++++++++++ token/invocation/schema.go | 34 ++++--- 5 files changed, 295 insertions(+), 52 deletions(-) create mode 100644 token/invocation/options.go diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index d48fee8..3a17ea2 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -13,6 +13,8 @@ import ( "fmt" "time" + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/datamodel" "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" @@ -20,25 +22,70 @@ import ( // 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 map[string]datamodel.Node + // 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. +// +// With the exception of the WithMeta option, all other 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) { + nonce := make([]byte, 12) + + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + + iat := time.Now() + + tkn := Token{ + issuer: iss, + subject: sub, + command: cmd, + proof: prf, + nonce: nonce, + invokedAt: &iat, + } + + for _, opt := range opts { + if err := opt(&tkn); err != nil { + return nil, err + } + } + + return &tkn, nil } // Issuer returns the did.DID representing the Token's issuer. @@ -46,28 +93,27 @@ 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 } -// Nonce returns the random Nonce encapsulated in this Token. -func (t *Token) Nonce() []byte { - return t.nonce +func (t *Token) Arguments() map[string]datamodel.Node { + return t.arguments +} + +func (t *Token) Proof() []cid.Cid { + return t.proof } // Meta returns the Token's metadata. @@ -75,11 +121,24 @@ func (t *Token) Meta() *meta.Meta { return t.meta } +// Nonce returns the random Nonce encapsulated in this Token. +func (t *Token) Nonce() []byte { + return t.nonce +} + // Expiration returns the time at which the Token expires. func (t *Token) Expiration() *time.Time { return t.expiration } +func (t *Token) InvokedAt() *time.Time { + return t.invokedAt +} + +func (t *Token) Cause() *cid.Cid { + return t.cause +} + func (t *Token) validate() error { var errs error 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..467e2be 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -193,29 +193,58 @@ 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 + } + + argsKey := make([]string, len(t.arguments)) + i := 0 + + for k := range t.arguments { + argsKey[i] = k + i++ + } + + args := struct { + Keys []string + Values map[string]datamodel.Node + }{ + Keys: argsKey, + Values: t.arguments, + } + + prf := make([]cid.Cid, len(t.proof)) + for i, c := range t.proof { + prf[i] = c + } + model := &tokenPayloadModel{ Iss: t.issuer.String(), - Aud: t.audience.String(), - Sub: sub, + Aud: aud, + Sub: t.subject.String(), Cmd: t.command.String(), + Args: args, + Prf: prf, + Meta: t.meta, Nonce: t.nonce, - Meta: *t.meta, Exp: exp, + Iat: iat, + Cause: t.cause, } return envelope.ToIPLD(privKey, model) diff --git a/token/invocation/options.go b/token/invocation/options.go new file mode 100644 index 0000000..08c7969 --- /dev/null +++ b/token/invocation/options.go @@ -0,0 +1,134 @@ +package invocation + +import ( + "time" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" +) + +// Option is a type that allows optional fields to be set during the +// creation of a Token. +type Option func(*Token) error + +// WithArgument adds a key/value pair to the Token's Arguments field. +func WithArgument(key string, val datamodel.Node) Option { + return func(t *Token) error { + t.arguments[key] = val + + return nil + } +} + +// WithArguments sets the Token's Arguments field to the provided map. +// +// Note that this will overwrite any existing Arguments whether provided +// by a previous call to this function or by one or more calls to +// WithArgument. +func WithArguments(args map[string]datamodel.Node) Option { + return func(t *Token) error { + t.arguments = args + + return nil + } +} + +// 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 require. +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 +// idem +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 { + t.expiration = &exp + + return nil + } +} + +// WithExpirationIn set's the Token's optional "expiration" field to +// Now() plus the given duration. +func WithExpirationIn(exp time.Duration) Option { + return func(t *Token) error { + expTime := time.Now().Add(exp) + t.expiration = &expTime + + return nil + } +} + +// WithInvokedAt sets the Token's invokedAt field to the provided +// time.Time. +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 func(t *Token) error { + iat := time.Now().Add(after) + t.invokedAt = &iat + + 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..f438e1f 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -5,7 +5,9 @@ import ( "fmt" "sync" + "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/datamodel" "github.com/ipld/go-ipld-prime/node/bindnode" "github.com/ipld/go-ipld-prime/schema" @@ -46,26 +48,36 @@ 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 struct { + Keys []string + Values map[string]datamodel.Node + } + // 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 { From f44cf8af78001063a3fac53bfcd6c4ebf02525e2 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 12:59:38 -0400 Subject: [PATCH 03/54] feat(invocation): produce example output similar to spec --- token/invocation/examples_test.go | 220 ++++++++++++++++++++++++++++++ token/invocation/invocation.go | 6 + token/invocation/options.go | 9 ++ 3 files changed, 235 insertions(+) create mode 100644 token/invocation/examples_test.go diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go new file mode 100644 index 0000000..530bcc0 --- /dev/null +++ b/token/invocation/examples_test.go @@ -0,0 +1,220 @@ +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.Errorf("failed to create setup: %w", err) + + return + } + + inv, err := invocation.New( + iss, + sub, + cmd, + prf, + invocation.WithArguments(args), + invocation.WithMeta("env", "development"), + invocation.WithMeta("tags", meta["tags"]), + invocation.WithExpirationIn(time.Minute), + invocation.WithoutInvokedAt()) + if err != nil { + fmt.Errorf("failed to create invocation: %w", err) + + return + } + + data, cid, err := inv.ToSealed(privKey) + if err != nil { + fmt.Errorf("failed to seal invocation: %w", err) + + return + } + + json, err := prettyDAGJSON(data) + if err != nil { + fmt.Errorf("failed to pretty DAG-JSON: %w", err) + + 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)) + } + payload, err := qp.BuildMap(basicnode.Prototype.Any, 4, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "title", qp.String("UCAN for Fun and Profit")) + qp.MapEntry(ma, "body", qp.String("UCAN is great")) + qp.MapEntry(ma, "topics", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("authz")) + qp.ListEntry(la, qp.String("journal")) + })) + qp.MapEntry(ma, "draft", qp.Bool(true)) + }) + 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)) + } + } + + 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 3a17ea2..0d43bc5 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -69,12 +69,14 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } iat := time.Now() + metadata := meta.NewMeta() tkn := Token{ issuer: iss, subject: sub, command: cmd, proof: prf, + meta: metadata, nonce: nonce, invokedAt: &iat, } @@ -85,6 +87,10 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } + if len(tkn.meta.Keys) == 0 { + tkn.meta = nil + } + return &tkn, nil } diff --git a/token/invocation/options.go b/token/invocation/options.go index 08c7969..0d232fe 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -124,6 +124,15 @@ func WithInvokedAtIn(after time.Duration) Option { } } +// 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 { From 6d85b2ba3c53be03d99c0b3c628297bbf7bba937 Mon Sep 17 00:00:00 2001 From: Fabio Bozzo Date: Fri, 1 Nov 2024 13:07:46 +0100 Subject: [PATCH 04/54] 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 05/54] 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 06/54] 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 07/54] 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 08/54] 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 09/54] 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 10/54] 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 11/54] 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 12/54] 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 13/54] 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 14/54] 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 15/54] 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 16/54] 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 d3ad6715d9c83efbe71f562d0706d0c0681d039d Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Mon, 4 Nov 2024 16:07:11 -0500 Subject: [PATCH 17/54] feat(invocation): ipld unseal to invocation --- token/invocation/examples_test.go | 23 ++++++++--- token/invocation/invocation.go | 66 +++++++++++++++++++++++++++++-- token/invocation/ipld.go | 10 +++-- token/invocation/options.go | 3 +- token/invocation/schema.go | 7 +--- 5 files changed, 88 insertions(+), 21 deletions(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index 530bcc0..484643b 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -23,7 +23,7 @@ import ( func ExampleNew() { privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() if err != nil { - fmt.Errorf("failed to create setup: %w", err) + fmt.Println("failed to create setup:", err.Error()) return } @@ -39,21 +39,21 @@ func ExampleNew() { invocation.WithExpirationIn(time.Minute), invocation.WithoutInvokedAt()) if err != nil { - fmt.Errorf("failed to create invocation: %w", err) + fmt.Println("failed to create invocation:", err.Error()) return } data, cid, err := inv.ToSealed(privKey) if err != nil { - fmt.Errorf("failed to seal invocation: %w", err) + fmt.Println("failed to seal invocation:", err.Error()) return } json, err := prettyDAGJSON(data) if err != nil { - fmt.Errorf("failed to pretty DAG-JSON: %w", err) + fmt.Println("failed to pretty DAG-JSON:", err.Error()) return } @@ -139,6 +139,8 @@ func prettyDAGJSON(data []byte) (string, error) { return "", err } + fmt.Println(string(jsonData)) + var out bytes.Buffer if err := json.Indent(&out, jsonData, "", " "); err != nil { return "", err @@ -171,14 +173,19 @@ func setupExampleNew() (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Co 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, "title", qp.String("UCAN for Fun and Profit")) 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")) })) - qp.MapEntry(ma, "draft", qp.Bool(true)) }) if err != nil { errs = errors.Join(errs, fmt.Errorf("failed to build payload: %w", err)) @@ -202,6 +209,10 @@ func setupExampleNew() (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Co } } + // ***** 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")) diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 0d43bc5..d5703a9 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -62,9 +62,8 @@ type Token struct { // With the exception of the WithMeta option, all other 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) { - nonce := make([]byte, 12) - - if _, err := rand.Read(nonce); err != nil { + nonce, err := generateNonce() + if err != nil { return nil, err } @@ -141,6 +140,7 @@ func (t *Token) InvokedAt() *time.Time { return t.invokedAt } +// Cause returns the (optional) func (t *Token) Cause() *cid.Cid { return t.cause } @@ -170,9 +170,41 @@ 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, err + } + + if tkn.subject, err = did.Parse(m.Sub); err != nil { + return nil, err + } + + if tkn.audience, err = parseOptionalDID(m.Aud); err != nil { + return nil, err + } + + if tkn.command, err = command.Parse(m.Cmd); err != nil { + return nil, err + } + + tkn.arguments = m.Args.Values + tkn.proof = m.Prf + tkn.meta = m.Meta + tkn.nonce = m.Nonce + + if tkn.expiration, err = parseOptionalTimestamp(m.Exp); err != nil { + return nil, err + } + + if tkn.invokedAt, err = parseOptionalTimestamp(m.Iat); err != nil { + return nil, err + } + + if tkn.cause, err = parseOptionalCID(m.Cause); err != nil { + return nil, err + } return &tkn, nil } @@ -187,3 +219,29 @@ func generateNonce() ([]byte, error) { } return res, nil } + +func parseOptionalCID(c *cid.Cid) (*cid.Cid, error) { + if c == nil { + return nil, nil + } + + return c, nil +} + +func parseOptionalDID(s *string) (did.DID, error) { + if s == nil { + return did.Undef, nil + } + + return did.Parse(*s) +} + +func parseOptionalTimestamp(sec *int64) (*time.Time, error) { + if sec == nil { + return nil, nil + } + + t := time.Unix(*sec, 0) + + return &t, nil +} diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 467e2be..817b265 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -192,6 +192,11 @@ func FromIPLD(node datamodel.Node) (*Token, error) { return tkn, err } +type stringAny struct { + Keys []string + Values map[string]datamodel.Node +} + func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { var aud *string @@ -220,10 +225,7 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { i++ } - args := struct { - Keys []string - Values map[string]datamodel.Node - }{ + args := stringAny{ Keys: argsKey, Values: t.arguments, } diff --git a/token/invocation/options.go b/token/invocation/options.go index 0d232fe..13a28d0 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -86,6 +86,7 @@ func WithEmptyNonce() Option { // 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 @@ -96,7 +97,7 @@ func WithExpiration(exp time.Time) Option { // Now() plus the given duration. func WithExpirationIn(exp time.Duration) Option { return func(t *Token) error { - expTime := time.Now().Add(exp) + expTime := time.Now().Add(exp).Round(time.Minute) t.expiration = &expTime return nil diff --git a/token/invocation/schema.go b/token/invocation/schema.go index f438e1f..b2b99f2 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -7,7 +7,6 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" - "github.com/ipld/go-ipld-prime/datamodel" "github.com/ipld/go-ipld-prime/node/bindnode" "github.com/ipld/go-ipld-prime/schema" @@ -46,7 +45,6 @@ func payloadType() schema.Type { var _ envelope.Tokener = (*tokenPayloadModel)(nil) -// TODO type tokenPayloadModel struct { // The DID of the Invoker Iss string @@ -58,10 +56,7 @@ type tokenPayloadModel struct { // The Command Cmd string // The Command's Arguments - Args struct { - Keys []string - Values map[string]datamodel.Node - } + Args stringAny // Delegations that prove the chain of authority Prf []cid.Cid From d7454156d20073fdb98ac69038e37e0147a088da Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 07:39:51 -0500 Subject: [PATCH 18/54] docs(invocation): fix truncated WithEmptyNonce description --- token/invocation/options.go | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/token/invocation/options.go b/token/invocation/options.go index 13a28d0..ca79577 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -73,7 +73,7 @@ func WithNonce(nonce []byte) Option { // WithEmptyNonce sets the Token's nonce to an empty byte slice as // suggested by the UCAN spec for invocation tokens that represent -// idem +// idempotent operations. func WithEmptyNonce() Option { return func(t *Token) error { t.nonce = []byte{} @@ -96,12 +96,7 @@ func WithExpiration(exp time.Time) Option { // WithExpirationIn set's the Token's optional "expiration" field to // Now() plus the given duration. func WithExpirationIn(exp time.Duration) Option { - return func(t *Token) error { - expTime := time.Now().Add(exp).Round(time.Minute) - t.expiration = &expTime - - return nil - } + return WithExpiration(time.Now().Add(exp)) } // WithInvokedAt sets the Token's invokedAt field to the provided @@ -117,12 +112,7 @@ func WithInvokedAt(iat time.Time) Option { // WithInvokedAtIn sets the Token's invokedAt field to Now() plus the // given duration. func WithInvokedAtIn(after time.Duration) Option { - return func(t *Token) error { - iat := time.Now().Add(after) - t.invokedAt = &iat - - return nil - } + return WithInvokedAt(time.Now().Add(after)) } // WithoutInvokedAt clears the Token's invokedAt field. From 7a7db684c378c51754dd54686e554929d7437b1f Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 08:39:43 -0500 Subject: [PATCH 19/54] test(invocation): adds schema round-trip test --- token/invocation/examples_test.go | 2 - token/invocation/ipld_test.go | 50 +++++++++++++++ token/invocation/schema_test.go | 89 +++++++++++++++++++++++++++ token/invocation/testdata/new.dagjson | 1 + 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 token/invocation/ipld_test.go create mode 100644 token/invocation/schema_test.go create mode 100644 token/invocation/testdata/new.dagjson diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index 484643b..83ec04e 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -139,8 +139,6 @@ func prettyDAGJSON(data []byte) (string, error) { return "", err } - fmt.Println(string(jsonData)) - var out bytes.Buffer if err := json.Indent(&out, jsonData, "", " "); err != nil { return "", err diff --git a/token/invocation/ipld_test.go b/token/invocation/ipld_test.go new file mode 100644 index 0000000..ae52685 --- /dev/null +++ b/token/invocation/ipld_test.go @@ -0,0 +1,50 @@ +package invocation_test + +import ( + "testing" + "time" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/command" + "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.WithArguments(args), + 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) +} + +func setupNew(t *testing.T) (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Command, args map[string]datamodel.Node, prf []cid.Cid, meta map[string]datamodel.Node) { + privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() + require.NoError(t, err) + + return // WARNING: named return values +} diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go new file mode 100644 index 0000000..8fcc857 --- /dev/null +++ b/token/invocation/schema_test.go @@ -0,0 +1,89 @@ +package invocation_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/token/internal/envelope" + "github.com/ucan-wg/go-ucan/token/invocation" + "gotest.tools/v3/golden" +) + +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 From f2b4c3ac206237e461f705bcbda887fad02f0a44 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 09:35:55 -0500 Subject: [PATCH 20/54] docs(invocation): edit (and finish) Go docs for exported types --- token/invocation/invocation.go | 12 ++++++++++-- token/invocation/options.go | 14 ++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index d5703a9..55ef4a2 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -57,7 +57,8 @@ type Token struct { // 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. +// 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 other will overwrite // the previous contents of their target field. @@ -113,10 +114,14 @@ 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() map[string]datamodel.Node { return t.arguments } +// Proof() returns the ordered list of cid.Cids which referenced the +// delegation Tokens that authorize this invocation. func (t *Token) Proof() []cid.Cid { return t.proof } @@ -136,11 +141,14 @@ 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 (optional) +// 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 } diff --git a/token/invocation/options.go b/token/invocation/options.go index ca79577..c8656d5 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -9,7 +9,7 @@ import ( ) // Option is a type that allows optional fields to be set during the -// creation of a Token. +// creation of a invocation Token. type Option func(*Token) error // WithArgument adds a key/value pair to the Token's Arguments field. @@ -62,7 +62,9 @@ func WithMeta(key string, val any) Option { // 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 require. +// 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 @@ -95,12 +97,16 @@ func WithExpiration(exp time.Time) Option { // WithExpirationIn set's the Token's optional "expiration" field to // Now() plus the given duration. -func WithExpirationIn(exp time.Duration) Option { - return WithExpiration(time.Now().Add(exp)) +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 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 21/54] 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 22/54] 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) { From 728696f169d26e0e5a8682b285eb9143c1bd03a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Tue, 5 Nov 2024 17:39:39 +0100 Subject: [PATCH 23/54] invocation: round of cleanups/fixes --- token/delegation/delegation.go | 30 +++-------- token/internal/parse/parse.go | 22 ++++++++ token/invocation/examples_test.go | 7 +-- token/invocation/invocation.go | 83 +++++++++++-------------------- token/invocation/ipld.go | 9 +++- token/invocation/ipld_test.go | 22 ++------ token/invocation/options.go | 3 +- token/invocation/schema_test.go | 4 +- 8 files changed, 79 insertions(+), 101 deletions(-) create mode 100644 token/internal/parse/parse.go diff --git a/token/delegation/delegation.go b/token/delegation/delegation.go index ccaed08..83b1ab3 100644 --- a/token/delegation/delegation.go +++ b/token/delegation/delegation.go @@ -21,6 +21,7 @@ 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/parse" ) // Token is an immutable type that holds the fields of a UCAN delegation. @@ -184,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) } @@ -215,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 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 index 83ec04e..f948041 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -15,6 +15,7 @@ import ( "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" @@ -28,11 +29,7 @@ func ExampleNew() { return } - inv, err := invocation.New( - iss, - sub, - cmd, - prf, + inv, err := invocation.New(iss, sub, cmd, prf, invocation.WithArguments(args), invocation.WithMeta("env", "development"), invocation.WithMeta("tags", meta["tags"]), diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 55ef4a2..7f68a02 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -15,9 +15,11 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" + "github.com/ucan-wg/go-ucan/token/internal/parse" ) // Token is an immutable type that holds the fields of a UCAN invocation. @@ -60,24 +62,18 @@ type Token struct { // 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 other will overwrite +// 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) { - nonce, err := generateNonce() - if err != nil { - return nil, err - } - iat := time.Now() - metadata := meta.NewMeta() tkn := Token{ issuer: iss, subject: sub, command: cmd, proof: prf, - meta: metadata, - nonce: nonce, + meta: meta.NewMeta(), + nonce: nil, invokedAt: &iat, } @@ -87,8 +83,15 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } - if len(tkn.meta.Keys) == 0 { - tkn.meta = nil + if len(tkn.nonce) == 0 { + tkn.nonce, err = generateNonce() + if err != nil { + return nil, err + } + } + + if err := tkn.validate(); err != nil { + return nil, err } return &tkn, nil @@ -120,7 +123,7 @@ func (t *Token) Arguments() map[string]datamodel.Node { return t.arguments } -// Proof() returns the ordered list of cid.Cids which referenced the +// 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 @@ -163,8 +166,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")) @@ -182,35 +184,36 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { ) if tkn.issuer, err = did.Parse(m.Iss); err != nil { - return nil, err + return nil, fmt.Errorf("parse iss: %w", err) } if tkn.subject, err = did.Parse(m.Sub); err != nil { - return nil, err + return nil, fmt.Errorf("parse subject: %w", err) } - if tkn.audience, err = parseOptionalDID(m.Aud); err != nil { - return nil, 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, err + 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.Values tkn.proof = m.Prf tkn.meta = m.Meta - tkn.nonce = m.Nonce - if tkn.expiration, err = parseOptionalTimestamp(m.Exp); err != nil { - return nil, err - } + tkn.expiration = parse.OptionalTimestamp(m.Exp) + tkn.invokedAt = parse.OptionalTimestamp(m.Iat) - if tkn.invokedAt, err = parseOptionalTimestamp(m.Iat); err != nil { - return nil, err - } + tkn.cause = m.Cause - if tkn.cause, err = parseOptionalCID(m.Cause); err != nil { + if err := tkn.validate(); err != nil { return nil, err } @@ -227,29 +230,3 @@ func generateNonce() ([]byte, error) { } return res, nil } - -func parseOptionalCID(c *cid.Cid) (*cid.Cid, error) { - if c == nil { - return nil, nil - } - - return c, nil -} - -func parseOptionalDID(s *string) (did.DID, error) { - if s == nil { - return did.Undef, nil - } - - return did.Parse(*s) -} - -func parseOptionalTimestamp(sec *int64) (*time.Time, error) { - if sec == nil { - return nil, nil - } - - t := time.Unix(*sec, 0) - - return &t, nil -} diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 817b265..431e3a4 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -218,8 +218,9 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { } argsKey := make([]string, len(t.arguments)) - i := 0 + // TODO: make specialized type and builder? + i := 0 for k := range t.arguments { argsKey[i] = k i++ @@ -230,6 +231,7 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { Values: t.arguments, } + // TODO: reuse instead of copy? it's immutable prf := make([]cid.Cid, len(t.proof)) for i, c := range t.proof { prf[i] = c @@ -249,5 +251,10 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { 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 index ae52685..ec3c04f 100644 --- a/token/invocation/ipld_test.go +++ b/token/invocation/ipld_test.go @@ -4,13 +4,9 @@ import ( "testing" "time" - "github.com/ipfs/go-cid" - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/libp2p/go-libp2p/core/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/ucan-wg/go-ucan/did" - "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/invocation" ) @@ -20,16 +16,13 @@ func TestSealUnsealRoundtrip(t *testing.T) { privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() require.NoError(t, err) - tkn1, err := invocation.New( - iss, - sub, - cmd, - prf, + tkn1, err := invocation.New(iss, sub, cmd, prf, invocation.WithArguments(args), invocation.WithMeta("env", "development"), invocation.WithMeta("tags", meta["tags"]), invocation.WithExpirationIn(time.Minute), - invocation.WithoutInvokedAt()) + invocation.WithoutInvokedAt(), + ) require.NoError(t, err) data, cid1, err := tkn1.ToSealed(privKey) @@ -41,10 +34,3 @@ func TestSealUnsealRoundtrip(t *testing.T) { assert.Equal(t, cid1, cid2) assert.Equal(t, tkn1, tkn2) } - -func setupNew(t *testing.T) (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Command, args map[string]datamodel.Node, prf []cid.Cid, meta map[string]datamodel.Node) { - privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() - require.NoError(t, err) - - return // WARNING: named return values -} diff --git a/token/invocation/options.go b/token/invocation/options.go index c8656d5..1c557b9 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -5,11 +5,12 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" ) // Option is a type that allows optional fields to be set during the -// creation of a invocation Token. +// creation of an invocation Token. type Option func(*Token) error // WithArgument adds a key/value pair to the Token's Arguments field. diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go index 8fcc857..77b1afb 100644 --- a/token/invocation/schema_test.go +++ b/token/invocation/schema_test.go @@ -8,9 +8,10 @@ import ( "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" - "gotest.tools/v3/golden" ) const ( @@ -78,6 +79,7 @@ func TestSchemaRoundTrip(t *testing.T) { 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) From a1aaf47d7c110470d65b79ac15744ac367532043 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 15:28:31 -0500 Subject: [PATCH 24/54] chore(invocation): clean up left-over (and unneeded) conversion of prf --- token/invocation/ipld.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 431e3a4..b477eb7 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -231,19 +231,13 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { Values: t.arguments, } - // TODO: reuse instead of copy? it's immutable - prf := make([]cid.Cid, len(t.proof)) - for i, c := range t.proof { - prf[i] = c - } - model := &tokenPayloadModel{ Iss: t.issuer.String(), Aud: aud, Sub: t.subject.String(), Cmd: t.command.String(), Args: args, - Prf: prf, + Prf: t.proof, Meta: t.meta, Nonce: t.nonce, Exp: exp, From 6aeb6a8b702a68299706b827b72fa995dc13afae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Wed, 6 Nov 2024 15:17:35 +0100 Subject: [PATCH 25/54] meta: make a read-only version to enforce token immutability --- pkg/meta/meta.go | 10 +++++--- pkg/meta/readonly.go | 42 ++++++++++++++++++++++++++++++++++ token/delegation/delegation.go | 4 ++-- token/interface.go | 2 +- token/invocation/invocation.go | 4 ++-- 5 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 pkg/meta/readonly.go diff --git a/pkg/meta/meta.go b/pkg/meta/meta.go index a51eec7..d1af3b8 100644 --- a/pkg/meta/meta.go +++ b/pkg/meta/meta.go @@ -1,7 +1,6 @@ package meta import ( - "errors" "fmt" "reflect" "strings" @@ -12,9 +11,9 @@ import ( "github.com/ipld/go-ipld-prime/printer" ) -var ErrUnsupported = errors.New("failure adding unsupported type to meta") +var ErrUnsupported = fmt.Errorf("failure adding unsupported type to meta") -var ErrNotFound = errors.New("key-value not found in meta") +var ErrNotFound = fmt.Errorf("key-value not found in meta") // Meta is a container for meta key-value pairs in a UCAN token. // This also serves as a way to construct the underlying IPLD data with minimum allocations and transformations, @@ -160,6 +159,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/token/delegation/delegation.go b/token/delegation/delegation.go index 10db932..d930f28 100644 --- a/token/delegation/delegation.go +++ b/token/delegation/delegation.go @@ -142,8 +142,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". 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/invocation/invocation.go b/token/invocation/invocation.go index d48fee8..b268481 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -71,8 +71,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() } // Expiration returns the time at which the Token expires. From 824c8fe5235847aae9333800c09dfc29403b0d29 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 6 Nov 2024 09:25:51 -0500 Subject: [PATCH 26/54] test(policy): adds example test case from invocation specification --- pkg/policy/spec_test.go | 93 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 pkg/policy/spec_test.go diff --git a/pkg/policy/spec_test.go b/pkg/policy/spec_test.go new file mode 100644 index 0000000..507dc4d --- /dev/null +++ b/pkg/policy/spec_test.go @@ -0,0 +1,93 @@ +package policy_test + +import ( + "testing" + + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" + 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" +) + +// TestInvocationValidation applies the example policy to the example +// arguments as defined in the [Validation] section of the invocation +// specification. +// +// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation +func TestInvocationValidation(t *testing.T) { + t.Parallel() + + polNode, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("==")) + qp.ListEntry(la, qp.String(".from")) + qp.ListEntry(la, qp.String("alice@example.com")) + })) + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("any")) + qp.ListEntry(la, qp.String(".to")) + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("like")) + qp.ListEntry(la, qp.String(".")) + qp.ListEntry(la, qp.String("*@example.com")) + })) + })) + }) + require.NoError(t, err) + + pol, err := policy.FromIPLD(polNode) + require.NoError(t, err) + + 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) + + assert.True(t, pol.Match(argsNode)) + }) + + t.Run("fails on sender (first 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("dan@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) + + assert.False(t, pol.Match(argsNode)) + }) + + 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@not.null.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.False(t, pol.Match(argsNode)) + }) +} From b4e222f8a0fa35e9af16352a3e088516b0abf382 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 6 Nov 2024 10:17:18 -0500 Subject: [PATCH 27/54] test(policy): refactor matching test from delegation spec --- pkg/policy/match_test.go | 51 ++++++++++++++++++++++ pkg/policy/spec_test.go | 93 ---------------------------------------- 2 files changed, 51 insertions(+), 93 deletions(-) delete mode 100644 pkg/policy/spec_test.go diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 9e3de4a..9940799 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" @@ -512,3 +515,51 @@ func FuzzMatch(f *testing.F) { policy.Match(dataNode) }) } + +// 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) + + assert.True(t, pol.Match(argsNode)) + }) + + 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) + + assert.False(t, pol.Match(argsNode)) + }) +} diff --git a/pkg/policy/spec_test.go b/pkg/policy/spec_test.go deleted file mode 100644 index 507dc4d..0000000 --- a/pkg/policy/spec_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package policy_test - -import ( - "testing" - - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ipld/go-ipld-prime/fluent/qp" - 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" -) - -// TestInvocationValidation applies the example policy to the example -// arguments as defined in the [Validation] section of the invocation -// specification. -// -// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation -func TestInvocationValidation(t *testing.T) { - t.Parallel() - - polNode, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("==")) - qp.ListEntry(la, qp.String(".from")) - qp.ListEntry(la, qp.String("alice@example.com")) - })) - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("any")) - qp.ListEntry(la, qp.String(".to")) - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("like")) - qp.ListEntry(la, qp.String(".")) - qp.ListEntry(la, qp.String("*@example.com")) - })) - })) - }) - require.NoError(t, err) - - pol, err := policy.FromIPLD(polNode) - require.NoError(t, err) - - 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) - - assert.True(t, pol.Match(argsNode)) - }) - - t.Run("fails on sender (first 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("dan@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) - - assert.False(t, pol.Match(argsNode)) - }) - - 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@not.null.com")) - })) - qp.MapEntry(ma, "title", qp.String("Coffee")) - qp.MapEntry(ma, "body", qp.String("Still on for coffee")) - }) - require.NoError(t, err) - - assert.False(t, pol.Match(argsNode)) - }) -} From 8447499c5a1b0ba728a7b1faa17cc644d6f765da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Wed, 6 Nov 2024 16:42:45 +0100 Subject: [PATCH 28/54] literal: add test suite --- pkg/policy/literal/literal.go | 14 +++- pkg/policy/literal/literal_test.go | 125 +++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 pkg/policy/literal/literal_test.go diff --git a/pkg/policy/literal/literal.go b/pkg/policy/literal/literal.go index b3e6aa3..26e5625 100644 --- a/pkg/policy/literal/literal.go +++ b/pkg/policy/literal/literal.go @@ -68,7 +68,19 @@ func anyAssemble(val any) qp.Assemble { } switch rt.Kind() { - case reflect.Array, reflect.Slice: + 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))) 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 +} From c9f3a6033ad519502365d100e871e328c01eca95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Wed, 6 Nov 2024 16:43:57 +0100 Subject: [PATCH 29/54] delegation: minor fix around meta --- token/delegation/delegation.go | 4 ---- token/delegation/ipld.go | 5 +++++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/token/delegation/delegation.go b/token/delegation/delegation.go index d930f28..77ba14d 100644 --- a/token/delegation/delegation.go +++ b/token/delegation/delegation.go @@ -79,10 +79,6 @@ func New(privKey crypto.PrivKey, aud did.DID, cmd command.Command, pol policy.Po } } - if len(tkn.meta.Keys) < 1 { - tkn.meta = nil - } - if err := tkn.validate(); err != nil { return nil, err } 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) } From d2b004c405aa1f9a6a160b93a10b64cdf2ec821f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Wed, 6 Nov 2024 18:06:46 +0100 Subject: [PATCH 30/54] meta: prevent overwrite of values --- pkg/meta/meta.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/meta/meta.go b/pkg/meta/meta.go index a51eec7..552612a 100644 --- a/pkg/meta/meta.go +++ b/pkg/meta/meta.go @@ -99,6 +99,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) From 31d16ac4680822f822b9840136b04c9f2204f39e Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 10:44:38 -0400 Subject: [PATCH 31/54] feat(invocation): provide New constructor and encoding to wire-format --- token/invocation/invocation.go | 103 ++++++++++++++++----- token/invocation/invocation.ipldsch | 29 +++--- token/invocation/ipld.go | 47 ++++++++-- token/invocation/options.go | 134 ++++++++++++++++++++++++++++ token/invocation/schema.go | 34 ++++--- 5 files changed, 295 insertions(+), 52 deletions(-) create mode 100644 token/invocation/options.go diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index b268481..845a69b 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -13,6 +13,8 @@ import ( "fmt" "time" + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/datamodel" "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" @@ -20,25 +22,70 @@ import ( // 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 map[string]datamodel.Node + // 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. +// +// With the exception of the WithMeta option, all other 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) { + nonce := make([]byte, 12) + + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + + iat := time.Now() + + tkn := Token{ + issuer: iss, + subject: sub, + command: cmd, + proof: prf, + nonce: nonce, + invokedAt: &iat, + } + + for _, opt := range opts { + if err := opt(&tkn); err != nil { + return nil, err + } + } + + return &tkn, nil } // Issuer returns the did.DID representing the Token's issuer. @@ -46,28 +93,27 @@ 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 } -// Nonce returns the random Nonce encapsulated in this Token. -func (t *Token) Nonce() []byte { - return t.nonce +func (t *Token) Arguments() map[string]datamodel.Node { + return t.arguments +} + +func (t *Token) Proof() []cid.Cid { + return t.proof } // Meta returns the Token's metadata. @@ -75,11 +121,24 @@ 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 +} + // Expiration returns the time at which the Token expires. func (t *Token) Expiration() *time.Time { return t.expiration } +func (t *Token) InvokedAt() *time.Time { + return t.invokedAt +} + +func (t *Token) Cause() *cid.Cid { + return t.cause +} + func (t *Token) validate() error { var errs error 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..467e2be 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -193,29 +193,58 @@ 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 + } + + argsKey := make([]string, len(t.arguments)) + i := 0 + + for k := range t.arguments { + argsKey[i] = k + i++ + } + + args := struct { + Keys []string + Values map[string]datamodel.Node + }{ + Keys: argsKey, + Values: t.arguments, + } + + prf := make([]cid.Cid, len(t.proof)) + for i, c := range t.proof { + prf[i] = c + } + model := &tokenPayloadModel{ Iss: t.issuer.String(), - Aud: t.audience.String(), - Sub: sub, + Aud: aud, + Sub: t.subject.String(), Cmd: t.command.String(), + Args: args, + Prf: prf, + Meta: t.meta, Nonce: t.nonce, - Meta: *t.meta, Exp: exp, + Iat: iat, + Cause: t.cause, } return envelope.ToIPLD(privKey, model) diff --git a/token/invocation/options.go b/token/invocation/options.go new file mode 100644 index 0000000..08c7969 --- /dev/null +++ b/token/invocation/options.go @@ -0,0 +1,134 @@ +package invocation + +import ( + "time" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" +) + +// Option is a type that allows optional fields to be set during the +// creation of a Token. +type Option func(*Token) error + +// WithArgument adds a key/value pair to the Token's Arguments field. +func WithArgument(key string, val datamodel.Node) Option { + return func(t *Token) error { + t.arguments[key] = val + + return nil + } +} + +// WithArguments sets the Token's Arguments field to the provided map. +// +// Note that this will overwrite any existing Arguments whether provided +// by a previous call to this function or by one or more calls to +// WithArgument. +func WithArguments(args map[string]datamodel.Node) Option { + return func(t *Token) error { + t.arguments = args + + return nil + } +} + +// 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 require. +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 +// idem +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 { + t.expiration = &exp + + return nil + } +} + +// WithExpirationIn set's the Token's optional "expiration" field to +// Now() plus the given duration. +func WithExpirationIn(exp time.Duration) Option { + return func(t *Token) error { + expTime := time.Now().Add(exp) + t.expiration = &expTime + + return nil + } +} + +// WithInvokedAt sets the Token's invokedAt field to the provided +// time.Time. +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 func(t *Token) error { + iat := time.Now().Add(after) + t.invokedAt = &iat + + 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..f438e1f 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -5,7 +5,9 @@ import ( "fmt" "sync" + "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/datamodel" "github.com/ipld/go-ipld-prime/node/bindnode" "github.com/ipld/go-ipld-prime/schema" @@ -46,26 +48,36 @@ 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 struct { + Keys []string + Values map[string]datamodel.Node + } + // 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 { From a98653b7695baeb8a48be15b6cea7f5958cf86fd Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 12:59:38 -0400 Subject: [PATCH 32/54] feat(invocation): produce example output similar to spec --- token/invocation/examples_test.go | 220 ++++++++++++++++++++++++++++++ token/invocation/invocation.go | 6 + token/invocation/options.go | 9 ++ 3 files changed, 235 insertions(+) create mode 100644 token/invocation/examples_test.go diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go new file mode 100644 index 0000000..530bcc0 --- /dev/null +++ b/token/invocation/examples_test.go @@ -0,0 +1,220 @@ +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.Errorf("failed to create setup: %w", err) + + return + } + + inv, err := invocation.New( + iss, + sub, + cmd, + prf, + invocation.WithArguments(args), + invocation.WithMeta("env", "development"), + invocation.WithMeta("tags", meta["tags"]), + invocation.WithExpirationIn(time.Minute), + invocation.WithoutInvokedAt()) + if err != nil { + fmt.Errorf("failed to create invocation: %w", err) + + return + } + + data, cid, err := inv.ToSealed(privKey) + if err != nil { + fmt.Errorf("failed to seal invocation: %w", err) + + return + } + + json, err := prettyDAGJSON(data) + if err != nil { + fmt.Errorf("failed to pretty DAG-JSON: %w", err) + + 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)) + } + payload, err := qp.BuildMap(basicnode.Prototype.Any, 4, func(ma datamodel.MapAssembler) { + qp.MapEntry(ma, "title", qp.String("UCAN for Fun and Profit")) + qp.MapEntry(ma, "body", qp.String("UCAN is great")) + qp.MapEntry(ma, "topics", qp.List(2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("authz")) + qp.ListEntry(la, qp.String("journal")) + })) + qp.MapEntry(ma, "draft", qp.Bool(true)) + }) + 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)) + } + } + + 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 845a69b..23dda06 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -69,12 +69,14 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } iat := time.Now() + metadata := meta.NewMeta() tkn := Token{ issuer: iss, subject: sub, command: cmd, proof: prf, + meta: metadata, nonce: nonce, invokedAt: &iat, } @@ -85,6 +87,10 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } + if len(tkn.meta.Keys) == 0 { + tkn.meta = nil + } + return &tkn, nil } diff --git a/token/invocation/options.go b/token/invocation/options.go index 08c7969..0d232fe 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -124,6 +124,15 @@ func WithInvokedAtIn(after time.Duration) Option { } } +// 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 { From 187e7a869c576fb0e845bc4c266765dd9f506ecb Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Mon, 4 Nov 2024 16:07:11 -0500 Subject: [PATCH 33/54] feat(invocation): ipld unseal to invocation --- token/invocation/examples_test.go | 23 ++++++++--- token/invocation/invocation.go | 66 +++++++++++++++++++++++++++++-- token/invocation/ipld.go | 10 +++-- token/invocation/options.go | 3 +- token/invocation/schema.go | 7 +--- 5 files changed, 88 insertions(+), 21 deletions(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index 530bcc0..484643b 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -23,7 +23,7 @@ import ( func ExampleNew() { privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() if err != nil { - fmt.Errorf("failed to create setup: %w", err) + fmt.Println("failed to create setup:", err.Error()) return } @@ -39,21 +39,21 @@ func ExampleNew() { invocation.WithExpirationIn(time.Minute), invocation.WithoutInvokedAt()) if err != nil { - fmt.Errorf("failed to create invocation: %w", err) + fmt.Println("failed to create invocation:", err.Error()) return } data, cid, err := inv.ToSealed(privKey) if err != nil { - fmt.Errorf("failed to seal invocation: %w", err) + fmt.Println("failed to seal invocation:", err.Error()) return } json, err := prettyDAGJSON(data) if err != nil { - fmt.Errorf("failed to pretty DAG-JSON: %w", err) + fmt.Println("failed to pretty DAG-JSON:", err.Error()) return } @@ -139,6 +139,8 @@ func prettyDAGJSON(data []byte) (string, error) { return "", err } + fmt.Println(string(jsonData)) + var out bytes.Buffer if err := json.Indent(&out, jsonData, "", " "); err != nil { return "", err @@ -171,14 +173,19 @@ func setupExampleNew() (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Co 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, "title", qp.String("UCAN for Fun and Profit")) 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")) })) - qp.MapEntry(ma, "draft", qp.Bool(true)) }) if err != nil { errs = errors.Join(errs, fmt.Errorf("failed to build payload: %w", err)) @@ -202,6 +209,10 @@ func setupExampleNew() (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Co } } + // ***** 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")) diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 23dda06..c55fd9e 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -62,9 +62,8 @@ type Token struct { // With the exception of the WithMeta option, all other 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) { - nonce := make([]byte, 12) - - if _, err := rand.Read(nonce); err != nil { + nonce, err := generateNonce() + if err != nil { return nil, err } @@ -141,6 +140,7 @@ func (t *Token) InvokedAt() *time.Time { return t.invokedAt } +// Cause returns the (optional) func (t *Token) Cause() *cid.Cid { return t.cause } @@ -170,9 +170,41 @@ 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, err + } + + if tkn.subject, err = did.Parse(m.Sub); err != nil { + return nil, err + } + + if tkn.audience, err = parseOptionalDID(m.Aud); err != nil { + return nil, err + } + + if tkn.command, err = command.Parse(m.Cmd); err != nil { + return nil, err + } + + tkn.arguments = m.Args.Values + tkn.proof = m.Prf + tkn.meta = m.Meta + tkn.nonce = m.Nonce + + if tkn.expiration, err = parseOptionalTimestamp(m.Exp); err != nil { + return nil, err + } + + if tkn.invokedAt, err = parseOptionalTimestamp(m.Iat); err != nil { + return nil, err + } + + if tkn.cause, err = parseOptionalCID(m.Cause); err != nil { + return nil, err + } return &tkn, nil } @@ -187,3 +219,29 @@ func generateNonce() ([]byte, error) { } return res, nil } + +func parseOptionalCID(c *cid.Cid) (*cid.Cid, error) { + if c == nil { + return nil, nil + } + + return c, nil +} + +func parseOptionalDID(s *string) (did.DID, error) { + if s == nil { + return did.Undef, nil + } + + return did.Parse(*s) +} + +func parseOptionalTimestamp(sec *int64) (*time.Time, error) { + if sec == nil { + return nil, nil + } + + t := time.Unix(*sec, 0) + + return &t, nil +} diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 467e2be..817b265 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -192,6 +192,11 @@ func FromIPLD(node datamodel.Node) (*Token, error) { return tkn, err } +type stringAny struct { + Keys []string + Values map[string]datamodel.Node +} + func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { var aud *string @@ -220,10 +225,7 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { i++ } - args := struct { - Keys []string - Values map[string]datamodel.Node - }{ + args := stringAny{ Keys: argsKey, Values: t.arguments, } diff --git a/token/invocation/options.go b/token/invocation/options.go index 0d232fe..13a28d0 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -86,6 +86,7 @@ func WithEmptyNonce() Option { // 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 @@ -96,7 +97,7 @@ func WithExpiration(exp time.Time) Option { // Now() plus the given duration. func WithExpirationIn(exp time.Duration) Option { return func(t *Token) error { - expTime := time.Now().Add(exp) + expTime := time.Now().Add(exp).Round(time.Minute) t.expiration = &expTime return nil diff --git a/token/invocation/schema.go b/token/invocation/schema.go index f438e1f..b2b99f2 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -7,7 +7,6 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" - "github.com/ipld/go-ipld-prime/datamodel" "github.com/ipld/go-ipld-prime/node/bindnode" "github.com/ipld/go-ipld-prime/schema" @@ -46,7 +45,6 @@ func payloadType() schema.Type { var _ envelope.Tokener = (*tokenPayloadModel)(nil) -// TODO type tokenPayloadModel struct { // The DID of the Invoker Iss string @@ -58,10 +56,7 @@ type tokenPayloadModel struct { // The Command Cmd string // The Command's Arguments - Args struct { - Keys []string - Values map[string]datamodel.Node - } + Args stringAny // Delegations that prove the chain of authority Prf []cid.Cid From 1b61f2e4dbe5e4e790ca2808e006687ca2967109 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 07:39:51 -0500 Subject: [PATCH 34/54] docs(invocation): fix truncated WithEmptyNonce description --- token/invocation/options.go | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/token/invocation/options.go b/token/invocation/options.go index 13a28d0..ca79577 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -73,7 +73,7 @@ func WithNonce(nonce []byte) Option { // WithEmptyNonce sets the Token's nonce to an empty byte slice as // suggested by the UCAN spec for invocation tokens that represent -// idem +// idempotent operations. func WithEmptyNonce() Option { return func(t *Token) error { t.nonce = []byte{} @@ -96,12 +96,7 @@ func WithExpiration(exp time.Time) Option { // WithExpirationIn set's the Token's optional "expiration" field to // Now() plus the given duration. func WithExpirationIn(exp time.Duration) Option { - return func(t *Token) error { - expTime := time.Now().Add(exp).Round(time.Minute) - t.expiration = &expTime - - return nil - } + return WithExpiration(time.Now().Add(exp)) } // WithInvokedAt sets the Token's invokedAt field to the provided @@ -117,12 +112,7 @@ func WithInvokedAt(iat time.Time) Option { // WithInvokedAtIn sets the Token's invokedAt field to Now() plus the // given duration. func WithInvokedAtIn(after time.Duration) Option { - return func(t *Token) error { - iat := time.Now().Add(after) - t.invokedAt = &iat - - return nil - } + return WithInvokedAt(time.Now().Add(after)) } // WithoutInvokedAt clears the Token's invokedAt field. From 08f821f23d8222e1b31f02c607097dc94e4d9695 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 08:39:43 -0500 Subject: [PATCH 35/54] test(invocation): adds schema round-trip test --- token/invocation/examples_test.go | 2 - token/invocation/ipld_test.go | 50 +++++++++++++++ token/invocation/schema_test.go | 89 +++++++++++++++++++++++++++ token/invocation/testdata/new.dagjson | 1 + 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 token/invocation/ipld_test.go create mode 100644 token/invocation/schema_test.go create mode 100644 token/invocation/testdata/new.dagjson diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index 484643b..83ec04e 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -139,8 +139,6 @@ func prettyDAGJSON(data []byte) (string, error) { return "", err } - fmt.Println(string(jsonData)) - var out bytes.Buffer if err := json.Indent(&out, jsonData, "", " "); err != nil { return "", err diff --git a/token/invocation/ipld_test.go b/token/invocation/ipld_test.go new file mode 100644 index 0000000..ae52685 --- /dev/null +++ b/token/invocation/ipld_test.go @@ -0,0 +1,50 @@ +package invocation_test + +import ( + "testing" + "time" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/command" + "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.WithArguments(args), + 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) +} + +func setupNew(t *testing.T) (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Command, args map[string]datamodel.Node, prf []cid.Cid, meta map[string]datamodel.Node) { + privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() + require.NoError(t, err) + + return // WARNING: named return values +} diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go new file mode 100644 index 0000000..8fcc857 --- /dev/null +++ b/token/invocation/schema_test.go @@ -0,0 +1,89 @@ +package invocation_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/token/internal/envelope" + "github.com/ucan-wg/go-ucan/token/invocation" + "gotest.tools/v3/golden" +) + +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 From 3dc0011628b8e573e3c467b8c35083a36f502dde Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 09:35:55 -0500 Subject: [PATCH 36/54] docs(invocation): edit (and finish) Go docs for exported types --- token/invocation/invocation.go | 12 ++++++++++-- token/invocation/options.go | 14 ++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index c55fd9e..38b1c68 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -57,7 +57,8 @@ type Token struct { // 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. +// 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 other will overwrite // the previous contents of their target field. @@ -113,10 +114,14 @@ 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() map[string]datamodel.Node { return t.arguments } +// Proof() returns the ordered list of cid.Cids which referenced the +// delegation Tokens that authorize this invocation. func (t *Token) Proof() []cid.Cid { return t.proof } @@ -136,11 +141,14 @@ 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 (optional) +// 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 } diff --git a/token/invocation/options.go b/token/invocation/options.go index ca79577..c8656d5 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -9,7 +9,7 @@ import ( ) // Option is a type that allows optional fields to be set during the -// creation of a Token. +// creation of a invocation Token. type Option func(*Token) error // WithArgument adds a key/value pair to the Token's Arguments field. @@ -62,7 +62,9 @@ func WithMeta(key string, val any) Option { // 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 require. +// 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 @@ -95,12 +97,16 @@ func WithExpiration(exp time.Time) Option { // WithExpirationIn set's the Token's optional "expiration" field to // Now() plus the given duration. -func WithExpirationIn(exp time.Duration) Option { - return WithExpiration(time.Now().Add(exp)) +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 From 7d4f973171cde87339c0b740863ace6f3015ba1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Tue, 5 Nov 2024 17:39:39 +0100 Subject: [PATCH 37/54] invocation: round of cleanups/fixes --- token/delegation/delegation.go | 30 +++-------- token/internal/parse/parse.go | 22 ++++++++ token/invocation/examples_test.go | 7 +-- token/invocation/invocation.go | 83 +++++++++++-------------------- token/invocation/ipld.go | 9 +++- token/invocation/ipld_test.go | 22 ++------ token/invocation/options.go | 3 +- token/invocation/schema_test.go | 4 +- 8 files changed, 79 insertions(+), 101 deletions(-) create mode 100644 token/internal/parse/parse.go diff --git a/token/delegation/delegation.go b/token/delegation/delegation.go index 77ba14d..f1e5553 100644 --- a/token/delegation/delegation.go +++ b/token/delegation/delegation.go @@ -21,6 +21,7 @@ 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/parse" ) // Token is an immutable type that holds the fields of a UCAN delegation. @@ -184,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) } @@ -215,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 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 index 83ec04e..f948041 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -15,6 +15,7 @@ import ( "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" @@ -28,11 +29,7 @@ func ExampleNew() { return } - inv, err := invocation.New( - iss, - sub, - cmd, - prf, + inv, err := invocation.New(iss, sub, cmd, prf, invocation.WithArguments(args), invocation.WithMeta("env", "development"), invocation.WithMeta("tags", meta["tags"]), diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 38b1c68..a1fdcb9 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -15,9 +15,11 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" + "github.com/ucan-wg/go-ucan/token/internal/parse" ) // Token is an immutable type that holds the fields of a UCAN invocation. @@ -60,24 +62,18 @@ type Token struct { // 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 other will overwrite +// 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) { - nonce, err := generateNonce() - if err != nil { - return nil, err - } - iat := time.Now() - metadata := meta.NewMeta() tkn := Token{ issuer: iss, subject: sub, command: cmd, proof: prf, - meta: metadata, - nonce: nonce, + meta: meta.NewMeta(), + nonce: nil, invokedAt: &iat, } @@ -87,8 +83,15 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } - if len(tkn.meta.Keys) == 0 { - tkn.meta = nil + if len(tkn.nonce) == 0 { + tkn.nonce, err = generateNonce() + if err != nil { + return nil, err + } + } + + if err := tkn.validate(); err != nil { + return nil, err } return &tkn, nil @@ -120,7 +123,7 @@ func (t *Token) Arguments() map[string]datamodel.Node { return t.arguments } -// Proof() returns the ordered list of cid.Cids which referenced the +// 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 @@ -163,8 +166,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")) @@ -182,35 +184,36 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { ) if tkn.issuer, err = did.Parse(m.Iss); err != nil { - return nil, err + return nil, fmt.Errorf("parse iss: %w", err) } if tkn.subject, err = did.Parse(m.Sub); err != nil { - return nil, err + return nil, fmt.Errorf("parse subject: %w", err) } - if tkn.audience, err = parseOptionalDID(m.Aud); err != nil { - return nil, 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, err + 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.Values tkn.proof = m.Prf tkn.meta = m.Meta - tkn.nonce = m.Nonce - if tkn.expiration, err = parseOptionalTimestamp(m.Exp); err != nil { - return nil, err - } + tkn.expiration = parse.OptionalTimestamp(m.Exp) + tkn.invokedAt = parse.OptionalTimestamp(m.Iat) - if tkn.invokedAt, err = parseOptionalTimestamp(m.Iat); err != nil { - return nil, err - } + tkn.cause = m.Cause - if tkn.cause, err = parseOptionalCID(m.Cause); err != nil { + if err := tkn.validate(); err != nil { return nil, err } @@ -227,29 +230,3 @@ func generateNonce() ([]byte, error) { } return res, nil } - -func parseOptionalCID(c *cid.Cid) (*cid.Cid, error) { - if c == nil { - return nil, nil - } - - return c, nil -} - -func parseOptionalDID(s *string) (did.DID, error) { - if s == nil { - return did.Undef, nil - } - - return did.Parse(*s) -} - -func parseOptionalTimestamp(sec *int64) (*time.Time, error) { - if sec == nil { - return nil, nil - } - - t := time.Unix(*sec, 0) - - return &t, nil -} diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 817b265..431e3a4 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -218,8 +218,9 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { } argsKey := make([]string, len(t.arguments)) - i := 0 + // TODO: make specialized type and builder? + i := 0 for k := range t.arguments { argsKey[i] = k i++ @@ -230,6 +231,7 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { Values: t.arguments, } + // TODO: reuse instead of copy? it's immutable prf := make([]cid.Cid, len(t.proof)) for i, c := range t.proof { prf[i] = c @@ -249,5 +251,10 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { 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 index ae52685..ec3c04f 100644 --- a/token/invocation/ipld_test.go +++ b/token/invocation/ipld_test.go @@ -4,13 +4,9 @@ import ( "testing" "time" - "github.com/ipfs/go-cid" - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/libp2p/go-libp2p/core/crypto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/ucan-wg/go-ucan/did" - "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/invocation" ) @@ -20,16 +16,13 @@ func TestSealUnsealRoundtrip(t *testing.T) { privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() require.NoError(t, err) - tkn1, err := invocation.New( - iss, - sub, - cmd, - prf, + tkn1, err := invocation.New(iss, sub, cmd, prf, invocation.WithArguments(args), invocation.WithMeta("env", "development"), invocation.WithMeta("tags", meta["tags"]), invocation.WithExpirationIn(time.Minute), - invocation.WithoutInvokedAt()) + invocation.WithoutInvokedAt(), + ) require.NoError(t, err) data, cid1, err := tkn1.ToSealed(privKey) @@ -41,10 +34,3 @@ func TestSealUnsealRoundtrip(t *testing.T) { assert.Equal(t, cid1, cid2) assert.Equal(t, tkn1, tkn2) } - -func setupNew(t *testing.T) (privKey crypto.PrivKey, iss, sub did.DID, cmd command.Command, args map[string]datamodel.Node, prf []cid.Cid, meta map[string]datamodel.Node) { - privKey, iss, sub, cmd, args, prf, meta, err := setupExampleNew() - require.NoError(t, err) - - return // WARNING: named return values -} diff --git a/token/invocation/options.go b/token/invocation/options.go index c8656d5..1c557b9 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -5,11 +5,12 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" ) // Option is a type that allows optional fields to be set during the -// creation of a invocation Token. +// creation of an invocation Token. type Option func(*Token) error // WithArgument adds a key/value pair to the Token's Arguments field. diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go index 8fcc857..77b1afb 100644 --- a/token/invocation/schema_test.go +++ b/token/invocation/schema_test.go @@ -8,9 +8,10 @@ import ( "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" - "gotest.tools/v3/golden" ) const ( @@ -78,6 +79,7 @@ func TestSchemaRoundTrip(t *testing.T) { 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) From ce7f653ab053a8d910471ab0192a74cffe328f5b Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 15:28:31 -0500 Subject: [PATCH 38/54] chore(invocation): clean up left-over (and unneeded) conversion of prf --- token/invocation/ipld.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index 431e3a4..b477eb7 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -231,19 +231,13 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { Values: t.arguments, } - // TODO: reuse instead of copy? it's immutable - prf := make([]cid.Cid, len(t.proof)) - for i, c := range t.proof { - prf[i] = c - } - model := &tokenPayloadModel{ Iss: t.issuer.String(), Aud: aud, Sub: t.subject.String(), Cmd: t.command.String(), Args: args, - Prf: prf, + Prf: t.proof, Meta: t.meta, Nonce: t.nonce, Exp: exp, From 58bb5cdb8fe61f4da4eda45ae7f114a8ddded0af Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 6 Nov 2024 09:25:51 -0500 Subject: [PATCH 39/54] test(policy): adds example test case from invocation specification --- pkg/policy/spec_test.go | 93 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 pkg/policy/spec_test.go diff --git a/pkg/policy/spec_test.go b/pkg/policy/spec_test.go new file mode 100644 index 0000000..507dc4d --- /dev/null +++ b/pkg/policy/spec_test.go @@ -0,0 +1,93 @@ +package policy_test + +import ( + "testing" + + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" + 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" +) + +// TestInvocationValidation applies the example policy to the example +// arguments as defined in the [Validation] section of the invocation +// specification. +// +// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation +func TestInvocationValidation(t *testing.T) { + t.Parallel() + + polNode, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("==")) + qp.ListEntry(la, qp.String(".from")) + qp.ListEntry(la, qp.String("alice@example.com")) + })) + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("any")) + qp.ListEntry(la, qp.String(".to")) + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("like")) + qp.ListEntry(la, qp.String(".")) + qp.ListEntry(la, qp.String("*@example.com")) + })) + })) + }) + require.NoError(t, err) + + pol, err := policy.FromIPLD(polNode) + require.NoError(t, err) + + 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) + + assert.True(t, pol.Match(argsNode)) + }) + + t.Run("fails on sender (first 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("dan@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) + + assert.False(t, pol.Match(argsNode)) + }) + + 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@not.null.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.False(t, pol.Match(argsNode)) + }) +} From 962e897ff5445c52804ed13611ca7492eeae8496 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 6 Nov 2024 10:17:18 -0500 Subject: [PATCH 40/54] Otest(policy): refactor matching test from delegation spec --- pkg/policy/match_test.go | 51 ++++++++++++++++++++++ pkg/policy/spec_test.go | 93 ---------------------------------------- 2 files changed, 51 insertions(+), 93 deletions(-) delete mode 100644 pkg/policy/spec_test.go diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 7d10d43..a3cbc39 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" @@ -889,3 +892,51 @@ func TestPartialMatch(t *testing.T) { }) } } + +// 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) + + assert.True(t, pol.Match(argsNode)) + }) + + 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) + + assert.False(t, pol.Match(argsNode)) + }) +} diff --git a/pkg/policy/spec_test.go b/pkg/policy/spec_test.go deleted file mode 100644 index 507dc4d..0000000 --- a/pkg/policy/spec_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package policy_test - -import ( - "testing" - - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ipld/go-ipld-prime/fluent/qp" - 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" -) - -// TestInvocationValidation applies the example policy to the example -// arguments as defined in the [Validation] section of the invocation -// specification. -// -// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation -func TestInvocationValidation(t *testing.T) { - t.Parallel() - - polNode, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("==")) - qp.ListEntry(la, qp.String(".from")) - qp.ListEntry(la, qp.String("alice@example.com")) - })) - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("any")) - qp.ListEntry(la, qp.String(".to")) - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("like")) - qp.ListEntry(la, qp.String(".")) - qp.ListEntry(la, qp.String("*@example.com")) - })) - })) - }) - require.NoError(t, err) - - pol, err := policy.FromIPLD(polNode) - require.NoError(t, err) - - 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) - - assert.True(t, pol.Match(argsNode)) - }) - - t.Run("fails on sender (first 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("dan@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) - - assert.False(t, pol.Match(argsNode)) - }) - - 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@not.null.com")) - })) - qp.MapEntry(ma, "title", qp.String("Coffee")) - qp.MapEntry(ma, "body", qp.String("Still on for coffee")) - }) - require.NoError(t, err) - - assert.False(t, pol.Match(argsNode)) - }) -} From a52b48cf47b9b6472051dc1f22293ccc61e814a7 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 10:44:38 -0400 Subject: [PATCH 41/54] feat(invocation): provide New constructor and encoding to wire-format --- token/invocation/invocation.go | 1 - token/invocation/options.go | 1 - 2 files changed, 2 deletions(-) diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index a1fdcb9..0e0a306 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -15,7 +15,6 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" diff --git a/token/invocation/options.go b/token/invocation/options.go index 1c557b9..f2f870a 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -5,7 +5,6 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ucan-wg/go-ucan/did" ) From 4932e320523822f69bb65d737eee85a4314e963f Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 24 Oct 2024 12:59:38 -0400 Subject: [PATCH 42/54] feat(invocation): produce example output similar to spec --- token/invocation/examples_test.go | 1 - token/invocation/invocation.go | 11 +++++++++++ token/invocation/options.go | 9 +++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index f948041..ce1743f 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -15,7 +15,6 @@ import ( "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" diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 0e0a306..ff6275f 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -65,14 +65,20 @@ type Token struct { // 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() + metadata := meta.NewMeta() tkn := Token{ issuer: iss, subject: sub, command: cmd, proof: prf, +<<<<<<< HEAD meta: meta.NewMeta(), nonce: nil, +======= + meta: metadata, + nonce: nonce, +>>>>>>> f44cf8a (feat(invocation): produce example output similar to spec) invokedAt: &iat, } @@ -82,6 +88,7 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } +<<<<<<< HEAD if len(tkn.nonce) == 0 { tkn.nonce, err = generateNonce() if err != nil { @@ -91,6 +98,10 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( if err := tkn.validate(); err != nil { return nil, err +======= + if len(tkn.meta.Keys) == 0 { + tkn.meta = nil +>>>>>>> f44cf8a (feat(invocation): produce example output similar to spec) } return &tkn, nil diff --git a/token/invocation/options.go b/token/invocation/options.go index f2f870a..fc6ca34 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -130,6 +130,15 @@ func WithoutInvokedAt() Option { } } +// 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 { From d89fb395e33003653757ca4631510f8ffd4dbdd8 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Mon, 4 Nov 2024 16:07:11 -0500 Subject: [PATCH 43/54] feat(invocation): ipld unseal to invocation --- token/invocation/examples_test.go | 2 ++ token/invocation/invocation.go | 37 ++++++++++++++++++++++--------- token/invocation/options.go | 9 -------- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index ce1743f..1f2e713 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -135,6 +135,8 @@ func prettyDAGJSON(data []byte) (string, error) { return "", err } + fmt.Println(string(jsonData)) + var out bytes.Buffer if err := json.Indent(&out, jsonData, "", " "); err != nil { return "", err diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index ff6275f..0a44285 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -65,20 +65,14 @@ type Token struct { // 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() - metadata := meta.NewMeta() tkn := Token{ issuer: iss, subject: sub, command: cmd, proof: prf, -<<<<<<< HEAD meta: meta.NewMeta(), nonce: nil, -======= - meta: metadata, - nonce: nonce, ->>>>>>> f44cf8a (feat(invocation): produce example output similar to spec) invokedAt: &iat, } @@ -88,7 +82,6 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } } -<<<<<<< HEAD if len(tkn.nonce) == 0 { tkn.nonce, err = generateNonce() if err != nil { @@ -98,10 +91,6 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( if err := tkn.validate(); err != nil { return nil, err -======= - if len(tkn.meta.Keys) == 0 { - tkn.meta = nil ->>>>>>> f44cf8a (feat(invocation): produce example output similar to spec) } return &tkn, nil @@ -240,3 +229,29 @@ func generateNonce() ([]byte, error) { } return res, nil } + +func parseOptionalCID(c *cid.Cid) (*cid.Cid, error) { + if c == nil { + return nil, nil + } + + return c, nil +} + +func parseOptionalDID(s *string) (did.DID, error) { + if s == nil { + return did.Undef, nil + } + + return did.Parse(*s) +} + +func parseOptionalTimestamp(sec *int64) (*time.Time, error) { + if sec == nil { + return nil, nil + } + + t := time.Unix(*sec, 0) + + return &t, nil +} diff --git a/token/invocation/options.go b/token/invocation/options.go index fc6ca34..f2f870a 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -130,15 +130,6 @@ func WithoutInvokedAt() Option { } } -// 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 { From d754c5837bb7f89e37266720050da82d81e223af Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Tue, 5 Nov 2024 08:39:43 -0500 Subject: [PATCH 44/54] test(invocation): adds schema round-trip test --- token/invocation/examples_test.go | 2 -- token/invocation/schema_test.go | 1 - 2 files changed, 3 deletions(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index 1f2e713..ce1743f 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -135,8 +135,6 @@ func prettyDAGJSON(data []byte) (string, error) { return "", err } - fmt.Println(string(jsonData)) - var out bytes.Buffer if err := json.Indent(&out, jsonData, "", " "); err != nil { return "", err diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go index 77b1afb..92483ac 100644 --- a/token/invocation/schema_test.go +++ b/token/invocation/schema_test.go @@ -79,7 +79,6 @@ func TestSchemaRoundTrip(t *testing.T) { 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) From bcdaf0cca3810e59525f0d7b96cbf622bc4ae23c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Tue, 5 Nov 2024 17:39:39 +0100 Subject: [PATCH 45/54] invocation: round of cleanups/fixes --- token/invocation/examples_test.go | 1 + token/invocation/invocation.go | 27 +-------------------------- token/invocation/options.go | 1 + token/invocation/schema_test.go | 1 + 4 files changed, 4 insertions(+), 26 deletions(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index ce1743f..f948041 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -15,6 +15,7 @@ import ( "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" diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index 0a44285..a1fdcb9 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -15,6 +15,7 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" "github.com/ucan-wg/go-ucan/pkg/command" "github.com/ucan-wg/go-ucan/pkg/meta" @@ -229,29 +230,3 @@ func generateNonce() ([]byte, error) { } return res, nil } - -func parseOptionalCID(c *cid.Cid) (*cid.Cid, error) { - if c == nil { - return nil, nil - } - - return c, nil -} - -func parseOptionalDID(s *string) (did.DID, error) { - if s == nil { - return did.Undef, nil - } - - return did.Parse(*s) -} - -func parseOptionalTimestamp(sec *int64) (*time.Time, error) { - if sec == nil { - return nil, nil - } - - t := time.Unix(*sec, 0) - - return &t, nil -} diff --git a/token/invocation/options.go b/token/invocation/options.go index f2f870a..1c557b9 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -5,6 +5,7 @@ import ( "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ucan-wg/go-ucan/did" ) diff --git a/token/invocation/schema_test.go b/token/invocation/schema_test.go index 92483ac..77b1afb 100644 --- a/token/invocation/schema_test.go +++ b/token/invocation/schema_test.go @@ -79,6 +79,7 @@ func TestSchemaRoundTrip(t *testing.T) { 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) From adc2b8d0da9e7d24ad8bb426c874f074c143fff7 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 6 Nov 2024 09:25:51 -0500 Subject: [PATCH 46/54] test(policy): adds example test case from invocation specification --- pkg/policy/spec_test.go | 93 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 pkg/policy/spec_test.go diff --git a/pkg/policy/spec_test.go b/pkg/policy/spec_test.go new file mode 100644 index 0000000..507dc4d --- /dev/null +++ b/pkg/policy/spec_test.go @@ -0,0 +1,93 @@ +package policy_test + +import ( + "testing" + + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/fluent/qp" + 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" +) + +// TestInvocationValidation applies the example policy to the example +// arguments as defined in the [Validation] section of the invocation +// specification. +// +// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation +func TestInvocationValidation(t *testing.T) { + t.Parallel() + + polNode, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("==")) + qp.ListEntry(la, qp.String(".from")) + qp.ListEntry(la, qp.String("alice@example.com")) + })) + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("any")) + qp.ListEntry(la, qp.String(".to")) + qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { + qp.ListEntry(la, qp.String("like")) + qp.ListEntry(la, qp.String(".")) + qp.ListEntry(la, qp.String("*@example.com")) + })) + })) + }) + require.NoError(t, err) + + pol, err := policy.FromIPLD(polNode) + require.NoError(t, err) + + 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) + + assert.True(t, pol.Match(argsNode)) + }) + + t.Run("fails on sender (first 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("dan@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) + + assert.False(t, pol.Match(argsNode)) + }) + + 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@not.null.com")) + })) + qp.MapEntry(ma, "title", qp.String("Coffee")) + qp.MapEntry(ma, "body", qp.String("Still on for coffee")) + }) + require.NoError(t, err) + + assert.False(t, pol.Match(argsNode)) + }) +} From 85557ab6b55eda7d3078368e903dfe15f0f71d52 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 6 Nov 2024 10:17:18 -0500 Subject: [PATCH 47/54] test(policy): refactor matching test from delegation spec --- pkg/policy/spec_test.go | 93 ----------------------------------------- 1 file changed, 93 deletions(-) delete mode 100644 pkg/policy/spec_test.go diff --git a/pkg/policy/spec_test.go b/pkg/policy/spec_test.go deleted file mode 100644 index 507dc4d..0000000 --- a/pkg/policy/spec_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package policy_test - -import ( - "testing" - - "github.com/ipld/go-ipld-prime/datamodel" - "github.com/ipld/go-ipld-prime/fluent/qp" - 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" -) - -// TestInvocationValidation applies the example policy to the example -// arguments as defined in the [Validation] section of the invocation -// specification. -// -// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation -func TestInvocationValidation(t *testing.T) { - t.Parallel() - - polNode, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("==")) - qp.ListEntry(la, qp.String(".from")) - qp.ListEntry(la, qp.String("alice@example.com")) - })) - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("any")) - qp.ListEntry(la, qp.String(".to")) - qp.ListEntry(la, qp.List(3, func(la datamodel.ListAssembler) { - qp.ListEntry(la, qp.String("like")) - qp.ListEntry(la, qp.String(".")) - qp.ListEntry(la, qp.String("*@example.com")) - })) - })) - }) - require.NoError(t, err) - - pol, err := policy.FromIPLD(polNode) - require.NoError(t, err) - - 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) - - assert.True(t, pol.Match(argsNode)) - }) - - t.Run("fails on sender (first 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("dan@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) - - assert.False(t, pol.Match(argsNode)) - }) - - 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@not.null.com")) - })) - qp.MapEntry(ma, "title", qp.String("Coffee")) - qp.MapEntry(ma, "body", qp.String("Still on for coffee")) - }) - require.NoError(t, err) - - assert.False(t, pol.Match(argsNode)) - }) -} From cfcb199818458823905d650484f6c6828962c753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Wed, 6 Nov 2024 18:06:46 +0100 Subject: [PATCH 48/54] meta: prevent overwrite of values --- pkg/meta/meta.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/meta/meta.go b/pkg/meta/meta.go index d1af3b8..ffc21f7 100644 --- a/pkg/meta/meta.go +++ b/pkg/meta/meta.go @@ -98,6 +98,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) From 948087744d595af76809cca7d78edca2e1a05089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Thu, 7 Nov 2024 11:12:40 +0100 Subject: [PATCH 49/54] literal: fix flacky test also: make tests less noisy everywhere --- pkg/policy/literal/literal.go | 21 ++++++++++++++++----- pkg/policy/selector/parsing_test.go | 10 ---------- pkg/policy/selector/selector_test.go | 14 -------------- pkg/policy/selector/supported_test.go | 22 +--------------------- token/delegation/delegation_test.go | 4 ---- token/delegation/schema_test.go | 7 ------- 6 files changed, 17 insertions(+), 61 deletions(-) diff --git a/pkg/policy/literal/literal.go b/pkg/policy/literal/literal.go index 26e5625..65ef32c 100644 --- a/pkg/policy/literal/literal.go +++ b/pkg/policy/literal/literal.go @@ -4,6 +4,7 @@ package literal import ( "fmt" "reflect" + "sort" "github.com/ipfs/go-cid" "github.com/ipld/go-ipld-prime" @@ -33,8 +34,14 @@ func Null() ipld.Node { // 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) { - for k, v := range m { - qp.MapEntry(ma, k, anyAssemble(v)) + // 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])) } }) } @@ -90,10 +97,14 @@ func anyAssemble(val any) qp.Assemble { if rt.Key().Kind() != reflect.String { break } - it := rv.MapRange() + // 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 it.Next() { - qp.MapEntry(ma, it.Key().String(), anyAssemble(it.Value())) + for _, key := range keys { + qp.MapEntry(ma, key.String(), anyAssemble(rv.MapIndex(key))) } }) case reflect.Bool: 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_test.go b/token/delegation/delegation_test.go index 3c662fc..42008b4 100644 --- a/token/delegation/delegation_test.go +++ b/token/delegation/delegation_test.go @@ -100,8 +100,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") }) @@ -119,8 +117,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/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)) From f9065d39d8d174c09f87773aed54574ea63d6cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Thu, 7 Nov 2024 15:33:21 +0100 Subject: [PATCH 50/54] policy: make Match also return the failing statement It's a powerful capability, so let's expose it. I also found a bug in the process. --- pkg/policy/match.go | 13 +- pkg/policy/match_test.go | 306 ++++++++++++++++++++------------------- 2 files changed, 166 insertions(+), 153 deletions(-) diff --git a/pkg/policy/match.go b/pkg/policy/match.go index c3862d5..59316ed 100644 --- a/pkg/policy/match.go +++ b/pkg/policy/match.go @@ -10,17 +10,18 @@ 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 { - res, _ := matchStatement(stmt, node) + res, leaf := matchStatement(stmt, node) switch res { case matchResultNoData, matchResultFalse: - return false + return false, leaf case matchResultOptionalNoData, matchResultTrue: // continue } } - return true + return true, nil } // PartialMatch returns false IIF one non-optional Statement has the corresponding data and doesn't match. @@ -131,9 +132,9 @@ func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Stat case matchResultNoData, matchResultOptionalNoData: return res, leaf case matchResultTrue: - return matchResultFalse, leaf + return matchResultFalse, cur case matchResultFalse: - return matchResultTrue, leaf + return matchResultTrue, nil } } case KindAnd: diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 7d10d43..108037a 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -17,228 +17,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 +270,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 +280,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 +299,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 +309,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 +330,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 +349,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 +416,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,7 +521,7 @@ func FuzzMatch(f *testing.F) { t.Skip() } - policy.Match(dataNode) + _, _ = policy.Match(dataNode) }) } @@ -584,7 +596,7 @@ func TestOptionalSelectors(t *testing.T) { err = nb.AssignNode(n) require.NoError(t, err) - result := tt.policy.Match(nb.Build()) + result, _ := tt.policy.Match(nb.Build()) require.Equal(t, tt.expected, result) }) } From d353dfe6529c90dfbd26949f91637687747cfb9d Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 7 Nov 2024 12:58:53 -0500 Subject: [PATCH 51/54] feat(args): create a specialized type to manage invocation Arguments --- pkg/args/args.go | 206 ++++++++++++++++++++++++++++++++++++++++++ pkg/args/args_test.go | 192 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 pkg/args/args.go create mode 100644 pkg/args/args_test.go 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 +} From 11bc085c60f02a873090df4111b1cf2f53210eee Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 7 Nov 2024 13:17:22 -0500 Subject: [PATCH 52/54] test(policy): update to handel statement returned from Match --- pkg/policy/match_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/policy/match_test.go b/pkg/policy/match_test.go index 582d3dd..6a85512 100644 --- a/pkg/policy/match_test.go +++ b/pkg/policy/match_test.go @@ -932,7 +932,9 @@ func TestInvocationValidationSpecExamples(t *testing.T) { }) require.NoError(t, err) - assert.True(t, pol.Match(argsNode)) + exec, stmt := pol.Match(argsNode) + assert.True(t, exec) + assert.Nil(t, stmt) }) t.Run("fails on recipients (second statement)", func(t *testing.T) { @@ -949,6 +951,8 @@ func TestInvocationValidationSpecExamples(t *testing.T) { }) require.NoError(t, err) - assert.False(t, pol.Match(argsNode)) + exec, stmt := pol.Match(argsNode) + assert.False(t, exec) + assert.NotNil(t, stmt) }) } From 1fa2b5e6fc0f0735a2dde7899213c418bb03ee06 Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Thu, 7 Nov 2024 13:50:20 -0500 Subject: [PATCH 53/54] feat(invocation): use dedicated type for invocation.Token.Arguments --- token/invocation/examples_test.go | 4 +++- token/invocation/invocation.go | 9 +++++---- token/invocation/ipld.go | 21 +-------------------- token/invocation/ipld_test.go | 4 +++- token/invocation/options.go | 20 ++------------------ token/invocation/schema.go | 3 ++- 6 files changed, 16 insertions(+), 45 deletions(-) diff --git a/token/invocation/examples_test.go b/token/invocation/examples_test.go index f948041..7ecf349 100644 --- a/token/invocation/examples_test.go +++ b/token/invocation/examples_test.go @@ -30,7 +30,9 @@ func ExampleNew() { } inv, err := invocation.New(iss, sub, cmd, prf, - invocation.WithArguments(args), + 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), diff --git a/token/invocation/invocation.go b/token/invocation/invocation.go index a1fdcb9..f6eb07a 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -14,9 +14,9 @@ import ( "time" "github.com/ipfs/go-cid" - "github.com/ipld/go-ipld-prime/datamodel" "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/parse" @@ -34,7 +34,7 @@ type Token struct { // The Command command command.Command // The Command's Arguments - arguments map[string]datamodel.Node + arguments *args.Args // Delegations that prove the chain of authority proof []cid.Cid @@ -71,6 +71,7 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( issuer: iss, subject: sub, command: cmd, + arguments: args.New(), proof: prf, meta: meta.NewMeta(), nonce: nil, @@ -119,7 +120,7 @@ func (t *Token) Command() command.Command { // Arguments returns the arguments to be used when the command is // invoked. -func (t *Token) Arguments() map[string]datamodel.Node { +func (t *Token) Arguments() *args.Args { return t.arguments } @@ -204,7 +205,7 @@ func tokenFromModel(m tokenPayloadModel) (*Token, error) { } tkn.nonce = m.Nonce - tkn.arguments = m.Args.Values + tkn.arguments = m.Args tkn.proof = m.Prf tkn.meta = m.Meta diff --git a/token/invocation/ipld.go b/token/invocation/ipld.go index b477eb7..acaaf98 100644 --- a/token/invocation/ipld.go +++ b/token/invocation/ipld.go @@ -192,11 +192,6 @@ func FromIPLD(node datamodel.Node) (*Token, error) { return tkn, err } -type stringAny struct { - Keys []string - Values map[string]datamodel.Node -} - func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { var aud *string @@ -217,26 +212,12 @@ func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) { iat = &i } - argsKey := make([]string, len(t.arguments)) - - // TODO: make specialized type and builder? - i := 0 - for k := range t.arguments { - argsKey[i] = k - i++ - } - - args := stringAny{ - Keys: argsKey, - Values: t.arguments, - } - model := &tokenPayloadModel{ Iss: t.issuer.String(), Aud: aud, Sub: t.subject.String(), Cmd: t.command.String(), - Args: args, + Args: t.arguments, Prf: t.proof, Meta: t.meta, Nonce: t.nonce, diff --git a/token/invocation/ipld_test.go b/token/invocation/ipld_test.go index ec3c04f..9754c16 100644 --- a/token/invocation/ipld_test.go +++ b/token/invocation/ipld_test.go @@ -17,7 +17,9 @@ func TestSealUnsealRoundtrip(t *testing.T) { require.NoError(t, err) tkn1, err := invocation.New(iss, sub, cmd, prf, - invocation.WithArguments(args), + 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), diff --git a/token/invocation/options.go b/token/invocation/options.go index 1c557b9..9322cd7 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -4,7 +4,6 @@ import ( "time" "github.com/ipfs/go-cid" - "github.com/ipld/go-ipld-prime/datamodel" "github.com/ucan-wg/go-ucan/did" ) @@ -14,24 +13,9 @@ import ( type Option func(*Token) error // WithArgument adds a key/value pair to the Token's Arguments field. -func WithArgument(key string, val datamodel.Node) Option { +func WithArgument(key string, val any) Option { return func(t *Token) error { - t.arguments[key] = val - - return nil - } -} - -// WithArguments sets the Token's Arguments field to the provided map. -// -// Note that this will overwrite any existing Arguments whether provided -// by a previous call to this function or by one or more calls to -// WithArgument. -func WithArguments(args map[string]datamodel.Node) Option { - return func(t *Token) error { - t.arguments = args - - return nil + return t.arguments.Add(key, val) } } diff --git a/token/invocation/schema.go b/token/invocation/schema.go index b2b99f2..d51cf4f 100644 --- a/token/invocation/schema.go +++ b/token/invocation/schema.go @@ -10,6 +10,7 @@ import ( "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" ) @@ -56,7 +57,7 @@ type tokenPayloadModel struct { // The Command Cmd string // The Command's Arguments - Args stringAny + Args *args.Args // Delegations that prove the chain of authority Prf []cid.Cid From 633b3d210ae150419d050de5fb603702fd68c4b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Tue, 12 Nov 2024 10:38:25 +0100 Subject: [PATCH 54/54] token: move nonce generation to a shared space --- token/delegation/delegation.go | 15 ++------------- token/internal/nonce/nonce.go | 14 ++++++++++++++ token/invocation/invocation.go | 15 ++------------- 3 files changed, 18 insertions(+), 26 deletions(-) create mode 100644 token/internal/nonce/nonce.go diff --git a/token/delegation/delegation.go b/token/delegation/delegation.go index f1e5553..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,7 @@ 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" ) @@ -74,7 +74,7 @@ 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 } @@ -217,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/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/invocation/invocation.go b/token/invocation/invocation.go index f6eb07a..c48121f 100644 --- a/token/invocation/invocation.go +++ b/token/invocation/invocation.go @@ -8,7 +8,6 @@ package invocation import ( - "crypto/rand" "errors" "fmt" "time" @@ -19,6 +18,7 @@ import ( "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" ) @@ -85,7 +85,7 @@ func New(iss, sub did.DID, cmd command.Command, prf []cid.Cid, opts ...Option) ( } if len(tkn.nonce) == 0 { - tkn.nonce, err = generateNonce() + tkn.nonce, err = nonce.Generate() if err != nil { return nil, err } @@ -220,14 +220,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 -}