Skip to content

Commit 05b6ae3

Browse files
committed
fix(recipe): deep-copy Validation in Merge to prevent cached store pollution
RecipeMetadataSpec.Merge aliased other.Validation when the destination was nil, then later wrote phase pointers through that alias. Across successive calls on the cached MetadataStore, this mutated whichever overlay's cached ValidationConfig the alias happened to point at — so `BuildRecipeResult` was order-dependent, and an overlay that genuinely lacked a phase could appear compliant after a prior query planted that phase via pollution. Fix: - Add cloneValidationConfig + cloneValidationPhase helpers that deep- copy phases and their backing slices / NodeSelection. - Clone in Merge whenever a phase pointer is assigned (both the nil-destination branch and the per-phase overwrite branch). - Clone in initBaseMergedSpec so the merged spec never aliases s.Base.Spec.Validation. Regression test in metadata_test.go: two sequential merges into a fresh destination must not mutate the first source. Without the fix the test detects Deployment leaking from the second merge into the first source's ValidationConfig. Refs #970
1 parent 6ea43cd commit 05b6ae3

4 files changed

Lines changed: 96 additions & 7 deletions

File tree

pkg/recipe/metadata.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -456,22 +456,27 @@ func (s *RecipeMetadataSpec) Merge(other *RecipeMetadataSpec) {
456456
return s.ComponentRefs[i].Name < s.ComponentRefs[j].Name
457457
})
458458

459-
// Merge validation config - overlay phases take precedence
459+
// Merge validation config - overlay phases take precedence. Phase
460+
// pointers are cloned (not aliased) so successive merges cannot mutate
461+
// the source's cached ValidationConfig — a previous version aliased
462+
// other.Validation when s.Validation was nil, then later writes through
463+
// s.Validation.Deployment etc. corrupted whichever overlay's cached
464+
// metadata the alias pointed at.
460465
if other.Validation != nil {
461466
if s.Validation == nil {
462-
s.Validation = other.Validation
467+
s.Validation = cloneValidationConfig(other.Validation)
463468
} else {
464469
if other.Validation.Readiness != nil {
465-
s.Validation.Readiness = other.Validation.Readiness
470+
s.Validation.Readiness = cloneValidationPhase(other.Validation.Readiness)
466471
}
467472
if other.Validation.Deployment != nil {
468-
s.Validation.Deployment = other.Validation.Deployment
473+
s.Validation.Deployment = cloneValidationPhase(other.Validation.Deployment)
469474
}
470475
if other.Validation.Performance != nil {
471-
s.Validation.Performance = other.Validation.Performance
476+
s.Validation.Performance = cloneValidationPhase(other.Validation.Performance)
472477
}
473478
if other.Validation.Conformance != nil {
474-
s.Validation.Conformance = other.Validation.Conformance
479+
s.Validation.Conformance = cloneValidationPhase(other.Validation.Conformance)
475480
}
476481
}
477482
}

pkg/recipe/metadata_store.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,11 +636,13 @@ func (s *MetadataStore) buildMixinConstraintCandidateIndex(candidateOverlays []s
636636
}
637637

638638
// initBaseMergedSpec creates a copy of the base spec for overlay merging.
639+
// Validation is deep-cloned so downstream Merge calls cannot reach back
640+
// into the cached base ValidationConfig and mutate it.
639641
func (s *MetadataStore) initBaseMergedSpec() (RecipeMetadataSpec, []string) {
640642
mergedSpec := RecipeMetadataSpec{
641643
Constraints: make([]Constraint, len(s.Base.Spec.Constraints)),
642644
ComponentRefs: make([]ComponentRef, len(s.Base.Spec.ComponentRefs)),
643-
Validation: s.Base.Spec.Validation,
645+
Validation: cloneValidationConfig(s.Base.Spec.Validation),
644646
}
645647
copy(mergedSpec.Constraints, s.Base.Spec.Constraints)
646648
copy(mergedSpec.ComponentRefs, s.Base.Spec.ComponentRefs)

pkg/recipe/metadata_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,39 @@ func TestMergeValidationConfig(t *testing.T) {
10271027
t.Fatal("validation should be preserved from base when overlay has nil")
10281028
}
10291029
})
1030+
1031+
// Regression: a prior version of Merge aliased other.Validation when the
1032+
// destination's was nil. Subsequent merges then wrote through that alias
1033+
// into whichever overlay's cached ValidationConfig the alias pointed at,
1034+
// polluting it across calls. The fix deep-copies via cloneValidationConfig.
1035+
t.Run("merge does not alias source validation", func(t *testing.T) {
1036+
source := &ValidationConfig{
1037+
Conformance: &ValidationPhase{Checks: []string{"check-from-source"}},
1038+
}
1039+
first := RecipeMetadataSpec{
1040+
Validation: source,
1041+
}
1042+
second := RecipeMetadataSpec{
1043+
Validation: &ValidationConfig{
1044+
Deployment: &ValidationPhase{Checks: []string{"deployment-from-second"}},
1045+
},
1046+
}
1047+
1048+
// dest starts with nil Validation, so without the fix it would alias
1049+
// source. Merging second then plants Deployment via the alias into
1050+
// the source — corrupting subsequent reads of the source.
1051+
var dest RecipeMetadataSpec
1052+
dest.Merge(&first)
1053+
dest.Merge(&second)
1054+
1055+
if source.Deployment != nil {
1056+
t.Errorf("source.Deployment leaked to %v — Merge aliased the source ValidationConfig",
1057+
source.Deployment.Checks)
1058+
}
1059+
if dest.Validation.Conformance == source.Conformance {
1060+
t.Error("dest.Validation.Conformance aliases source.Conformance — phase pointers must be cloned")
1061+
}
1062+
})
10301063
}
10311064

10321065
func TestFinalizeRecipeResultIncludesValidation(t *testing.T) {

pkg/recipe/validation.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
package recipe
1616

17+
import "maps"
18+
1719
// ValidationConfig defines validation phases and settings.
1820
type ValidationConfig struct {
1921
// Readiness defines readiness validation phase settings.
@@ -59,3 +61,50 @@ type NodeSelection struct {
5961
// ExcludeNodes lists node names to exclude from validation.
6062
ExcludeNodes []string `json:"excludeNodes,omitempty" yaml:"excludeNodes,omitempty"`
6163
}
64+
65+
// cloneValidationConfig returns a deep copy of v. RecipeMetadataSpec.Merge
66+
// uses this to avoid aliasing the source's nested phase pointers — without
67+
// it, successive merges mutate whichever cached overlay's ValidationConfig
68+
// the destination aliased.
69+
func cloneValidationConfig(v *ValidationConfig) *ValidationConfig {
70+
if v == nil {
71+
return nil
72+
}
73+
return &ValidationConfig{
74+
Readiness: cloneValidationPhase(v.Readiness),
75+
Deployment: cloneValidationPhase(v.Deployment),
76+
Performance: cloneValidationPhase(v.Performance),
77+
Conformance: cloneValidationPhase(v.Conformance),
78+
}
79+
}
80+
81+
// cloneValidationPhase returns a deep copy of p with independent backing
82+
// slices and a freshly allocated NodeSelection, so callers writing through
83+
// the clone cannot reach the source's cached metadata.
84+
func cloneValidationPhase(p *ValidationPhase) *ValidationPhase {
85+
if p == nil {
86+
return nil
87+
}
88+
out := *p
89+
if p.Constraints != nil {
90+
out.Constraints = make([]Constraint, len(p.Constraints))
91+
copy(out.Constraints, p.Constraints)
92+
}
93+
if p.Checks != nil {
94+
out.Checks = make([]string, len(p.Checks))
95+
copy(out.Checks, p.Checks)
96+
}
97+
if p.NodeSelection != nil {
98+
ns := *p.NodeSelection
99+
if p.NodeSelection.Selector != nil {
100+
ns.Selector = make(map[string]string, len(p.NodeSelection.Selector))
101+
maps.Copy(ns.Selector, p.NodeSelection.Selector)
102+
}
103+
if p.NodeSelection.ExcludeNodes != nil {
104+
ns.ExcludeNodes = make([]string, len(p.NodeSelection.ExcludeNodes))
105+
copy(ns.ExcludeNodes, p.NodeSelection.ExcludeNodes)
106+
}
107+
out.NodeSelection = &ns
108+
}
109+
return &out
110+
}

0 commit comments

Comments
 (0)