From 32de453cb7de4074e193ccd08579b9aa2493b520 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 27 May 2026 08:45:54 +0200 Subject: [PATCH 1/8] chore: scaffold draft PR for #4253 item 2 (validators) Assisted-by: AI From a50fc8402f5c87666114e4b8f9179743bdd545fd Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Jul 2026 11:10:36 +0200 Subject: [PATCH 2/8] feat(pe): add ValidateSelectionKeys (Policy 1) credential_selection keys are validated against the union of field ids across the supplied presentation definitions (one for single-VP, two for two-VP), by name only. All unknown keys are reported, sorted, in a typed UnknownSelectionKeysError so the API layer can build the OAuth invalid_request description without string parsing. Assisted-by: AI --- vcr/pe/validate.go | 67 +++++++++++++++++++++++ vcr/pe/validate_test.go | 117 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 vcr/pe/validate.go create mode 100644 vcr/pe/validate_test.go diff --git a/vcr/pe/validate.go b/vcr/pe/validate.go new file mode 100644 index 0000000000..c2d14263fb --- /dev/null +++ b/vcr/pe/validate.go @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +import ( + "sort" + "strings" +) + +// UnknownSelectionKeysError is returned by ValidateSelectionKeys when the caller supplied +// credential_selection keys that are not field ids in any of the supplied presentation +// definitions. +type UnknownSelectionKeysError struct { + // Keys holds every unknown key, sorted. + Keys []string +} + +func (e *UnknownSelectionKeysError) Error() string { + return "unknown credential_selection keys: " + strings.Join(e.Keys, ", ") +} + +// ValidateSelectionKeys checks that every credential_selection key is a field id in at least one +// of the supplied presentation definitions (the union: one PD for a single-VP request, two for a +// two-VP request). Key names only are validated; values, including empty strings, play no role. +// It returns an UnknownSelectionKeysError naming every unknown key, or nil. +func ValidateSelectionKeys(selection map[string]string, pds ...PresentationDefinition) error { + known := make(map[string]bool) + for _, pd := range pds { + for _, descriptor := range pd.InputDescriptors { + if descriptor.Constraints == nil { + continue + } + for _, field := range descriptor.Constraints.Fields { + if field.Id != nil { + known[*field.Id] = true + } + } + } + } + var unknown []string + for key := range selection { + if !known[key] { + unknown = append(unknown, key) + } + } + if len(unknown) == 0 { + return nil + } + sort.Strings(unknown) + return &UnknownSelectionKeysError{Keys: unknown} +} diff --git a/vcr/pe/validate_test.go b/vcr/pe/validate_test.go new file mode 100644 index 0000000000..4981fca6f3 --- /dev/null +++ b/vcr/pe/validate_test.go @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mustParsePD unmarshals a presentation definition from its JSON wire form. JSON keeps the +// deeply-nested PDs readable; DisallowUnknownFields turns a misspelled key into a loud error. +func mustParsePD(t *testing.T, jsonStr string) PresentationDefinition { + t.Helper() + dec := json.NewDecoder(strings.NewReader(jsonStr)) + dec.DisallowUnknownFields() + var pd PresentationDefinition + require.NoError(t, dec.Decode(&pd)) + return pd +} + +func TestValidateSelectionKeys(t *testing.T) { + singlePD := `{ + "id": "single-pd", + "input_descriptors": [{ + "id": "patient", + "constraints": {"fields": [ + {"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}, + {"path": ["$.type"], "filter": {"type": "string", "const": "PatientCredential"}} + ]} + }] + }` + spPD := `{ + "id": "sp-pd", + "input_descriptors": [{ + "id": "delegation", + "constraints": {"fields": [{"id": "sp_did", "path": ["$.credentialSubject.id"]}]} + }] + }` + + t.Run("all keys known passes", func(t *testing.T) { + err := ValidateSelectionKeys(map[string]string{"patient_bsn": "999911234"}, mustParsePD(t, singlePD)) + + assert.NoError(t, err) + }) + + t.Run("unknown key against a single PD is reported", func(t *testing.T) { + err := ValidateSelectionKeys(map[string]string{"favourite_color": "blue"}, mustParsePD(t, singlePD)) + + var unknownErr *UnknownSelectionKeysError + require.ErrorAs(t, err, &unknownErr) + assert.Equal(t, []string{"favourite_color"}, unknownErr.Keys) + assert.EqualError(t, err, "unknown credential_selection keys: favourite_color") + }) + + t.Run("key targeting one side of a two-PD union passes", func(t *testing.T) { + err := ValidateSelectionKeys(map[string]string{"patient_bsn": "999911234", "sp_did": "did:web:sp"}, + mustParsePD(t, singlePD), mustParsePD(t, spPD)) + + assert.NoError(t, err) + }) + + t.Run("unknown key is validated against the union, not each PD independently", func(t *testing.T) { + err := ValidateSelectionKeys(map[string]string{"org_urra": "1"}, + mustParsePD(t, singlePD), mustParsePD(t, spPD)) + + var unknownErr *UnknownSelectionKeysError + require.ErrorAs(t, err, &unknownErr) + assert.Equal(t, []string{"org_urra"}, unknownErr.Keys) + }) + + t.Run("empty-string values are validated by name only", func(t *testing.T) { + err := ValidateSelectionKeys(map[string]string{"patient_bsn": ""}, mustParsePD(t, singlePD)) + + assert.NoError(t, err) + }) + + t.Run("multiple unknown keys are all listed, sorted", func(t *testing.T) { + err := ValidateSelectionKeys(map[string]string{"zeta": "1", "alpha": "2", "patient_bsn": "3"}, + mustParsePD(t, singlePD)) + + var unknownErr *UnknownSelectionKeysError + require.ErrorAs(t, err, &unknownErr) + assert.Equal(t, []string{"alpha", "zeta"}, unknownErr.Keys) + }) + + t.Run("no PDs supplied makes every key unknown", func(t *testing.T) { + err := ValidateSelectionKeys(map[string]string{"patient_bsn": "999911234"}) + + var unknownErr *UnknownSelectionKeysError + require.ErrorAs(t, err, &unknownErr) + assert.Equal(t, []string{"patient_bsn"}, unknownErr.Keys) + }) + + t.Run("nil selection passes", func(t *testing.T) { + assert.NoError(t, ValidateSelectionKeys(nil, mustParsePD(t, singlePD))) + }) +} From 080f571a860be5bea201270a6502403fc69fd409 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Jul 2026 11:13:17 +0200 Subject: [PATCH 3/8] feat(pe): add Validate with same-id value-set consistency (Policy 7) A field id used across descriptors binds one value, so every filter carrying the id must be able to accept that value at once. Filters are modelled as accepted-value sets following the matcher's actual semantics (enum shadows other keywords; const and pattern compare as strings): declared types must agree, and whenever a const or enum pins a finite candidate set, the intersection filtered through every declared pattern must be non-empty. Only pattern-versus-pattern is deferred to request time. Also checks id uniqueness within a constraints object and input descriptor id uniqueness. All conflicts are aggregated, sorted, in a typed PDValidationError. Assisted-by: AI --- vcr/pe/validate.go | 297 ++++++++++++++++++++++++++++++++++++++++ vcr/pe/validate_test.go | 214 +++++++++++++++++++++++++++++ 2 files changed, 511 insertions(+) diff --git a/vcr/pe/validate.go b/vcr/pe/validate.go index c2d14263fb..35d9e625f7 100644 --- a/vcr/pe/validate.go +++ b/vcr/pe/validate.go @@ -19,10 +19,307 @@ package pe import ( + "fmt" "sort" "strings" + + "github.com/dlclark/regexp2" ) +// ConflictKind classifies a FieldIDConflict. +type ConflictKind string + +const ( + // ConflictDuplicate is an id declared more than once where it must be unique. + ConflictDuplicate ConflictKind = "duplicate" + // ConflictType is a field id whose filters declare different value types across descriptors. + ConflictType ConflictKind = "type" + // ConflictUnsatisfiable is a filter, or a combination of same-id filters, that no value can + // ever satisfy. + ConflictUnsatisfiable ConflictKind = "unsatisfiable" + // ConflictInvalidPattern is a pattern that fails to compile or errors on every match. + ConflictInvalidPattern ConflictKind = "invalid_pattern" + // ConflictIgnoredConstraint is a declared constraint the matcher silently does not enforce, + // so the filter is weaker than the author intended. + ConflictIgnoredConstraint ConflictKind = "ignored_constraint" + // ConflictSubmissionRequirement is a submission requirement problem that fails every request. + ConflictSubmissionRequirement ConflictKind = "submission_requirement" +) + +// FieldIDConflict is one problem found by Validate. FieldID names the field id involved; for +// structural problems it names the input descriptor id, group, or submission requirement instead. +type FieldIDConflict struct { + FieldID string + Kind ConflictKind + Detail string +} + +// PDValidationError is returned by Validate; it aggregates every conflict in the presentation +// definition, sorted, so the author sees all problems at once. +type PDValidationError struct { + PDID string + Conflicts []FieldIDConflict +} + +func (e *PDValidationError) Error() string { + lines := make([]string, len(e.Conflicts)) + for i, conflict := range e.Conflicts { + lines[i] = conflict.FieldID + ": " + conflict.Detail + } + return fmt.Sprintf("presentation definition '%s' is invalid: %s", e.PDID, strings.Join(lines, "; ")) +} + +// Validate checks a presentation definition for problems that make it misbehave deterministically +// at request time: duplicate ids, same-id filters no value can satisfy at once, filters that can +// never match, silently ignored constraints, and submission requirement mistakes. It is a +// semantic pass on the parsed definition, layered after JSON-schema validation (v2.Validate), +// which cannot express these cross-field rules. It returns a PDValidationError aggregating every +// conflict, or nil. +func Validate(pd PresentationDefinition) error { + var conflicts []FieldIDConflict + conflicts = append(conflicts, duplicateConflicts(pd)...) + conflicts = append(conflicts, sameIDConflicts(pd)...) + if len(conflicts) == 0 { + return nil + } + sort.Slice(conflicts, func(i, j int) bool { + a, b := conflicts[i], conflicts[j] + if a.FieldID != b.FieldID { + return a.FieldID < b.FieldID + } + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + return a.Detail < b.Detail + }) + return &PDValidationError{PDID: pd.Id, Conflicts: conflicts} +} + +// duplicateConflicts reports input descriptor ids used more than once, and field ids declared +// more than once within a single constraints object. The same field id on different descriptors +// is not a duplicate: that is the binding mechanism. +func duplicateConflicts(pd PresentationDefinition) []FieldIDConflict { + var conflicts []FieldIDConflict + descriptorSeen := make(map[string]bool) + descriptorReported := make(map[string]bool) + for _, descriptor := range pd.InputDescriptors { + if descriptorSeen[descriptor.Id] && !descriptorReported[descriptor.Id] { + descriptorReported[descriptor.Id] = true + conflicts = append(conflicts, FieldIDConflict{ + FieldID: descriptor.Id, + Kind: ConflictDuplicate, + Detail: fmt.Sprintf("input descriptor id '%s' is declared more than once", descriptor.Id), + }) + } + descriptorSeen[descriptor.Id] = true + if descriptor.Constraints == nil { + continue + } + fieldSeen := make(map[string]bool) + fieldReported := make(map[string]bool) + for _, field := range descriptor.Constraints.Fields { + if field.Id == nil { + continue + } + if fieldSeen[*field.Id] && !fieldReported[*field.Id] { + fieldReported[*field.Id] = true + conflicts = append(conflicts, FieldIDConflict{ + FieldID: *field.Id, + Kind: ConflictDuplicate, + Detail: fmt.Sprintf("field id '%s' is declared more than once in input descriptor '%s'", *field.Id, descriptor.Id), + }) + } + fieldSeen[*field.Id] = true + } + } + return conflicts +} + +// valueSet models the string values a filter accepts, as matchFilter implements them: enum +// shadows every other keyword; const and pattern require type "string". +type valueSet struct { + // effectiveType is "string" whenever enum or const constrains the value (both compare as + // strings at match time), otherwise the declared type; empty means unconstrained. + effectiveType string + // finite holds the candidate values when enum or const pins them; nil otherwise. + finite []string + // pattern is the predicate when only a pattern constrains the value. + pattern *regexp2.Regexp + // dead marks a filter that can never accept any value. + dead bool +} + +// filterValueSet derives the accepted-value set of one filter, following the matcher's actual +// semantics (see matchFilter), not JSON Schema's. +func filterValueSet(filter *Filter) valueSet { + if filter == nil { + return valueSet{} + } + if filter.Enum != nil { + // enum shadows type, const and pattern + return valueSet{effectiveType: "string", finite: filter.Enum, dead: len(filter.Enum) == 0} + } + if filter.Const != nil { + set := valueSet{effectiveType: "string", finite: []string{*filter.Const}} + if filter.Type != "string" { + // the const compares as a string, but the type gate rejects string values + set.dead = true + return set + } + if filter.Pattern != nil { + if re, err := regexp2.Compile(*filter.Pattern, regexp2.ECMAScript); err == nil { + if match, _ := re.FindStringMatch(*filter.Const); match == nil { + set.dead = true + } + } + } + return set + } + if filter.Pattern != nil && filter.Type == "string" { + if re, err := regexp2.Compile(*filter.Pattern, regexp2.ECMAScript); err == nil { + return valueSet{effectiveType: "string", pattern: re} + } + // a non-compiling pattern is reported as invalid_pattern; no predicate here + return valueSet{effectiveType: "string"} + } + return valueSet{effectiveType: filter.Type} +} + +// idOccurrence is one field carrying a shared id, with its derived value set. +type idOccurrence struct { + descriptorID string + set valueSet +} + +// sameIDConflicts checks every field id used on two or more fields across descriptors: the value +// the id binds to must be able to satisfy all of them at once (Policy 7). Types must agree, and +// the intersection of the accepted-value sets must not be provably empty. Only the +// pattern-versus-pattern case (no const or enum anywhere) is deferred to request time. +func sameIDConflicts(pd PresentationDefinition) []FieldIDConflict { + occurrences := make(map[string][]idOccurrence) + var order []string + for _, descriptor := range pd.InputDescriptors { + if descriptor.Constraints == nil { + continue + } + for _, field := range descriptor.Constraints.Fields { + if field.Id == nil { + continue + } + if _, seen := occurrences[*field.Id]; !seen { + order = append(order, *field.Id) + } + occurrences[*field.Id] = append(occurrences[*field.Id], idOccurrence{ + descriptorID: descriptor.Id, + set: filterValueSet(field.Filter), + }) + } + } + + var conflicts []FieldIDConflict + for _, id := range order { + fields := occurrences[id] + if len(fields) < 2 { + continue + } + // dead filters are reported on their own; they carry no usable value set here + var sets []valueSet + for _, occurrence := range fields { + if !occurrence.set.dead { + sets = append(sets, occurrence.set) + } + } + + // type agreement: every constrained occurrence must agree on the value type + types := make(map[string]bool) + for _, set := range sets { + if set.effectiveType != "" { + types[set.effectiveType] = true + } + } + if len(types) > 1 { + names := make([]string, 0, len(types)) + for name := range types { + names = append(names, name) + } + sort.Strings(names) + conflicts = append(conflicts, FieldIDConflict{ + FieldID: id, + Kind: ConflictType, + Detail: "conflicting filter types: " + strings.Join(names, " vs "), + }) + continue // value sets of different types cannot be intersected meaningfully + } + + // value-set intersection, decidable whenever some filter pins a finite candidate set + var candidates []string + haveFinite := false + for _, set := range sets { + if set.finite == nil { + continue + } + if !haveFinite { + candidates, haveFinite = set.finite, true + continue + } + candidates = intersect(candidates, set.finite) + } + if !haveFinite { + continue // universes always overlap; pattern-versus-pattern is deferred + } + if len(candidates) == 0 { + conflicts = append(conflicts, FieldIDConflict{ + FieldID: id, + Kind: ConflictUnsatisfiable, + Detail: "const/enum values across descriptors share no common value", + }) + continue + } + for _, set := range sets { + if set.pattern == nil { + continue + } + candidates = matchingCandidates(candidates, set.pattern) + if len(candidates) == 0 { + conflicts = append(conflicts, FieldIDConflict{ + FieldID: id, + Kind: ConflictUnsatisfiable, + Detail: fmt.Sprintf("no shared const/enum value matches pattern '%s'", set.pattern.String()), + }) + break + } + } + } + return conflicts +} + +// intersect returns the values present in both slices, preserving the order of the first. +func intersect(a, b []string) []string { + inB := make(map[string]bool, len(b)) + for _, value := range b { + inB[value] = true + } + var result []string + for _, value := range a { + if inB[value] { + result = append(result, value) + } + } + return result +} + +// matchingCandidates keeps the candidates the pattern accepts, using the same regex semantics as +// the matcher (regexp2, ECMAScript). +func matchingCandidates(candidates []string, pattern *regexp2.Regexp) []string { + var result []string + for _, candidate := range candidates { + if match, err := pattern.FindStringMatch(candidate); err == nil && match != nil { + result = append(result, candidate) + } + } + return result +} + // UnknownSelectionKeysError is returned by ValidateSelectionKeys when the caller supplied // credential_selection keys that are not field ids in any of the supplied presentation // definitions. diff --git a/vcr/pe/validate_test.go b/vcr/pe/validate_test.go index 4981fca6f3..4c5a029414 100644 --- a/vcr/pe/validate_test.go +++ b/vcr/pe/validate_test.go @@ -115,3 +115,217 @@ func TestValidateSelectionKeys(t *testing.T) { assert.NoError(t, ValidateSelectionKeys(nil, mustParsePD(t, singlePD))) }) } + +// twoFieldPD builds a PD with two descriptors, each carrying one field with the given id and +// filter JSON (empty filter string means no filter). Most same-id consistency cases fit this shape. +func twoFieldPD(t *testing.T, id string, filterA string, filterB string) PresentationDefinition { + t.Helper() + field := func(filter string) string { + if filter == "" { + return `{"id": "` + id + `", "path": ["$.credentialSubject.a"]}` + } + return `{"id": "` + id + `", "path": ["$.credentialSubject.a"], "filter": ` + filter + `}` + } + return mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "d1", "constraints": {"fields": [`+field(filterA)+`]}}, + {"id": "d2", "constraints": {"fields": [`+field(filterB)+`]}} + ] + }`) +} + +// conflictKinds maps FieldID to the conflict kinds reported for it. +func conflictKinds(t *testing.T, err error) map[string][]ConflictKind { + t.Helper() + var pdErr *PDValidationError + require.ErrorAs(t, err, &pdErr) + kinds := make(map[string][]ConflictKind) + for _, conflict := range pdErr.Conflicts { + kinds[conflict.FieldID] = append(kinds[conflict.FieldID], conflict.Kind) + } + return kinds +} + +func TestValidate_Duplicates(t *testing.T) { + t.Run("duplicate field id within one constraints object", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "d1", + "constraints": {"fields": [ + {"id": "org_ura", "path": ["$.credentialSubject.ura"]}, + {"id": "org_ura", "path": ["$.credentialSubject.organization.ura"]} + ]} + }] + }`) + + kinds := conflictKinds(t, Validate(pd)) + assert.Equal(t, []ConflictKind{ConflictDuplicate}, kinds["org_ura"]) + }) + + t.Run("same id across descriptors is not a duplicate", func(t *testing.T) { + assert.NoError(t, Validate(twoFieldPD(t, "org_ura", "", ""))) + }) + + t.Run("duplicate input descriptor ids", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "d1", "constraints": {"fields": [{"path": ["$.a"]}]}}, + {"id": "d1", "constraints": {"fields": [{"path": ["$.b"]}]}} + ] + }`) + + kinds := conflictKinds(t, Validate(pd)) + assert.Equal(t, []ConflictKind{ConflictDuplicate}, kinds["d1"]) + }) +} + +func TestValidate_SameIDConsistency(t *testing.T) { + t.Run("conflicting filter types", func(t *testing.T) { + err := Validate(twoFieldPD(t, "org_ura", + `{"type": "string", "pattern": "^\\d{8}$"}`, + `{"type": "number"}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictType}, kinds["org_ura"]) + assert.ErrorContains(t, err, "number") + assert.ErrorContains(t, err, "string") + }) + + t.Run("enum forces string matching, so enum vs type number conflicts", func(t *testing.T) { + err := Validate(twoFieldPD(t, "x", + `{"type": "string", "enum": ["a"]}`, + `{"type": "number"}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictType}, kinds["x"]) + }) + + t.Run("differing consts", func(t *testing.T) { + err := Validate(twoFieldPD(t, "x", + `{"type": "string", "const": "A"}`, + `{"type": "string", "const": "B"}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"]) + }) + + t.Run("const not in enum", func(t *testing.T) { + err := Validate(twoFieldPD(t, "x", + `{"type": "string", "const": "Z"}`, + `{"type": "string", "enum": ["A", "B"]}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"]) + }) + + t.Run("disjoint enums", func(t *testing.T) { + err := Validate(twoFieldPD(t, "x", + `{"type": "string", "enum": ["A", "B"]}`, + `{"type": "string", "enum": ["C"]}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"]) + }) + + t.Run("const failing another field's pattern", func(t *testing.T) { + err := Validate(twoFieldPD(t, "x", + `{"type": "string", "const": "99991123"}`, + `{"type": "string", "pattern": "^\\d{9}$"}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"]) + }) + + t.Run("enum with no member matching another field's pattern", func(t *testing.T) { + err := Validate(twoFieldPD(t, "x", + `{"type": "string", "enum": ["GRANTED", "WITHDRAWN"]}`, + `{"type": "string", "pattern": "^[a-z]+$"}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"]) + }) + + t.Run("allowed: overlapping value sets", func(t *testing.T) { + assert.NoError(t, Validate(twoFieldPD(t, "x", + `{"type": "string", "const": "granted"}`, + `{"type": "string", "enum": ["granted", "withdrawn"]}`))) + }) + + t.Run("allowed: path-only difference, no filters", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "d1", "constraints": {"fields": [{"id": "org_did", "path": ["$.credentialSubject.id"]}]}}, + {"id": "d2", "constraints": {"fields": [{"id": "org_did", "path": ["$.credentialSubject.issuedTo"]}]}} + ] + }`) + assert.NoError(t, Validate(pd)) + }) + + t.Run("allowed: matching type-only filters", func(t *testing.T) { + assert.NoError(t, Validate(twoFieldPD(t, "x", + `{"type": "string"}`, + `{"type": "string"}`))) + }) + + t.Run("allowed: single occurrence with a filter", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "d1", + "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "const": "A"}}]} + }] + }`) + assert.NoError(t, Validate(pd)) + }) + + t.Run("deferred: pattern versus pattern", func(t *testing.T) { + // two compilable patterns with an empty intersection are not detected at load; + // this degrades to a request-time no-match, surfaced by the MatchReport + assert.NoError(t, Validate(twoFieldPD(t, "x", + `{"type": "string", "pattern": "^a"}`, + `{"type": "string", "pattern": "^b"}`))) + }) + + t.Run("multiple conflicts are aggregated and sorted", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "d1", "constraints": {"fields": [ + {"id": "zeta", "path": ["$.a"], "filter": {"type": "string", "const": "A"}}, + {"id": "alpha", "path": ["$.b"], "filter": {"type": "string"}} + ]}}, + {"id": "d2", "constraints": {"fields": [ + {"id": "zeta", "path": ["$.a"], "filter": {"type": "string", "const": "B"}}, + {"id": "alpha", "path": ["$.b"], "filter": {"type": "number"}} + ]}} + ] + }`) + + err := Validate(pd) + + var pdErr *PDValidationError + require.ErrorAs(t, err, &pdErr) + require.Len(t, pdErr.Conflicts, 2) + assert.Equal(t, "alpha", pdErr.Conflicts[0].FieldID) + assert.Equal(t, ConflictType, pdErr.Conflicts[0].Kind) + assert.Equal(t, "zeta", pdErr.Conflicts[1].FieldID) + assert.Equal(t, ConflictUnsatisfiable, pdErr.Conflicts[1].Kind) + assert.ErrorContains(t, err, "test-pd") + }) + + t.Run("descriptors without constraints are skipped", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{"id": "d1"}, {"id": "d2"}] + }`) + assert.NoError(t, Validate(pd)) + }) + + t.Run("empty PD is valid", func(t *testing.T) { + assert.NoError(t, Validate(mustParsePD(t, `{"id": "empty", "input_descriptors": []}`))) + }) +} From a52a9a4c5d51a09224b1ccba49c129f1969e628d Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Jul 2026 11:16:16 +0200 Subject: [PATCH 4/8] feat(pe): flag dead filters, broken patterns and ignored constraints Single-filter checks following the matcher's actual semantics: a const on a non-string type and an empty enum can never match; a pattern that does not compile, or carries more than one capture group, errors at request time. Constraints the matcher silently ignores (const or pattern shadowed by enum, a non-string type next to enum, a pattern on a non-string type) are reported as ignored_constraint: the filter is weaker than its author declared. Filter parsing now records unsupported JSON Schema keywords (minimum, allOf, ...) instead of dropping them silently; parsing stays lenient so remote presentation definitions keep working, and only Validate reports them. Assisted-by: AI --- vcr/pe/types.go | 35 ++++++++++++- vcr/pe/validate.go | 108 ++++++++++++++++++++++++++++++++++++++++ vcr/pe/validate_test.go | 100 +++++++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 1 deletion(-) diff --git a/vcr/pe/types.go b/vcr/pe/types.go index 56253ded78..84241c71c2 100644 --- a/vcr/pe/types.go +++ b/vcr/pe/types.go @@ -19,7 +19,11 @@ // Package pe stands for Presentation Exchange which includes Presentation Definition and Presentation Submission package pe -import "errors" +import ( + "encoding/json" + "errors" + "sort" +) var ErrNoCredentials = errors.New("missing credentials") var ErrMultipleCredentials = errors.New("multiple matching credentials") @@ -130,6 +134,35 @@ type Filter struct { Enum []string `json:"enum,omitempty"` // Pattern is a pattern to match according to ECMA-262, section 21.2.1 Pattern *string `json:"pattern,omitempty"` + // unsupported records JSON Schema keywords present in the filter JSON that this + // implementation does not evaluate. Parsing stays lenient (remote presentation definitions + // keep working); Validate reports them, because a dropped keyword silently weakens the + // filter below what its author declared. + unsupported []string +} + +// UnmarshalJSON parses the filter and records unsupported keywords instead of silently dropping +// them. +func (f *Filter) UnmarshalJSON(data []byte) error { + type alias Filter + var parsed alias + if err := json.Unmarshal(data, &parsed); err != nil { + return err + } + *f = Filter(parsed) + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + for keyword := range raw { + switch keyword { + case "type", "const", "enum", "pattern": + default: + f.unsupported = append(f.unsupported, keyword) + } + } + sort.Strings(f.unsupported) + return nil } // SubmissionRequirement diff --git a/vcr/pe/validate.go b/vcr/pe/validate.go index 35d9e625f7..2addd19b26 100644 --- a/vcr/pe/validate.go +++ b/vcr/pe/validate.go @@ -78,6 +78,7 @@ func (e *PDValidationError) Error() string { func Validate(pd PresentationDefinition) error { var conflicts []FieldIDConflict conflicts = append(conflicts, duplicateConflicts(pd)...) + conflicts = append(conflicts, filterSanityConflicts(pd)...) conflicts = append(conflicts, sameIDConflicts(pd)...) if len(conflicts) == 0 { return nil @@ -135,6 +136,113 @@ func duplicateConflicts(pd PresentationDefinition) []FieldIDConflict { return conflicts } +// filterSanityConflicts checks every filter on its own, following the matcher's actual semantics +// (see matchFilter): filters that can never match anything, patterns that error, and declared +// constraints the matcher silently ignores (leaving the filter weaker than its author intended). +// A conflict is reported under the field id, or under the descriptor id for id-less fields. +func filterSanityConflicts(pd PresentationDefinition) []FieldIDConflict { + var conflicts []FieldIDConflict + for _, descriptor := range pd.InputDescriptors { + if descriptor.Constraints == nil { + continue + } + for _, field := range descriptor.Constraints.Fields { + if field.Filter == nil { + continue + } + subject := descriptor.Id + if field.Id != nil { + subject = *field.Id + } + for _, conflict := range singleFilterConflicts(*field.Filter) { + conflict.FieldID = subject + conflicts = append(conflicts, conflict) + } + } + } + return conflicts +} + +// singleFilterConflicts derives the problems of one filter; FieldID is filled in by the caller. +func singleFilterConflicts(filter Filter) []FieldIDConflict { + var conflicts []FieldIDConflict + if len(filter.unsupported) > 0 { + conflicts = append(conflicts, FieldIDConflict{ + Kind: ConflictIgnoredConstraint, + Detail: fmt.Sprintf("unsupported filter keywords [%s] are not evaluated: the filter is weaker than declared", strings.Join(filter.unsupported, ", ")), + }) + } + + if filter.Enum != nil { + // enum shadows type, const and pattern (matchFilter returns from the enum branch) + if len(filter.Enum) == 0 { + conflicts = append(conflicts, FieldIDConflict{ + Kind: ConflictUnsatisfiable, + Detail: "enum is empty: the filter can never match", + }) + } + if filter.Const != nil { + conflicts = append(conflicts, FieldIDConflict{ + Kind: ConflictIgnoredConstraint, + Detail: fmt.Sprintf("const %q is ignored because enum is set", *filter.Const), + }) + } + if filter.Pattern != nil { + conflicts = append(conflicts, FieldIDConflict{ + Kind: ConflictIgnoredConstraint, + Detail: "pattern is ignored because enum is set", + }) + } + if filter.Type != "" && filter.Type != "string" { + conflicts = append(conflicts, FieldIDConflict{ + Kind: ConflictIgnoredConstraint, + Detail: fmt.Sprintf("type %q is ignored because enum forces string matching", filter.Type), + }) + } + return conflicts + } + + if filter.Const != nil && filter.Type != "string" { + // the const compares as a string, but the type gate never lets a string value through + conflicts = append(conflicts, FieldIDConflict{ + Kind: ConflictUnsatisfiable, + Detail: fmt.Sprintf("const %q can never match: it requires type \"string\", declared type is %q", *filter.Const, filter.Type), + }) + } + + if filter.Pattern != nil { + pattern, err := regexp2.Compile(*filter.Pattern, regexp2.ECMAScript) + switch { + case err != nil: + conflicts = append(conflicts, FieldIDConflict{ + Kind: ConflictInvalidPattern, + Detail: fmt.Sprintf("pattern %q does not compile: %s", *filter.Pattern, err), + }) + case len(pattern.GetGroupNumbers()) > 2: + // matchFilter returns an error whenever a pattern with more than one capture + // group matches a value + conflicts = append(conflicts, FieldIDConflict{ + Kind: ConflictInvalidPattern, + Detail: fmt.Sprintf("pattern %q has more than one capture group: every match errors", *filter.Pattern), + }) + case filter.Type != "string": + // the matcher applies patterns to string-typed filters only + conflicts = append(conflicts, FieldIDConflict{ + Kind: ConflictIgnoredConstraint, + Detail: fmt.Sprintf("pattern is ignored because the declared type is %q: patterns apply to strings only", filter.Type), + }) + case filter.Const != nil: + if match, _ := pattern.FindStringMatch(*filter.Const); match == nil { + conflicts = append(conflicts, FieldIDConflict{ + Kind: ConflictUnsatisfiable, + Detail: fmt.Sprintf("const %q does not match the field's own pattern %q", *filter.Const, *filter.Pattern), + }) + } + } + } + return conflicts +} + // valueSet models the string values a filter accepts, as matchFilter implements them: enum // shadows every other keyword; const and pattern require type "string". type valueSet struct { diff --git a/vcr/pe/validate_test.go b/vcr/pe/validate_test.go index 4c5a029414..737ea65721 100644 --- a/vcr/pe/validate_test.go +++ b/vcr/pe/validate_test.go @@ -329,3 +329,103 @@ func TestValidate_SameIDConsistency(t *testing.T) { assert.NoError(t, Validate(mustParsePD(t, `{"id": "empty", "input_descriptors": []}`))) }) } + +// singleFilterPD builds a PD with one descriptor carrying one field with the given filter JSON. +func singleFilterPD(t *testing.T, filter string) PresentationDefinition { + t.Helper() + return mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "d1", + "constraints": {"fields": [{"id": "x", "path": ["$.credentialSubject.x"], "filter": `+filter+`}]} + }] + }`) +} + +func TestValidate_FilterSanity(t *testing.T) { + t.Run("const on a non-string type can never match", func(t *testing.T) { + // matchFilter compares const as a string; a boolean value never equals it + err := Validate(singleFilterPD(t, `{"type": "boolean", "const": "true"}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"]) + assert.ErrorContains(t, err, "true") + }) + + t.Run("empty enum can never match", func(t *testing.T) { + err := Validate(singleFilterPD(t, `{"type": "string", "enum": []}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"]) + }) + + t.Run("const failing its own pattern can never match", func(t *testing.T) { + err := Validate(singleFilterPD(t, `{"type": "string", "const": "abc", "pattern": "^\\d+$"}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"]) + }) + + t.Run("non-compiling pattern is reported", func(t *testing.T) { + err := Validate(singleFilterPD(t, `{"type": "string", "pattern": "["}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictInvalidPattern}, kinds["x"]) + }) + + t.Run("pattern with more than one capture group errors on every match", func(t *testing.T) { + err := Validate(singleFilterPD(t, `{"type": "string", "pattern": "^(\\d+)-(\\d+)$"}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictInvalidPattern}, kinds["x"]) + }) + + t.Run("pattern on a non-string type is silently ignored by the matcher", func(t *testing.T) { + err := Validate(singleFilterPD(t, `{"type": "number", "pattern": "^\\d+$"}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictIgnoredConstraint}, kinds["x"]) + }) + + t.Run("const alongside enum is silently ignored by the matcher", func(t *testing.T) { + err := Validate(singleFilterPD(t, `{"type": "string", "enum": ["A", "B"], "const": "Z"}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictIgnoredConstraint}, kinds["x"]) + }) + + t.Run("non-string type alongside enum is silently ignored by the matcher", func(t *testing.T) { + err := Validate(singleFilterPD(t, `{"type": "number", "enum": ["1", "2"]}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictIgnoredConstraint}, kinds["x"]) + }) + + t.Run("unsupported JSON Schema keywords are reported instead of silently dropped", func(t *testing.T) { + err := Validate(singleFilterPD(t, `{"type": "number", "minimum": 18}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictIgnoredConstraint}, kinds["x"]) + assert.ErrorContains(t, err, "minimum") + }) + + t.Run("a dead filter on a field without an id is reported under the descriptor id", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "d1", + "constraints": {"fields": [{"path": ["$.credentialSubject.x"], "filter": {"type": "boolean", "const": "true"}}]} + }] + }`) + + kinds := conflictKinds(t, Validate(pd)) + assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["d1"]) + }) + + t.Run("allowed: well-formed filters", func(t *testing.T) { + assert.NoError(t, Validate(singleFilterPD(t, `{"type": "string", "const": "A", "pattern": "^[A-Z]$"}`))) + assert.NoError(t, Validate(singleFilterPD(t, `{"type": "string", "enum": ["A", "B"]}`))) + assert.NoError(t, Validate(singleFilterPD(t, `{"type": "string", "pattern": "^(\\d+)$"}`))) + assert.NoError(t, Validate(singleFilterPD(t, `{"type": "number"}`))) + }) +} From e62dfd04c4da5f2425acc70597be6c47e9b6039b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Jul 2026 11:17:50 +0200 Subject: [PATCH 5/8] feat(pe): validate submission requirements and group coverage Structural mistakes that fail every request at runtime now fail at validation instead: a descriptor group not covered by any submission requirement, unknown rules, from/from_nested violations, negative or inverted bounds, and a pick rule demanding credentials from a group no descriptor carries. Nested requirements are walked recursively. Groups without any submission requirements stay untouched: the matcher ignores them in all-required mode. Assisted-by: AI --- vcr/pe/validate.go | 94 +++++++++++++++++++++++++++++++ vcr/pe/validate_test.go | 121 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) diff --git a/vcr/pe/validate.go b/vcr/pe/validate.go index 2addd19b26..22ee710a1c 100644 --- a/vcr/pe/validate.go +++ b/vcr/pe/validate.go @@ -80,6 +80,7 @@ func Validate(pd PresentationDefinition) error { conflicts = append(conflicts, duplicateConflicts(pd)...) conflicts = append(conflicts, filterSanityConflicts(pd)...) conflicts = append(conflicts, sameIDConflicts(pd)...) + conflicts = append(conflicts, submissionRequirementConflicts(pd)...) if len(conflicts) == 0 { return nil } @@ -401,6 +402,99 @@ func sameIDConflicts(pd PresentationDefinition) []FieldIDConflict { return conflicts } +// submissionRequirementConflicts checks the submission requirements for mistakes that fail every +// request at runtime: a descriptor group no requirement covers, unknown rules, from/from_nested +// violations, impossible bounds, and a rule demanding credentials from a group no descriptor +// carries. Groups are only meaningful when submission requirements are present; without them the +// matcher ignores groups entirely. +func submissionRequirementConflicts(pd PresentationDefinition) []FieldIDConflict { + if len(pd.SubmissionRequirements) == 0 { + return nil + } + var conflicts []FieldIDConflict + + covered := make(map[string]bool) + for _, requirement := range pd.SubmissionRequirements { + for _, group := range requirement.groups() { + covered[group] = true + } + } + descriptorGroups := make(map[string]bool) + for _, descriptor := range pd.InputDescriptors { + for _, group := range descriptor.Group { + descriptorGroups[group] = true + if !covered[group] { + covered[group] = true // report once + conflicts = append(conflicts, FieldIDConflict{ + FieldID: group, + Kind: ConflictSubmissionRequirement, + Detail: fmt.Sprintf("group '%s' (input descriptor '%s') is not covered by any submission requirement: every request fails", group, descriptor.Id), + }) + } + } + } + + var walk func(requirement SubmissionRequirement, name string) + walk = func(requirement SubmissionRequirement, name string) { + if requirement.Name != "" { + name = fmt.Sprintf("%s (%s)", name, requirement.Name) + } + if requirement.Rule != "all" && requirement.Rule != "pick" { + conflicts = append(conflicts, FieldIDConflict{ + FieldID: name, + Kind: ConflictSubmissionRequirement, + Detail: fmt.Sprintf("unknown rule %q (must be \"all\" or \"pick\")", requirement.Rule), + }) + } + hasFrom := requirement.From != "" + hasNested := len(requirement.FromNested) > 0 + if hasFrom == hasNested { + conflicts = append(conflicts, FieldIDConflict{ + FieldID: name, + Kind: ConflictSubmissionRequirement, + Detail: "exactly one of 'from' and 'from_nested' must be set", + }) + } + for _, bound := range []struct { + name string + value *int + }{{"count", requirement.Count}, {"min", requirement.Min}, {"max", requirement.Max}} { + if bound.value != nil && *bound.value < 0 { + conflicts = append(conflicts, FieldIDConflict{ + FieldID: name, + Kind: ConflictSubmissionRequirement, + Detail: fmt.Sprintf("'%s' must not be negative", bound.name), + }) + } + } + if requirement.Min != nil && requirement.Max != nil && *requirement.Min > *requirement.Max { + conflicts = append(conflicts, FieldIDConflict{ + FieldID: name, + Kind: ConflictSubmissionRequirement, + Detail: fmt.Sprintf("'min' (%d) exceeds 'max' (%d)", *requirement.Min, *requirement.Max), + }) + } + // a rule demanding at least one credential from a group no descriptor carries fails + // every request; "all" over an empty group passes vacuously and is left alone + demandsCredentials := (requirement.Count != nil && *requirement.Count > 0) || + (requirement.Min != nil && *requirement.Min > 0) + if hasFrom && demandsCredentials && !descriptorGroups[requirement.From] { + conflicts = append(conflicts, FieldIDConflict{ + FieldID: requirement.From, + Kind: ConflictSubmissionRequirement, + Detail: fmt.Sprintf("no input descriptor carries group '%s', but the rule demands credentials from it: every request fails", requirement.From), + }) + } + for i, nested := range requirement.FromNested { + walk(*nested, fmt.Sprintf("%s.from_nested[%d]", name, i)) + } + } + for i, requirement := range pd.SubmissionRequirements { + walk(*requirement, fmt.Sprintf("submission_requirements[%d]", i)) + } + return conflicts +} + // intersect returns the values present in both slices, preserving the order of the first. func intersect(a, b []string) []string { inB := make(map[string]bool, len(b)) diff --git a/vcr/pe/validate_test.go b/vcr/pe/validate_test.go index 737ea65721..23e5f604b4 100644 --- a/vcr/pe/validate_test.go +++ b/vcr/pe/validate_test.go @@ -422,6 +422,127 @@ func TestValidate_FilterSanity(t *testing.T) { assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["d1"]) }) + t.Run("structural: descriptor group not covered by any submission requirement", func(t *testing.T) { + // runtime fails every request on this PD with "group is required but not available" + pd := mustParsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "all", "from": "org"}], + "input_descriptors": [ + {"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}, + {"id": "d2", "group": ["delegation"], "constraints": {"fields": [{"path": ["$.b"]}]}} + ] + }`) + + kinds := conflictKinds(t, Validate(pd)) + assert.Equal(t, []ConflictKind{ConflictSubmissionRequirement}, kinds["delegation"]) + }) + + t.Run("structural: groups without submission requirements are fine", func(t *testing.T) { + // with no submission requirements at all, groups are decorative (all-required matching) + pd := mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}] + }`) + assert.NoError(t, Validate(pd)) + }) + + t.Run("structural: unknown rule", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "any", "from": "org"}], + "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}] + }`) + + err := Validate(pd) + var pdErr *PDValidationError + require.ErrorAs(t, err, &pdErr) + require.Len(t, pdErr.Conflicts, 1) + assert.Equal(t, ConflictSubmissionRequirement, pdErr.Conflicts[0].Kind) + assert.ErrorContains(t, err, "any") + }) + + t.Run("structural: from and from_nested are mutually exclusive and required", func(t *testing.T) { + both := mustParsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "all", "from": "org", "from_nested": [{"rule": "all", "from": "org"}]}], + "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}] + }`) + neither := mustParsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "all"}], + "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}] + }`) + + for _, pd := range []PresentationDefinition{both, neither} { + err := Validate(pd) + var pdErr *PDValidationError + require.ErrorAs(t, err, &pdErr) + require.NotEmpty(t, pdErr.Conflicts) + assert.Equal(t, ConflictSubmissionRequirement, pdErr.Conflicts[0].Kind) + } + }) + + t.Run("structural: impossible pick bounds", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "pick", "from": "org", "min": 3, "max": 1}], + "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}] + }`) + + err := Validate(pd) + var pdErr *PDValidationError + require.ErrorAs(t, err, &pdErr) + require.Len(t, pdErr.Conflicts, 1) + assert.Equal(t, ConflictSubmissionRequirement, pdErr.Conflicts[0].Kind) + }) + + t.Run("structural: pick demanding credentials from a group no descriptor carries", func(t *testing.T) { + // apply() sees an empty group and fails every request + pd := mustParsePD(t, `{ + "id": "test-pd", + "submission_requirements": [ + {"rule": "all", "from": "org"}, + {"rule": "pick", "from": "ghost", "count": 1} + ], + "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}] + }`) + + kinds := conflictKinds(t, Validate(pd)) + assert.Equal(t, []ConflictKind{ConflictSubmissionRequirement}, kinds["ghost"]) + }) + + t.Run("structural: nested submission requirements are walked", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "pick", "count": 1, "from_nested": [ + {"rule": "any", "from": "org"} + ]}], + "input_descriptors": [{"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}] + }`) + + err := Validate(pd) + var pdErr *PDValidationError + require.ErrorAs(t, err, &pdErr) + require.Len(t, pdErr.Conflicts, 1) + assert.Equal(t, ConflictSubmissionRequirement, pdErr.Conflicts[0].Kind) + assert.ErrorContains(t, err, "any") + }) + + t.Run("structural: a valid submission requirement setup passes", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "submission_requirements": [ + {"rule": "all", "from": "org"}, + {"rule": "pick", "from": "consent", "min": 0, "max": 1} + ], + "input_descriptors": [ + {"id": "d1", "group": ["org"], "constraints": {"fields": [{"path": ["$.a"]}]}}, + {"id": "d2", "group": ["consent"], "constraints": {"fields": [{"path": ["$.b"]}]}} + ] + }`) + assert.NoError(t, Validate(pd)) + }) + t.Run("allowed: well-formed filters", func(t *testing.T) { assert.NoError(t, Validate(singleFilterPD(t, `{"type": "string", "const": "A", "pattern": "^[A-Z]$"}`))) assert.NoError(t, Validate(singleFilterPD(t, `{"type": "string", "enum": ["A", "B"]}`))) From bb5c2af5c5b69a2f1036c6bff2ba0acff1f90479 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Jul 2026 11:19:00 +0200 Subject: [PATCH 6/8] fix(pe): do not panic on array values with a string pattern filter An array whose elements all fail the filter falls through the type switch, and the pattern check asserted the value to be a string: a crafted or unlucky combination of a pattern filter and a non-matching array claim (e.g. a filter on $.type) crashed the matcher per request. Non-string values are now a plain non-match. Assisted-by: AI --- vcr/pe/presentation_definition.go | 8 +++++++- vcr/pe/presentation_definition_test.go | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index f204463f13..631ce28ced 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -553,11 +553,17 @@ func matchFilter(filter Filter, value interface{}) (bool, interface{}, error) { } if filter.Pattern != nil && filter.Type == "string" { + str, isString := value.(string) + if !isString { + // arrays whose elements all failed the filter fall through the type switch; + // a pattern can only accept strings + return false, nil, nil + } re, err := regexp2.Compile(*filter.Pattern, regexp2.ECMAScript) if err != nil { return false, nil, err } - match, err := re.FindStringMatch(value.(string)) + match, err := re.FindStringMatch(str) if err != nil { return false, nil, err } diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index 0d8e19068d..d4df8eb6e9 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -764,6 +764,19 @@ func Test_matchField(t *testing.T) { } func Test_matchFilter(t *testing.T) { + t.Run("array value with a string pattern filter and no matching element does not panic", func(t *testing.T) { + // an array whose elements all fail the filter falls through the type switch; the + // pattern check must not assume the value is a string + pattern := "^Nuts" + filter := Filter{Type: "string", Pattern: &pattern} + + match, value, err := matchFilter(filter, []interface{}{"SomethingElse"}) + + require.NoError(t, err) + assert.False(t, match) + assert.Nil(t, value) + }) + // values for pointer fields stringValue := "test" boolValue := true From 469321874a156675e8a0dc73e12d35e759b37a4b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Jul 2026 11:20:36 +0200 Subject: [PATCH 7/8] docs(pe): document filter semantics and validation rules The Validate godoc carries the matcher's filter truth table (enum shadows other keywords, const and pattern are string-only, numeric values cannot be value-constrained) so the rules the validator encodes are stated next to the code that enforces them. The policy deployment page gets a validation section for operators and policy authors: what is checked at startup, an example aggregated error, and authoring guidance (issue filterable claims as strings, declare ids, keep same-id filters compatible). Assisted-by: AI --- docs/pages/deployment/policy.rst | 38 ++++++++++++++++++++++++++++++++ vcr/pe/validate.go | 22 ++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index a50875a0bd..cff3ea18bc 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -132,6 +132,44 @@ You can define the following field in the input descriptor constraint, to have t Only 1 capture group is supported in regular expressions. If multiple capture groups are defined, an error will be returned. +Presentation definition validation +================================== + +The node validates presentation definitions when loading policy files, and refuses to start on a definition +that would fail or misbehave on every request. All problems are reported at once. The checks are: + +- **Duplicate ids.** An input descriptor id may be used once per definition, and a field id once per + input descriptor. The same field id on fields of *different* descriptors is allowed and meaningful: + the chosen credentials must agree on that field's value. +- **Same-id consistency.** Filters on fields that share an id must allow at least one common value. + For example, a ``const`` on one descriptor that is not in another descriptor's ``enum`` for the same + id can never be satisfied and fails validation. Two ``pattern`` filters without any ``const`` or + ``enum`` are not checked (undecidable); such a conflict surfaces at request time instead. +- **Filters that can never match.** A ``const`` combined with a non-string ``type``, or an empty + ``enum``, rejects every credential. A ``pattern`` that does not compile, or that has more than one + capture group, errors on every evaluation. +- **Ignored constraints.** Keywords the matcher does not evaluate are rejected instead of silently + weakening the filter: ``const`` or ``pattern`` next to ``enum`` (``enum`` takes precedence), + ``pattern`` on a non-string ``type``, and unsupported JSON Schema keywords such as ``minimum``. +- **Submission requirements.** Every group referenced by an input descriptor must be covered by a + submission requirement, rules must be ``all`` or ``pick``, exactly one of ``from``/``from_nested`` + must be set, and bounds must be possible (``min`` not above ``max``, no negative values). + +A failing definition is reported with every conflict, for example:: + + presentation definition 'example-care-pd' is invalid: active: const "true" can never match: it + requires type "string", declared type is "boolean"; org_ura: conflicting filter types: string vs number + +Guidance for policy authors: + +- Issue claims whose values must be filtered or selected **as strings**. The matcher can require a + value to be a number (``"type": "number"``), but cannot constrain a number's value: ``const``, + ``enum`` and ``pattern`` compare as strings, and numeric JSON Schema keywords are not evaluated. +- Give every claim used for logging, authorization, or credential selection a distinct field ``id``; + only declared field ids are returned by token introspection and usable in ``credential_selection``. +- When reusing a field ``id`` across input descriptors, make sure the filters agree: the id binds a + single value across the chosen credentials. + Two-VP flow and cross-VP binding (experimental) *********************************************** diff --git a/vcr/pe/validate.go b/vcr/pe/validate.go index 22ee710a1c..46c692be3e 100644 --- a/vcr/pe/validate.go +++ b/vcr/pe/validate.go @@ -75,6 +75,28 @@ func (e *PDValidationError) Error() string { // semantic pass on the parsed definition, layered after JSON-schema validation (v2.Validate), // which cannot express these cross-field rules. It returns a PDValidationError aggregating every // conflict, or nil. +// +// # Filter semantics +// +// The checks follow the matcher's actual filter semantics (matchFilter), which implement a +// subset of JSON Schema with these properties: +// +// - enum shadows every other keyword: when enum is set, type, const and pattern are not +// evaluated, and the enum values compare as strings. +// - const compares as a string and therefore requires type "string"; combined with any other +// type the filter can never match. +// - pattern applies only when type is "string"; on any other type it is not evaluated. +// - numeric values can be required to be numbers (type "number"), but their value cannot be +// constrained: const, enum and pattern are string-only, and the numeric JSON Schema keywords +// (minimum, maximum, multipleOf) are not evaluated. Issue claims as strings when their +// values must be filtered or selected. +// - any other JSON Schema keyword is parsed but not evaluated; Validate reports it as an +// ignored constraint, because the filter matches more than its author declared. +// +// A field id used on fields of several descriptors binds one value across the chosen +// credentials, so the filters carrying the id are checked for a common acceptable value. The +// only case deferred to request time is two or more patterns with no const or enum anywhere: +// pattern intersection is undecidable in general. func Validate(pd PresentationDefinition) error { var conflicts []FieldIDConflict conflicts = append(conflicts, duplicateConflicts(pd)...) From dfed8f987b9981b743ce46a367236eeea21b789c Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Jul 2026 11:31:04 +0200 Subject: [PATCH 8/8] refactor(pe): apply validator self-review findings Unrecognized filter type values (e.g. "integer", valid JSON Schema but not evaluated by the matcher) are now flagged; annotation keywords (description, title, examples, ...) are no longer captured as unsupported, so they cannot fail a valid PD. FieldIDConflict is renamed to ValidationConflict with a Subject field: it names field ids, descriptor ids, groups or submission requirement positions, and a field called FieldID holding four kinds of identifier misleads every consumer. The docs section is promoted to a top-level heading, Error() methods gained doc comments, and tests were added for three-way intersections, dead filters in shared ids, duplicate participation, the stable error string, the production parse path for unsupported keywords, and the array/pattern guard shapes. Assisted-by: AI --- docs/pages/deployment/policy.rst | 2 +- vcr/pe/presentation_definition_test.go | 26 +++++- vcr/pe/types.go | 3 + vcr/pe/validate.go | 122 ++++++++++++++----------- vcr/pe/validate_test.go | 118 +++++++++++++++++++++++- 5 files changed, 209 insertions(+), 62 deletions(-) diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index cff3ea18bc..7be9bd3246 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -133,7 +133,7 @@ You can define the following field in the input descriptor constraint, to have t Only 1 capture group is supported in regular expressions. If multiple capture groups are defined, an error will be returned. Presentation definition validation -================================== +********************************** The node validates presentation definitions when loading policy files, and refuses to start on a definition that would fail or misbehave on every request. All problems are reported at once. The checks are: diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index d4df8eb6e9..7a3a5b63cc 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -764,17 +764,35 @@ func Test_matchField(t *testing.T) { } func Test_matchFilter(t *testing.T) { - t.Run("array value with a string pattern filter and no matching element does not panic", func(t *testing.T) { + t.Run("array values with a string pattern filter do not panic", func(t *testing.T) { // an array whose elements all fail the filter falls through the type switch; the // pattern check must not assume the value is a string pattern := "^Nuts" filter := Filter{Type: "string", Pattern: &pattern} - match, value, err := matchFilter(filter, []interface{}{"SomethingElse"}) + for _, value := range [][]interface{}{ + {"SomethingElse"}, + {"SomethingElse", "AlsoNoMatch"}, + {42.0, true}, + {}, + } { + match, matched, err := matchFilter(filter, value) + + require.NoError(t, err) + assert.False(t, match) + assert.Nil(t, matched) + } + }) + + t.Run("an array element matching the pattern still returns the match", func(t *testing.T) { + pattern := "^Nuts" + filter := Filter{Type: "string", Pattern: &pattern} + + match, value, err := matchFilter(filter, []interface{}{"SomethingElse", "NutsOrganizationCredential"}) require.NoError(t, err) - assert.False(t, match) - assert.Nil(t, value) + assert.True(t, match) + assert.Equal(t, []interface{}{"SomethingElse", "NutsOrganizationCredential"}, value) }) // values for pointer fields diff --git a/vcr/pe/types.go b/vcr/pe/types.go index 84241c71c2..86e2e585ab 100644 --- a/vcr/pe/types.go +++ b/vcr/pe/types.go @@ -157,6 +157,9 @@ func (f *Filter) UnmarshalJSON(data []byte) error { for keyword := range raw { switch keyword { case "type", "const", "enum", "pattern": + // annotation keywords carry no constraint, so dropping them weakens nothing + case "description", "title", "$comment", "$schema", "$id", "examples", "default", + "readOnly", "writeOnly", "deprecated": default: f.unsupported = append(f.unsupported, keyword) } diff --git a/vcr/pe/validate.go b/vcr/pe/validate.go index 46c692be3e..3344d5cc2b 100644 --- a/vcr/pe/validate.go +++ b/vcr/pe/validate.go @@ -26,7 +26,7 @@ import ( "github.com/dlclark/regexp2" ) -// ConflictKind classifies a FieldIDConflict. +// ConflictKind classifies a ValidationConflict. type ConflictKind string const ( @@ -46,10 +46,11 @@ const ( ConflictSubmissionRequirement ConflictKind = "submission_requirement" ) -// FieldIDConflict is one problem found by Validate. FieldID names the field id involved; for -// structural problems it names the input descriptor id, group, or submission requirement instead. -type FieldIDConflict struct { - FieldID string +// ValidationConflict is one problem found by Validate. Subject names what the conflict is +// about: a field id for filter and same-id problems, an input descriptor id for duplicates and +// id-less fields, or a group name / submission requirement position for structural problems. +type ValidationConflict struct { + Subject string Kind ConflictKind Detail string } @@ -58,13 +59,14 @@ type FieldIDConflict struct { // definition, sorted, so the author sees all problems at once. type PDValidationError struct { PDID string - Conflicts []FieldIDConflict + Conflicts []ValidationConflict } +// Error renders every conflict in one line, for logs and startup output. func (e *PDValidationError) Error() string { lines := make([]string, len(e.Conflicts)) for i, conflict := range e.Conflicts { - lines[i] = conflict.FieldID + ": " + conflict.Detail + lines[i] = conflict.Subject + ": " + conflict.Detail } return fmt.Sprintf("presentation definition '%s' is invalid: %s", e.PDID, strings.Join(lines, "; ")) } @@ -98,7 +100,7 @@ func (e *PDValidationError) Error() string { // only case deferred to request time is two or more patterns with no const or enum anywhere: // pattern intersection is undecidable in general. func Validate(pd PresentationDefinition) error { - var conflicts []FieldIDConflict + var conflicts []ValidationConflict conflicts = append(conflicts, duplicateConflicts(pd)...) conflicts = append(conflicts, filterSanityConflicts(pd)...) conflicts = append(conflicts, sameIDConflicts(pd)...) @@ -108,8 +110,8 @@ func Validate(pd PresentationDefinition) error { } sort.Slice(conflicts, func(i, j int) bool { a, b := conflicts[i], conflicts[j] - if a.FieldID != b.FieldID { - return a.FieldID < b.FieldID + if a.Subject != b.Subject { + return a.Subject < b.Subject } if a.Kind != b.Kind { return a.Kind < b.Kind @@ -122,15 +124,15 @@ func Validate(pd PresentationDefinition) error { // duplicateConflicts reports input descriptor ids used more than once, and field ids declared // more than once within a single constraints object. The same field id on different descriptors // is not a duplicate: that is the binding mechanism. -func duplicateConflicts(pd PresentationDefinition) []FieldIDConflict { - var conflicts []FieldIDConflict +func duplicateConflicts(pd PresentationDefinition) []ValidationConflict { + var conflicts []ValidationConflict descriptorSeen := make(map[string]bool) descriptorReported := make(map[string]bool) for _, descriptor := range pd.InputDescriptors { if descriptorSeen[descriptor.Id] && !descriptorReported[descriptor.Id] { descriptorReported[descriptor.Id] = true - conflicts = append(conflicts, FieldIDConflict{ - FieldID: descriptor.Id, + conflicts = append(conflicts, ValidationConflict{ + Subject: descriptor.Id, Kind: ConflictDuplicate, Detail: fmt.Sprintf("input descriptor id '%s' is declared more than once", descriptor.Id), }) @@ -147,8 +149,8 @@ func duplicateConflicts(pd PresentationDefinition) []FieldIDConflict { } if fieldSeen[*field.Id] && !fieldReported[*field.Id] { fieldReported[*field.Id] = true - conflicts = append(conflicts, FieldIDConflict{ - FieldID: *field.Id, + conflicts = append(conflicts, ValidationConflict{ + Subject: *field.Id, Kind: ConflictDuplicate, Detail: fmt.Sprintf("field id '%s' is declared more than once in input descriptor '%s'", *field.Id, descriptor.Id), }) @@ -163,8 +165,8 @@ func duplicateConflicts(pd PresentationDefinition) []FieldIDConflict { // (see matchFilter): filters that can never match anything, patterns that error, and declared // constraints the matcher silently ignores (leaving the filter weaker than its author intended). // A conflict is reported under the field id, or under the descriptor id for id-less fields. -func filterSanityConflicts(pd PresentationDefinition) []FieldIDConflict { - var conflicts []FieldIDConflict +func filterSanityConflicts(pd PresentationDefinition) []ValidationConflict { + var conflicts []ValidationConflict for _, descriptor := range pd.InputDescriptors { if descriptor.Constraints == nil { continue @@ -178,7 +180,7 @@ func filterSanityConflicts(pd PresentationDefinition) []FieldIDConflict { subject = *field.Id } for _, conflict := range singleFilterConflicts(*field.Filter) { - conflict.FieldID = subject + conflict.Subject = subject conflicts = append(conflicts, conflict) } } @@ -186,11 +188,11 @@ func filterSanityConflicts(pd PresentationDefinition) []FieldIDConflict { return conflicts } -// singleFilterConflicts derives the problems of one filter; FieldID is filled in by the caller. -func singleFilterConflicts(filter Filter) []FieldIDConflict { - var conflicts []FieldIDConflict +// singleFilterConflicts derives the problems of one filter; Subject is filled in by the caller. +func singleFilterConflicts(filter Filter) []ValidationConflict { + var conflicts []ValidationConflict if len(filter.unsupported) > 0 { - conflicts = append(conflicts, FieldIDConflict{ + conflicts = append(conflicts, ValidationConflict{ Kind: ConflictIgnoredConstraint, Detail: fmt.Sprintf("unsupported filter keywords [%s] are not evaluated: the filter is weaker than declared", strings.Join(filter.unsupported, ", ")), }) @@ -199,25 +201,25 @@ func singleFilterConflicts(filter Filter) []FieldIDConflict { if filter.Enum != nil { // enum shadows type, const and pattern (matchFilter returns from the enum branch) if len(filter.Enum) == 0 { - conflicts = append(conflicts, FieldIDConflict{ + conflicts = append(conflicts, ValidationConflict{ Kind: ConflictUnsatisfiable, Detail: "enum is empty: the filter can never match", }) } if filter.Const != nil { - conflicts = append(conflicts, FieldIDConflict{ + conflicts = append(conflicts, ValidationConflict{ Kind: ConflictIgnoredConstraint, Detail: fmt.Sprintf("const %q is ignored because enum is set", *filter.Const), }) } if filter.Pattern != nil { - conflicts = append(conflicts, FieldIDConflict{ + conflicts = append(conflicts, ValidationConflict{ Kind: ConflictIgnoredConstraint, Detail: "pattern is ignored because enum is set", }) } if filter.Type != "" && filter.Type != "string" { - conflicts = append(conflicts, FieldIDConflict{ + conflicts = append(conflicts, ValidationConflict{ Kind: ConflictIgnoredConstraint, Detail: fmt.Sprintf("type %q is ignored because enum forces string matching", filter.Type), }) @@ -225,9 +227,20 @@ func singleFilterConflicts(filter Filter) []FieldIDConflict { return conflicts } + switch filter.Type { + case "", "string", "number", "boolean": + default: + // matchFilter's type gate recognizes string, number and boolean only; any other value + // (e.g. "integer", "object") rejects every scalar and matches array values by accident + conflicts = append(conflicts, ValidationConflict{ + Kind: ConflictIgnoredConstraint, + Detail: fmt.Sprintf("type %q is not evaluated: the matcher recognizes \"string\", \"number\" and \"boolean\" only", filter.Type), + }) + } + if filter.Const != nil && filter.Type != "string" { // the const compares as a string, but the type gate never lets a string value through - conflicts = append(conflicts, FieldIDConflict{ + conflicts = append(conflicts, ValidationConflict{ Kind: ConflictUnsatisfiable, Detail: fmt.Sprintf("const %q can never match: it requires type \"string\", declared type is %q", *filter.Const, filter.Type), }) @@ -237,26 +250,26 @@ func singleFilterConflicts(filter Filter) []FieldIDConflict { pattern, err := regexp2.Compile(*filter.Pattern, regexp2.ECMAScript) switch { case err != nil: - conflicts = append(conflicts, FieldIDConflict{ + conflicts = append(conflicts, ValidationConflict{ Kind: ConflictInvalidPattern, Detail: fmt.Sprintf("pattern %q does not compile: %s", *filter.Pattern, err), }) case len(pattern.GetGroupNumbers()) > 2: // matchFilter returns an error whenever a pattern with more than one capture // group matches a value - conflicts = append(conflicts, FieldIDConflict{ + conflicts = append(conflicts, ValidationConflict{ Kind: ConflictInvalidPattern, Detail: fmt.Sprintf("pattern %q has more than one capture group: every match errors", *filter.Pattern), }) case filter.Type != "string": // the matcher applies patterns to string-typed filters only - conflicts = append(conflicts, FieldIDConflict{ + conflicts = append(conflicts, ValidationConflict{ Kind: ConflictIgnoredConstraint, Detail: fmt.Sprintf("pattern is ignored because the declared type is %q: patterns apply to strings only", filter.Type), }) case filter.Const != nil: if match, _ := pattern.FindStringMatch(*filter.Const); match == nil { - conflicts = append(conflicts, FieldIDConflict{ + conflicts = append(conflicts, ValidationConflict{ Kind: ConflictUnsatisfiable, Detail: fmt.Sprintf("const %q does not match the field's own pattern %q", *filter.Const, *filter.Pattern), }) @@ -326,7 +339,7 @@ type idOccurrence struct { // the id binds to must be able to satisfy all of them at once (Policy 7). Types must agree, and // the intersection of the accepted-value sets must not be provably empty. Only the // pattern-versus-pattern case (no const or enum anywhere) is deferred to request time. -func sameIDConflicts(pd PresentationDefinition) []FieldIDConflict { +func sameIDConflicts(pd PresentationDefinition) []ValidationConflict { occurrences := make(map[string][]idOccurrence) var order []string for _, descriptor := range pd.InputDescriptors { @@ -347,7 +360,7 @@ func sameIDConflicts(pd PresentationDefinition) []FieldIDConflict { } } - var conflicts []FieldIDConflict + var conflicts []ValidationConflict for _, id := range order { fields := occurrences[id] if len(fields) < 2 { @@ -374,8 +387,8 @@ func sameIDConflicts(pd PresentationDefinition) []FieldIDConflict { names = append(names, name) } sort.Strings(names) - conflicts = append(conflicts, FieldIDConflict{ - FieldID: id, + conflicts = append(conflicts, ValidationConflict{ + Subject: id, Kind: ConflictType, Detail: "conflicting filter types: " + strings.Join(names, " vs "), }) @@ -399,8 +412,8 @@ func sameIDConflicts(pd PresentationDefinition) []FieldIDConflict { continue // universes always overlap; pattern-versus-pattern is deferred } if len(candidates) == 0 { - conflicts = append(conflicts, FieldIDConflict{ - FieldID: id, + conflicts = append(conflicts, ValidationConflict{ + Subject: id, Kind: ConflictUnsatisfiable, Detail: "const/enum values across descriptors share no common value", }) @@ -412,8 +425,8 @@ func sameIDConflicts(pd PresentationDefinition) []FieldIDConflict { } candidates = matchingCandidates(candidates, set.pattern) if len(candidates) == 0 { - conflicts = append(conflicts, FieldIDConflict{ - FieldID: id, + conflicts = append(conflicts, ValidationConflict{ + Subject: id, Kind: ConflictUnsatisfiable, Detail: fmt.Sprintf("no shared const/enum value matches pattern '%s'", set.pattern.String()), }) @@ -429,11 +442,11 @@ func sameIDConflicts(pd PresentationDefinition) []FieldIDConflict { // violations, impossible bounds, and a rule demanding credentials from a group no descriptor // carries. Groups are only meaningful when submission requirements are present; without them the // matcher ignores groups entirely. -func submissionRequirementConflicts(pd PresentationDefinition) []FieldIDConflict { +func submissionRequirementConflicts(pd PresentationDefinition) []ValidationConflict { if len(pd.SubmissionRequirements) == 0 { return nil } - var conflicts []FieldIDConflict + var conflicts []ValidationConflict covered := make(map[string]bool) for _, requirement := range pd.SubmissionRequirements { @@ -447,8 +460,8 @@ func submissionRequirementConflicts(pd PresentationDefinition) []FieldIDConflict descriptorGroups[group] = true if !covered[group] { covered[group] = true // report once - conflicts = append(conflicts, FieldIDConflict{ - FieldID: group, + conflicts = append(conflicts, ValidationConflict{ + Subject: group, Kind: ConflictSubmissionRequirement, Detail: fmt.Sprintf("group '%s' (input descriptor '%s') is not covered by any submission requirement: every request fails", group, descriptor.Id), }) @@ -462,8 +475,8 @@ func submissionRequirementConflicts(pd PresentationDefinition) []FieldIDConflict name = fmt.Sprintf("%s (%s)", name, requirement.Name) } if requirement.Rule != "all" && requirement.Rule != "pick" { - conflicts = append(conflicts, FieldIDConflict{ - FieldID: name, + conflicts = append(conflicts, ValidationConflict{ + Subject: name, Kind: ConflictSubmissionRequirement, Detail: fmt.Sprintf("unknown rule %q (must be \"all\" or \"pick\")", requirement.Rule), }) @@ -471,8 +484,8 @@ func submissionRequirementConflicts(pd PresentationDefinition) []FieldIDConflict hasFrom := requirement.From != "" hasNested := len(requirement.FromNested) > 0 if hasFrom == hasNested { - conflicts = append(conflicts, FieldIDConflict{ - FieldID: name, + conflicts = append(conflicts, ValidationConflict{ + Subject: name, Kind: ConflictSubmissionRequirement, Detail: "exactly one of 'from' and 'from_nested' must be set", }) @@ -482,16 +495,16 @@ func submissionRequirementConflicts(pd PresentationDefinition) []FieldIDConflict value *int }{{"count", requirement.Count}, {"min", requirement.Min}, {"max", requirement.Max}} { if bound.value != nil && *bound.value < 0 { - conflicts = append(conflicts, FieldIDConflict{ - FieldID: name, + conflicts = append(conflicts, ValidationConflict{ + Subject: name, Kind: ConflictSubmissionRequirement, Detail: fmt.Sprintf("'%s' must not be negative", bound.name), }) } } if requirement.Min != nil && requirement.Max != nil && *requirement.Min > *requirement.Max { - conflicts = append(conflicts, FieldIDConflict{ - FieldID: name, + conflicts = append(conflicts, ValidationConflict{ + Subject: name, Kind: ConflictSubmissionRequirement, Detail: fmt.Sprintf("'min' (%d) exceeds 'max' (%d)", *requirement.Min, *requirement.Max), }) @@ -501,8 +514,8 @@ func submissionRequirementConflicts(pd PresentationDefinition) []FieldIDConflict demandsCredentials := (requirement.Count != nil && *requirement.Count > 0) || (requirement.Min != nil && *requirement.Min > 0) if hasFrom && demandsCredentials && !descriptorGroups[requirement.From] { - conflicts = append(conflicts, FieldIDConflict{ - FieldID: requirement.From, + conflicts = append(conflicts, ValidationConflict{ + Subject: requirement.From, Kind: ConflictSubmissionRequirement, Detail: fmt.Sprintf("no input descriptor carries group '%s', but the rule demands credentials from it: every request fails", requirement.From), }) @@ -552,6 +565,7 @@ type UnknownSelectionKeysError struct { Keys []string } +// Error lists every unknown key in one line. func (e *UnknownSelectionKeysError) Error() string { return "unknown credential_selection keys: " + strings.Join(e.Keys, ", ") } diff --git a/vcr/pe/validate_test.go b/vcr/pe/validate_test.go index 23e5f604b4..656e98c7ce 100644 --- a/vcr/pe/validate_test.go +++ b/vcr/pe/validate_test.go @@ -114,6 +114,27 @@ func TestValidateSelectionKeys(t *testing.T) { t.Run("nil selection passes", func(t *testing.T) { assert.NoError(t, ValidateSelectionKeys(nil, mustParsePD(t, singlePD))) }) + + t.Run("id-less fields do not make the empty key known", func(t *testing.T) { + err := ValidateSelectionKeys(map[string]string{"": "x"}, mustParsePD(t, singlePD)) + + var unknownErr *UnknownSelectionKeysError + require.ErrorAs(t, err, &unknownErr) + assert.Equal(t, []string{""}, unknownErr.Keys) + }) + + t.Run("keys are harvested regardless of PD validity", func(t *testing.T) { + // a duplicated descriptor id fails Validate, but key validation stays decoupled + pd := mustParsePD(t, `{ + "id": "invalid-pd", + "input_descriptors": [ + {"id": "d1", "constraints": {"fields": [{"id": "patient_bsn", "path": ["$.a"]}]}}, + {"id": "d1", "constraints": {"fields": [{"path": ["$.b"]}]}} + ] + }`) + + assert.NoError(t, ValidateSelectionKeys(map[string]string{"patient_bsn": "x"}, pd)) + }) } // twoFieldPD builds a PD with two descriptors, each carrying one field with the given id and @@ -142,7 +163,7 @@ func conflictKinds(t *testing.T, err error) map[string][]ConflictKind { require.ErrorAs(t, err, &pdErr) kinds := make(map[string][]ConflictKind) for _, conflict := range pdErr.Conflicts { - kinds[conflict.FieldID] = append(kinds[conflict.FieldID], conflict.Kind) + kinds[conflict.Subject] = append(kinds[conflict.Subject], conflict.Kind) } return kinds } @@ -310,13 +331,73 @@ func TestValidate_SameIDConsistency(t *testing.T) { var pdErr *PDValidationError require.ErrorAs(t, err, &pdErr) require.Len(t, pdErr.Conflicts, 2) - assert.Equal(t, "alpha", pdErr.Conflicts[0].FieldID) + assert.Equal(t, "alpha", pdErr.Conflicts[0].Subject) assert.Equal(t, ConflictType, pdErr.Conflicts[0].Kind) - assert.Equal(t, "zeta", pdErr.Conflicts[1].FieldID) + assert.Equal(t, "zeta", pdErr.Conflicts[1].Subject) assert.Equal(t, ConflictUnsatisfiable, pdErr.Conflicts[1].Kind) assert.ErrorContains(t, err, "test-pd") }) + t.Run("three-way intersection: pairwise overlapping but globally disjoint enums", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "d1", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "enum": ["A", "B"]}}]}}, + {"id": "d2", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "enum": ["B", "C"]}}]}}, + {"id": "d3", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "enum": ["A", "C"]}}]}} + ] + }`) + + kinds := conflictKinds(t, Validate(pd)) + assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"]) + }) + + t.Run("three-way intersection with a surviving value passes", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "d1", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "enum": ["A", "B"]}}]}}, + {"id": "d2", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "enum": ["B", "C"]}}]}}, + {"id": "d3", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "enum": ["B"]}}]}} + ] + }`) + assert.NoError(t, Validate(pd)) + }) + + t.Run("a within-descriptor duplicate still participates in same-id consistency", func(t *testing.T) { + pd := mustParsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "d1", "constraints": {"fields": [ + {"id": "x", "path": ["$.a"], "filter": {"type": "string", "const": "A"}}, + {"id": "x", "path": ["$.b"], "filter": {"type": "string", "const": "B"}} + ]}}, + {"id": "d2", "constraints": {"fields": [{"id": "x", "path": ["$.a"], "filter": {"type": "string", "const": "A"}}]}} + ] + }`) + + kinds := conflictKinds(t, Validate(pd)) + assert.ElementsMatch(t, []ConflictKind{ConflictDuplicate, ConflictUnsatisfiable}, kinds["x"]) + }) + + t.Run("a dead filter is excluded from same-id consistency", func(t *testing.T) { + // the dead filter earns its own conflict; it must not also produce a bogus type conflict + err := Validate(twoFieldPD(t, "x", + `{"type": "boolean", "const": "true"}`, + `{"type": "string", "const": "A"}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictUnsatisfiable}, kinds["x"]) + }) + + t.Run("the aggregated error string is stable", func(t *testing.T) { + err := Validate(twoFieldPD(t, "x", + `{"type": "string", "const": "A"}`, + `{"type": "string", "const": "B"}`)) + + assert.EqualError(t, err, "presentation definition 'test-pd' is invalid: x: const/enum values across descriptors share no common value") + }) + t.Run("descriptors without constraints are skipped", func(t *testing.T) { pd := mustParsePD(t, `{ "id": "test-pd", @@ -543,6 +624,37 @@ func TestValidate_FilterSanity(t *testing.T) { assert.NoError(t, Validate(pd)) }) + t.Run("annotation keywords are not flagged", func(t *testing.T) { + assert.NoError(t, Validate(singleFilterPD(t, + `{"type": "string", "pattern": "^Nuts", "description": "the credential type", "title": "Type", "examples": ["NutsOrganizationCredential"]}`))) + }) + + t.Run("unrecognized type values are flagged", func(t *testing.T) { + // matchFilter's type gate recognizes string, number and boolean only; "integer" is + // valid JSON Schema but rejects every scalar at runtime + err := Validate(singleFilterPD(t, `{"type": "integer"}`)) + + kinds := conflictKinds(t, err) + assert.Equal(t, []ConflictKind{ConflictIgnoredConstraint}, kinds["x"]) + assert.ErrorContains(t, err, "integer") + }) + + t.Run("unsupported keywords survive the production parser", func(t *testing.T) { + // ParsePresentationDefinition runs the DIF JSON schema first (which allows any draft-07 + // keyword), then unmarshals; the capture must reach Validate through that path + pd, err := ParsePresentationDefinition([]byte(`{ + "id": "e2e-pd", + "input_descriptors": [{ + "id": "d1", + "constraints": {"fields": [{"id": "x", "path": ["$.credentialSubject.age"], "filter": {"type": "number", "minimum": 18}}]} + }] + }`)) + require.NoError(t, err) + + kinds := conflictKinds(t, Validate(*pd)) + assert.Equal(t, []ConflictKind{ConflictIgnoredConstraint}, kinds["x"]) + }) + t.Run("allowed: well-formed filters", func(t *testing.T) { assert.NoError(t, Validate(singleFilterPD(t, `{"type": "string", "const": "A", "pattern": "^[A-Z]$"}`))) assert.NoError(t, Validate(singleFilterPD(t, `{"type": "string", "enum": ["A", "B"]}`)))