Skip to content

Commit 4e78146

Browse files
committed
fix(attestation): deduplicate policy evaluations after protojson round-trip
Replace reflect.DeepEqual with maps.Equal for policy evaluation dedup so nil and empty maps are treated as equivalent. protojson.Marshal omits empty maps, causing deserialized With fields to be nil instead of empty, which made the dedup comparison fail and duplicate evaluations appear. Signed-off-by: Jose I. Paris <jiparis@chainloop.dev>
1 parent 2d6b939 commit 4e78146

2 files changed

Lines changed: 113 additions & 6 deletions

File tree

pkg/attestation/crafter/crafter.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ import (
1919
"context"
2020
"errors"
2121
"fmt"
22+
"maps"
2223
"net/url"
2324
"os"
24-
"reflect"
2525
"slices"
2626
"strings"
2727
"time"
@@ -752,6 +752,13 @@ func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_M
752752
return mt, nil
753753
}
754754

755+
// policyEvalMatches returns true if two policy evaluations refer to the same policy
756+
// with the same arguments. It treats nil and empty maps as equivalent to handle
757+
// protojson round-trip serialization where empty maps are omitted.
758+
func policyEvalMatches(a, b *api.PolicyEvaluation) bool {
759+
return proto.Equal(a.GetPolicyReference(), b.GetPolicyReference()) && maps.Equal(a.GetWith(), b.GetWith())
760+
}
761+
755762
// EvaluateAttestationPolicies evaluates the attestation-level policies and stores them in the attestation state.
756763
// The phase parameter controls which policies are evaluated based on their attestation_phases spec field.
757764
func (c *Crafter) EvaluateAttestationPolicies(ctx context.Context, attestationID string, statement *intoto.Statement, phase policies.EvalPhase) error {
@@ -783,7 +790,7 @@ func (c *Crafter) EvaluateAttestationPolicies(ctx context.Context, attestationID
783790
for _, ev := range policyEvaluations {
784791
var duplicated bool
785792
for _, existing := range filteredPolicyEvaluations {
786-
if proto.Equal(existing.PolicyReference, ev.PolicyReference) && reflect.DeepEqual(existing.With, ev.With) {
793+
if policyEvalMatches(existing, ev) {
787794
duplicated = true
788795
break
789796
}
@@ -808,7 +815,7 @@ func (c *Crafter) EvaluateAttestationPolicies(ctx context.Context, attestationID
808815
// Check if this attestation-level evaluation was re-evaluated in the current phase
809816
var reEvaluated bool
810817
for _, newEv := range policyEvaluations {
811-
if proto.Equal(newEv.PolicyReference, ev.PolicyReference) && reflect.DeepEqual(newEv.With, ev.With) {
818+
if policyEvalMatches(newEv, ev) {
812819
reEvaluated = true
813820
break
814821
}

pkg/attestation/crafter/crafter_unit_test.go

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2023 The Chainloop Authors.
2+
// Copyright 2023-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -21,12 +21,12 @@ import (
2121
"testing"
2222
"time"
2323

24+
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2425
"github.com/go-git/go-git/v5"
26+
"github.com/go-git/go-git/v5/config"
2527
"github.com/go-git/go-git/v5/plumbing/object"
2628
"github.com/stretchr/testify/assert"
2729
"github.com/stretchr/testify/require"
28-
29-
"github.com/go-git/go-git/v5/config"
3030
"github.com/stretchr/testify/suite"
3131
)
3232

@@ -214,6 +214,106 @@ func (s *crafterUnitSuite) TestGitRepoHead() {
214214
}
215215
}
216216

217+
func (s *crafterUnitSuite) TestPolicyEvaluationDedup() {
218+
// Simulate the protojson round-trip issue:
219+
// - Init phase sets With = map[string]string{} (empty map)
220+
// - protojson.Marshal omits empty maps
221+
// - protojson.Unmarshal sets With = nil (absent field)
222+
// - Push phase produces With = map[string]string{} again
223+
// The dedup comparison must treat nil and empty map as equal.
224+
225+
policyRef := &api.PolicyEvaluation_Reference{
226+
Name: "source-commit",
227+
Digest: "sha256:abc123",
228+
}
229+
230+
testCases := []struct {
231+
name string
232+
existing []*api.PolicyEvaluation
233+
newEvals []*api.PolicyEvaluation
234+
wantCount int
235+
description string
236+
}{
237+
{
238+
name: "nil vs empty map With are deduplicated",
239+
existing: []*api.PolicyEvaluation{
240+
{Name: "source-commit", PolicyReference: policyRef, With: nil},
241+
},
242+
newEvals: []*api.PolicyEvaluation{
243+
{Name: "source-commit", PolicyReference: policyRef, With: map[string]string{}},
244+
},
245+
wantCount: 1,
246+
description: "after protojson round-trip, nil With should match empty map With",
247+
},
248+
{
249+
name: "empty map vs empty map With are deduplicated",
250+
existing: []*api.PolicyEvaluation{
251+
{Name: "source-commit", PolicyReference: policyRef, With: map[string]string{}},
252+
},
253+
newEvals: []*api.PolicyEvaluation{
254+
{Name: "source-commit", PolicyReference: policyRef, With: map[string]string{}},
255+
},
256+
wantCount: 1,
257+
description: "identical empty maps should deduplicate",
258+
},
259+
{
260+
name: "nil vs nil With are deduplicated",
261+
existing: []*api.PolicyEvaluation{
262+
{Name: "source-commit", PolicyReference: policyRef, With: nil},
263+
},
264+
newEvals: []*api.PolicyEvaluation{
265+
{Name: "source-commit", PolicyReference: policyRef, With: nil},
266+
},
267+
wantCount: 1,
268+
description: "both nil should deduplicate",
269+
},
270+
{
271+
name: "different With args are not deduplicated",
272+
existing: []*api.PolicyEvaluation{
273+
{Name: "source-commit", PolicyReference: policyRef, With: map[string]string{"key": "val1"}},
274+
},
275+
newEvals: []*api.PolicyEvaluation{
276+
{Name: "source-commit", PolicyReference: policyRef, With: map[string]string{"key": "val2"}},
277+
},
278+
wantCount: 2,
279+
description: "different With values should not deduplicate",
280+
},
281+
{
282+
name: "different policy references are not deduplicated",
283+
existing: []*api.PolicyEvaluation{
284+
{Name: "policy-a", PolicyReference: &api.PolicyEvaluation_Reference{Name: "policy-a"}, With: nil},
285+
},
286+
newEvals: []*api.PolicyEvaluation{
287+
{Name: "policy-b", PolicyReference: &api.PolicyEvaluation_Reference{Name: "policy-b"}, With: nil},
288+
},
289+
wantCount: 2,
290+
description: "different policies should both be kept",
291+
},
292+
}
293+
294+
for _, tc := range testCases {
295+
s.Run(tc.name, func() {
296+
all := append(tc.existing, tc.newEvals...)
297+
298+
var filtered []*api.PolicyEvaluation
299+
for _, ev := range all {
300+
var duplicated bool
301+
for _, existing := range filtered {
302+
if policyEvalMatches(existing, ev) {
303+
duplicated = true
304+
break
305+
}
306+
}
307+
if !duplicated {
308+
filtered = append(filtered, ev)
309+
}
310+
}
311+
312+
s.Len(filtered, tc.wantCount, tc.description)
313+
})
314+
}
315+
}
316+
217317
func TestSuite(t *testing.T) {
218318
suite.Run(t, new(crafterUnitSuite))
219319
}

0 commit comments

Comments
 (0)