diff --git a/Makefile b/Makefile index 7221884..2b48d42 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ gen: rm ./errors/datamodel/*_gen.go || true cd ./errors/datamodel/gen && go run ./main.go - rm ./examples/types/cbor_gen.go || true + rm ./examples/types/cbor_gen.go ./examples/types/fields/*_gen.go || true cd ./examples/types/gen && go run ./main.go rm ./result/datamodel/*_gen.go || true @@ -35,6 +35,9 @@ gen: cd ./ucan/delegation/policy/datamodel/gen && go run ./main.go + rm ./ucan/delegation/policy/policytest/fields/*_gen.go || true + cd ./ucan/delegation/policy/policytest/gen && go run ./main.go + rm ./ucan/delegation/policy/internal/fixtures/datamodel/*_gen.go || true cd ./ucan/delegation/policy/internal/fixtures/datamodel/gen && go run ./main.go diff --git a/README.md b/README.md index 6ec29d3..5791c6f 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,27 @@ invocation, err := messageSend.Invoke( ) ``` +#### Typed policies + +Policies can be authored against generated field descriptors instead of raw jq +selector strings. A descriptor is generated from a command's argument struct +(see the `fieldgen` package), so the comparison value is type-checked against +the field and the selector path is derived from a real field — a wrong type or +a mistyped path is a compile error. The builders return the same statements as +the string-selector builders, so they drop straight into `policy.Build`. + +See examples in [policies_test.go](./examples/policies_test.go) + +```go +pol, err := policy.Build( + // every recipient must be an example.com address + policy.Each(fields.MessageSendArguments.To, func(addr policy.Selector[string]) []policy.StatementBuilderFunc { + return []policy.StatementBuilderFunc{policy.Glob(addr, "*@example.com")} + }), + policy.Eq(fields.MessageSendArguments.Subject, "Hello!"), +) +``` + #### Container See examples in [container_test.go](./examples/container_test.go) diff --git a/examples/policies_test.go b/examples/policies_test.go index 33edfbb..207adbb 100644 --- a/examples/policies_test.go +++ b/examples/policies_test.go @@ -3,6 +3,7 @@ package examples import ( "testing" + "github.com/fil-forge/ucantone/examples/types/fields" "github.com/fil-forge/ucantone/ipld" "github.com/fil-forge/ucantone/ucan/delegation/policy" ) @@ -46,3 +47,30 @@ func TestParsePolicy(t *testing.T) { panic("policy did not match") } } + +// TestTypedPolicy authors the same shape of policy against generated field +// descriptors (see examples/types/fields). The selector paths and value types +// come from the MessageSendArguments struct, so a wrong-typed value or a +// mistyped path would be a compile error rather than a runtime mismatch. +func TestTypedPolicy(t *testing.T) { + msg := ipld.Map{ + "to": []string{"bob@example.com", "carol@example.com"}, + "subject": "Hello!", + "message": "Hi there", + } + + pol, err := policy.Build( + // every recipient must be an example.com address + policy.Each(fields.MessageSendArguments.To, func(addr policy.Selector[string]) []policy.StatementBuilderFunc { + return []policy.StatementBuilderFunc{policy.Glob(addr, "*@example.com")} + }), + policy.Eq(fields.MessageSendArguments.Subject, "Hello!"), + ) + if err != nil { + panic(err) + } + + if err := policy.Match(pol, msg); err != nil { + panic("policy did not match") + } +} diff --git a/examples/typed_policy_test.go b/examples/typed_policy_test.go new file mode 100644 index 0000000..d97b0a5 --- /dev/null +++ b/examples/typed_policy_test.go @@ -0,0 +1,122 @@ +package examples + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fil-forge/ucantone/examples/types" + "github.com/fil-forge/ucantone/examples/types/fields" + "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/fil-forge/ucantone/ucan/delegation/policy" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/fil-forge/ucantone/validator" +) + +// TestTypedPolicyEndToEnd walks the full lifecycle of a *typed* delegation +// policy: a service delegates a command to a user under a policy, the user +// invokes the command, and the validator enforces the policy against the +// invocation's arguments. +// +// The policy is authored against generated field descriptors +// (examples/types/fields, produced from types.MessageSendArguments by the +// fieldgen generator). Each builder takes a typed Selector, so: +// +// - the comparison value is checked against the field's Go type, and +// - the selector path (".to", ".subject", ...) is derived from a real field. +// +// A wrong-typed value or a mistyped path is therefore a compile error, not a +// policy that silently never matches at runtime. For example, these would not +// compile: +// +// policy.Eq(fields.MessageSendArguments.Subject, 42) // .subject is a string field +// policy.Glob(fields.MessageSendArguments.To, "*") // .to is a SliceSelector, not Selector[string] +// policy.Gt(fields.MessageSendArguments.To, nil) // a list field is not Ordered +// +// The builders return the same policy.StatementBuilderFunc as the legacy +// string-selector builders, so they drop straight into +// delegation.WithPolicyBuilder and reuse the matcher and wire format unchanged. +func TestTypedPolicyEndToEnd(t *testing.T) { + // mailer owns the /message/send capability; alice is the user it delegates to. + mailer, err := ed25519.Generate() + require.NoError(t, err) + alice, err := ed25519.Generate() + require.NoError(t, err) + + const messageSend = "/message/send" + + // mailer delegates /message/send to alice, but constrains how she may use + // it with a typed policy: + // - every recipient must be an example.com address, + // - the subject must not be empty, and + // - the message body must mention "ucantone". + dlg, err := delegation.Delegate( + mailer, // issuer (root authority over the capability) + alice.DID(), // audience (who receives the delegation) + mailer.DID(), // subject (the resource the capability acts on) + messageSend, // command + delegation.WithPolicyBuilder( + // fields.MessageSendArguments.To is a SliceSelector[Selector[string]]; + // Each hands the closure the element descriptor — here an identity + // Selector[string] pointing at each address in turn. + policy.Each(fields.MessageSendArguments.To, func(addr policy.Selector[string]) []policy.StatementBuilderFunc { + return []policy.StatementBuilderFunc{policy.Glob(addr, "*@example.com")} + }), + // .subject is a Selector[string]; Ne pins the value type to string. + policy.Ne(fields.MessageSendArguments.Subject, ""), + policy.Glob(fields.MessageSendArguments.Message, "*ucantone*"), + ), + ) + require.NoError(t, err) + + // invoke builds a /message/send invocation carrying typed arguments and the + // delegation above as proof of authority. + invoke := func(args *types.MessageSendArguments) ucan.Invocation { + inv, err := invocation.Invoke(alice, mailer.DID(), messageSend, args, invocation.WithProofs(dlg.Link())) + require.NoError(t, err) + return inv + } + + // validate runs the full pipeline: signature + time bounds + proof chain + + // the policy check (cap.Allows -> policy.Match) against the decoded args. + // The delegation is supplied to the validator via a container so its proof + // resolver can walk the chain. + validate := func(inv ucan.Invocation) error { + return validator.ValidateInvocation(t.Context(), inv, + validator.WithProofResolver(validator.ProofsFromContainer( + container.New(container.WithDelegations(dlg)), + )), + ) + } + + // 1) A fully conforming invocation passes validation. + require.NoError(t, validate(invoke(&types.MessageSendArguments{ + To: []string{"bob@example.com", "carol@example.com"}, + Subject: "Status update", + Message: "the ucantone migration is done", + })), "all clauses satisfied") + + // 2) A recipient outside example.com is rejected by the Each/Glob clause. + require.Error(t, validate(invoke(&types.MessageSendArguments{ + To: []string{"bob@example.com", "mallory@evil.test"}, + Subject: "Status update", + Message: "the ucantone migration is done", + })), "a non-example.com recipient violates the policy") + + // 3) An empty subject is rejected by the Ne clause. + require.Error(t, validate(invoke(&types.MessageSendArguments{ + To: []string{"bob@example.com"}, + Subject: "", + Message: "the ucantone migration is done", + })), "an empty subject violates the policy") + + // 4) A message that doesn't mention "ucantone" is rejected by the Glob clause. + require.Error(t, validate(invoke(&types.MessageSendArguments{ + To: []string{"bob@example.com"}, + Subject: "Status update", + Message: "unrelated chatter", + })), "a message missing the keyword violates the policy") +} diff --git a/examples/types/fields/policy_fields_gen.go b/examples/types/fields/policy_fields_gen.go new file mode 100644 index 0000000..9231881 --- /dev/null +++ b/examples/types/fields/policy_fields_gen.go @@ -0,0 +1,41 @@ +// Code generated by fieldgen; DO NOT EDIT. + +package fields + +import ( + policy "github.com/fil-forge/ucantone/ucan/delegation/policy" +) + +// EmailsListArgumentsFields is the policy field descriptor type for EmailsListArguments. +type EmailsListArgumentsFields struct { + Limit policy.Selector[uint64] +} + +// MessageSendArgumentsFields is the policy field descriptor type for MessageSendArguments. +type MessageSendArgumentsFields struct { + To policy.SliceSelector[policy.Selector[string]] + Subject policy.Selector[string] + Message policy.Selector[string] +} + +// EchoArgumentsFields is the policy field descriptor type for EchoArguments. +type EchoArgumentsFields struct { + Message policy.Selector[string] +} + +// EmailsListArguments is the policy field descriptor for EmailsListArguments. +var EmailsListArguments = EmailsListArgumentsFields{ + Limit: policy.NewSelector[uint64](".limit"), +} + +// MessageSendArguments is the policy field descriptor for MessageSendArguments. +var MessageSendArguments = MessageSendArgumentsFields{ + To: policy.NewSliceSelector[policy.Selector[string]](".to", policy.NewSelector[string](".")), + Subject: policy.NewSelector[string](".subject"), + Message: policy.NewSelector[string](".message"), +} + +// EchoArguments is the policy field descriptor for EchoArguments. +var EchoArguments = EchoArgumentsFields{ + Message: policy.NewSelector[string](".message"), +} diff --git a/examples/types/gen/main.go b/examples/types/gen/main.go index 0982f2b..2585829 100644 --- a/examples/types/gen/main.go +++ b/examples/types/gen/main.go @@ -2,6 +2,7 @@ package main import ( "github.com/fil-forge/ucantone/examples/types" + "github.com/fil-forge/ucantone/ucan/delegation/policy/fieldgen" cbg "github.com/whyrusleeping/cbor-gen" ) @@ -14,4 +15,17 @@ func main() { ); err != nil { panic(err) } + + // Typed policy field descriptors for the same argument types, written to a + // sibling package so the entry-point vars (e.g. fields.MessageSend) and the + // descriptor types (e.g. fields.MessageSendArgumentsFields) don't collide + // with the source types. PromisedMsgSendArguments is omitted: its + // "await/ok" map key is not a valid jq selector segment. + if err := fieldgen.WriteFieldDescriptors("../fields/policy_fields_gen.go", "fields", + types.EmailsListArguments{}, + types.MessageSendArguments{}, + types.EchoArguments{}, + ); err != nil { + panic(err) + } } diff --git a/ucan/delegation/policy/DESIGN-typed-policy.md b/ucan/delegation/policy/DESIGN-typed-policy.md new file mode 100644 index 0000000..a0e0058 --- /dev/null +++ b/ucan/delegation/policy/DESIGN-typed-policy.md @@ -0,0 +1,147 @@ +# Typed, struct-bound policy builders (spike) + +A spike adding a stronger-typed authoring API to `ucan/delegation/policy`, +motivated by `piri-pdp .../access/grant.go:212`, where the generic +`policy.Equal(".blob.digest", value any)` forced a `[]byte(digest)` cast and a +five-line comment to explain why. + +The typed builders **coexist** with the legacy string-selector builders +(`Equal`, `And`, `Like`, …) in the same package and reuse the same operator +constants, wire model, and matcher. Nothing on the wire changes. + +## The bug it removes + +The cast was load-bearing for **two** reasons, both rooted in the same flaw: +the policy layer never lowered a literal to its canonical IPLD form. + +1. **Serialization.** `datamodel.Any` dispatches on *exact* type (`case []byte:`), + so a named `multihash.Multihash` fell through to the reflection path and + marshalled element-by-element → `"unsupported type: uint8"`. +2. **Matching (silent).** `MatchStatement` compares with `reflect.DeepEqual`, + which is type-identity sensitive. A `multihash.Multihash` literal would + never equal the plain `[]byte` the selector decodes — so even a "successful" + policy would never match. + +### Fix: `canonicalize` (`canonicalize.go`) + +Lowers any Go value to the canonical IPLD kind set +(`nil | bool | int64 | string | []byte | cid.Cid | []any | map[string]any`) by +dispatching on `reflect.Kind`, so named types (`multihash.Multihash` ~ `[]byte`, +a `Size` ~ `uint64`) are handled transparently. Applied at build time in every +typed constraint, and in `match.go`'s `normalize` so the decoded side matches. +This is the base layer the builders sit on. + +## The builder (`bind.go`, `constraint.go`) + +Hold an instance of the argument struct, point at fields, let `Bind` resolve +addresses → selector paths by walking the struct's `cborgen` tags. The path +cannot be mistyped (it's a real field) and the value is type-checked against +the field by the compiler. No code generation. + +```go +var a blob.RetrieveArguments +pol, err := policy.Bind(&a, + policy.Eq(&a.Blob.Digest, digest), // T inferred = multihash.Multihash + policy.Gte(&a.Blob.Size, uint64(0)), + policy.AnyOf( + policy.Eq(&a.Blob.Size, uint64(0)), + policy.Gt(&a.Blob.Size, uint64(1024)), + ), + policy.Each(&a.Shards, func(s *blob.Shard) []policy.Constraint { + return []policy.Constraint{policy.Eq(&s.Codec, uint64(0x55))} + }), +) +``` + +### Operator coverage (full spec) + +| Spec op | Legacy | Typed | Shape | +|---|---|---|---| +| `== != < <= > >=` | `Equal`/… | `Eq Ne Lt Lte Gt Gte` | `(&a.X, v)` | +| `like` | `Like` | `Glob` | `(&a.X, "p*")` | +| `and` | `And` | `AllOf` | `(c, …)` | +| `or` | `Or` | `AnyOf` | `(c, …)` | +| `not` | `Not` | `Negate` | `(c)` | +| `all` | `All` | `Each` / `EachMap` | `(&a.Xs, func(x) []Constraint)` | +| `any` | `Any` | `Some` / `SomeMap` | `(&a.Xs, func(x) []Constraint)` | + +Typed verbs are renamed only where they would collide with the legacy package +funcs; a real change could supersede the legacy set. + +### How resolution works + +`Bind` walks the subject once into an `address → selector` map +(`fieldPaths`), then `buildModel` recurses the constraint tree to the wire +`StatementModel`: + +- **leaf / connective / negation** — children point at the *same* subject, so + they resolve against the same map. +- **quantifier** — the collection field (`&a.Shards`) resolves against the + subject for its selector (`.shards`); the element constraints reference a + *fresh* element instance the builder allocates, walked separately so their + selectors are relative (`.codec`). Multiple element constraints collapse into + an implicit `and`, matching the single-inner-statement shape of all/any. + +A field pointer that doesn't belong to the bound subject is a hard error, not a +silent mismatch (`TestBind_ForeignPointer`). + +## grant.go, before / after + +```go +// before +delegation.WithPolicyBuilder(policy.Equal(".blob.digest", []byte(digest))) + +// after +var a blob.RetrieveArguments +pol, _ := policy.Bind(&a, policy.Eq(&a.Blob.Digest, digest)) +delegation.WithPolicy(pol) +``` + +Natural next step: `bind.Binding[A, O]` already pins the argument type `A`, so a +`Binding.PolicyFor(func(a *A) []policy.Constraint)` helper would scope the +builder to exactly the right struct without the caller naming the type twice. + +## Tests + +`bind_test.go` uses fixtures mirroring the libforge `blob` structs (same +`cborgen` tags), covering comparisons, glob, and/or/not, all/any quantifiers +(including a multi-constraint element group), and the foreign-pointer error — +each asserting both the built statement shape and that `Match` accepts/rejects +decoded args correctly. + +## Bignums (`*big.Int`) — coordinated with `datamodel.Any` + +See `NOTE-bigint-datamodel.md`: a CBOR-bignum fix landed in `datamodel.Any` +(decode/encode of tag-2 bignums) for libforge's `pdp/sign` args, whose IDs are +`*big.Int`. That is the *same* class of "datamodel is stricter / DeepEqual is +identity-sensitive" bug this spike targets, so the policy layer now handles +bignums end to end: + +- **`canonicalize`** accepts `*big.Int`/`big.Int`: values that fit int64 + collapse to int64 (common path, and so a small bignum equals an int64-encoded + peer); larger magnitudes are kept as `*big.Int`. A `uint64` that overflows + int64 now promotes to `*big.Int` instead of erroring. +- **Matching** compares integers by value: `valuesEqual` and `isOrdered` + promote both sides through `asBigInt` and use `(*big.Int).Cmp`, so a bignum + no longer silently never-matches (the multihash trap, for integers). List/map + equality recurses through the same rule. +- **Dependency:** serializing a policy whose literal exceeds int64 relies on the + `Any` bignum *encode* change; that must be upstreamed + version-bumped rather + than left in the local `replace` (per the note). Policy-level matching is + tested here without needing the wire round-trip. + +## Open edges + +- **Maps in quantifiers** are supported via `EachMap`/`SomeMap`; nested + quantifiers work through the same recursion. +- **Quantifiers over scalar elements.** `Each`/`Some` constrain *fields* of a + struct element, so a `[]*big.Int` or `[]string` (elements with no fields) + can't yet be constrained by element *value* — the legacy `All`/`Any` with an + identity selector still can. Needs a value-constraint form for the element. +- **Pointer fields.** `fieldPaths` records the pointer field's address and + recurses through non-nil pointers; constraining *through* a nil pointer field + is not yet exercised. +- **DAG-JSON bignums.** `Any`'s bignum support is CBOR-only (DAG-JSON numbers + are f64); a policy literal exceeding int64 has no lossless DAG-JSON form yet. +- **Naming.** `Glob`/`AllOf`/`AnyOf`/`Negate`/`Each`/`Some` avoid colliding with + the legacy builders; superseding them would free the shorter names. diff --git a/ucan/delegation/policy/NOTE-bigint-datamodel.md b/ucan/delegation/policy/NOTE-bigint-datamodel.md new file mode 100644 index 0000000..8fb4630 --- /dev/null +++ b/ucan/delegation/policy/NOTE-bigint-datamodel.md @@ -0,0 +1,91 @@ +# Note: `*big.Int` support in `datamodel.Any` (coordinate with the policy spike) + +Heads-up for whoever is working the typed-policy spike. A separate fix landed in +`ipld/datamodel/any.go` to make `Any` understand CBOR bignums. It overlaps the +same "datamodel is stricter than the types flowing through it" theme this spike +addresses, so the two need to stay consistent. + +## The original problem + +The `piri-signing-service` UCAN migration uses libforge's `pdp/sign` capability +arguments, whose ID fields are `*big.Int`: + +```go +DataSet *big.Int // /pdp/sign/dataset/{create,delete} +Nonce *big.Int // /pdp/sign/pieces/add +Pieces []*big.Int // /pdp/sign/pieces/remove/schedule +``` + +cbor-gen encodes `*big.Int` as a **CBOR bignum: tag 2 + byte string** +(`cbor-gen/gen.go:931-966`). Every server invocation failed validation with: + +``` +decoding invocation arguments for capability check: unmarshaling map value +for key "dataSet": unsupported CBOR type: 6 +``` + +### Why it happened + +`validator.Authorize` (`validator/validator.go:56-60`) decodes the raw argument +bytes into a schema-less `datamodel.Map` *before* running `cap.Allows`: + +```go +var mapArgs datamodel.Map +err = mapArgs.UnmarshalCBOR(bytes.NewReader(inv.ArgumentsBytes())) +``` + +`Map` decodes each value via `Any.UnmarshalCBOR`, whose `MajTag` switch only +knew tag 42 (CID). A bignum is tag 2 (major type 6) → `unsupported CBOR type: 6`. + +Key point: the failure is a **decode** error that fires **before** any policy +matching runs. It is *not* the marshal/`DeepEqual` bug this spike targets — but +it lives in the same `Any` type. + +## The fix that landed + +In `ipld/datamodel/any.go` (currently only in the local `../ucantone` replace +copy — **must be upstreamed + version-bumped**): + +- **Decode**: added `case 2` to the `MajTag` switch — consumes the tag-2 header, + reads the inner byte string (≤256 bytes, cbor-gen's cap), yields + `new(big.Int).SetBytes(b)`. +- **Encode**: added `*big.Int` / `big.Int` cases (`marshalCborBigInt`) writing + tag 2 + byte string, rejecting negatives — mirrors cbor-gen so values + round-trip. +- `Any.Value` can now hold a `*big.Int`. DagJSON paths were intentionally **not** + touched (DAG-JSON numbers are f64; encoding a large bignum there would lose + data — needs a deliberate representation decision). + +## What the spike must account for + +1. **`canonicalize` has no bignum arm.** Its kind set is + `nil | bool | int64 | string | []byte | cid.Cid | []any | map[string]any`, + and it explicitly rejects `uint64 > MaxInt64` ("IPLD has no uint64"). + `big.Int` is a struct, so a `*big.Int` literal (or the `*big.Int` the selector + now decodes out of arguments) hits the `reflect.Pointer` → `reflect.Struct` + fall-through → `unsupported type for IPLD value: *big.Int`. + - If any typed policy ever constrains `dataSet` / `nonce` / `pieces`, add a + `*big.Int` (and probably `big.Int`) case to `canonicalize` that returns the + `*big.Int` as-is (or a normalized copy), consistent with how `Any` now + stores it. + +2. **`MatchStatement` uses `reflect.DeepEqual` on `*big.Int`.** Two `*big.Int` + that are numerically equal but distinct pointers are **not** `DeepEqual`-equal + in general (different internal `nat` backing arrays / capacities). A bignum + constraint would silently never match — the exact failure mode the spike's + DESIGN doc calls out for `multihash.Multihash`. Matching on bignums needs a + value comparison (`(*big.Int).Cmp`), not `DeepEqual`. + +3. **No policy currently constrains these fields**, so neither of the above is + exercised today — the decode fix alone unblocks the signing-service tests. + But the moment the typed builders are used to bind a `*big.Int` field, both + gaps become live. Worth folding a bignum case into `canonicalize` + match in + the same pass as the spike so `Any` (decode/encode) and policy (canonicalize/ + compare) agree end to end. + +## TL;DR + +`Any` now supports `*big.Int` over CBOR. The spike's `canonicalize` and +`reflect.DeepEqual`-based matching do **not** yet — same class of bug as the +`multihash.Multihash` case, just for the Integer-bignum kind. Keep them in sync +and land the `Any` change upstream rather than relying on the `replace`. diff --git a/ucan/delegation/policy/bind.go b/ucan/delegation/policy/bind.go new file mode 100644 index 0000000..7ad7db8 --- /dev/null +++ b/ucan/delegation/policy/bind.go @@ -0,0 +1,194 @@ +package policy + +import ( + "fmt" + "math/big" + + "github.com/fil-forge/ucantone/ipld/datamodel" + pdm "github.com/fil-forge/ucantone/ucan/delegation/policy/datamodel" +) + +// Typed policy builders. These mirror the legacy string-selector builders +// ([Equal], [Like], [All], ...) but take a generated [Selector] instead of a +// raw jq string, so the comparison value is type-checked against the field and +// the selector path is generated from a real field. They return the same +// [StatementBuilderFunc] and produce the same wire model, so the matcher and +// serialization are unchanged. + +// Ordered is the set of field types the ordered comparison builders +// ([Gt], [Gte], [Lt], [Lte]) accept: exactly the types the matcher knows how +// to order — the integer kinds, string (lexicographic), and big integers +// (int64/CBOR-bignum). Float kinds are intentionally excluded: the matcher +// does not order them and neither the CBOR nor the DAG-JSON codec represents +// them, so ordering a float field is a compile error rather than a value that +// silently never matches. +type Ordered interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | + ~string | big.Int | *big.Int +} + +// --- leaf comparisons ------------------------------------------------------- + +// Eq constrains the selected field to equal value (==). +func Eq[T any](s Selector[T], value T) StatementBuilderFunc { + return comparison(OpEqual, s.path, value) +} + +// Ne constrains the selected field to differ from value (!=). +func Ne[T any](s Selector[T], value T) StatementBuilderFunc { + return comparison(OpNotEqual, s.path, value) +} + +// Gt constrains the selected field to be greater than value (>). +func Gt[T Ordered](s Selector[T], value T) StatementBuilderFunc { + return comparison(OpGreaterThan, s.path, value) +} + +// Gte constrains the selected field to be greater than or equal to value (>=). +func Gte[T Ordered](s Selector[T], value T) StatementBuilderFunc { + return comparison(OpGreaterThanOrEqual, s.path, value) +} + +// Lt constrains the selected field to be less than value (<). +func Lt[T Ordered](s Selector[T], value T) StatementBuilderFunc { + return comparison(OpLessThan, s.path, value) +} + +// Lte constrains the selected field to be less than or equal to value (<=). +func Lte[T Ordered](s Selector[T], value T) StatementBuilderFunc { + return comparison(OpLessThanOrEqual, s.path, value) +} + +func comparison[T any](op, path string, value T) StatementBuilderFunc { + return func() (Statement, error) { + cv, err := canonicalize(value) + if err != nil { + return Statement{}, fmt.Errorf("canonicalizing %q value: %w", op, err) + } + return newStatement(pdm.StatementModel{ + Op: op, + Selector: path, + Value: &datamodel.Any{Value: cv}, + }) + } +} + +// Glob constrains the selected string field to match the glob pattern (like). +func Glob(s Selector[string], pattern string) StatementBuilderFunc { + return func() (Statement, error) { + return newStatement(pdm.StatementModel{ + Op: OpLike, + Selector: s.path, + Pattern: pattern, + }) + } +} + +// --- connectives ------------------------------------------------------------ + +// AllOf passes only if every child statement passes (and). +func AllOf(statements ...StatementBuilderFunc) StatementBuilderFunc { + return func() (Statement, error) { + models, err := childModels(statements) + if err != nil { + return Statement{}, err + } + return newStatement(pdm.StatementModel{Op: OpAnd, Statements: models}) + } +} + +// AnyOf passes if at least one child statement passes (or). +func AnyOf(statements ...StatementBuilderFunc) StatementBuilderFunc { + return func() (Statement, error) { + models, err := childModels(statements) + if err != nil { + return Statement{}, err + } + return newStatement(pdm.StatementModel{Op: OpOr, Statements: models}) + } +} + +// Negate passes only if the child statement fails (not). +func Negate(statement StatementBuilderFunc) StatementBuilderFunc { + return func() (Statement, error) { + s, err := statement() + if err != nil { + return Statement{}, err + } + return newStatement(pdm.StatementModel{Op: OpNot, Statement: &s.model}) + } +} + +// --- quantifiers ------------------------------------------------------------ + +// Each passes only if every element of the selected list satisfies the +// statements the closure builds against the element descriptor (all). +// +// policy.Each(ManifestFields.Shards, func(s ShardFields) []policy.StatementBuilderFunc { +// return []policy.StatementBuilderFunc{policy.Eq(s.Codec, uint64(0x55))} +// }) +func Each[E any](s SliceSelector[E], build func(E) []StatementBuilderFunc) StatementBuilderFunc { + return quantifier(OpAll, s.path, s.elem, build) +} + +// Some passes if at least one element of the selected list satisfies the +// closure statements (any). +func Some[E any](s SliceSelector[E], build func(E) []StatementBuilderFunc) StatementBuilderFunc { + return quantifier(OpAny, s.path, s.elem, build) +} + +// EachMap is [Each] over the values of a map-valued field. +func EachMap[E any](s MapSelector[E], build func(E) []StatementBuilderFunc) StatementBuilderFunc { + return quantifier(OpAll, s.path, s.elem, build) +} + +// SomeMap is [Some] over the values of a map-valued field. +func SomeMap[E any](s MapSelector[E], build func(E) []StatementBuilderFunc) StatementBuilderFunc { + return quantifier(OpAny, s.path, s.elem, build) +} + +func quantifier[E any](op, path string, elem E, build func(E) []StatementBuilderFunc) StatementBuilderFunc { + return func() (Statement, error) { + inner, err := groupInner(build(elem)) + if err != nil { + return Statement{}, fmt.Errorf("%q element: %w", op, err) + } + return newStatement(pdm.StatementModel{Op: op, Selector: path, Statement: inner}) + } +} + +// --- helpers ---------------------------------------------------------------- + +func childModels(statements []StatementBuilderFunc) ([]*pdm.StatementModel, error) { + models := make([]*pdm.StatementModel, 0, len(statements)) + for i, ctor := range statements { + s, err := ctor() + if err != nil { + return nil, fmt.Errorf("child %d: %w", i, err) + } + models = append(models, &s.model) + } + return models, nil +} + +// groupInner collapses an element's statements into the single inner statement +// a quantifier requires: the lone statement, or an implicit AND of several. +func groupInner(statements []StatementBuilderFunc) (*pdm.StatementModel, error) { + switch len(statements) { + case 0: + return nil, fmt.Errorf("element closure returned no statements") + case 1: + s, err := statements[0]() + if err != nil { + return nil, err + } + return &s.model, nil + default: + models, err := childModels(statements) + if err != nil { + return nil, err + } + return &pdm.StatementModel{Op: OpAnd, Statements: models}, nil + } +} diff --git a/ucan/delegation/policy/canonicalize.go b/ucan/delegation/policy/canonicalize.go new file mode 100644 index 0000000..d975376 --- /dev/null +++ b/ucan/delegation/policy/canonicalize.go @@ -0,0 +1,124 @@ +package policy + +import ( + "fmt" + "math" + "math/big" + "reflect" + + "github.com/ipfs/go-cid" +) + +// canonicalize lowers an arbitrary Go value to its canonical IPLD +// representation so that policy literals compare and serialize the same way +// invocation arguments do once they have round-tripped through CBOR. +// +// It dispatches on reflect.Kind rather than concrete type, so named types +// whose underlying type is supported — multihash.Multihash (~[]byte), +// a custom Size (~uint64), etc. — are handled transparently. This is what +// removes the need for callers to pre-cast (e.g. []byte(digest)) and what +// keeps reflect.DeepEqual in matching from failing on type identity +// (multihash.Multihash{…} vs the []byte the selector decodes). +// +// The canonical kinds mirror datamodel.Any's supported set: +// +// nil | bool | int64 | string | []byte | cid.Cid | []any | map[string]any +// +// Integers that overflow int64 are kept as *big.Int (CBOR bignum), the one +// integer kind that does not collapse to int64; see [normalizeBigInt]. +func canonicalize(v any) (any, error) { + if v == nil { + return nil, nil + } + // cid.Cid and big.Int are structs; match them before the reflect path, + // which would treat any struct as unsupported. + switch x := v.(type) { + case cid.Cid: + return x, nil + case *big.Int: + if x == nil { + return nil, nil + } + return normalizeBigInt(x), nil + case big.Int: + return normalizeBigInt(&x), nil + } + + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Bool: + return rv.Bool(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return rv.Int(), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + u := rv.Uint() + if u > math.MaxInt64 { + // Too big for int64 but still a valid non-negative integer: carry + // it as a CBOR bignum rather than failing. + return new(big.Int).SetUint64(u), nil + } + return int64(u), nil + case reflect.String: + return rv.String(), nil + case reflect.Slice, reflect.Array: + // A slice/array of bytes (incl. named types like multihash.Multihash) + // is IPLD Bytes, not an IPLD List. + if rv.Type().Elem().Kind() == reflect.Uint8 { + b := make([]byte, rv.Len()) + reflect.Copy(reflect.ValueOf(b), rv) + return b, nil + } + out := make([]any, rv.Len()) + for i := range rv.Len() { + cv, err := canonicalize(rv.Index(i).Interface()) + if err != nil { + return nil, fmt.Errorf("list index %d: %w", i, err) + } + out[i] = cv + } + return out, nil + case reflect.Map: + if rv.Type().Key().Kind() != reflect.String { + return nil, fmt.Errorf("map keys must be strings, got %s", rv.Type().Key()) + } + out := make(map[string]any, rv.Len()) + iter := rv.MapRange() + for iter.Next() { + cv, err := canonicalize(iter.Value().Interface()) + if err != nil { + return nil, fmt.Errorf("map key %q: %w", iter.Key().String(), err) + } + out[iter.Key().String()] = cv + } + return out, nil + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + return nil, nil + } + return canonicalize(rv.Elem().Interface()) + } + return nil, fmt.Errorf("unsupported type for IPLD value: %T", v) +} + +// normalizeBigInt lowers a CBOR bignum to the canonical integer form: a plain +// int64 when it fits (so it shares the common, well-trodden int64 path and +// compares equal to int64-encoded values), otherwise the *big.Int as-is for +// magnitudes that overflow int64. Matching treats both via [asBigInt]. This +// mirrors how datamodel.Any now decodes/encodes bignums (CBOR tag 2); see +// NOTE-bigint-datamodel.md. +func normalizeBigInt(x *big.Int) any { + if x.IsInt64() { + return x.Int64() + } + return x +} + +// normalizeValue is the error-swallowing form used on the matching hot path, +// where a value that cannot be canonicalized is simply left as-is for the +// downstream comparison to reject. +func normalizeValue(v any) any { + if cv, err := canonicalize(v); err == nil { + return cv + } + return v +} diff --git a/ucan/delegation/policy/field_test.go b/ucan/delegation/policy/field_test.go new file mode 100644 index 0000000..7974be1 --- /dev/null +++ b/ucan/delegation/policy/field_test.go @@ -0,0 +1,198 @@ +package policy_test + +import ( + "math/big" + "testing" + + "github.com/multiformats/go-multihash" + "github.com/stretchr/testify/require" + + "github.com/fil-forge/ucantone/ucan/delegation/policy" + "github.com/fil-forge/ucantone/ucan/delegation/policy/policytest/fields" +) + +// These tests author policies against generated field descriptors (see +// policytest/fields). The descriptor pins the comparison value type and the +// selector path, so the builders below could not be called with a wrong-typed +// value or a mistyped path — that would be a compile error. + +func mustDigest(t *testing.T) multihash.Multihash { + t.Helper() + mh, err := multihash.Sum([]byte("hello"), multihash.SHA2_256, -1) + require.NoError(t, err) + return mh +} + +// decodedArgs is what the selector sees after invocation args round-trip +// through CBOR: bytes are plain []byte, ints are int64, keys are strings. +func decodedArgs(digest multihash.Multihash, size int64) map[string]any { + return map[string]any{ + "blob": map[string]any{ + "digest": []byte(digest), + "size": size, + }, + } +} + +func TestField_Comparisons(t *testing.T) { + digest := mustDigest(t) + + pol, err := policy.Build( + policy.Eq(fields.RetrieveArgs.Blob.Digest, digest), // value pinned to multihash.Multihash + policy.Gte(fields.RetrieveArgs.Blob.Size, uint64(0)), + ) + require.NoError(t, err) + + stmts := pol.Statements() + require.Len(t, stmts, 2) + require.Equal(t, policy.OpEqual, stmts[0].Operator()) + require.Equal(t, ".blob.digest", stmts[0].Selector()) + require.Equal(t, policy.OpGreaterThanOrEqual, stmts[1].Operator()) + require.Equal(t, ".blob.size", stmts[1].Selector()) + + // The literal is canonicalized to plain []byte, so matching against the + // decoded args succeeds. + require.NoError(t, policy.Match(pol, decodedArgs(digest, 10))) + + // A different digest must not match. + other := mustDigest(t) + other[len(other)-1] ^= 0xff + require.Error(t, policy.Match(pol, decodedArgs(other, 10))) +} + +func TestField_Connectives(t *testing.T) { + pol, err := policy.Build( + policy.AnyOf( + policy.Eq(fields.Manifest.Name, "alpha"), + policy.Eq(fields.Manifest.Name, "beta"), + ), + policy.Negate(policy.Eq(fields.Manifest.Name, "forbidden")), + ) + require.NoError(t, err) + + stmts := pol.Statements() + require.Len(t, stmts, 2) + require.Equal(t, policy.OpOr, stmts[0].Operator()) + require.Equal(t, policy.OpNot, stmts[1].Operator()) + + mk := func(name string) map[string]any { + return map[string]any{"name": name, "shards": []any{}} + } + require.NoError(t, policy.Match(pol, mk("alpha"))) + require.NoError(t, policy.Match(pol, mk("beta"))) + require.Error(t, policy.Match(pol, mk("gamma"))) // matches neither branch of the or + require.Error(t, policy.Match(pol, mk("forbidden"))) // hits the negate +} + +func TestField_StringOrdering(t *testing.T) { + // String ordering is lexicographic (matcher extension). The descriptor + // makes Gt(Manifest.Name, ...) legal because Name is a string field. + pol, err := policy.Build(policy.Gt(fields.Manifest.Name, "m")) + require.NoError(t, err) + + mk := func(name string) map[string]any { return map[string]any{"name": name, "shards": []any{}} } + require.NoError(t, policy.Match(pol, mk("ن"))) // "z..."-ish > "m" + require.NoError(t, policy.Match(pol, mk("zeta"))) + require.Error(t, policy.Match(pol, mk("apple"))) // < "m" +} + +func withShards(codecs ...int64) map[string]any { + items := make([]any, len(codecs)) + for i, c := range codecs { + items[i] = map[string]any{"codec": c} + } + return map[string]any{"name": "m", "shards": items} +} + +func TestField_QuantifierEach(t *testing.T) { + // Every shard must use codec 0x55 (raw). The closure receives the element + // descriptor (element-relative selector paths). + pol, err := policy.Build( + policy.Each(fields.Manifest.Shards, func(s fields.ShardFields) []policy.StatementBuilderFunc { + return []policy.StatementBuilderFunc{policy.Eq(s.Codec, uint64(0x55))} + }), + ) + require.NoError(t, err) + + stmts := pol.Statements() + require.Len(t, stmts, 1) + require.Equal(t, policy.OpAll, stmts[0].Operator()) + require.Equal(t, ".shards", stmts[0].Selector()) + + require.NoError(t, policy.Match(pol, withShards(0x55, 0x55))) + require.Error(t, policy.Match(pol, withShards(0x55, 0x71))) // one bad shard fails "all" +} + +func TestField_QuantifierSome(t *testing.T) { + pol, err := policy.Build( + policy.Some(fields.Manifest.Shards, func(s fields.ShardFields) []policy.StatementBuilderFunc { + return []policy.StatementBuilderFunc{policy.Gt(s.Codec, uint64(0x70))} + }), + ) + require.NoError(t, err) + require.Equal(t, policy.OpAny, pol.Statements()[0].Operator()) + + require.NoError(t, policy.Match(pol, withShards(0x55, 0x71))) // one shard > 0x70 satisfies "any" + require.Error(t, policy.Match(pol, withShards(0x55, 0x55))) +} + +// A *big.Int that overflows int64 must match by value, not pointer identity. +func TestField_BigInt(t *testing.T) { + want := new(big.Int).Lsh(big.NewInt(1), 100) // 2^100, overflows int64 + + pol, err := policy.Build(policy.Eq(fields.SignArgs.DataSet, want)) + require.NoError(t, err) + require.Equal(t, ".dataSet", pol.Statements()[0].Selector()) + + got := new(big.Int).Lsh(big.NewInt(1), 100) + require.NotSame(t, want, got) + require.NoError(t, policy.Match(pol, map[string]any{"dataSet": got})) + require.Error(t, policy.Match(pol, map[string]any{"dataSet": big.NewInt(2)})) +} + +func TestField_BigIntOrdering(t *testing.T) { + threshold := new(big.Int).Lsh(big.NewInt(1), 64) // 2^64 + + pol, err := policy.Build(policy.Gt(fields.SignArgs.DataSet, threshold)) + require.NoError(t, err) + + above := new(big.Int).Lsh(big.NewInt(1), 65) + require.NoError(t, policy.Match(pol, map[string]any{"dataSet": above})) + require.Error(t, policy.Match(pol, map[string]any{"dataSet": big.NewInt(5)})) +} + +func TestField_QuantifierEachMap(t *testing.T) { + // Every value in the meta map must match the glob. + pol, err := policy.Build( + policy.EachMap(fields.Labels.Meta, func(v policy.Selector[string]) []policy.StatementBuilderFunc { + return []policy.StatementBuilderFunc{policy.Glob(v, "v-*")} + }), + ) + require.NoError(t, err) + require.Equal(t, policy.OpAll, pol.Statements()[0].Operator()) + require.Equal(t, ".meta", pol.Statements()[0].Selector()) + + require.NoError(t, policy.Match(pol, map[string]any{"meta": map[string]any{"a": "v-1", "b": "v-2"}})) + require.Error(t, policy.Match(pol, map[string]any{"meta": map[string]any{"a": "v-1", "b": "bad"}})) +} + +// A glob and a multi-constraint quantifier element (implicit AND) together. +func TestField_GlobAndElementGroup(t *testing.T) { + pol, err := policy.Build( + policy.Glob(fields.Manifest.Name, "blob-*"), + policy.Each(fields.Manifest.Shards, func(s fields.ShardFields) []policy.StatementBuilderFunc { + return []policy.StatementBuilderFunc{ + policy.Gte(s.Codec, uint64(0x50)), + policy.Lte(s.Codec, uint64(0x60)), + } + }), + ) + require.NoError(t, err) + require.Len(t, pol.Statements(), 2) + + val := map[string]any{ + "name": "blob-123", + "shards": []any{map[string]any{"codec": int64(0x55)}}, + } + require.NoError(t, policy.Match(pol, val)) +} diff --git a/ucan/delegation/policy/fieldgen/fieldgen.go b/ucan/delegation/policy/fieldgen/fieldgen.go new file mode 100644 index 0000000..0051aef --- /dev/null +++ b/ucan/delegation/policy/fieldgen/fieldgen.go @@ -0,0 +1,350 @@ +// Package fieldgen generates typed policy field descriptors from Go argument +// structs. For each type it emits a *Fields descriptor whose leaves are +// policy.Selector[T] values carrying a jq selector path, so policies can be +// authored against real fields with compile-time type checking. It is a +// sidecar to cbor-gen: point it at the same argument types and it derives the +// selector paths from their cborgen tags. +// +// Descriptor *types* are path-free (a leaf is policy.Selector[T]); the +// generated *values* bake in the paths. The same descriptor type therefore +// serves both nested access (absolute path, e.g. ".blob.digest") and slice/map +// elements (element-relative path, e.g. ".digest"), the latter handed to the +// closure of policy.Each / policy.Some. +package fieldgen + +import ( + "bytes" + "fmt" + "go/format" + "os" + "reflect" + "sort" + "strings" +) + +const policyPkg = "github.com/fil-forge/ucantone/ucan/delegation/policy" + +// Options configures descriptor generation. +type Options struct { + // Opaque lists types to treat as leaf selectors even though they are + // structs with exported fields — i.e. do not recurse into them. Use this + // for types whose on-the-wire shape is produced by a hand-written CBOR + // marshaler that does not match their Go struct (e.g. promise.AwaitOK, + // which marshals as {"await/ok": cid} regardless of its Go fields). Each + // entry is a zero value of the type (e.g. promise.AwaitOK{}). + Opaque []any +} + +// WriteFieldDescriptors generates policy field descriptors for the given types +// and writes them, gofmt'd, to outPath in package pkgName. Each argument should +// be a zero value of a struct type (e.g. types.RetrieveArguments{}). +func WriteFieldDescriptors(outPath, pkgName string, types ...any) error { + return WriteFieldDescriptorsWithOptions(outPath, pkgName, Options{}, types...) +} + +// WriteFieldDescriptorsWithOptions is [WriteFieldDescriptors] with extra +// configuration; see [Options]. +func WriteFieldDescriptorsWithOptions(outPath, pkgName string, opts Options, types ...any) error { + g := &gen{ + pkgName: pkgName, + imports: map[string]string{policyPkg: "policy"}, + descNames: map[reflect.Type]string{}, + opaque: map[reflect.Type]bool{}, + } + for _, o := range opts.Opaque { + if ot := reflect.TypeOf(o); ot != nil { + g.opaque[deref(ot)] = true + } + } + for _, t := range types { + rt := reflect.TypeOf(t) + if rt == nil || rt.Kind() != reflect.Struct { + return fmt.Errorf("WriteFieldDescriptors: %T is not a struct", t) + } + if g.selfPkg == "" { + g.selfPkg = rt.PkgPath() + } + g.collect(rt) + g.roots = append(g.roots, rt) + } + src, err := g.render() + if err != nil { + return err + } + formatted, err := format.Source(src) + if err != nil { + return fmt.Errorf("gofmt generated source: %w\n%s", err, src) + } + return os.WriteFile(outPath, formatted, 0o644) +} + +type gen struct { + pkgName string + selfPkg string // import path of the target package (rendered unqualified) + imports map[string]string // pkgpath -> alias + descNames map[reflect.Type]string + opaque map[reflect.Type]bool // types to treat as leaves (no recursion) + order []reflect.Type // struct types needing a descriptor, in discovery order + roots []reflect.Type +} + +// collect walks t, assigning a descriptor name to every navigable struct type +// reachable through fields, slice elements, and map values. +func (g *gen) collect(t reflect.Type) { + if _, done := g.descNames[t]; done { + return + } + if t.Name() == "" { + return // anonymous struct, unsupported + } + g.descNames[t] = t.Name() + "Fields" + g.order = append(g.order, t) + for i := range t.NumField() { + sf := t.Field(i) + if !sf.IsExported() || ipldKey(sf) == "-" { + continue + } + if et := g.navigableElem(sf.Type); et != nil { + g.collect(et) + } + } +} + +func (g *gen) render() ([]byte, error) { + var body bytes.Buffer + + // Descriptor type declarations. + for _, t := range g.order { + fmt.Fprintf(&body, "// %s is the policy field descriptor type for %s.\n", g.descNames[t], t.Name()) + fmt.Fprintf(&body, "type %s struct {\n", g.descNames[t]) + for i := range t.NumField() { + sf := t.Field(i) + if !sf.IsExported() || ipldKey(sf) == "-" { + continue + } + fmt.Fprintf(&body, "\t%s %s\n", sf.Name, g.descFieldType(sf.Type)) + } + body.WriteString("}\n\n") + } + + // Root descriptor values. The entry-point var is named after the source + // type; its descriptor type is Fields (used as the Each/Some closure + // parameter type), so the two never collide. + for _, t := range g.roots { + fmt.Fprintf(&body, "// %s is the policy field descriptor for %s.\n", t.Name(), t.Name()) + fmt.Fprintf(&body, "var %s = %s\n\n", t.Name(), g.descValue(t, "")) + } + + var out bytes.Buffer + fmt.Fprintf(&out, "// Code generated by fieldgen; DO NOT EDIT.\n\npackage %s\n\n", g.pkgName) + if len(g.imports) > 0 { + out.WriteString("import (\n") + paths := make([]string, 0, len(g.imports)) + for p := range g.imports { + paths = append(paths, p) + } + sort.Strings(paths) + for _, p := range paths { + fmt.Fprintf(&out, "\t%s %q\n", g.imports[p], p) + } + out.WriteString(")\n\n") + } + out.Write(body.Bytes()) + return out.Bytes(), nil +} + +// descFieldType renders the descriptor struct field type for a source field. +func (g *gen) descFieldType(ft reflect.Type) string { + pol := g.pkgAlias(policyPkg) + switch { + case g.isLeaf(ft): + return fmt.Sprintf("%s.Selector[%s]", pol, g.qual(ft)) + case g.navStruct(ft) != nil: // (possibly pointer to) navigable struct + return g.descNames[g.navStruct(ft)] + case ft.Kind() == reflect.Slice: + return fmt.Sprintf("%s.SliceSelector[%s]", pol, g.elemDescType(ft.Elem())) + case ft.Kind() == reflect.Map: + return fmt.Sprintf("%s.MapSelector[%s]", pol, g.elemDescType(ft.Elem())) + default: + // Fallback: treat as an opaque leaf. + return fmt.Sprintf("%s.Selector[%s]", pol, g.qual(ft)) + } +} + +// elemDescType is the descriptor type of a slice/map element. +func (g *gen) elemDescType(et reflect.Type) string { + if ns := g.navStruct(et); ns != nil { + return g.descNames[ns] + } + return fmt.Sprintf("%s.Selector[%s]", g.pkgAlias(policyPkg), g.qual(et)) +} + +// descValue renders the composite literal initializing the descriptor for t, +// with selector paths prefixed by prefix (root: ""). +func (g *gen) descValue(t reflect.Type, prefix string) string { + var b strings.Builder + fmt.Fprintf(&b, "%s{\n", g.descNames[t]) + for i := range t.NumField() { + sf := t.Field(i) + if !sf.IsExported() || ipldKey(sf) == "-" { + continue + } + path := prefix + "." + ipldKey(sf) + fmt.Fprintf(&b, "\t%s: %s,\n", sf.Name, g.fieldValue(sf.Type, path)) + } + b.WriteString("}") + return b.String() +} + +func (g *gen) fieldValue(ft reflect.Type, path string) string { + pol := g.pkgAlias(policyPkg) + switch { + case g.isLeaf(ft): + return fmt.Sprintf("%s.NewSelector[%s](%q)", pol, g.qual(ft), path) + case g.navStruct(ft) != nil: // a (pointer to) navigable struct occupies the same path + return g.descValue(g.navStruct(ft), path) + case ft.Kind() == reflect.Slice: + return fmt.Sprintf("%s.NewSliceSelector[%s](%q, %s)", pol, g.elemDescType(ft.Elem()), path, g.elemValue(ft.Elem())) + case ft.Kind() == reflect.Map: + return fmt.Sprintf("%s.NewMapSelector[%s](%q, %s)", pol, g.elemDescType(ft.Elem()), path, g.elemValue(ft.Elem())) + default: + return fmt.Sprintf("%s.NewSelector[%s](%q)", pol, g.qual(ft), path) + } +} + +// elemValue renders the element descriptor of a slice/map: a struct descriptor +// with element-relative paths, or an identity selector for scalar elements. +func (g *gen) elemValue(et reflect.Type) string { + if ns := g.navStruct(et); ns != nil { + return g.descValue(ns, "") + } + return fmt.Sprintf("%s.NewSelector[%s](%q)", g.pkgAlias(policyPkg), g.qual(et), ".") +} + +// qual renders a Go type as source, recording any package import, e.g. +// "multihash.Multihash", "*big.Int", "[]byte", "uint64". Named types (including +// named slices like multihash.Multihash) are kept whole; only unnamed composite +// types are decomposed. +func (g *gen) qual(t reflect.Type) string { + if t.Name() != "" && t.PkgPath() != "" { + if t.PkgPath() == g.selfPkg { + return t.Name() + } + // reflect's String() qualifies with the real package name (e.g. + // "multihash.Multihash" for package multihash at .../go-multihash), + // which the path's last segment does not always match. + s := t.String() + pkgName, _, _ := strings.Cut(s, ".") + g.imports[t.PkgPath()] = pkgName + return s + } + switch t.Kind() { + case reflect.Pointer: + return "*" + g.qual(t.Elem()) + case reflect.Slice: + return "[]" + g.qual(t.Elem()) + case reflect.Array: + return fmt.Sprintf("[%d]%s", t.Len(), g.qual(t.Elem())) + case reflect.Map: + return fmt.Sprintf("map[%s]%s", g.qual(t.Key()), g.qual(t.Elem())) + } + return t.String() // builtin (uint64, string, ...) +} + +// pkgAlias returns the import alias for an already-registered package path +// (the policy package, registered at construction). +func (g *gen) pkgAlias(path string) string { + return g.imports[path] +} + +// isLeaf reports whether t is treated as a scalar policy value rather than a +// navigable map. Scalars and byte slices are leaves; structs with no exported +// fields (cid.Cid, did.DID, big.Int) are opaque leaves; structs with exported +// fields are navigable. +func isLeaf(t reflect.Type) bool { + switch t.Kind() { + case reflect.Bool, reflect.String, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, + reflect.Float32, reflect.Float64: + return true + case reflect.Slice, reflect.Array: + return t.Elem().Kind() == reflect.Uint8 // []byte / multihash.Multihash + case reflect.Pointer: + return isLeaf(t.Elem()) + case reflect.Struct: + return !hasExportedField(t) + } + return false +} + +// deref strips a single pointer, e.g. *Blob -> Blob. +func deref(t reflect.Type) reflect.Type { + if t.Kind() == reflect.Pointer { + return t.Elem() + } + return t +} + +// isLeaf reports whether t is a leaf for this generation, consulting the +// opaque set in addition to the structural rule (see the package [isLeaf]). +func (g *gen) isLeaf(t reflect.Type) bool { + return g.opaque[deref(t)] || isLeaf(t) +} + +// navStruct returns the navigable struct type t denotes, dereferencing a +// single pointer (e.g. *Blob -> Blob), or nil if t is a leaf (structurally or +// because it was declared opaque) or not a struct. +func (g *gen) navStruct(t reflect.Type) reflect.Type { + if g.opaque[deref(t)] { + return nil + } + return navStruct(t) +} + +// navigableElem returns the navigable struct type reachable through field type +// ft (the field itself, or a slice/map element), or nil if ft bottoms out in a +// leaf. Used to discover descriptor types to generate. +func (g *gen) navigableElem(ft reflect.Type) reflect.Type { + if ns := g.navStruct(ft); ns != nil { + return ns + } + switch ft.Kind() { + case reflect.Slice, reflect.Array, reflect.Map: + return g.navStruct(ft.Elem()) + } + return nil +} + +// navStruct returns the navigable struct type t denotes, dereferencing a +// single pointer (e.g. *Blob -> Blob), or nil if t is a leaf or not a struct. +// A struct with no exported fields (cid.Cid, did.DID, big.Int) is a leaf. +func navStruct(t reflect.Type) reflect.Type { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() == reflect.Struct && !isLeaf(t) { + return t + } + return nil +} + +func hasExportedField(t reflect.Type) bool { + for i := range t.NumField() { + if t.Field(i).IsExported() { + return true + } + } + return false +} + +// ipldKey returns the IPLD map key for a struct field: the cborgen tag name if +// present, otherwise the Go field name (cbor-gen's default). +func ipldKey(sf reflect.StructField) string { + if tag, ok := sf.Tag.Lookup("cborgen"); ok { + if name, _, _ := strings.Cut(tag, ","); name != "" { + return name + } + } + return sf.Name +} diff --git a/ucan/delegation/policy/fieldgen/fieldgen_test.go b/ucan/delegation/policy/fieldgen/fieldgen_test.go new file mode 100644 index 0000000..753bcf3 --- /dev/null +++ b/ucan/delegation/policy/fieldgen/fieldgen_test.go @@ -0,0 +1,111 @@ +package fieldgen + +import ( + "math/big" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/ipfs/go-cid" + "github.com/multiformats/go-multihash" + "github.com/stretchr/testify/require" + + "github.com/fil-forge/ucantone/ucan/delegation/policy/policytest" +) + +func TestIsLeaf(t *testing.T) { + leaf := func(v any) bool { return isLeaf(reflect.TypeOf(v)) } + + require.True(t, leaf("")) // string + require.True(t, leaf(uint64(0))) // integer + require.True(t, leaf([]byte(nil))) // bytes + require.True(t, leaf(multihash.Multihash(nil))) // named byte slice + require.True(t, leaf(cid.Cid{})) // struct, no exported fields + require.True(t, leaf((*big.Int)(nil))) // pointer to leaf + + require.False(t, leaf(policytest.Blob{})) // struct with exported fields + require.False(t, leaf(policytest.Manifest{})) // navigable +} + +func TestNavStruct(t *testing.T) { + blob := reflect.TypeOf(policytest.Blob{}) + + require.Equal(t, blob, navStruct(reflect.TypeOf(policytest.Blob{}))) // struct + require.Equal(t, blob, navStruct(reflect.TypeOf(&policytest.Blob{}))) // pointer is dereferenced + + require.Nil(t, navStruct(reflect.TypeOf([]policytest.Shard(nil)))) // slice, not a struct + require.Nil(t, navStruct(reflect.TypeOf((*big.Int)(nil)))) // leaf + require.Nil(t, navStruct(reflect.TypeOf(""))) // scalar +} + +func TestIPLDKey(t *testing.T) { + env := reflect.TypeOf(policytest.Envelope{}) + fallback, _ := env.FieldByName("Fallback") + require.Equal(t, "fallback", ipldKey(fallback)) // ",omitempty" stripped + + blob := reflect.TypeOf(policytest.Blob{}) + digest, _ := blob.FieldByName("Digest") + require.Equal(t, "digest", ipldKey(digest)) + + // No cborgen tag falls back to the Go field name (cbor-gen's default). + type noTag struct{ Foo string } + foo, _ := reflect.TypeOf(noTag{}).FieldByName("Foo") + require.Equal(t, "Foo", ipldKey(foo)) +} + +// TestWriteFieldDescriptors generates descriptors for the policytest fixtures +// and asserts the structural invariants of the output. WriteFieldDescriptors +// runs gofmt internally, so a nil error already means the source is valid Go; +// these checks pin the paths, generic type arguments, and naming scheme. +func TestWriteFieldDescriptors(t *testing.T) { + out := filepath.Join(t.TempDir(), "policy_fields_gen.go") + err := WriteFieldDescriptors(out, "fields", + policytest.Blob{}, + policytest.RetrieveArgs{}, + policytest.Shard{}, + policytest.Manifest{}, + policytest.SignArgs{}, + policytest.Envelope{}, + policytest.Tagged{}, + policytest.Bundle{}, + ) + require.NoError(t, err) + + b, err := os.ReadFile(out) + require.NoError(t, err) + s := string(b) + + wantContains := []string{ + // named leaf types are preserved (not decomposed to []uint8) + "policy.Selector[multihash.Multihash]", + "policy.Selector[*big.Int]", + // nested struct field -> absolute selector path + `policy.NewSelector[multihash.Multihash](".blob.digest")`, + // slice of struct -> SliceSelector of the element descriptor type + "policy.SliceSelector[ShardFields]", + `policy.NewSliceSelector[ShardFields](".shards"`, + // slice of scalar -> SliceSelector of an identity Selector + "policy.SliceSelector[policy.Selector[string]]", + `policy.NewSelector[string](".")`, + // pointer-to-struct field dereferenced, occupying the same path + `policy.NewSelector[multihash.Multihash](".fallback.digest")`, + // slice element struct with a nested struct: paths are element-relative + `policy.NewSliceSelector[EnvelopeFields](".items"`, + `policy.NewSelector[multihash.Multihash](".primary.digest")`, + // entry var named after the type; descriptor type is Fields + "type BlobFields struct", + "var Blob = BlobFields{", + // explicit import alias matches the real package name + `multihash "github.com/multiformats/go-multihash"`, + } + for _, w := range wantContains { + require.Contains(t, s, w, "generated output should contain %q", w) + } + + // Quantifier element paths are element-relative, not absolute. + require.NotContains(t, s, ".shards.codec") + require.NotContains(t, s, ".items.primary") // nested-in-element stays element-relative + // The target package is never self-imported. + require.NotContains(t, s, `"github.com/fil-forge/ucantone/ucan/delegation/policy/policytest"`) +} diff --git a/ucan/delegation/policy/match.go b/ucan/delegation/policy/match.go index 3c5e512..62066c8 100644 --- a/ucan/delegation/policy/match.go +++ b/ucan/delegation/policy/match.go @@ -1,10 +1,11 @@ package policy import ( - "cmp" "errors" "fmt" + "math/big" "reflect" + "strings" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/delegation/policy/selector" @@ -22,14 +23,12 @@ func Match(policy ucan.Policy, value any) error { return nil } -// normalize converts values to their normalized forms for comparison. -// Currently, it converts int to int64. It may do more in the future to cover -// other types. +// normalize converts values to their canonical IPLD form for comparison, so +// that a statement literal (which may be a named Go type like +// multihash.Multihash) compares equal to the plain []byte/int64/etc. the +// selector decodes out of invocation arguments. See [canonicalize]. func normalize(value any) any { - if intVal, ok := value.(int); ok { - return int64(intVal) - } - return value + return normalizeValue(value) } func MatchStatement(statement ucan.Statement, value any) error { @@ -55,12 +54,12 @@ func MatchStatement(statement ucan.Statement, value any) error { switch statement.Operator() { case OpEqual: - if !reflect.DeepEqual(statementValue, selectedValue) { + if !valuesEqual(statementValue, selectedValue) { return NewMatchError(statement, fmt.Errorf(`matching "%s": "%v" does not equal "%v"`, s.Selector(), selectedValue, statementValue)) } return nil case OpNotEqual: - if reflect.DeepEqual(statementValue, selectedValue) { + if valuesEqual(statementValue, selectedValue) { return NewMatchError(statement, fmt.Errorf(`matching "%s": "%v" equals "%v"`, s.Selector(), selectedValue, statementValue)) } return nil @@ -200,15 +199,79 @@ func MatchStatement(statement ucan.Statement, value any) error { } func isOrdered(a any, b any, satisfies func(order int) bool) bool { - if aint64, ok := a.(int64); ok { - if bint64, ok := b.(int64); ok { - return satisfies(cmp.Compare(aint64, bint64)) + // Integers — int64 and CBOR bignums (*big.Int) — share one ordering: both + // sides are promoted to *big.Int and compared by value, so an int64 and a + // numerically-equal bignum order consistently. See [canonicalize]. + if ab, ok := asBigInt(a); ok { + if bb, ok := asBigInt(b); ok { + return satisfies(ab.Cmp(bb)) + } + } + // Strings order lexicographically. Both sides come through canonicalize as + // plain strings (named string types are flattened), so a string field and + // a string literal compare directly. + if as, ok := a.(string); ok { + if bs, ok := b.(string); ok { + return satisfies(strings.Compare(as, bs)) } } - // TODO: support float + // Floats are intentionally unsupported: neither the CBOR nor the DAG-JSON + // codec represents them, so a float literal could not round-trip. The + // typed builders exclude float field types via the policy.Ordered + // constraint, so this is unreachable from generated descriptors. return false } +// asBigInt promotes the integer kinds canonicalize can produce (int64, or a +// *big.Int for magnitudes that overflow int64) to a *big.Int for comparison. +func asBigInt(v any) (*big.Int, bool) { + switch x := v.(type) { + case *big.Int: + return x, x != nil + case int64: + return big.NewInt(x), true + } + return nil, false +} + +// valuesEqual reports IPLD value equality for the == / != operators. It differs +// from reflect.DeepEqual in two ways: integers compare by numeric value across +// the int64/bignum split (so DeepEqual's type-identity sensitivity does not +// make a bignum silently never match), and lists/maps recurse through the same +// rule. All other kinds fall back to DeepEqual. +func valuesEqual(a, b any) bool { + if ai, ok := asBigInt(a); ok { + bi, ok := asBigInt(b) + return ok && ai.Cmp(bi) == 0 + } + switch av := a.(type) { + case []any: + bv, ok := b.([]any) + if !ok || len(av) != len(bv) { + return false + } + for i := range av { + if !valuesEqual(av[i], bv[i]) { + return false + } + } + return true + case map[string]any: + bv, ok := b.(map[string]any) + if !ok || len(av) != len(bv) { + return false + } + for k, x := range av { + y, ok := bv[k] + if !ok || !valuesEqual(x, y) { + return false + } + } + return true + } + return reflect.DeepEqual(a, b) +} + func gt(order int) bool { return order == 1 } func gte(order int) bool { return order == 0 || order == 1 } func lt(order int) bool { return order == -1 } diff --git a/ucan/delegation/policy/match_test.go b/ucan/delegation/policy/match_test.go index cf1496a..b523b40 100644 --- a/ucan/delegation/policy/match_test.go +++ b/ucan/delegation/policy/match_test.go @@ -356,6 +356,49 @@ func TestMatch(t *testing.T) { value: "138", match: false, }, + // String ordering is lexicographic (matcher extension; see isOrdered). + { + name: "comparison greater than string match", + policy: policy.GreaterThan(".", "m"), + value: "zebra", + match: true, + }, + { + name: "comparison greater than string no match", + policy: policy.GreaterThan(".", "m"), + value: "apple", + match: false, + }, + { + name: "comparison greater than string no match equal", + policy: policy.GreaterThan(".", "m"), + value: "m", + match: false, + }, + { + name: "comparison greater than or equal string match equal", + policy: policy.GreaterThanOrEqual(".", "m"), + value: "m", + match: true, + }, + { + name: "comparison less than string match", + policy: policy.LessThan(".", "m"), + value: "apple", + match: true, + }, + { + name: "comparison less than or equal string match equal", + policy: policy.LessThanOrEqual(".", "m"), + value: "m", + match: true, + }, + { + name: "comparison greater than string no match non-string", + policy: policy.GreaterThan(".", "m"), + value: 138, + match: false, + }, { name: "negation match", policy: policy.Not(policy.Equal(".", true)), diff --git a/ucan/delegation/policy/policytest/fields/policy_fields_gen.go b/ucan/delegation/policy/policytest/fields/policy_fields_gen.go new file mode 100644 index 0000000..6ee7934 --- /dev/null +++ b/ucan/delegation/policy/policytest/fields/policy_fields_gen.go @@ -0,0 +1,125 @@ +// Code generated by fieldgen; DO NOT EDIT. + +package fields + +import ( + policy "github.com/fil-forge/ucantone/ucan/delegation/policy" + multihash "github.com/multiformats/go-multihash" + big "math/big" +) + +// BlobFields is the policy field descriptor type for Blob. +type BlobFields struct { + Digest policy.Selector[multihash.Multihash] + Size policy.Selector[uint64] +} + +// RetrieveArgsFields is the policy field descriptor type for RetrieveArgs. +type RetrieveArgsFields struct { + Blob BlobFields +} + +// ShardFields is the policy field descriptor type for Shard. +type ShardFields struct { + Codec policy.Selector[uint64] +} + +// ManifestFields is the policy field descriptor type for Manifest. +type ManifestFields struct { + Name policy.Selector[string] + Shards policy.SliceSelector[ShardFields] +} + +// SignArgsFields is the policy field descriptor type for SignArgs. +type SignArgsFields struct { + DataSet policy.Selector[*big.Int] +} + +// EnvelopeFields is the policy field descriptor type for Envelope. +type EnvelopeFields struct { + Primary BlobFields + Fallback BlobFields +} + +// TaggedFields is the policy field descriptor type for Tagged. +type TaggedFields struct { + Tags policy.SliceSelector[policy.Selector[string]] +} + +// LabelsFields is the policy field descriptor type for Labels. +type LabelsFields struct { + Meta policy.MapSelector[policy.Selector[string]] +} + +// BundleFields is the policy field descriptor type for Bundle. +type BundleFields struct { + Items policy.SliceSelector[EnvelopeFields] +} + +// Blob is the policy field descriptor for Blob. +var Blob = BlobFields{ + Digest: policy.NewSelector[multihash.Multihash](".digest"), + Size: policy.NewSelector[uint64](".size"), +} + +// RetrieveArgs is the policy field descriptor for RetrieveArgs. +var RetrieveArgs = RetrieveArgsFields{ + Blob: BlobFields{ + Digest: policy.NewSelector[multihash.Multihash](".blob.digest"), + Size: policy.NewSelector[uint64](".blob.size"), + }, +} + +// Shard is the policy field descriptor for Shard. +var Shard = ShardFields{ + Codec: policy.NewSelector[uint64](".codec"), +} + +// Manifest is the policy field descriptor for Manifest. +var Manifest = ManifestFields{ + Name: policy.NewSelector[string](".name"), + Shards: policy.NewSliceSelector[ShardFields](".shards", ShardFields{ + Codec: policy.NewSelector[uint64](".codec"), + }), +} + +// SignArgs is the policy field descriptor for SignArgs. +var SignArgs = SignArgsFields{ + DataSet: policy.NewSelector[*big.Int](".dataSet"), +} + +// Envelope is the policy field descriptor for Envelope. +var Envelope = EnvelopeFields{ + Primary: BlobFields{ + Digest: policy.NewSelector[multihash.Multihash](".primary.digest"), + Size: policy.NewSelector[uint64](".primary.size"), + }, + Fallback: BlobFields{ + Digest: policy.NewSelector[multihash.Multihash](".fallback.digest"), + Size: policy.NewSelector[uint64](".fallback.size"), + }, +} + +// Tagged is the policy field descriptor for Tagged. +var Tagged = TaggedFields{ + Tags: policy.NewSliceSelector[policy.Selector[string]](".tags", policy.NewSelector[string](".")), +} + +// Labels is the policy field descriptor for Labels. +var Labels = LabelsFields{ + Meta: policy.NewMapSelector[policy.Selector[string]](".meta", policy.NewSelector[string](".")), +} + +// Bundle is the policy field descriptor for Bundle. +var Bundle = BundleFields{ + Items: policy.NewSliceSelector[EnvelopeFields](".items", EnvelopeFields{ + Primary: BlobFields{ + Digest: policy.NewSelector[multihash.Multihash](".primary.digest"), + Size: policy.NewSelector[uint64](".primary.size"), + }, + Fallback: BlobFields{ + Digest: policy.NewSelector[multihash.Multihash](".fallback.digest"), + Size: policy.NewSelector[uint64](".fallback.size"), + }, + }), +} diff --git a/ucan/delegation/policy/policytest/gen/main.go b/ucan/delegation/policy/policytest/gen/main.go new file mode 100644 index 0000000..af1c73a --- /dev/null +++ b/ucan/delegation/policy/policytest/gen/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "github.com/fil-forge/ucantone/ucan/delegation/policy/fieldgen" + "github.com/fil-forge/ucantone/ucan/delegation/policy/policytest" +) + +func main() { + if err := fieldgen.WriteFieldDescriptors("../fields/policy_fields_gen.go", "fields", + policytest.Blob{}, + policytest.RetrieveArgs{}, + policytest.Shard{}, + policytest.Manifest{}, + policytest.SignArgs{}, + policytest.Envelope{}, + policytest.Tagged{}, + policytest.Labels{}, + policytest.Bundle{}, + ); err != nil { + panic(err) + } +} diff --git a/ucan/delegation/policy/policytest/types.go b/ucan/delegation/policy/policytest/types.go new file mode 100644 index 0000000..720416f --- /dev/null +++ b/ucan/delegation/policy/policytest/types.go @@ -0,0 +1,66 @@ +// Package policytest holds argument-struct fixtures (and their generated policy +// field descriptors) used by the policy package's external tests. The types +// mirror real libforge command arguments and the cborgen tags that drive the +// on-the-wire map keys, so selector-path derivation is exercised exactly as it +// would be in production. +package policytest + +import ( + "math/big" + + "github.com/multiformats/go-multihash" +) + +// Blob mirrors github.com/fil-forge/libforge/commands/blob.Blob. +type Blob struct { + Digest multihash.Multihash `cborgen:"digest"` + Size uint64 `cborgen:"size"` +} + +// RetrieveArgs mirrors blob.RetrieveArguments (a nested struct field). +type RetrieveArgs struct { + Blob Blob `cborgen:"blob"` +} + +// Shard is a slice element used to exercise the Each/Some quantifiers. +type Shard struct { + Codec uint64 `cborgen:"codec"` +} + +// Manifest exercises a scalar field plus a slice-of-struct field. +type Manifest struct { + Name string `cborgen:"name"` + Shards []Shard `cborgen:"shards"` +} + +// SignArgs mirrors libforge pdp/sign args, whose ID fields are *big.Int +// (CBOR bignums) — a struct-with-no-exported-fields leaf. +type SignArgs struct { + DataSet *big.Int `cborgen:"dataSet"` +} + +// Envelope exercises an optional (pointer) navigable struct field: *Blob is +// dereferenced to the Blob descriptor and occupies the same selector position. +type Envelope struct { + Primary Blob `cborgen:"primary"` + Fallback *Blob `cborgen:"fallback,omitempty"` +} + +// Tagged exercises a slice of scalars, whose element descriptor is an identity +// selector (".") quantified over by Each/Some. +type Tagged struct { + Tags []string `cborgen:"tags"` +} + +// Labels exercises a string-keyed map field; EachMap/SomeMap quantify over the +// map's values. +type Labels struct { + Meta map[string]string `cborgen:"meta"` +} + +// Bundle exercises a slice whose element struct itself contains nested structs, +// so the element descriptor's selector paths must be element-relative +// (".primary.digest"), not rooted at the slice (".items.primary.digest"). +type Bundle struct { + Items []Envelope `cborgen:"items"` +} diff --git a/ucan/delegation/policy/selector.go b/ucan/delegation/policy/selector.go new file mode 100644 index 0000000..ea09c98 --- /dev/null +++ b/ucan/delegation/policy/selector.go @@ -0,0 +1,82 @@ +package policy + +// Typed, code-generated policy authoring. +// +// A [Selector] is a jq selector path (".blob.digest") paired, at the type +// level, with the Go type of the value at that path. Field-descriptor values +// holding selectors are produced by the descriptor generator (see the +// fieldgen package) from a command's argument struct, so: +// +// pol, err := policy.Build( +// policy.Eq(RetrieveArgsFields.Blob.Digest, digest), // value pinned to multihash.Multihash +// policy.Gte(RetrieveArgsFields.Blob.Size, uint64(0)), +// policy.Each(ManifestFields.Shards, func(s ShardFields) []policy.StatementBuilderFunc { +// return []policy.StatementBuilderFunc{policy.Eq(s.Codec, uint64(0x55))} +// }), +// ) +// +// The comparison value is type-checked against the field type by the compiler, +// and the selector path cannot be mistyped because it is generated from a real +// field. The builders ([Eq], [Gte], [Glob], [Each], ...) return the same +// [StatementBuilderFunc] as the legacy string-selector builders, so they drop +// straight into [Build] / delegation.WithPolicyBuilder and share the matcher +// and wire format unchanged. + +// Selector is a jq selector path into an argument value, carrying — at the type +// level only — the Go type T of the value found at that path. T pins the value +// type accepted by the comparison builders ([Eq], [Gt], ...). +type Selector[T any] struct { + path string +} + +// NewSelector constructs a [Selector] for the given jq path. It is called by +// generated descriptor code; hand-written callers normally reference a +// generated descriptor instead. +func NewSelector[T any](path string) Selector[T] { + return Selector[T]{path: path} +} + +// Path returns the jq selector path. +func (s Selector[T]) Path() string { return s.path } + +// SliceSelector is a [Selector] at a list-valued path, paired with the +// descriptor of its elements. E is the element descriptor type: a generated +// *Fields struct for struct elements (its selector paths are relative to an +// element), or a [Selector] of the element type for scalar elements (whose +// path is the identity selector "."). The element descriptor is handed to the +// closure passed to [Each] / [Some]. +type SliceSelector[E any] struct { + path string + elem E +} + +// NewSliceSelector constructs a [SliceSelector] for the given list path and +// element descriptor. It is called by generated descriptor code. +func NewSliceSelector[E any](path string, elem E) SliceSelector[E] { + return SliceSelector[E]{path: path, elem: elem} +} + +// Path returns the jq selector path of the list. +func (s SliceSelector[E]) Path() string { return s.path } + +// Elem returns the element descriptor (with element-relative selector paths). +func (s SliceSelector[E]) Elem() E { return s.elem } + +// MapSelector is a [Selector] at a map-valued path, paired with the descriptor +// of its values, used by [Each] / [Some] to quantify over the map's values. +type MapSelector[E any] struct { + path string + elem E +} + +// NewMapSelector constructs a [MapSelector] for the given map path and value +// descriptor. It is called by generated descriptor code. +func NewMapSelector[E any](path string, elem E) MapSelector[E] { + return MapSelector[E]{path: path, elem: elem} +} + +// Path returns the jq selector path of the map. +func (s MapSelector[E]) Path() string { return s.path } + +// Elem returns the value descriptor (with value-relative selector paths). +func (s MapSelector[E]) Elem() E { return s.elem }