Skip to content
Open
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
108 changes: 108 additions & 0 deletions vcr/pe/selector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*
*/

package pe

import (
"fmt"

"github.com/nuts-foundation/go-did/vc"
)

type fieldSelection struct {
fieldID string
expected string
}

// NewSelectionSelector creates a CredentialSelector that filters candidates using
// named field ID values from the credential_selection parameter.
func NewSelectionSelector(selection map[string]string, pd PresentationDefinition, fallback CredentialSelector) (CredentialSelector, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like it selects a selection, but it just selects credentials? So why not NewCredentialSelector?

descriptorSelections := make(map[string][]fieldSelection)
matchedKeys := make(map[string]bool)

for _, desc := range pd.InputDescriptors {
if desc.Constraints == nil {
continue
}
for _, field := range desc.Constraints.Fields {
if field.Id == nil {
continue
}
if expected, ok := selection[*field.Id]; ok {
descriptorSelections[desc.Id] = append(descriptorSelections[desc.Id], fieldSelection{
fieldID: *field.Id,
expected: expected,
})
matchedKeys[*field.Id] = true
}
}
}

// Validate all selection keys match at least one field ID in the PD.
for key := range selection {
if !matchedKeys[key] {
return nil, fmt.Errorf("credential_selection key '%s' does not match any field id in the presentation definition", key)
}
}

return func(descriptor InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
selections, ok := descriptorSelections[descriptor.Id]
if !ok {
return fallback(descriptor, candidates)
}
Comment on lines +63 to +67
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewSelectionSelector may call fallback(descriptor, candidates) without guarding against a nil fallback. Since this is a public factory, passing a nil fallback would panic at runtime. Consider defaulting fallback to FirstMatchSelector when nil (or returning an error at construction time).

Copilot uses AI. Check for mistakes.

var matched []vc.VerifiableCredential
for _, candidate := range candidates {
if descriptor.Constraints == nil {
continue
}
isMatch, values, err := matchConstraint(descriptor.Constraints, candidate)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have multiple constraint types in Presentation Definitions. We generally use constant (value must be exactly X) or pattern (value must comply to regex pattern Y). Do we want to support both?

Does that work properly with matchesSelections() which compares the given value to the matched value? Because it sounds like we only want to support constant.

if err != nil || !isMatch {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it worth logging the error, if we're not returning it? In which cases cant his occur?

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors from matchConstraint are currently swallowed (if err != nil || !isMatch { continue }). If matchConstraint ever fails (e.g., due to unexpected credential encoding/path issues), this will silently drop candidates and surface as ErrNoCredentials/ErrMultipleCredentials, masking the real problem. Return the error immediately when err != nil so the caller gets a hard failure with the root cause.

Suggested change
if err != nil || !isMatch {
if err != nil {
return nil, fmt.Errorf("input descriptor '%s': %w", descriptor.Id, err)
}
if !isMatch {

Copilot uses AI. Check for mistakes.
continue
}
if matchesSelections(values, selections) {
matched = append(matched, candidate)
Comment on lines +69 to +79
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewSelectionSelector re-runs matchConstraint for each candidate even though the selector contract says all candidates already match the input descriptor (they’re produced by PresentationDefinition.matchConstraints). This duplicates JSON remarshal + full constraint evaluation and can be a noticeable overhead when wallets contain many VCs. Consider resolving only the selected field values (e.g., build a fieldID→Field map at construction and use matchField/getValueAtPath on a single remarshal), without re-checking the entire constraint set.

Copilot uses AI. Check for mistakes.
}
}

if len(matched) == 0 {
return nil, fmt.Errorf("input descriptor '%s': %w", descriptor.Id, ErrNoCredentials)
}
if len(matched) > 1 {
return nil, fmt.Errorf("input descriptor '%s': %w", descriptor.Id, ErrMultipleCredentials)
}
return &matched[0], nil
}, nil
}

func matchesSelections(values map[string]interface{}, selections []fieldSelection) bool {
for _, sel := range selections {
resolved, ok := values[sel.fieldID]
if !ok {
return false
}
if str, ok := resolved.(string); ok {
if str != sel.expected {
return false
}
} else if fmt.Sprintf("%v", resolved) != sel.expected {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we doing this? Support for bools and numbers? Does that actually work?

Why not just comparison through ==?

return false
}
}
return true
}
220 changes: 220 additions & 0 deletions vcr/pe/selector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*
*/

package pe

import (
"testing"

ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/core/to"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// nopeSelector returns a fallback that fails the test if called.
func nopeSelector(t *testing.T) CredentialSelector {
return func(_ InputDescriptor, _ []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
t.Fatal("fallback selector should not be called")
return nil, nil
}
}

func TestNewSelectionSelector(t *testing.T) {
id1 := ssi.MustParseURI("1")
id2 := ssi.MustParseURI("2")
vc1 := credentialToJSONLD(vc.VerifiableCredential{ID: &id1, CredentialSubject: []map[string]any{{"patientId": "123"}}})
vc2 := credentialToJSONLD(vc.VerifiableCredential{ID: &id2, CredentialSubject: []map[string]any{{"patientId": "456"}}})
pd := PresentationDefinition{
InputDescriptors: []*InputDescriptor{
{
Id: "patient_credential",
Constraints: &Constraints{
Fields: []Field{
{
Id: to.Ptr("patient_id"),
Path: []string{"$.credentialSubject.patientId"},
},
},
},
},
},
}

t.Run("selection picks the right credential by field value", func(t *testing.T) {
selector, err := NewSelectionSelector(map[string]string{
"patient_id": "456",
}, pd, nopeSelector(t))
require.NoError(t, err)

result, err := selector(
*pd.InputDescriptors[0],
[]vc.VerifiableCredential{vc1, vc2},
)

require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, &id2, result.ID)
})
t.Run("zero matches returns ErrNoCredentials", func(t *testing.T) {
selector, err := NewSelectionSelector(map[string]string{
"patient_id": "nonexistent",
}, pd, nopeSelector(t))
require.NoError(t, err)

_, err = selector(
*pd.InputDescriptors[0],
[]vc.VerifiableCredential{vc1, vc2},
)

assert.ErrorIs(t, err, ErrNoCredentials)
})
t.Run("multiple matches returns ErrMultipleCredentials", func(t *testing.T) {
// Both VCs have a patientId field — selecting on a field that exists in both
// without narrowing to one should fail.
id3 := ssi.MustParseURI("3")
vc3 := credentialToJSONLD(vc.VerifiableCredential{ID: &id3, CredentialSubject: []map[string]any{{"patientId": "456"}}})
selector, err := NewSelectionSelector(map[string]string{
"patient_id": "456",
}, pd, nopeSelector(t))
require.NoError(t, err)

_, err = selector(
*pd.InputDescriptors[0],
[]vc.VerifiableCredential{vc2, vc3},
)

assert.ErrorIs(t, err, ErrMultipleCredentials)
})
t.Run("unknown selection key returns construction error", func(t *testing.T) {
_, err := NewSelectionSelector(map[string]string{
"nonexistent_field": "value",
}, pd, FirstMatchSelector)

assert.ErrorContains(t, err, "nonexistent_field")
})
t.Run("no selection keys for descriptor falls back to default", func(t *testing.T) {
// Selection targets patient_credential, but we call with a different descriptor.
selector, err := NewSelectionSelector(map[string]string{
"patient_id": "456",
}, pd, FirstMatchSelector)
require.NoError(t, err)

otherDescriptor := InputDescriptor{Id: "other_descriptor"}
result, err := selector(
otherDescriptor,
[]vc.VerifiableCredential{vc1, vc2},
)

require.NoError(t, err)
require.NotNil(t, result)
// FirstMatchSelector picks vc1
assert.Equal(t, &id1, result.ID)
})
t.Run("multiple selection keys use AND semantics", func(t *testing.T) {
andPD := PresentationDefinition{
InputDescriptors: []*InputDescriptor{
{
Id: "enrollment",
Constraints: &Constraints{
Fields: []Field{
{Id: to.Ptr("patient_id"), Path: []string{"$.credentialSubject.patientId"}},
{Id: to.Ptr("org_city"), Path: []string{"$.credentialSubject.city"}},
},
},
},
},
}
// vc matching both criteria
idA := ssi.MustParseURI("A")
vcA := credentialToJSONLD(vc.VerifiableCredential{ID: &idA, CredentialSubject: []map[string]any{{"patientId": "123", "city": "Amsterdam"}}})
// vc matching only patient_id
idB := ssi.MustParseURI("B")
vcB := credentialToJSONLD(vc.VerifiableCredential{ID: &idB, CredentialSubject: []map[string]any{{"patientId": "123", "city": "Rotterdam"}}})

selector, err := NewSelectionSelector(map[string]string{
"patient_id": "123",
"org_city": "Amsterdam",
}, andPD, nopeSelector(t))
require.NoError(t, err)

result, err := selector(
*andPD.InputDescriptors[0],
[]vc.VerifiableCredential{vcA, vcB},
)

require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, &idA, result.ID)
})
t.Run("multiple descriptors with independent selection keys", func(t *testing.T) {
multiPD := PresentationDefinition{
InputDescriptors: []*InputDescriptor{
{
Id: "org_credential",
Constraints: &Constraints{
Fields: []Field{
{Id: to.Ptr("ura"), Path: []string{"$.credentialSubject.ura"}},
},
},
},
{
Id: "patient_enrollment",
Constraints: &Constraints{
Fields: []Field{
{Id: to.Ptr("bsn"), Path: []string{"$.credentialSubject.bsn"}},
},
},
},
},
}
idA := ssi.MustParseURI("A")
idB := ssi.MustParseURI("B")
idC := ssi.MustParseURI("C")
idD := ssi.MustParseURI("D")
vcA := credentialToJSONLD(vc.VerifiableCredential{ID: &idA, CredentialSubject: []map[string]any{{"ura": "URA-001"}}})
vcB := credentialToJSONLD(vc.VerifiableCredential{ID: &idB, CredentialSubject: []map[string]any{{"ura": "URA-002"}}})
vcC := credentialToJSONLD(vc.VerifiableCredential{ID: &idC, CredentialSubject: []map[string]any{{"bsn": "BSN-111"}}})
vcD := credentialToJSONLD(vc.VerifiableCredential{ID: &idD, CredentialSubject: []map[string]any{{"bsn": "BSN-222"}}})

selector, err := NewSelectionSelector(map[string]string{
"ura": "URA-002",
"bsn": "BSN-111",
}, multiPD, nopeSelector(t))
require.NoError(t, err)

// First descriptor: selects vcB (URA-002)
result, err := selector(
*multiPD.InputDescriptors[0],
[]vc.VerifiableCredential{vcA, vcB},
)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, &idB, result.ID)

// Second descriptor: selects vcC (BSN-111)
result, err = selector(
*multiPD.InputDescriptors[1],
[]vc.VerifiableCredential{vcC, vcD},
)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, &idC, result.ID)
})
}
Loading