Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 40 additions & 34 deletions vcr/pe/match_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,11 @@ type Dismissal struct {
}

// buildReport assembles the MatchReport after a Select run. It re-evaluates step 1 per descriptor
// and candidate to recover the dismissal reasons, so a non-traced run pays nothing. Binding
// conflicts are explained against the decisive assignment (the chosen one, or the best-effort
// diagnostic assignment on failure), not against every backtracking visit.
func buildReport(pd PresentationDefinition, candidates []vc.VerifiableCredential, required []bool,
// and candidate (on the pre-converted credentials, so no repeated JSON conversion) to recover
// the dismissal reasons, so a non-traced run pays nothing. Binding conflicts are explained
// against the decisive assignment (the chosen one, or the best-effort diagnostic assignment on
// failure), not against every backtracking visit.
func buildReport(pd PresentationDefinition, candidates []evaluatedCredential, required []bool,
assignment []*candidateGroup, ambiguous []string, result Result, err error, initialBindings map[string]string) *MatchReport {
report := &MatchReport{
Outcome: OutcomeMatched,
Expand Down Expand Up @@ -194,31 +195,36 @@ func buildReport(pd PresentationDefinition, candidates []vc.VerifiableCredential
var selectedIDValues map[string]string
selectedKey := ""
if selected != nil {
_, selectedIDValues, _ = evaluateCandidate(pd, *descriptor, *selected)
for _, candidate := range candidates {
if vcEqual(candidate.vc, *selected) {
_, selectedIDValues, _ = evaluateCandidate(*descriptor, candidate)
break
}
}
selectedKey = tupleKey(selectedIDValues)
}
selectedSeen := false
for _, candidate := range candidates {
candidateReport := CandidateReport{}
if candidate.ID != nil {
candidateReport.CredentialID = candidate.ID.String()
if candidate.vc.ID != nil {
candidateReport.CredentialID = candidate.vc.ID.String()
}
if selected != nil && !selectedSeen && vcEqual(candidate, *selected) {
if selected != nil && !selectedSeen && vcEqual(candidate.vc, *selected) {
// the chosen credential carries no dismissal; its evaluation is already done
selectedSeen = true
candidateReport.Eligible = true
descriptorReport.Considered = append(descriptorReport.Considered, candidateReport)
continue
}
eligible, idValues, _ := evaluateCandidate(pd, *descriptor, candidate)
eligible, idValues, evalErr := evaluateCandidate(*descriptor, candidate)
candidateReport.Eligible = eligible
if !eligible {
candidateReport.Dismissal = explainIneligible(pd, *descriptor, candidate)
candidateReport.Dismissal = explainIneligible(*descriptor, candidate, evalErr)
} else {
candidateReport.Dismissal = explainNotChosen(idValues, bindings, strictIDs)
// An interchangeable alternative (same binding tuple as the chosen credential)
// whose subject nevertheless differs is worth the operator's attention.
if selected != nil && tupleKey(idValues) == selectedKey && !subjectsEqual(candidate, *selected) {
if selected != nil && tupleKey(idValues) == selectedKey && !subjectsEqual(candidate.vc, *selected) {
descriptorReport.DivergingAlternatives = true
}
}
Expand All @@ -237,49 +243,49 @@ func subjectsEqual(a, b vc.VerifiableCredential) bool {
return errA == nil && errB == nil && bytes.Equal(aJSON, bJSON)
}

// explainIneligible pinpoints why a credential failed step 1: the first failing constraint field
// (filter rejection or missing value), or the format gate.
func explainIneligible(pd PresentationDefinition, descriptor InputDescriptor, credential vc.VerifiableCredential) *Dismissal {
// explainIneligible derives the dismissal for a credential that failed step 1 from the same
// primitive the selection uses: the failing field's outcome (matchConstraintOnMap), or the
// format gate.
func explainIneligible(descriptor InputDescriptor, candidate evaluatedCredential, evalErr error) *Dismissal {
if evalErr != nil {
return &Dismissal{Reason: ReasonNoValue, Message: evalErr.Error()}
}
if descriptor.Constraints != nil {
credentialJSON, err := credentialAsMap(credential)
isMatch, fields, err := matchConstraintOnMap(descriptor.Constraints, candidate.json)
if err != nil {
return &Dismissal{Reason: ReasonNoValue, Message: err.Error()}
}
for _, field := range descriptor.Constraints.Fields {
if match, _, err := matchField(field, credentialJSON); err == nil && match {
continue
}
return explainFieldMismatch(field, credentialJSON)
if !isMatch {
// on a mismatch the last outcome is the failing field's
failed := len(fields) - 1
return renderFieldDismissal(descriptor.Constraints.Fields[failed], fields[failed])
}
}
dismissal := &Dismissal{Reason: ReasonFormat}
dismissal.Message = fmt.Sprintf("credential format does not satisfy the format requirements of the presentation definition or input descriptor '%s'", descriptor.Id)
return dismissal
return &Dismissal{
Reason: ReasonFormat,
Message: fmt.Sprintf("credential format does not satisfy the format requirements of the presentation definition or input descriptor '%s'", descriptor.Id),
}
}

// explainFieldMismatch distinguishes "a value was found but the filter rejected it" from "no path
// produced a value".
func explainFieldMismatch(field Field, credentialJSON map[string]interface{}) *Dismissal {
// renderFieldDismissal renders a failing field outcome: a value was found but the filter rejected
// it, or no path produced a value.
func renderFieldDismissal(field Field, outcome fieldResult) *Dismissal {
dismissal := &Dismissal{}
if field.Id != nil {
dismissal.FieldID = *field.Id
}
for _, path := range field.Path {
value, err := getValueAtPath(path, credentialJSON)
if err != nil || value == nil {
continue
}
if outcome.rejectedValue != nil {
dismissal.Reason = ReasonFilter
dismissal.Path = path
dismissal.Found = fmt.Sprintf("%v", value)
dismissal.Path = outcome.rejectedPath
dismissal.Found = fmt.Sprintf("%v", outcome.rejectedValue)
if field.Filter != nil {
if field.Filter.Const != nil {
dismissal.Expected = *field.Filter.Const
} else {
dismissal.Expected = field.Filter.Type
}
}
dismissal.Message = fmt.Sprintf("value at %s does not satisfy the filter: expected %s, found %s", path, dismissal.Expected, dismissal.Found)
dismissal.Message = fmt.Sprintf("value at %s does not satisfy the filter: expected %s, found %s", outcome.rejectedPath, dismissal.Expected, dismissal.Found)
return dismissal
}
dismissal.Reason = ReasonNoValue
Expand Down
75 changes: 54 additions & 21 deletions vcr/pe/presentation_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,27 +398,55 @@ func matchCredential(descriptor InputDescriptor, credential vc.VerifiableCredent
// LimitDisclosure is not supported for now.
// If the constraint matches, it returns true and a map containing constraint field IDs and matched values.
func matchConstraint(constraint *Constraints, credential vc.VerifiableCredential) (bool, map[string]interface{}, error) {
credentialAsMap, err := credentialAsMap(credential)
credentialJSON, err := credentialAsMap(credential)
if err != nil {
return false, nil, err
}

// for each field in constraint.fields:
// a vc must match the field
isMatch, fields, err := matchConstraintOnMap(constraint, credentialJSON)
if err != nil || !isMatch {
return false, nil, err
}
values := make(map[string]interface{})
for i, field := range constraint.Fields {
if field.Id != nil {
values[*field.Id] = fields[i].value
}
}
return true, values, nil
}

// matchConstraintOnMap matches the constraint against a credential already converted to its JSON
// object form (credentialAsMap), returning the per-field outcomes. All fields need to match; the
// evaluation stops at the first field that does not, so on a mismatch the last element of the
// returned slice is the failing field's outcome. Conversion is the caller's job, so that one
// conversion can serve many constraint evaluations: the selection engine evaluates every input
// descriptor against every candidate credential.
func matchConstraintOnMap(constraint *Constraints, credentialJSON map[string]interface{}) (bool, []fieldResult, error) {
results := make([]fieldResult, 0, len(constraint.Fields))
for _, field := range constraint.Fields {
match, value, err := matchField(field, credentialAsMap)
result, err := matchField(field, credentialJSON)
if err != nil {
return false, nil, err
}
if !match {
return false, nil, nil
}
if field.Id != nil {
values[*field.Id] = value
results = append(results, result)
if !result.match {
return false, results, nil
}
}
return true, values, nil
return true, results, nil
}

// fieldResult is the outcome of evaluating one constraint field against a credential. It carries
// enough detail for diagnostics, so no second evaluation pass is needed to explain a mismatch.
type fieldResult struct {
// match is whether the field accepts the credential.
match bool
// value is the resolved value; nil for an unresolved optional field.
value interface{}
// rejectedPath and rejectedValue describe the first path where a value was found but the
// filter rejected it. rejectedValue is nil when no path produced a value at all.
rejectedPath string
rejectedValue interface{}
}

// credentialAsMap converts a VC to a plain JSON object, the form the JSONPath evaluation works on.
Expand All @@ -439,44 +467,49 @@ func credentialAsMap(credential vc.VerifiableCredential) (map[string]interface{}
}
}

// matchField matches the field against the VC.
// If the field matches, it returns true and the matched value. The matched value can be nil if the field is optional.
// matchField matches the field against the VC (in its JSON object form).
// The matched value can be nil if the field is optional.
// All fields need to match unless optional is set to true and no values are found for all the paths.
func matchField(field Field, credential map[string]interface{}) (bool, interface{}, error) {
func matchField(field Field, credential map[string]interface{}) (fieldResult, error) {
// for each path in field.paths:
// a vc must match one of the path
var result fieldResult
var optionalInvalid int
for _, path := range field.Path {
// if path is not found continue
value, err := getValueAtPath(path, credential)
if err != nil {
return false, nil, fmt.Errorf("path %q: %w", path, err)
return fieldResult{}, fmt.Errorf("path %q: %w", path, err)
}
if value == nil {
continue
}

if field.Filter == nil {
return true, value, nil
return fieldResult{match: true, value: value}, nil
}

// if filter at path matches return true
match, matchedValue, err := matchFilter(*field.Filter, value)
if err != nil {
return false, nil, fmt.Errorf("path %q: %w", path, err)
return fieldResult{}, fmt.Errorf("path %q: %w", path, err)
}
if match {
return true, matchedValue, nil
return fieldResult{match: true, value: matchedValue}, nil
}
// if filter at path does not match continue and set optionalInvalid;
// remember the first rejection for diagnostics
if optionalInvalid == 0 {
result.rejectedPath, result.rejectedValue = path, value
}
// if filter at path does not match continue and set optionalInvalid
optionalInvalid++
}
// no matches, check optional. Optional is only valid if all paths returned no results
// not if a filter did not match
if field.Optional != nil && *field.Optional && optionalInvalid == 0 {
return true, nil, nil
return fieldResult{match: true}, nil
}
return false, nil, nil
return result, nil
}

// getValueAtPath uses the JSON path expression to get the value from the VC
Expand Down
60 changes: 30 additions & 30 deletions vcr/pe/presentation_definition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -674,58 +674,58 @@ func Test_matchField(t *testing.T) {
testCredentialMap, _ := remarshalToMap(testCredential)

t.Run("single path match", func(t *testing.T) {
match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field"}}, testCredentialMap)
result, err := matchField(Field{Path: []string{"$.credentialSubject.field"}}, testCredentialMap)

require.NoError(t, err)
assert.Equal(t, "value", value)
assert.True(t, match)
assert.Equal(t, "value", result.value)
assert.True(t, result.match)
})
t.Run("multi path match", func(t *testing.T) {
match, value, err := matchField(Field{Path: []string{"$.other", "$.credentialSubject.field"}}, testCredentialMap)
result, err := matchField(Field{Path: []string{"$.other", "$.credentialSubject.field"}}, testCredentialMap)

require.NoError(t, err)
assert.Equal(t, "value", value)
assert.True(t, match)
assert.Equal(t, "value", result.value)
assert.True(t, result.match)
})
t.Run("no match", func(t *testing.T) {
match, value, err := matchField(Field{Path: []string{"$.foo", "$.bar"}}, testCredentialMap)
result, err := matchField(Field{Path: []string{"$.foo", "$.bar"}}, testCredentialMap)

require.NoError(t, err)
assert.Nil(t, value)
assert.False(t, match)
assert.Nil(t, result.value)
assert.False(t, result.match)
})
t.Run("no match, but optional", func(t *testing.T) {
trueVal := true
match, value, err := matchField(Field{Path: []string{"$.foo", "$.bar"}, Optional: &trueVal}, testCredentialMap)
result, err := matchField(Field{Path: []string{"$.foo", "$.bar"}, Optional: &trueVal}, testCredentialMap)

require.NoError(t, err)
assert.Nil(t, value)
assert.True(t, match)
assert.Nil(t, result.value)
assert.True(t, result.match)
})
t.Run("invalid match and optional", func(t *testing.T) {
trueVal := true
stringVal := "bar"
match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field", "$.foo"}, Optional: &trueVal, Filter: &Filter{Const: &stringVal}}, testCredentialMap)
result, err := matchField(Field{Path: []string{"$.credentialSubject.field", "$.foo"}, Optional: &trueVal, Filter: &Filter{Const: &stringVal}}, testCredentialMap)

require.NoError(t, err)
assert.Nil(t, value)
assert.False(t, match)
assert.Nil(t, result.value)
assert.False(t, result.match)
})
t.Run("valid match with Filter", func(t *testing.T) {
stringVal := "value"
match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap)
result, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap)

require.NoError(t, err)
assert.Equal(t, stringVal, value)
assert.True(t, match)
assert.Equal(t, stringVal, result.value)
assert.True(t, result.match)
})
t.Run("match on type", func(t *testing.T) {
stringVal := "VerifiableCredential"
match, value, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap)
result, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap)

require.NoError(t, err)
assert.Equal(t, stringVal, value)
assert.True(t, match)
assert.Equal(t, stringVal, result.value)
assert.True(t, result.match)
})
t.Run("match on type array", func(t *testing.T) {
testCredentialString = `
Expand All @@ -737,28 +737,28 @@ func Test_matchField(t *testing.T) {
}`
_ = json.Unmarshal([]byte(testCredentialString), &testCredentialMap)
stringVal := "VerifiableCredential"
match, value, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap)
result, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap)

require.NoError(t, err)
assert.Equal(t, []interface{}{"VerifiableCredential"}, value)
assert.True(t, match)
assert.Equal(t, []interface{}{"VerifiableCredential"}, result.value)
assert.True(t, result.match)
})

t.Run("errors", func(t *testing.T) {
t.Run("invalid path", func(t *testing.T) {
match, value, err := matchField(Field{Path: []string{"$$"}}, testCredentialMap)
result, err := matchField(Field{Path: []string{"$$"}}, testCredentialMap)

require.Error(t, err)
assert.Nil(t, value)
assert.False(t, match)
assert.Nil(t, result.value)
assert.False(t, result.match)
})
t.Run("invalid pattern", func(t *testing.T) {
pattern := "["
match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Pattern: &pattern}}, testCredentialMap)
result, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Pattern: &pattern}}, testCredentialMap)

require.Error(t, err)
assert.Nil(t, value)
assert.False(t, match)
assert.Nil(t, result.value)
assert.False(t, result.match)
})
})
}
Expand Down
Loading
Loading