From a8b20c96dd0ecf2ea4f0923d55233ae7466db894 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 29 Jul 2026 15:32:21 +0200 Subject: [PATCH 01/14] feat(contracts): add PREFIX match mode to policy MaterialSelector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A policy attachment's material selector now supports a match mode. The default (unspecified) remains an exact name match, so existing contracts are unchanged; PREFIX matches every material whose name starts with the selector name, letting a single attachment target a set of related materials (e.g. an archive exploded into "", "-1", …). Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- .../workflowcontract/v1/crafting_schema.ts | 70 +++++- ...ttachment.MaterialSelector.jsonschema.json | 40 ++++ ...icyAttachment.MaterialSelector.schema.json | 40 ++++ .../workflowcontract/v1/crafting_schema.pb.go | 209 ++++++++++++------ .../workflowcontract/v1/crafting_schema.proto | 12 + pkg/policies/policies.go | 15 +- pkg/policies/policies_test.go | 47 ++++ 7 files changed, 359 insertions(+), 74 deletions(-) diff --git a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts index e90eb31de..048abeb81 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -648,6 +648,56 @@ export interface PolicyAttachment_WithEntry { export interface PolicyAttachment_MaterialSelector { /** material name */ name: string; + /** + * How `name` is matched against a material's name. Defaults to exact match + * (UNSPECIFIED behaves as EXACT), so existing selectors are unchanged. Use + * PREFIX to target a set of materials sharing a name prefix (e.g. an + * archive exploded into ``, `-1`, `-2`). + */ + matchMode: PolicyAttachment_MaterialSelector_MatchMode; +} + +export enum PolicyAttachment_MaterialSelector_MatchMode { + MATCH_MODE_UNSPECIFIED = 0, + MATCH_MODE_EXACT = 1, + MATCH_MODE_PREFIX = 2, + UNRECOGNIZED = -1, +} + +export function policyAttachment_MaterialSelector_MatchModeFromJSON( + object: any, +): PolicyAttachment_MaterialSelector_MatchMode { + switch (object) { + case 0: + case "MATCH_MODE_UNSPECIFIED": + return PolicyAttachment_MaterialSelector_MatchMode.MATCH_MODE_UNSPECIFIED; + case 1: + case "MATCH_MODE_EXACT": + return PolicyAttachment_MaterialSelector_MatchMode.MATCH_MODE_EXACT; + case 2: + case "MATCH_MODE_PREFIX": + return PolicyAttachment_MaterialSelector_MatchMode.MATCH_MODE_PREFIX; + case -1: + case "UNRECOGNIZED": + default: + return PolicyAttachment_MaterialSelector_MatchMode.UNRECOGNIZED; + } +} + +export function policyAttachment_MaterialSelector_MatchModeToJSON( + object: PolicyAttachment_MaterialSelector_MatchMode, +): string { + switch (object) { + case PolicyAttachment_MaterialSelector_MatchMode.MATCH_MODE_UNSPECIFIED: + return "MATCH_MODE_UNSPECIFIED"; + case PolicyAttachment_MaterialSelector_MatchMode.MATCH_MODE_EXACT: + return "MATCH_MODE_EXACT"; + case PolicyAttachment_MaterialSelector_MatchMode.MATCH_MODE_PREFIX: + return "MATCH_MODE_PREFIX"; + case PolicyAttachment_MaterialSelector_MatchMode.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } } /** Represents a policy to be applied to a material or attestation */ @@ -1826,7 +1876,7 @@ export const PolicyAttachment_WithEntry = { }; function createBasePolicyAttachment_MaterialSelector(): PolicyAttachment_MaterialSelector { - return { name: "" }; + return { name: "", matchMode: 0 }; } export const PolicyAttachment_MaterialSelector = { @@ -1834,6 +1884,9 @@ export const PolicyAttachment_MaterialSelector = { if (message.name !== "") { writer.uint32(10).string(message.name); } + if (message.matchMode !== 0) { + writer.uint32(16).int32(message.matchMode); + } return writer; }, @@ -1851,6 +1904,13 @@ export const PolicyAttachment_MaterialSelector = { message.name = reader.string(); continue; + case 2: + if (tag !== 16) { + break; + } + + message.matchMode = reader.int32() as any; + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -1861,12 +1921,17 @@ export const PolicyAttachment_MaterialSelector = { }, fromJSON(object: any): PolicyAttachment_MaterialSelector { - return { name: isSet(object.name) ? String(object.name) : "" }; + return { + name: isSet(object.name) ? String(object.name) : "", + matchMode: isSet(object.matchMode) ? policyAttachment_MaterialSelector_MatchModeFromJSON(object.matchMode) : 0, + }; }, toJSON(message: PolicyAttachment_MaterialSelector): unknown { const obj: any = {}; message.name !== undefined && (obj.name = message.name); + message.matchMode !== undefined && + (obj.matchMode = policyAttachment_MaterialSelector_MatchModeToJSON(message.matchMode)); return obj; }, @@ -1881,6 +1946,7 @@ export const PolicyAttachment_MaterialSelector = { ): PolicyAttachment_MaterialSelector { const message = createBasePolicyAttachment_MaterialSelector(); message.name = object.name ?? ""; + message.matchMode = object.matchMode ?? 0; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.jsonschema.json index c21464a07..7b46ecec6 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.jsonschema.json @@ -2,7 +2,47 @@ "$id": "workflowcontract.v1.PolicyAttachment.MaterialSelector.jsonschema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "patternProperties": { + "^(match_mode)$": { + "anyOf": [ + { + "enum": [ + "MATCH_MODE_UNSPECIFIED", + "MATCH_MODE_EXACT", + "MATCH_MODE_PREFIX" + ], + "title": "Match Mode", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ], + "description": "How `name` is matched against a material's name. Defaults to exact match\n (UNSPECIFIED behaves as EXACT), so existing selectors are unchanged. Use\n PREFIX to target a set of materials sharing a name prefix (e.g. an\n archive exploded into `\u003cname\u003e`, `\u003cname\u003e-1`, `\u003cname\u003e-2`)." + } + }, "properties": { + "matchMode": { + "anyOf": [ + { + "enum": [ + "MATCH_MODE_UNSPECIFIED", + "MATCH_MODE_EXACT", + "MATCH_MODE_PREFIX" + ], + "title": "Match Mode", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ], + "description": "How `name` is matched against a material's name. Defaults to exact match\n (UNSPECIFIED behaves as EXACT), so existing selectors are unchanged. Use\n PREFIX to target a set of materials sharing a name prefix (e.g. an\n archive exploded into `\u003cname\u003e`, `\u003cname\u003e-1`, `\u003cname\u003e-2`)." + }, "name": { "description": "material name", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.schema.json index db91803c3..53ae09fb6 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.schema.json @@ -2,7 +2,47 @@ "$id": "workflowcontract.v1.PolicyAttachment.MaterialSelector.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "patternProperties": { + "^(matchMode)$": { + "anyOf": [ + { + "enum": [ + "MATCH_MODE_UNSPECIFIED", + "MATCH_MODE_EXACT", + "MATCH_MODE_PREFIX" + ], + "title": "Match Mode", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ], + "description": "How `name` is matched against a material's name. Defaults to exact match\n (UNSPECIFIED behaves as EXACT), so existing selectors are unchanged. Use\n PREFIX to target a set of materials sharing a name prefix (e.g. an\n archive exploded into `\u003cname\u003e`, `\u003cname\u003e-1`, `\u003cname\u003e-2`)." + } + }, "properties": { + "match_mode": { + "anyOf": [ + { + "enum": [ + "MATCH_MODE_UNSPECIFIED", + "MATCH_MODE_EXACT", + "MATCH_MODE_PREFIX" + ], + "title": "Match Mode", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ], + "description": "How `name` is matched against a material's name. Defaults to exact match\n (UNSPECIFIED behaves as EXACT), so existing selectors are unchanged. Use\n PREFIX to target a set of materials sharing a name prefix (e.g. an\n archive exploded into `\u003cname\u003e`, `\u003cname\u003e-1`, `\u003cname\u003e-2`)." + }, "name": { "description": "material name", "type": "string" diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index 9e17497ef..ee2a58811 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -371,6 +371,55 @@ func (CraftingSchema_Material_MaterialType) EnumDescriptor() ([]byte, []int) { return file_workflowcontract_v1_crafting_schema_proto_rawDescGZIP(), []int{0, 1, 0} } +type PolicyAttachment_MaterialSelector_MatchMode int32 + +const ( + PolicyAttachment_MaterialSelector_MATCH_MODE_UNSPECIFIED PolicyAttachment_MaterialSelector_MatchMode = 0 + PolicyAttachment_MaterialSelector_MATCH_MODE_EXACT PolicyAttachment_MaterialSelector_MatchMode = 1 + PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX PolicyAttachment_MaterialSelector_MatchMode = 2 +) + +// Enum value maps for PolicyAttachment_MaterialSelector_MatchMode. +var ( + PolicyAttachment_MaterialSelector_MatchMode_name = map[int32]string{ + 0: "MATCH_MODE_UNSPECIFIED", + 1: "MATCH_MODE_EXACT", + 2: "MATCH_MODE_PREFIX", + } + PolicyAttachment_MaterialSelector_MatchMode_value = map[string]int32{ + "MATCH_MODE_UNSPECIFIED": 0, + "MATCH_MODE_EXACT": 1, + "MATCH_MODE_PREFIX": 2, + } +) + +func (x PolicyAttachment_MaterialSelector_MatchMode) Enum() *PolicyAttachment_MaterialSelector_MatchMode { + p := new(PolicyAttachment_MaterialSelector_MatchMode) + *p = x + return p +} + +func (x PolicyAttachment_MaterialSelector_MatchMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PolicyAttachment_MaterialSelector_MatchMode) Descriptor() protoreflect.EnumDescriptor { + return file_workflowcontract_v1_crafting_schema_proto_enumTypes[3].Descriptor() +} + +func (PolicyAttachment_MaterialSelector_MatchMode) Type() protoreflect.EnumType { + return &file_workflowcontract_v1_crafting_schema_proto_enumTypes[3] +} + +func (x PolicyAttachment_MaterialSelector_MatchMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PolicyAttachment_MaterialSelector_MatchMode.Descriptor instead. +func (PolicyAttachment_MaterialSelector_MatchMode) EnumDescriptor() ([]byte, []int) { + return file_workflowcontract_v1_crafting_schema_proto_rawDescGZIP(), []int{5, 1, 0} +} + // Schema definition provided by the user to the tool // that defines the schema of the workflowRun // @@ -1791,7 +1840,12 @@ func (x *CraftingSchema_Material) GetGroup() string { type PolicyAttachment_MaterialSelector struct { state protoimpl.MessageState `protogen:"open.v1"` // material name - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // How `name` is matched against a material's name. Defaults to exact match + // (UNSPECIFIED behaves as EXACT), so existing selectors are unchanged. Use + // PREFIX to target a set of materials sharing a name prefix (e.g. an + // archive exploded into ``, `-1`, `-2`). + MatchMode PolicyAttachment_MaterialSelector_MatchMode `protobuf:"varint,2,opt,name=match_mode,json=matchMode,proto3,enum=workflowcontract.v1.PolicyAttachment_MaterialSelector_MatchMode" json:"match_mode,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1833,6 +1887,13 @@ func (x *PolicyAttachment_MaterialSelector) GetName() string { return "" } +func (x *PolicyAttachment_MaterialSelector) GetMatchMode() PolicyAttachment_MaterialSelector_MatchMode { + if x != nil { + return x.MatchMode + } + return PolicyAttachment_MaterialSelector_MATCH_MODE_UNSPECIFIED +} + type PolicyGroup_PolicyGroupSpec struct { state protoimpl.MessageState `protogen:"open.v1"` Policies *PolicyGroup_PolicyGroupPolicies `protobuf:"bytes,1,opt,name=policies,proto3" json:"policies,omitempty"` @@ -2118,7 +2179,7 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\tR\x05value\"\x98\x01\n" + "\bPolicies\x12C\n" + "\tmaterials\x18\x01 \x03(\v2%.workflowcontract.v1.PolicyAttachmentR\tmaterials\x12G\n" + - "\vattestation\x18\x02 \x03(\v2%.workflowcontract.v1.PolicyAttachmentR\vattestation\"\x98\x04\n" + + "\vattestation\x18\x02 \x03(\v2%.workflowcontract.v1.PolicyAttachmentR\vattestation\"\xd0\x05\n" + "\x10PolicyAttachment\x12\x1b\n" + "\x03ref\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\x03ref\x129\n" + "\bembedded\x18\x02 \x01(\v2\x1b.workflowcontract.v1.PolicyH\x00R\bembedded\x12R\n" + @@ -2129,9 +2190,15 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x04gate\x18\a \x01(\bH\x01R\x04gate\x88\x01\x01\x1a7\n" + "\tWithEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a&\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a\xdd\x01\n" + "\x10MaterialSelector\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04nameB\x0f\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12_\n" + + "\n" + + "match_mode\x18\x02 \x01(\x0e2@.workflowcontract.v1.PolicyAttachment.MaterialSelector.MatchModeR\tmatchMode\"T\n" + + "\tMatchMode\x12\x1a\n" + + "\x16MATCH_MODE_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10MATCH_MODE_EXACT\x10\x01\x12\x15\n" + + "\x11MATCH_MODE_PREFIX\x10\x02B\x0f\n" + "\x06policy\x12\x05\xbaH\x02\b\x01B\a\n" + "\x05_gate\"\x88\x02\n" + "\x06Policy\x12[\n" + @@ -2229,80 +2296,82 @@ func file_workflowcontract_v1_crafting_schema_proto_rawDescGZIP() []byte { return file_workflowcontract_v1_crafting_schema_proto_rawDescData } -var file_workflowcontract_v1_crafting_schema_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_workflowcontract_v1_crafting_schema_proto_enumTypes = make([]protoimpl.EnumInfo, 4) var file_workflowcontract_v1_crafting_schema_proto_msgTypes = make([]protoimpl.MessageInfo, 23) var file_workflowcontract_v1_crafting_schema_proto_goTypes = []any{ - (AttestationPhase)(0), // 0: workflowcontract.v1.AttestationPhase - (CraftingSchema_Runner_RunnerType)(0), // 1: workflowcontract.v1.CraftingSchema.Runner.RunnerType - (CraftingSchema_Material_MaterialType)(0), // 2: workflowcontract.v1.CraftingSchema.Material.MaterialType - (*CraftingSchema)(nil), // 3: workflowcontract.v1.CraftingSchema - (*CraftingSchemaV2)(nil), // 4: workflowcontract.v1.CraftingSchemaV2 - (*CraftingSchemaV2Spec)(nil), // 5: workflowcontract.v1.CraftingSchemaV2Spec - (*Annotation)(nil), // 6: workflowcontract.v1.Annotation - (*Policies)(nil), // 7: workflowcontract.v1.Policies - (*PolicyAttachment)(nil), // 8: workflowcontract.v1.PolicyAttachment - (*Policy)(nil), // 9: workflowcontract.v1.Policy - (*Metadata)(nil), // 10: workflowcontract.v1.Metadata - (*PolicySpec)(nil), // 11: workflowcontract.v1.PolicySpec - (*PolicyInput)(nil), // 12: workflowcontract.v1.PolicyInput - (*PolicySpecV2)(nil), // 13: workflowcontract.v1.PolicySpecV2 - (*AutoMatch)(nil), // 14: workflowcontract.v1.AutoMatch - (*PolicyGroupAttachment)(nil), // 15: workflowcontract.v1.PolicyGroupAttachment - (*PolicyGroup)(nil), // 16: workflowcontract.v1.PolicyGroup - (*CraftingSchema_Runner)(nil), // 17: workflowcontract.v1.CraftingSchema.Runner - (*CraftingSchema_Material)(nil), // 18: workflowcontract.v1.CraftingSchema.Material - nil, // 19: workflowcontract.v1.PolicyAttachment.WithEntry - (*PolicyAttachment_MaterialSelector)(nil), // 20: workflowcontract.v1.PolicyAttachment.MaterialSelector - nil, // 21: workflowcontract.v1.Metadata.AnnotationsEntry - nil, // 22: workflowcontract.v1.PolicyGroupAttachment.WithEntry - (*PolicyGroup_PolicyGroupSpec)(nil), // 23: workflowcontract.v1.PolicyGroup.PolicyGroupSpec - (*PolicyGroup_PolicyGroupPolicies)(nil), // 24: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies - (*PolicyGroup_Material)(nil), // 25: workflowcontract.v1.PolicyGroup.Material + (AttestationPhase)(0), // 0: workflowcontract.v1.AttestationPhase + (CraftingSchema_Runner_RunnerType)(0), // 1: workflowcontract.v1.CraftingSchema.Runner.RunnerType + (CraftingSchema_Material_MaterialType)(0), // 2: workflowcontract.v1.CraftingSchema.Material.MaterialType + (PolicyAttachment_MaterialSelector_MatchMode)(0), // 3: workflowcontract.v1.PolicyAttachment.MaterialSelector.MatchMode + (*CraftingSchema)(nil), // 4: workflowcontract.v1.CraftingSchema + (*CraftingSchemaV2)(nil), // 5: workflowcontract.v1.CraftingSchemaV2 + (*CraftingSchemaV2Spec)(nil), // 6: workflowcontract.v1.CraftingSchemaV2Spec + (*Annotation)(nil), // 7: workflowcontract.v1.Annotation + (*Policies)(nil), // 8: workflowcontract.v1.Policies + (*PolicyAttachment)(nil), // 9: workflowcontract.v1.PolicyAttachment + (*Policy)(nil), // 10: workflowcontract.v1.Policy + (*Metadata)(nil), // 11: workflowcontract.v1.Metadata + (*PolicySpec)(nil), // 12: workflowcontract.v1.PolicySpec + (*PolicyInput)(nil), // 13: workflowcontract.v1.PolicyInput + (*PolicySpecV2)(nil), // 14: workflowcontract.v1.PolicySpecV2 + (*AutoMatch)(nil), // 15: workflowcontract.v1.AutoMatch + (*PolicyGroupAttachment)(nil), // 16: workflowcontract.v1.PolicyGroupAttachment + (*PolicyGroup)(nil), // 17: workflowcontract.v1.PolicyGroup + (*CraftingSchema_Runner)(nil), // 18: workflowcontract.v1.CraftingSchema.Runner + (*CraftingSchema_Material)(nil), // 19: workflowcontract.v1.CraftingSchema.Material + nil, // 20: workflowcontract.v1.PolicyAttachment.WithEntry + (*PolicyAttachment_MaterialSelector)(nil), // 21: workflowcontract.v1.PolicyAttachment.MaterialSelector + nil, // 22: workflowcontract.v1.Metadata.AnnotationsEntry + nil, // 23: workflowcontract.v1.PolicyGroupAttachment.WithEntry + (*PolicyGroup_PolicyGroupSpec)(nil), // 24: workflowcontract.v1.PolicyGroup.PolicyGroupSpec + (*PolicyGroup_PolicyGroupPolicies)(nil), // 25: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies + (*PolicyGroup_Material)(nil), // 26: workflowcontract.v1.PolicyGroup.Material } var file_workflowcontract_v1_crafting_schema_proto_depIdxs = []int32{ - 18, // 0: workflowcontract.v1.CraftingSchema.materials:type_name -> workflowcontract.v1.CraftingSchema.Material - 17, // 1: workflowcontract.v1.CraftingSchema.runner:type_name -> workflowcontract.v1.CraftingSchema.Runner - 6, // 2: workflowcontract.v1.CraftingSchema.annotations:type_name -> workflowcontract.v1.Annotation - 7, // 3: workflowcontract.v1.CraftingSchema.policies:type_name -> workflowcontract.v1.Policies - 15, // 4: workflowcontract.v1.CraftingSchema.policy_groups:type_name -> workflowcontract.v1.PolicyGroupAttachment - 10, // 5: workflowcontract.v1.CraftingSchemaV2.metadata:type_name -> workflowcontract.v1.Metadata - 5, // 6: workflowcontract.v1.CraftingSchemaV2.spec:type_name -> workflowcontract.v1.CraftingSchemaV2Spec - 18, // 7: workflowcontract.v1.CraftingSchemaV2Spec.materials:type_name -> workflowcontract.v1.CraftingSchema.Material - 17, // 8: workflowcontract.v1.CraftingSchemaV2Spec.runner:type_name -> workflowcontract.v1.CraftingSchema.Runner - 7, // 9: workflowcontract.v1.CraftingSchemaV2Spec.policies:type_name -> workflowcontract.v1.Policies - 15, // 10: workflowcontract.v1.CraftingSchemaV2Spec.policy_groups:type_name -> workflowcontract.v1.PolicyGroupAttachment - 6, // 11: workflowcontract.v1.CraftingSchemaV2Spec.annotations:type_name -> workflowcontract.v1.Annotation - 8, // 12: workflowcontract.v1.Policies.materials:type_name -> workflowcontract.v1.PolicyAttachment - 8, // 13: workflowcontract.v1.Policies.attestation:type_name -> workflowcontract.v1.PolicyAttachment - 9, // 14: workflowcontract.v1.PolicyAttachment.embedded:type_name -> workflowcontract.v1.Policy - 20, // 15: workflowcontract.v1.PolicyAttachment.selector:type_name -> workflowcontract.v1.PolicyAttachment.MaterialSelector - 19, // 16: workflowcontract.v1.PolicyAttachment.with:type_name -> workflowcontract.v1.PolicyAttachment.WithEntry - 10, // 17: workflowcontract.v1.Policy.metadata:type_name -> workflowcontract.v1.Metadata - 11, // 18: workflowcontract.v1.Policy.spec:type_name -> workflowcontract.v1.PolicySpec - 21, // 19: workflowcontract.v1.Metadata.annotations:type_name -> workflowcontract.v1.Metadata.AnnotationsEntry + 19, // 0: workflowcontract.v1.CraftingSchema.materials:type_name -> workflowcontract.v1.CraftingSchema.Material + 18, // 1: workflowcontract.v1.CraftingSchema.runner:type_name -> workflowcontract.v1.CraftingSchema.Runner + 7, // 2: workflowcontract.v1.CraftingSchema.annotations:type_name -> workflowcontract.v1.Annotation + 8, // 3: workflowcontract.v1.CraftingSchema.policies:type_name -> workflowcontract.v1.Policies + 16, // 4: workflowcontract.v1.CraftingSchema.policy_groups:type_name -> workflowcontract.v1.PolicyGroupAttachment + 11, // 5: workflowcontract.v1.CraftingSchemaV2.metadata:type_name -> workflowcontract.v1.Metadata + 6, // 6: workflowcontract.v1.CraftingSchemaV2.spec:type_name -> workflowcontract.v1.CraftingSchemaV2Spec + 19, // 7: workflowcontract.v1.CraftingSchemaV2Spec.materials:type_name -> workflowcontract.v1.CraftingSchema.Material + 18, // 8: workflowcontract.v1.CraftingSchemaV2Spec.runner:type_name -> workflowcontract.v1.CraftingSchema.Runner + 8, // 9: workflowcontract.v1.CraftingSchemaV2Spec.policies:type_name -> workflowcontract.v1.Policies + 16, // 10: workflowcontract.v1.CraftingSchemaV2Spec.policy_groups:type_name -> workflowcontract.v1.PolicyGroupAttachment + 7, // 11: workflowcontract.v1.CraftingSchemaV2Spec.annotations:type_name -> workflowcontract.v1.Annotation + 9, // 12: workflowcontract.v1.Policies.materials:type_name -> workflowcontract.v1.PolicyAttachment + 9, // 13: workflowcontract.v1.Policies.attestation:type_name -> workflowcontract.v1.PolicyAttachment + 10, // 14: workflowcontract.v1.PolicyAttachment.embedded:type_name -> workflowcontract.v1.Policy + 21, // 15: workflowcontract.v1.PolicyAttachment.selector:type_name -> workflowcontract.v1.PolicyAttachment.MaterialSelector + 20, // 16: workflowcontract.v1.PolicyAttachment.with:type_name -> workflowcontract.v1.PolicyAttachment.WithEntry + 11, // 17: workflowcontract.v1.Policy.metadata:type_name -> workflowcontract.v1.Metadata + 12, // 18: workflowcontract.v1.Policy.spec:type_name -> workflowcontract.v1.PolicySpec + 22, // 19: workflowcontract.v1.Metadata.annotations:type_name -> workflowcontract.v1.Metadata.AnnotationsEntry 2, // 20: workflowcontract.v1.PolicySpec.type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType - 13, // 21: workflowcontract.v1.PolicySpec.policies:type_name -> workflowcontract.v1.PolicySpecV2 - 12, // 22: workflowcontract.v1.PolicySpec.inputs:type_name -> workflowcontract.v1.PolicyInput - 14, // 23: workflowcontract.v1.PolicySpec.auto_match:type_name -> workflowcontract.v1.AutoMatch + 14, // 21: workflowcontract.v1.PolicySpec.policies:type_name -> workflowcontract.v1.PolicySpecV2 + 13, // 22: workflowcontract.v1.PolicySpec.inputs:type_name -> workflowcontract.v1.PolicyInput + 15, // 23: workflowcontract.v1.PolicySpec.auto_match:type_name -> workflowcontract.v1.AutoMatch 2, // 24: workflowcontract.v1.PolicySpecV2.kind:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType 0, // 25: workflowcontract.v1.PolicySpecV2.attestation_phases:type_name -> workflowcontract.v1.AttestationPhase - 22, // 26: workflowcontract.v1.PolicyGroupAttachment.with:type_name -> workflowcontract.v1.PolicyGroupAttachment.WithEntry - 10, // 27: workflowcontract.v1.PolicyGroup.metadata:type_name -> workflowcontract.v1.Metadata - 23, // 28: workflowcontract.v1.PolicyGroup.spec:type_name -> workflowcontract.v1.PolicyGroup.PolicyGroupSpec + 23, // 26: workflowcontract.v1.PolicyGroupAttachment.with:type_name -> workflowcontract.v1.PolicyGroupAttachment.WithEntry + 11, // 27: workflowcontract.v1.PolicyGroup.metadata:type_name -> workflowcontract.v1.Metadata + 24, // 28: workflowcontract.v1.PolicyGroup.spec:type_name -> workflowcontract.v1.PolicyGroup.PolicyGroupSpec 1, // 29: workflowcontract.v1.CraftingSchema.Runner.type:type_name -> workflowcontract.v1.CraftingSchema.Runner.RunnerType 2, // 30: workflowcontract.v1.CraftingSchema.Material.type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType - 6, // 31: workflowcontract.v1.CraftingSchema.Material.annotations:type_name -> workflowcontract.v1.Annotation - 24, // 32: workflowcontract.v1.PolicyGroup.PolicyGroupSpec.policies:type_name -> workflowcontract.v1.PolicyGroup.PolicyGroupPolicies - 12, // 33: workflowcontract.v1.PolicyGroup.PolicyGroupSpec.inputs:type_name -> workflowcontract.v1.PolicyInput - 25, // 34: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies.materials:type_name -> workflowcontract.v1.PolicyGroup.Material - 8, // 35: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies.attestation:type_name -> workflowcontract.v1.PolicyAttachment - 2, // 36: workflowcontract.v1.PolicyGroup.Material.type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType - 8, // 37: workflowcontract.v1.PolicyGroup.Material.policies:type_name -> workflowcontract.v1.PolicyAttachment - 38, // [38:38] is the sub-list for method output_type - 38, // [38:38] is the sub-list for method input_type - 38, // [38:38] is the sub-list for extension type_name - 38, // [38:38] is the sub-list for extension extendee - 0, // [0:38] is the sub-list for field type_name + 7, // 31: workflowcontract.v1.CraftingSchema.Material.annotations:type_name -> workflowcontract.v1.Annotation + 3, // 32: workflowcontract.v1.PolicyAttachment.MaterialSelector.match_mode:type_name -> workflowcontract.v1.PolicyAttachment.MaterialSelector.MatchMode + 25, // 33: workflowcontract.v1.PolicyGroup.PolicyGroupSpec.policies:type_name -> workflowcontract.v1.PolicyGroup.PolicyGroupPolicies + 13, // 34: workflowcontract.v1.PolicyGroup.PolicyGroupSpec.inputs:type_name -> workflowcontract.v1.PolicyInput + 26, // 35: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies.materials:type_name -> workflowcontract.v1.PolicyGroup.Material + 9, // 36: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies.attestation:type_name -> workflowcontract.v1.PolicyAttachment + 2, // 37: workflowcontract.v1.PolicyGroup.Material.type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType + 9, // 38: workflowcontract.v1.PolicyGroup.Material.policies:type_name -> workflowcontract.v1.PolicyAttachment + 39, // [39:39] is the sub-list for method output_type + 39, // [39:39] is the sub-list for method input_type + 39, // [39:39] is the sub-list for extension type_name + 39, // [39:39] is the sub-list for extension extendee + 0, // [0:39] is the sub-list for field type_name } func init() { file_workflowcontract_v1_crafting_schema_proto_init() } @@ -2335,7 +2404,7 @@ func file_workflowcontract_v1_crafting_schema_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_workflowcontract_v1_crafting_schema_proto_rawDesc), len(file_workflowcontract_v1_crafting_schema_proto_rawDesc)), - NumEnums: 3, + NumEnums: 4, NumMessages: 23, NumExtensions: 0, NumServices: 0, diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto index 476132dcd..fbcb7bd71 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -293,6 +293,18 @@ message PolicyAttachment { message MaterialSelector { // material name string name = 1; + + // How `name` is matched against a material's name. Defaults to exact match + // (UNSPECIFIED behaves as EXACT), so existing selectors are unchanged. Use + // PREFIX to target a set of materials sharing a name prefix (e.g. an + // archive exploded into ``, `-1`, `-2`). + MatchMode match_mode = 2; + + enum MatchMode { + MATCH_MODE_UNSPECIFIED = 0; + MATCH_MODE_EXACT = 1; + MATCH_MODE_PREFIX = 2; + } } } diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index 00ecfab93..80bd7fb7f 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -957,8 +957,8 @@ func (pv *PolicyVerifier) shouldApplyPolicy(ctx context.Context, policyAtt *v1.P return false, nil } - if filteredName != "" && filteredName != material.GetId() { - // a filer exists and doesn't match + if filteredName != "" && !selectorMatches(policyAtt.GetSelector(), material.GetId()) { + // a filter exists and doesn't match return false, nil } @@ -970,6 +970,17 @@ func (pv *PolicyVerifier) shouldApplyPolicy(ctx context.Context, policyAtt *v1.P return true, nil } +// selectorMatches reports whether a material name satisfies the selector's name +// filter. The default (unspecified) mode is an exact match, so existing +// selectors are unaffected; PREFIX matches any material whose name begins with +// the selector name (e.g. an archive exploded into "", "-1", …). +func selectorMatches(selector *v1.PolicyAttachment_MaterialSelector, materialID string) bool { + if selector.GetMatchMode() == v1.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX { + return strings.HasPrefix(materialID, selector.GetName()) + } + return selector.GetName() == materialID +} + func getPolicyTypes(p *v1.Policy) []v1.CraftingSchema_Material_MaterialType { policyTypes := make([]v1.CraftingSchema_Material_MaterialType, 0) v1Type := p.GetSpec().GetType() diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index 5597cd75f..da8588e7f 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -414,6 +414,13 @@ func (s *testSuite) TestMaterialSelectionCriteria() { Selector: &v12.PolicyAttachment_MaterialSelector{Name: "custom-material"}, } attMultikind := &v12.PolicyAttachment{Policy: &v12.PolicyAttachment_Ref{Ref: "file://testdata/multi-kind.yaml"}} + attPrefixPolicyTyped := &v12.PolicyAttachment{ + Policy: &v12.PolicyAttachment_Ref{Ref: "file://testdata/sbom_syft.yaml"}, + Selector: &v12.PolicyAttachment_MaterialSelector{ + Name: "sbom", + MatchMode: v12.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX, + }, + } testcases := []struct { name string @@ -502,6 +509,46 @@ func (s *testSuite) TestMaterialSelectionCriteria() { }, result: 0, }, + { + name: "prefix selector matches the exact base name", + policies: []*v12.PolicyAttachment{attPrefixPolicyTyped}, + material: &v1.Attestation_Material{ + Id: "sbom", + M: &v1.Attestation_Material_Artifact_{Artifact: &v1.Attestation_Material_Artifact{}}, + MaterialType: v12.CraftingSchema_Material_SBOM_SPDX_JSON, + }, + result: 1, + }, + { + name: "prefix selector matches a suffixed name", + policies: []*v12.PolicyAttachment{attPrefixPolicyTyped}, + material: &v1.Attestation_Material{ + Id: "sbom-1", + M: &v1.Attestation_Material_Artifact_{Artifact: &v1.Attestation_Material_Artifact{}}, + MaterialType: v12.CraftingSchema_Material_SBOM_SPDX_JSON, + }, + result: 1, + }, + { + name: "prefix selector does not match a different name", + policies: []*v12.PolicyAttachment{attPrefixPolicyTyped}, + material: &v1.Attestation_Material{ + Id: "other", + M: &v1.Attestation_Material_Artifact_{Artifact: &v1.Attestation_Material_Artifact{}}, + MaterialType: v12.CraftingSchema_Material_SBOM_SPDX_JSON, + }, + result: 0, + }, + { + name: "exact selector (default) does not match a suffixed name", + policies: []*v12.PolicyAttachment{attFilteredPolicyTyped}, + material: &v1.Attestation_Material{ + Id: "sbom-1", + M: &v1.Attestation_Material_Artifact_{Artifact: &v1.Attestation_Material_Artifact{}}, + MaterialType: v12.CraftingSchema_Material_SBOM_SPDX_JSON, + }, + result: 0, + }, } for _, tc := range testcases { From eb8f4f7e50f0115dad9ffa6138885cb67a059db5 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 29 Jul 2026 15:32:33 +0200 Subject: [PATCH 02/14] feat(crafter): stable, reproducible names for exploded archive materials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archive explosion now names the first entry (in sorted path order) with the exact --name and the rest with positional suffixes "-1", "-2", …. Entries are sorted by their normalized path before naming so the file-to-name mapping is deterministic and reproducible regardless of how the archive stored them, and the first name lines up with a fixed contract's named required slot. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- pkg/attestation/crafter/crafter.go | 66 +++++++++------- pkg/attestation/crafter/crafter_test.go | 76 ++++++++++++++----- pkg/attestation/crafter/materials/archive.go | 32 ++++++++ .../crafter/materials/archive_test.go | 27 +++++++ 4 files changed, 153 insertions(+), 48 deletions(-) diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 15f8f1a25..3ae055ad7 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -25,6 +25,7 @@ import ( "os" "path/filepath" "slices" + "sort" "strings" "time" @@ -885,19 +886,22 @@ func (c *Crafter) AddMaterialsFromArchive( c.CraftingState.Attestation.PolicyEvaluations = c.CraftingState.Attestation.PolicyEvaluations[:policyEvalCheckpoint] } + // First pass: extract every entry to its own temp file. Material names are + // assigned in a second pass over a sorted list so the file→name mapping is + // deterministic and reproducible, independent of the order the archive + // writer stored the entries. Each entry gets its own temp subdirectory so + // two entries sharing a basename (e.g. "a/x.json" and "b/x.json") never + // collide, while the temp file keeps the original basename so the recorded + // material metadata preserves the real filename. + type archiveEntry struct { + name string // in-archive path; drives the deterministic ordering + tmpPath string + } + var entries []archiveEntry + walkErr := materials.WalkArchiveEntries(archivePath, format, limits, func(name string, r io.Reader) error { - // Material names are sequential ("-1", "-2", … or - // "material-N" with no prefix). The original basename is still derived - // (with archive "/" semantics, OS-independently) and used for the temp - // file so the recorded artifact filename preserves the real name. base := materials.ArchiveEntryBaseName(name) - matName := allocator.AllocateSequential(namePrefix) - - // Give each entry its own temp subdirectory (named by the unique material - // name) so two entries sharing a basename (e.g. "a/x.json" and "b/x.json") - // never collide, while the temp file itself keeps the original basename so - // the recorded material metadata preserves the real filename. - entryDir, err := os.MkdirTemp(tmpDir, matName+"-*") + entryDir, err := os.MkdirTemp(tmpDir, "entry-*") if err != nil { return fmt.Errorf("creating temp dir for entry %q: %w", name, err) } @@ -916,34 +920,40 @@ func (c *Crafter) AddMaterialsFromArchive( return fmt.Errorf("closing temp file for entry %q: %w", name, err) } + entries = append(entries, archiveEntry{name: name, tmpPath: tmpPath}) + return nil + }) + if walkErr != nil { + return nil, fmt.Errorf("expanding archive %q: %w", archivePath, walkErr) + } + if len(entries) == 0 { + return nil, fmt.Errorf("archive %q contains no processable entries", archivePath) + } + + // Deterministic order: sort by the normalized ("/"-separated) entry path so + // the same archive content always yields the same material names, and the + // first sorted entry takes the exact --name (namePrefix). + sort.SliceStable(entries, func(i, j int) bool { + return strings.ReplaceAll(entries[i].name, "\\", "/") < strings.ReplaceAll(entries[j].name, "\\", "/") + }) + + // Second pass: name (first entry = exact prefix, then "-1", …) and stage. + for _, e := range entries { + matName := allocator.AllocateNamed(namePrefix) m := &schemaapi.CraftingSchema_Material{ Optional: true, Type: materialKind, Name: matName, } - mt, err := c.stageMaterial(ctx, m, tmpPath, casBackend, runtimeAnnotations, opts...) - // Remove the entry's temp subdir immediately after staging to keep disk - // usage bounded; the deferred os.RemoveAll(tmpDir) is the safety net. - os.RemoveAll(entryDir) //nolint:errcheck // best-effort cleanup + mt, err := c.stageMaterial(ctx, m, e.tmpPath, casBackend, runtimeAnnotations, opts...) if err != nil { - return fmt.Errorf("staging entry %q as material %q: %w", name, matName, err) + rollback() + return nil, fmt.Errorf("staging entry %q as material %q: %w", e.name, matName, err) } stagedNames = append(stagedNames, matName) result = append(result, mt) - return nil - }) - - if walkErr != nil { - // Roll back any in-memory staging: remove material map entries and - // truncate policy evaluations back to the pre-call checkpoint. - rollback() - return nil, fmt.Errorf("expanding archive %q: %w", archivePath, walkErr) - } - - if len(result) == 0 { - return nil, fmt.Errorf("archive %q contains no processable entries", archivePath) } // All entries staged successfully; persist once. diff --git a/pkg/attestation/crafter/crafter_test.go b/pkg/attestation/crafter/crafter_test.go index f9b1f68e9..376baa0b4 100644 --- a/pkg/attestation/crafter/crafter_test.go +++ b/pkg/attestation/crafter/crafter_test.go @@ -773,17 +773,18 @@ func (s *crafterSuite) TestAddMaterialsFromArchiveAtomic() { stateMap := c.CraftingState.GetAttestation().GetMaterials() assert.Len(s.T(), stateMap, 2) - // Material names are sequential (0-indexed) with the --name value as - // prefix, independent of the entry order. - m1, has1 := stateMap["entry-0"] + // The first entry (in sorted path order) takes the exact --name; the + // rest get stable positional suffixes. alpha.txt sorts before beta.txt. + m1, has1 := stateMap["entry"] m2, has2 := stateMap["entry-1"] - assert.True(s.T(), has1, "expected material entry-0 in state") + assert.True(s.T(), has1, "expected material entry in state") assert.True(s.T(), has2, "expected material entry-1 in state") // The recorded artifact filename must preserve each original entry - // basename, not the sequential material key. - gotFilenames := []string{m1.GetArtifact().GetName(), m2.GetArtifact().GetName()} - assert.ElementsMatch(s.T(), []string{"alpha.txt", "beta.txt"}, gotFilenames) + // basename, not the derived material key. Deterministic sorted order: + // entry -> alpha.txt, entry-1 -> beta.txt. + assert.Equal(s.T(), "alpha.txt", m1.GetArtifact().GetName()) + assert.Equal(s.T(), "beta.txt", m2.GetArtifact().GetName()) }) s.Run("atomicity: over-tight limit leaves state empty", func() { @@ -906,14 +907,15 @@ func (s *crafterSuite) TestAddMaterialsFromArchiveBehavior() { stateMap := c.CraftingState.GetAttestation().GetMaterials() assert.Len(s.T(), stateMap, 2) - // Entries sharing a basename still get distinct sequential names. - _, hasMat0 := stateMap["material-0"] + // Entries sharing a basename still get distinct names: no --name given, + // so the first sorted entry is "material" and the next "material-1". + _, hasMat := stateMap["material"] _, hasMat1 := stateMap["material-1"] - assert.True(s.T(), hasMat0, "expected material material-0 in state") + assert.True(s.T(), hasMat, "expected material material in state") assert.True(s.T(), hasMat1, "expected material material-1 in state") }) - s.Run("name prefix: used as the sequential name prefix", func() { + s.Run("name: first entry takes the exact --name", func() { dir := s.T().TempDir() p := filepath.Join(dir, "prefix.zip") buildZip(s.T(), p, map[string]string{ @@ -935,8 +937,8 @@ func (s *crafterSuite) TestAddMaterialsFromArchiveBehavior() { stateMap := c.CraftingState.GetAttestation().GetMaterials() assert.Len(s.T(), stateMap, 1) - _, found := stateMap["sboms-0"] - assert.True(s.T(), found, "expected material sboms-0 in state") + _, found := stateMap["sboms"] + assert.True(s.T(), found, "expected material sboms in state") }) s.Run("skip dirs and symlinks in tar.gz: only regular file becomes material", func() { @@ -963,8 +965,8 @@ func (s *crafterSuite) TestAddMaterialsFromArchiveBehavior() { stateMap := c.CraftingState.GetAttestation().GetMaterials() assert.Len(s.T(), stateMap, 1) - realMat, hasReal := stateMap["material-0"] - assert.True(s.T(), hasReal, "expected material material-0 in state") + realMat, hasReal := stateMap["material"] + assert.True(s.T(), hasReal, "expected material material in state") // The original filename is still preserved in the artifact metadata. assert.Equal(s.T(), "real.txt", realMat.GetArtifact().GetName()) }) @@ -1020,13 +1022,47 @@ func (s *crafterSuite) TestAddMaterialsFromArchiveBehavior() { stateMap := c.CraftingState.GetAttestation().GetMaterials() assert.Len(s.T(), stateMap, 2) - m1, has1 := stateMap["material-0"] + m1, has1 := stateMap["material"] m2, has2 := stateMap["material-1"] - assert.True(s.T(), has1, "expected material material-0 in state") + assert.True(s.T(), has1, "expected material material in state") assert.True(s.T(), has2, "expected material material-1 in state") - // Original filenames preserved regardless of the sequential keys. - gotFilenames := []string{m1.GetArtifact().GetName(), m2.GetArtifact().GetName()} - assert.ElementsMatch(s.T(), []string{"alpha.txt", "beta.txt"}, gotFilenames) + // Deterministic sorted order: material -> alpha.txt, material-1 -> beta.txt. + assert.Equal(s.T(), "alpha.txt", m1.GetArtifact().GetName()) + assert.Equal(s.T(), "beta.txt", m2.GetArtifact().GetName()) + }) + + s.Run("reproducible: names map to entries by sorted path, independent of stored order", func() { + // Build two archives with the same three files; map iteration in buildZip + // stores them in arbitrary order, so this also exercises order-independence. + dir := s.T().TempDir() + p1 := filepath.Join(dir, "one.zip") + p2 := filepath.Join(dir, "two.zip") + files := map[string]string{"m.json": "1", "a.json": "2", "z.json": "3"} + buildZip(s.T(), p1, files) + buildZip(s.T(), p2, files) + + mapping := func(p string) map[string]string { + runner := runners.NewGeneric() + c, err := newInitializedCrafter(s.T(), contract, &v1.WorkflowMetadata{}, true, "", runner) + require.NoError(s.T(), err) + _, err = c.AddMaterialsFromArchive( + context.Background(), + "", "ARTIFACT", "scan", p, + materials.ArchiveZip, backend, nil, + materials.DefaultArchiveLimits(), + ) + require.NoError(s.T(), err) + out := map[string]string{} + for k, m := range c.CraftingState.GetAttestation().GetMaterials() { + out[k] = m.GetArtifact().GetName() + } + return out + } + + // Sorted paths a.json < m.json < z.json → scan, scan-1, scan-2. + want := map[string]string{"scan": "a.json", "scan-1": "m.json", "scan-2": "z.json"} + assert.Equal(s.T(), want, mapping(p1)) + assert.Equal(s.T(), want, mapping(p2), "same content must yield an identical name→file mapping") }) } diff --git a/pkg/attestation/crafter/materials/archive.go b/pkg/attestation/crafter/materials/archive.go index 9e3a80f70..9186b258b 100644 --- a/pkg/attestation/crafter/materials/archive.go +++ b/pkg/attestation/crafter/materials/archive.go @@ -325,6 +325,9 @@ func SanitizeMaterialName(s string) string { type NameAllocator struct { used map[string]struct{} seq int + // named tracks, per sanitized base, the next suffix to try so AllocateNamed + // yields "", "-1", "-2", … deterministically. + named map[string]int } // NewNameAllocator seeds the allocator with existing material names. @@ -355,3 +358,32 @@ func (a *NameAllocator) AllocateSequential(prefix string) string { } } } + +// AllocateNamed returns a unique material name derived from base: the bare +// sanitized base on first use, then "-1", "-2", …. It skips names +// already in use (seeded existing materials or a base reused across archives) +// and falls back to the "material" base when base sanitizes to empty. Callers +// that want the whole set named deterministically must feed entries in a stable +// order (the explode path sorts entries by name before allocating). +func (a *NameAllocator) AllocateNamed(base string) string { + b := defaultMaterialName + if s := SanitizeMaterialName(base); s != "" { + b = s + } + if a.named == nil { + a.named = make(map[string]int) + } + + for { + n := a.named[b] + a.named[b]++ + candidate := b + if n > 0 { + candidate = fmt.Sprintf("%s-%d", b, n) + } + if _, taken := a.used[candidate]; !taken { + a.used[candidate] = struct{}{} + return candidate + } + } +} diff --git a/pkg/attestation/crafter/materials/archive_test.go b/pkg/attestation/crafter/materials/archive_test.go index 127cb3266..37ddcb50d 100644 --- a/pkg/attestation/crafter/materials/archive_test.go +++ b/pkg/attestation/crafter/materials/archive_test.go @@ -273,6 +273,33 @@ func TestNameAllocatorSequential(t *testing.T) { }) } +func TestNameAllocatorNamed(t *testing.T) { + t.Run("first is the bare name, rest are positional suffixes", func(t *testing.T) { + a := NewNameAllocator(nil) + assert.Equal(t, "scan-report", a.AllocateNamed("scan-report")) + assert.Equal(t, "scan-report-1", a.AllocateNamed("scan-report")) + assert.Equal(t, "scan-report-2", a.AllocateNamed("scan-report")) + }) + + t.Run("name is sanitized", func(t *testing.T) { + a := NewNameAllocator(nil) + assert.Equal(t, "q3-scans", a.AllocateNamed("Q3 Scans")) + assert.Equal(t, "q3-scans-1", a.AllocateNamed("Q3 Scans")) + }) + + t.Run("collision with an existing material skips the bare name", func(t *testing.T) { + a := NewNameAllocator([]string{"scan-report"}) + assert.Equal(t, "scan-report-1", a.AllocateNamed("scan-report")) + assert.Equal(t, "scan-report-2", a.AllocateNamed("scan-report")) + }) + + t.Run("empty/symbol-only name falls back to material", func(t *testing.T) { + a := NewNameAllocator(nil) + assert.Equal(t, "material", a.AllocateNamed("")) + assert.Equal(t, "material-1", a.AllocateNamed("!!!")) + }) +} + func TestIsExplodableKind(t *testing.T) { // Explodable: SBOM and SARIF bundles. assert.True(t, IsExplodableKind("SBOM_CYCLONEDX_JSON")) From bc0ea8c7f1f4a30a3f747b6dd9ade966dee357f1 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 29 Jul 2026 15:32:42 +0200 Subject: [PATCH 03/14] feat(cli): record the source archive as evidence when exploding When `att add` explodes an archive, the original archive is now recorded once as an EVIDENCE material and cross-linked with every exploded material in both directions via chainloop.material.references, so the source bundle itself is attested. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- app/cli/pkg/action/attestation_add.go | 45 ++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/app/cli/pkg/action/attestation_add.go b/app/cli/pkg/action/attestation_add.go index b6b8fe219..31f8dbc2c 100644 --- a/app/cli/pkg/action/attestation_add.go +++ b/app/cli/pkg/action/attestation_add.go @@ -160,13 +160,20 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa } if format != materials.ArchiveNone { if len(policyInputFiles) > 0 { - action.Logger.Warn().Msg("--policy-input-from-file is ignored when expanding an archive; evidence cross-links are not recorded for exploded materials") + action.Logger.Warn().Msg("--policy-input-from-file is ignored when expanding an archive") } limits := materials.ArchiveLimits{MaxEntries: action.maxExtractEntries, MaxTotalSize: action.maxExtractSize} mts, err := crafter.AddMaterialsFromArchive(ctx, attestationID, materialType, materialName, materialValue, format, casBackend, annotations, limits, addOpts...) if err != nil { return nil, fmt.Errorf("adding materials from archive: %w", err) } + + // Record the original archive once as an EVIDENCE material, cross-linked + // with every exploded material so the source bundle itself is attested. + if err := action.addSourceArchiveEvidence(ctx, crafter, attestationID, materialName, materialValue, mts, casBackend); err != nil { + return nil, fmt.Errorf("recording source archive evidence: %w", err) + } + results := make([]*AttestationStatusMaterial, 0, len(mts)) for _, mt := range mts { r, err := attMaterialToAction(mt) @@ -313,6 +320,42 @@ func (action *AttestationAdd) addPolicyInputEvidence(ctx context.Context, c *cra return nil } +// addSourceArchiveEvidence records the original archive (the value passed to an +// exploding `att add`) once as an EVIDENCE material and cross-links it with the +// exploded materials in both directions via chainloop.material.references: every +// exploded material points at the archive, and the archive points back at all of +// them. The evidence name is derived deterministically from the material name +// ("-archive"), falling back to "material-archive" when no name was given. +func (action *AttestationAdd) addSourceArchiveEvidence(ctx context.Context, c *crafter.Crafter, attestationID, materialName, archivePath string, exploded []*api.Attestation_Material, casBackend *casclient.CASBackend) error { + base := "material" + if s := sanitizeMaterialNamePart(materialName); s != "" { + base = s + } + archiveName := base + "-archive" + + explodedNames := make([]string, 0, len(exploded)) + for _, m := range exploded { + explodedNames = append(explodedNames, m.GetId()) + } + + // Reverse edge: point each exploded material back at the archive. These are + // the same in-memory objects held in the crafting state, so they are + // persisted when the archive material below is written. + for _, m := range exploded { + addReference(m, archiveName) + } + + // Forward edge: the archive references every exploded material. + annotations := map[string]string{ + materials.AnnotationMaterialReferences: strings.Join(explodedNames, ","), + } + if _, err := c.AddMaterialContractFree(ctx, attestationID, schemaapi.CraftingSchema_Material_EVIDENCE.String(), archiveName, archivePath, casBackend, annotations); err != nil { + return fmt.Errorf("adding source archive evidence %q: %w", archiveName, err) + } + + return nil +} + // addReference appends the given material names to m's chainloop.material.references // annotation (comma-separated), preserving any existing references and skipping // duplicates. From d68d9b99d3b2eb3052d1ea190d735be3997f3811 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 29 Jul 2026 15:52:54 +0200 Subject: [PATCH 04/14] refactor(crafter,policies): simplify explode + selector, address review - extract materials.NormalizeArchivePath and reuse it (safeArchivePath, ArchiveEntryBaseName, and the explode sort key), computed once per entry - drop the now-dead AllocateSequential and its seq field (explode uses AllocateNamed exclusively) - source-archive evidence: derive the name via SanitizeMaterialName (so an empty --name yields "material-archive", not "input-archive"), make it collision-safe against existing materials, and cross-link in a single loop - document that archive explode commits before the evidence step and is safe to re-run (deterministic names) - replace selectorMatches with a shared nameMatches predicate - test that an exploded first entry satisfies a required named contract slot Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- app/cli/pkg/action/attestation_add.go | 28 ++++++++---- .../v1/crafting_state_validations_test.go | 21 +++++++++ pkg/attestation/crafter/crafter.go | 17 ++++--- pkg/attestation/crafter/materials/archive.go | 45 +++++++------------ .../crafter/materials/archive_test.go | 26 ----------- pkg/policies/policies.go | 22 ++++----- 6 files changed, 78 insertions(+), 81 deletions(-) diff --git a/app/cli/pkg/action/attestation_add.go b/app/cli/pkg/action/attestation_add.go index 31f8dbc2c..c835c5d60 100644 --- a/app/cli/pkg/action/attestation_add.go +++ b/app/cli/pkg/action/attestation_add.go @@ -170,6 +170,12 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa // Record the original archive once as an EVIDENCE material, cross-linked // with every exploded material so the source bundle itself is attested. + // Note: AddMaterialsFromArchive has already committed the exploded + // materials, so this runs as a second commit. If it fails, the exploded + // materials remain persisted (they are complete and valid); re-running + // the same command is safe because explosion is deterministic — the same + // archive yields the same material names, so the retry overwrites + // identically and re-records the evidence. if err := action.addSourceArchiveEvidence(ctx, crafter, attestationID, materialName, materialValue, mts, casBackend); err != nil { return nil, fmt.Errorf("recording source archive evidence: %w", err) } @@ -328,20 +334,26 @@ func (action *AttestationAdd) addPolicyInputEvidence(ctx context.Context, c *cra // ("-archive"), falling back to "material-archive" when no name was given. func (action *AttestationAdd) addSourceArchiveEvidence(ctx context.Context, c *crafter.Crafter, attestationID, materialName, archivePath string, exploded []*api.Attestation_Material, casBackend *casclient.CASBackend) error { base := "material" - if s := sanitizeMaterialNamePart(materialName); s != "" { + if s := materials.SanitizeMaterialName(materialName); s != "" { base = s } - archiveName := base + "-archive" + // Collision-safe evidence name: seed an allocator with the names already in + // the attestation so "-archive" (or a "-N" variant) never overwrites an + // existing material. + existing := c.CraftingState.GetAttestation().GetMaterials() + existingNames := make([]string, 0, len(existing)) + for k := range existing { + existingNames = append(existingNames, k) + } + archiveName := materials.NewNameAllocator(existingNames).AllocateNamed(base + "-archive") + // Cross-link both directions via chainloop.material.references. The reverse + // edge mutates each exploded material in place — they are the same objects + // held in the crafting state, so it is persisted when the archive material is + // written below. explodedNames := make([]string, 0, len(exploded)) for _, m := range exploded { explodedNames = append(explodedNames, m.GetId()) - } - - // Reverse edge: point each exploded material back at the archive. These are - // the same in-memory objects held in the crafting state, so they are - // persisted when the archive material below is written. - for _, m := range exploded { addReference(m, archiveName) } diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state_validations_test.go b/pkg/attestation/crafter/api/attestation/v1/crafting_state_validations_test.go index 1fe93a032..3b029bfde 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state_validations_test.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state_validations_test.go @@ -128,6 +128,27 @@ func TestCraftingStateValidateComplete(t *testing.T) { wantErr: true, errContains: []string{"req"}, }, + { + // An exploded archive names its first entry exactly the --name value, + // so the required contract slot is satisfied while the extra entries + // coexist as additional (undeclared) materials. + name: "exploded first entry satisfies a required named slot", + materials: []*workflowcontract.CraftingSchema_Material{ + {Type: art, Name: "scan-report"}, + }, + crafted: []string{"scan-report", "scan-report-1", "scan-report-archive"}, + }, + { + // If only suffixed entries exist (no exact slot name), the required + // slot is NOT satisfied. + name: "exploded suffixed entries without the exact slot name leave it missing", + materials: []*workflowcontract.CraftingSchema_Material{ + {Type: art, Name: "scan-report"}, + }, + crafted: []string{"scan-report-1", "scan-report-2"}, + wantErr: true, + errContains: []string{"scan-report"}, + }, } for _, tc := range testCases { diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 3ae055ad7..d052c3956 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -892,9 +892,12 @@ func (c *Crafter) AddMaterialsFromArchive( // writer stored the entries. Each entry gets its own temp subdirectory so // two entries sharing a basename (e.g. "a/x.json" and "b/x.json") never // collide, while the temp file keeps the original basename so the recorded - // material metadata preserves the real filename. + // material metadata preserves the real filename. Because names can only be + // assigned once every entry is known, all extracted files coexist on disk + // until the deferred cleanup — bounded by limits.MaxTotalSize. type archiveEntry struct { - name string // in-archive path; drives the deterministic ordering + name string // in-archive path, for error messages + sortKey string // normalized path, precomputed once to order entries deterministically tmpPath string } var entries []archiveEntry @@ -920,7 +923,7 @@ func (c *Crafter) AddMaterialsFromArchive( return fmt.Errorf("closing temp file for entry %q: %w", name, err) } - entries = append(entries, archiveEntry{name: name, tmpPath: tmpPath}) + entries = append(entries, archiveEntry{name: name, sortKey: materials.NormalizeArchivePath(name), tmpPath: tmpPath}) return nil }) if walkErr != nil { @@ -930,11 +933,11 @@ func (c *Crafter) AddMaterialsFromArchive( return nil, fmt.Errorf("archive %q contains no processable entries", archivePath) } - // Deterministic order: sort by the normalized ("/"-separated) entry path so - // the same archive content always yields the same material names, and the - // first sorted entry takes the exact --name (namePrefix). + // Deterministic order: sort by the normalized entry path so the same archive + // content always yields the same material names, and the first sorted entry + // takes the exact --name (namePrefix). sort.SliceStable(entries, func(i, j int) bool { - return strings.ReplaceAll(entries[i].name, "\\", "/") < strings.ReplaceAll(entries[j].name, "\\", "/") + return entries[i].sortKey < entries[j].sortKey }) // Second pass: name (first entry = exact prefix, then "-1", …) and stage. diff --git a/pkg/attestation/crafter/materials/archive.go b/pkg/attestation/crafter/materials/archive.go index 9186b258b..ff23ae52a 100644 --- a/pkg/attestation/crafter/materials/archive.go +++ b/pkg/attestation/crafter/materials/archive.go @@ -171,7 +171,7 @@ func WalkArchiveEntries(path string, format ArchiveFormat, limits ArchiveLimits, // ".." as a substring (e.g. "foo..bar.json") is accepted; only actual path // components equal to ".." are rejected. func safeArchivePath(name string) bool { - normalized := strings.ReplaceAll(name, "\\", "/") + normalized := NormalizeArchivePath(name) // Reject absolute paths, including Windows drive-letter (e.g. "C:/x") and // UNC paths (which normalize to a leading "/"). if strings.HasPrefix(normalized, "/") || hasWindowsDriveLetter(normalized) { @@ -285,13 +285,19 @@ func IsExplodableKind(kind string) bool { return ok } +// NormalizeArchivePath converts an archive entry path to its canonical +// "/"-separated form, independent of the host OS. Archive entry names are +// "/"-separated by spec; backslashes produced on Windows are folded to "/" so +// the same entry resolves identically everywhere (filepath would treat "\\" as +// a separator only on Windows, yielding OS-dependent results). +func NormalizeArchivePath(name string) string { + return strings.ReplaceAll(name, "\\", "/") +} + // ArchiveEntryBaseName returns the final element of an archive entry name using -// archive ("/") path semantics, independent of the host OS. Archive entry names -// are "/"-separated by spec; backslashes are normalized first so names produced -// on Windows resolve to the same basename everywhere (filepath.Base would treat -// "\\" as a separator only on Windows, yielding OS-dependent results). +// archive ("/") path semantics, independent of the host OS. func ArchiveEntryBaseName(name string) string { - return path.Base(strings.ReplaceAll(name, "\\", "/")) + return path.Base(NormalizeArchivePath(name)) } // defaultMaterialName is the fallback base used when a name cannot be derived @@ -319,12 +325,11 @@ func SanitizeMaterialName(s string) string { return b.String() } -// NameAllocator hands out sequential, unique DNS-1123 material names of the -// form "-" (n starting at 1). It is seeded with names already present -// in the attestation so derived names never overwrite existing materials. +// NameAllocator hands out unique DNS-1123 material names of the form "", +// then "-1", "-2", …. It is seeded with names already present in the +// attestation so derived names never overwrite existing materials. type NameAllocator struct { used map[string]struct{} - seq int // named tracks, per sanitized base, the next suffix to try so AllocateNamed // yields "", "-1", "-2", … deterministically. named map[string]int @@ -339,26 +344,6 @@ func NewNameAllocator(existing []string) *NameAllocator { return &NameAllocator{used: used} } -// AllocateSequential returns the next unused "-" material name, where -// n is a zero-based counter that advances across calls and skips names already -// in use. prefix is sanitized to DNS-1123; an empty or symbol-only prefix yields -// the base "material" (so entries are named material-0, material-1, …). -func (a *NameAllocator) AllocateSequential(prefix string) string { - base := defaultMaterialName - if s := SanitizeMaterialName(prefix); s != "" { - base = s - } - - for { - candidate := fmt.Sprintf("%s-%d", base, a.seq) - a.seq++ - if _, taken := a.used[candidate]; !taken { - a.used[candidate] = struct{}{} - return candidate - } - } -} - // AllocateNamed returns a unique material name derived from base: the bare // sanitized base on first use, then "-1", "-2", …. It skips names // already in use (seeded existing materials or a base reused across archives) diff --git a/pkg/attestation/crafter/materials/archive_test.go b/pkg/attestation/crafter/materials/archive_test.go index 37ddcb50d..769b44342 100644 --- a/pkg/attestation/crafter/materials/archive_test.go +++ b/pkg/attestation/crafter/materials/archive_test.go @@ -247,32 +247,6 @@ func TestSanitizeMaterialName(t *testing.T) { } } -func TestNameAllocatorSequential(t *testing.T) { - t.Run("default prefix numbers from 0", func(t *testing.T) { - a := NewNameAllocator(nil) - assert.Equal(t, "material-0", a.AllocateSequential("")) - assert.Equal(t, "material-1", a.AllocateSequential("")) - assert.Equal(t, "material-2", a.AllocateSequential("")) - }) - - t.Run("custom prefix is sanitized and numbered", func(t *testing.T) { - a := NewNameAllocator(nil) - assert.Equal(t, "q3-scans-0", a.AllocateSequential("Q3 Scans")) - assert.Equal(t, "q3-scans-1", a.AllocateSequential("Q3 Scans")) - }) - - t.Run("skips names already present in the attestation", func(t *testing.T) { - a := NewNameAllocator([]string{"material-0", "material-1"}) - assert.Equal(t, "material-2", a.AllocateSequential("")) - assert.Equal(t, "material-3", a.AllocateSequential("")) - }) - - t.Run("symbol-only prefix falls back to material", func(t *testing.T) { - a := NewNameAllocator(nil) - assert.Equal(t, "material-0", a.AllocateSequential("!!!")) - }) -} - func TestNameAllocatorNamed(t *testing.T) { t.Run("first is the bare name, rest are positional suffixes", func(t *testing.T) { a := NewNameAllocator(nil) diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index 80bd7fb7f..abc5b4464 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -957,7 +957,7 @@ func (pv *PolicyVerifier) shouldApplyPolicy(ctx context.Context, policyAtt *v1.P return false, nil } - if filteredName != "" && !selectorMatches(policyAtt.GetSelector(), material.GetId()) { + if filteredName != "" && !nameMatches(filteredName, policyAtt.GetSelector().GetMatchMode(), material.GetId()) { // a filter exists and doesn't match return false, nil } @@ -970,15 +970,17 @@ func (pv *PolicyVerifier) shouldApplyPolicy(ctx context.Context, policyAtt *v1.P return true, nil } -// selectorMatches reports whether a material name satisfies the selector's name -// filter. The default (unspecified) mode is an exact match, so existing -// selectors are unaffected; PREFIX matches any material whose name begins with -// the selector name (e.g. an archive exploded into "", "-1", …). -func selectorMatches(selector *v1.PolicyAttachment_MaterialSelector, materialID string) bool { - if selector.GetMatchMode() == v1.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX { - return strings.HasPrefix(materialID, selector.GetName()) - } - return selector.GetName() == materialID +// nameMatches reports whether a material name satisfies a selector/group-material +// name filter under the given match mode. The default (unspecified) mode is an +// exact match, so existing contracts are unaffected; PREFIX matches any material +// whose name begins with the filter (e.g. an archive exploded into "", +// "-1", …). It is the single predicate shared by standalone policy +// attachments (shouldApplyPolicy) and policy groups (VerifyMaterial). +func nameMatches(name string, mode v1.PolicyAttachment_MaterialSelector_MatchMode, materialID string) bool { + if mode == v1.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX { + return strings.HasPrefix(materialID, name) + } + return name == materialID } func getPolicyTypes(p *v1.Policy) []v1.CraftingSchema_Material_MaterialType { From 7f48a777c4333381254fd67c20215d2ad3ccbeaa Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 29 Jul 2026 15:53:07 +0200 Subject: [PATCH 05/14] feat(contracts): extend PREFIX match mode to policy group materials Adds match_mode to PolicyGroup.Material so a policy group can target a set of same-prefixed materials (e.g. an exploded archive), matching the standalone PolicyAttachment selector. Both match sites now share the nameMatches predicate, and InterpolateGroupMaterial carries match_mode through placeholder interpolation. Default remains exact (UNSPECIFIED), so existing groups are unchanged. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- .../workflowcontract/v1/crafting_schema.ts | 22 +++++++++- ...ct.v1.PolicyGroup.Material.jsonschema.json | 40 +++++++++++++++++ ...ntract.v1.PolicyGroup.Material.schema.json | 40 +++++++++++++++++ .../workflowcontract/v1/crafting_schema.pb.go | 32 ++++++++++---- .../workflowcontract/v1/crafting_schema.proto | 5 +++ pkg/policies/policy_groups.go | 11 ++--- pkg/policies/policy_groups_test.go | 44 +++++++++++++++++++ 7 files changed, 179 insertions(+), 15 deletions(-) diff --git a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts index 048abeb81..13c74811d 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -866,6 +866,12 @@ export interface PolicyGroup_Material { */ name: string; optional: boolean; + /** + * How `name` is matched against a material's name. Defaults to exact match + * (UNSPECIFIED behaves as EXACT); PREFIX targets a set of materials sharing + * a name prefix, mirroring PolicyAttachment.MaterialSelector. + */ + matchMode: PolicyAttachment_MaterialSelector_MatchMode; /** Policies to be applied to this material */ policies: PolicyAttachment[]; } @@ -3149,7 +3155,7 @@ export const PolicyGroup_PolicyGroupPolicies = { }; function createBasePolicyGroup_Material(): PolicyGroup_Material { - return { type: 0, name: "", optional: false, policies: [] }; + return { type: 0, name: "", optional: false, matchMode: 0, policies: [] }; } export const PolicyGroup_Material = { @@ -3163,6 +3169,9 @@ export const PolicyGroup_Material = { if (message.optional === true) { writer.uint32(24).bool(message.optional); } + if (message.matchMode !== 0) { + writer.uint32(32).int32(message.matchMode); + } for (const v of message.policies) { PolicyAttachment.encode(v!, writer.uint32(50).fork()).ldelim(); } @@ -3197,6 +3206,13 @@ export const PolicyGroup_Material = { message.optional = reader.bool(); continue; + case 4: + if (tag !== 32) { + break; + } + + message.matchMode = reader.int32() as any; + continue; case 6: if (tag !== 50) { break; @@ -3218,6 +3234,7 @@ export const PolicyGroup_Material = { type: isSet(object.type) ? craftingSchema_Material_MaterialTypeFromJSON(object.type) : 0, name: isSet(object.name) ? String(object.name) : "", optional: isSet(object.optional) ? Boolean(object.optional) : false, + matchMode: isSet(object.matchMode) ? policyAttachment_MaterialSelector_MatchModeFromJSON(object.matchMode) : 0, policies: Array.isArray(object?.policies) ? object.policies.map((e: any) => PolicyAttachment.fromJSON(e)) : [], }; }, @@ -3227,6 +3244,8 @@ export const PolicyGroup_Material = { message.type !== undefined && (obj.type = craftingSchema_Material_MaterialTypeToJSON(message.type)); message.name !== undefined && (obj.name = message.name); message.optional !== undefined && (obj.optional = message.optional); + message.matchMode !== undefined && + (obj.matchMode = policyAttachment_MaterialSelector_MatchModeToJSON(message.matchMode)); if (message.policies) { obj.policies = message.policies.map((e) => e ? PolicyAttachment.toJSON(e) : undefined); } else { @@ -3244,6 +3263,7 @@ export const PolicyGroup_Material = { message.type = object.type ?? 0; message.name = object.name ?? ""; message.optional = object.optional ?? false; + message.matchMode = object.matchMode ?? 0; message.policies = object.policies?.map((e) => PolicyAttachment.fromPartial(e)) || []; return message; }, diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json index 34c9a1bec..7462b3555 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json @@ -3,7 +3,47 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "Policy group materials", + "patternProperties": { + "^(match_mode)$": { + "anyOf": [ + { + "enum": [ + "MATCH_MODE_UNSPECIFIED", + "MATCH_MODE_EXACT", + "MATCH_MODE_PREFIX" + ], + "title": "Match Mode", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ], + "description": "How `name` is matched against a material's name. Defaults to exact match\n (UNSPECIFIED behaves as EXACT); PREFIX targets a set of materials sharing\n a name prefix, mirroring PolicyAttachment.MaterialSelector." + } + }, "properties": { + "matchMode": { + "anyOf": [ + { + "enum": [ + "MATCH_MODE_UNSPECIFIED", + "MATCH_MODE_EXACT", + "MATCH_MODE_PREFIX" + ], + "title": "Match Mode", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ], + "description": "How `name` is matched against a material's name. Defaults to exact match\n (UNSPECIFIED behaves as EXACT); PREFIX targets a set of materials sharing\n a name prefix, mirroring PolicyAttachment.MaterialSelector." + }, "name": { "description": "Free form name, as we support placeholders eg `{{ inputs.input_name }}`\n If no name is provided, material won't be enforced and will apply policies if `type` matches", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json index 3dbeb1aec..88011d12b 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json @@ -3,7 +3,47 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "Policy group materials", + "patternProperties": { + "^(matchMode)$": { + "anyOf": [ + { + "enum": [ + "MATCH_MODE_UNSPECIFIED", + "MATCH_MODE_EXACT", + "MATCH_MODE_PREFIX" + ], + "title": "Match Mode", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ], + "description": "How `name` is matched against a material's name. Defaults to exact match\n (UNSPECIFIED behaves as EXACT); PREFIX targets a set of materials sharing\n a name prefix, mirroring PolicyAttachment.MaterialSelector." + } + }, "properties": { + "match_mode": { + "anyOf": [ + { + "enum": [ + "MATCH_MODE_UNSPECIFIED", + "MATCH_MODE_EXACT", + "MATCH_MODE_PREFIX" + ], + "title": "Match Mode", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ], + "description": "How `name` is matched against a material's name. Defaults to exact match\n (UNSPECIFIED behaves as EXACT); PREFIX targets a set of materials sharing\n a name prefix, mirroring PolicyAttachment.MaterialSelector." + }, "name": { "description": "Free form name, as we support placeholders eg `{{ inputs.input_name }}`\n If no name is provided, material won't be enforced and will apply policies if `type` matches", "type": "string" diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index ee2a58811..0143b834d 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -2006,6 +2006,10 @@ type PolicyGroup_Material struct { // If no name is provided, material won't be enforced and will apply policies if `type` matches Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` Optional bool `protobuf:"varint,3,opt,name=optional,proto3" json:"optional,omitempty"` + // How `name` is matched against a material's name. Defaults to exact match + // (UNSPECIFIED behaves as EXACT); PREFIX targets a set of materials sharing + // a name prefix, mirroring PolicyAttachment.MaterialSelector. + MatchMode PolicyAttachment_MaterialSelector_MatchMode `protobuf:"varint,4,opt,name=match_mode,json=matchMode,proto3,enum=workflowcontract.v1.PolicyAttachment_MaterialSelector_MatchMode" json:"match_mode,omitempty"` // Policies to be applied to this material Policies []*PolicyAttachment `protobuf:"bytes,6,rep,name=policies,proto3" json:"policies,omitempty"` unknownFields protoimpl.UnknownFields @@ -2063,6 +2067,13 @@ func (x *PolicyGroup_Material) GetOptional() bool { return false } +func (x *PolicyGroup_Material) GetMatchMode() PolicyAttachment_MaterialSelector_MatchMode { + if x != nil { + return x.MatchMode + } + return PolicyAttachment_MaterialSelector_MATCH_MODE_UNSPECIFIED +} + func (x *PolicyGroup_Material) GetPolicies() []*PolicyAttachment { if x != nil { return x.Policies @@ -2259,7 +2270,7 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\tWithEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\a\n" + - "\x05_gate\"\xc7\a\n" + + "\x05_gate\"\xa8\b\n" + "\vPolicyGroup\x12[\n" + "\vapi_version\x18\x01 \x01(\tB:\xbaH7r5R\x10chainloop.dev/v1R!workflowcontract.chainloop.dev/v1R\n" + "apiVersion\x12&\n" + @@ -2272,11 +2283,13 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x06inputs\x18\x02 \x03(\v2 .workflowcontract.v1.PolicyInputR\x06inputs\x1a\xa7\x01\n" + "\x13PolicyGroupPolicies\x12G\n" + "\tmaterials\x18\x01 \x03(\v2).workflowcontract.v1.PolicyGroup.MaterialR\tmaterials\x12G\n" + - "\vattestation\x18\x02 \x03(\v2%.workflowcontract.v1.PolicyAttachmentR\vattestation\x1a\xd7\x02\n" + + "\vattestation\x18\x02 \x03(\v2%.workflowcontract.v1.PolicyAttachmentR\vattestation\x1a\xb8\x03\n" + "\bMaterial\x12W\n" + "\x04type\x18\x01 \x01(\x0e29.workflowcontract.v1.CraftingSchema.Material.MaterialTypeB\b\xbaH\x05\x82\x01\x02\x10\x01R\x04type\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x1a\n" + - "\boptional\x18\x03 \x01(\bR\boptional\x12A\n" + + "\boptional\x18\x03 \x01(\bR\boptional\x12_\n" + + "\n" + + "match_mode\x18\x04 \x01(\x0e2@.workflowcontract.v1.PolicyAttachment.MaterialSelector.MatchModeR\tmatchMode\x12A\n" + "\bpolicies\x18\x06 \x03(\v2%.workflowcontract.v1.PolicyAttachmentR\bpolicies:\x7f\xbaH|\x1az\n" + "\x0egroup_material\x123if name is provided, type should have a valid value\x1a3!has(this.name) || has(this.name) && this.type != 0*I\n" + "\x10AttestationPhase\x12!\n" + @@ -2366,12 +2379,13 @@ var file_workflowcontract_v1_crafting_schema_proto_depIdxs = []int32{ 26, // 35: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies.materials:type_name -> workflowcontract.v1.PolicyGroup.Material 9, // 36: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies.attestation:type_name -> workflowcontract.v1.PolicyAttachment 2, // 37: workflowcontract.v1.PolicyGroup.Material.type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType - 9, // 38: workflowcontract.v1.PolicyGroup.Material.policies:type_name -> workflowcontract.v1.PolicyAttachment - 39, // [39:39] is the sub-list for method output_type - 39, // [39:39] is the sub-list for method input_type - 39, // [39:39] is the sub-list for extension type_name - 39, // [39:39] is the sub-list for extension extendee - 0, // [0:39] is the sub-list for field type_name + 3, // 38: workflowcontract.v1.PolicyGroup.Material.match_mode:type_name -> workflowcontract.v1.PolicyAttachment.MaterialSelector.MatchMode + 9, // 39: workflowcontract.v1.PolicyGroup.Material.policies:type_name -> workflowcontract.v1.PolicyAttachment + 40, // [40:40] is the sub-list for method output_type + 40, // [40:40] is the sub-list for method input_type + 40, // [40:40] is the sub-list for extension type_name + 40, // [40:40] is the sub-list for extension extendee + 0, // [0:40] is the sub-list for field type_name } func init() { file_workflowcontract_v1_crafting_schema_proto_init() } diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto index fbcb7bd71..b3406209c 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -499,6 +499,11 @@ message PolicyGroup { string name = 2; bool optional = 3; + // How `name` is matched against a material's name. Defaults to exact match + // (UNSPECIFIED behaves as EXACT); PREFIX targets a set of materials sharing + // a name prefix, mirroring PolicyAttachment.MaterialSelector. + PolicyAttachment.MaterialSelector.MatchMode match_mode = 4; + // Policies to be applied to this material repeated PolicyAttachment policies = 6; diff --git a/pkg/policies/policy_groups.go b/pkg/policies/policy_groups.go index 85360624d..e42a6e893 100644 --- a/pkg/policies/policy_groups.go +++ b/pkg/policies/policy_groups.go @@ -300,7 +300,7 @@ func (pgv *PolicyGroupVerifier) requiredPoliciesForMaterial(ctx context.Context, return nil, err } - if gm.Name != "" && gm.Name != material.GetId() { + if gm.GetName() != "" && !nameMatches(gm.GetName(), gm.GetMatchMode(), material.GetId()) { continue } @@ -330,10 +330,11 @@ func InterpolateGroupMaterial(gm *v1.PolicyGroup_Material, bindings map[string]s } return &v1.PolicyGroup_Material{ - Type: gm.Type, - Name: name, - Optional: gm.Optional, - Policies: gm.Policies, + Type: gm.Type, + Name: name, + MatchMode: gm.MatchMode, + Optional: gm.Optional, + Policies: gm.Policies, }, nil } diff --git a/pkg/policies/policy_groups_test.go b/pkg/policies/policy_groups_test.go index 887d294d4..c78de0291 100644 --- a/pkg/policies/policy_groups_test.go +++ b/pkg/policies/policy_groups_test.go @@ -181,6 +181,50 @@ func (s *groupsTestSuite) TestRequiredPoliciesForMaterial() { } } +func (s *groupsTestSuite) TestGroupMaterialPrefixMatch() { + // A group material with PREFIX match mode applies its policies to every + // material whose name starts with the group material name (e.g. an archive + // exploded into "sbom", "sbom-1", …), while EXACT (default) matches only the + // exact name. + group := &v1.PolicyGroup{ + Spec: &v1.PolicyGroup_PolicyGroupSpec{ + Policies: &v1.PolicyGroup_PolicyGroupPolicies{ + Materials: []*v1.PolicyGroup_Material{ + { + Name: "sbom", + MatchMode: v1.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX, + Type: v1.CraftingSchema_Material_SBOM_SPDX_JSON, + Policies: []*v1.PolicyAttachment{{Policy: &v1.PolicyAttachment_Ref{Ref: "file://testdata/sbom_syft.yaml"}}}, + }, + }, + }, + }, + } + + cases := []struct { + name string + id string + expected int + }{ + {"prefix matches the exact base name", "sbom", 1}, + {"prefix matches a suffixed name", "sbom-1", 1}, + {"prefix does not match a different name", "other", 0}, + } + for _, tc := range cases { + s.Run(tc.name, func() { + material := &api.Attestation_Material{ + MaterialType: v1.CraftingSchema_Material_SBOM_SPDX_JSON, + Id: tc.id, + M: &api.Attestation_Material_Artifact_{Artifact: &api.Attestation_Material_Artifact{}}, + } + v := NewPolicyGroupVerifier(nil, nil, nil, &s.logger) + atts, err := v.requiredPoliciesForMaterial(context.TODO(), material, group, nil) + s.Require().NoError(err) + s.Len(atts, tc.expected) + }) + } +} + func (s *groupsTestSuite) TestGroupLoader() { cases := []struct { name string From 4b1c143519bd7ed27cdd7ad5cc8aaf4431efcb64 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 29 Jul 2026 16:06:19 +0200 Subject: [PATCH 06/14] test: battle-test archive explode combinations and evidence cross-link Adds coverage across the explode matrix: uncompressed tar; --name with multiple entries (name, name-1, name-2); --name colliding with an existing material (derived names start at -1); max-total-size rollback; a mid-stage staging failure rolling back every already-staged entry. Adds an end-to-end test for the source-archive EVIDENCE cross-link (bidirectional references). Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- app/cli/pkg/action/attestation_add_test.go | 73 ++++++++++++++ pkg/attestation/crafter/crafter_test.go | 111 +++++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/app/cli/pkg/action/attestation_add_test.go b/app/cli/pkg/action/attestation_add_test.go index 0b35052e4..edd5484fb 100644 --- a/app/cli/pkg/action/attestation_add_test.go +++ b/app/cli/pkg/action/attestation_add_test.go @@ -16,13 +16,20 @@ package action import ( + "archive/zip" + "context" "os" "path/filepath" "regexp" "testing" + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter" api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/statemanager/filesystem" + "github.com/chainloop-dev/chainloop/pkg/casclient" "github.com/chainloop-dev/chainloop/pkg/policies" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -32,6 +39,72 @@ import ( // names by the proto validation (name.dns-1123). var materialNameRe = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) +// TestAddSourceArchiveEvidence exercises the Part B cross-link end to end: an +// exploded archive is recorded once as an EVIDENCE material and linked with the +// exploded materials in both directions. +func TestAddSourceArchiveEvidence(t *testing.T) { + ctx := context.Background() + + // A dry-run crafter backed by a local state file (no control plane). + statePath := filepath.Join(t.TempDir(), "attestation.json") + sm, err := filesystem.New(statePath) + require.NoError(t, err) + c, err := crafter.NewCrafter(sm, nil) + require.NoError(t, err) + require.NoError(t, c.Init(ctx, &crafter.InitOpts{ + SchemaV1: &schemaapi.CraftingSchema{SchemaVersion: "v1"}, + WfInfo: &api.WorkflowMetadata{}, + DryRun: true, + AttestationID: "", + Runner: runners.NewGeneric(), + })) + + // A zip of two files exploded into "scan" / "scan-1". + zipPath := filepath.Join(t.TempDir(), "bundle.zip") + writeZipWithFiles(t, zipPath, map[string]string{"a.txt": "a", "b.txt": "b"}) + + backend := &casclient.CASBackend{} + mts, err := c.AddMaterialsFromArchive(ctx, "", "ARTIFACT", "scan", zipPath, materials.ArchiveZip, backend, nil, materials.DefaultArchiveLimits()) + require.NoError(t, err) + require.Len(t, mts, 2) + + action := &AttestationAdd{} + require.NoError(t, action.addSourceArchiveEvidence(ctx, c, "", "scan", zipPath, mts, backend)) + + state := c.CraftingState.GetAttestation().GetMaterials() + + // The archive is recorded once as EVIDENCE under "scan-archive". + ev, ok := state["scan-archive"] + require.True(t, ok, "expected scan-archive evidence material") + assert.Equal(t, schemaapi.CraftingSchema_Material_EVIDENCE, ev.GetMaterialType()) + + // Forward edge: the archive references every exploded material. + fwd := ev.GetAnnotations()[materials.AnnotationMaterialReferences] + assert.Contains(t, fwd, "scan") + assert.Contains(t, fwd, "scan-1") + + // Reverse edge: every exploded material references the archive. + for _, name := range []string{"scan", "scan-1"} { + assert.Contains(t, state[name].GetAnnotations()[materials.AnnotationMaterialReferences], "scan-archive", + "exploded material %q must reference the archive", name) + } +} + +func writeZipWithFiles(t *testing.T, path string, files map[string]string) { + t.Helper() + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + zw := zip.NewWriter(f) + for name, content := range files { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) +} + func TestPolicyInputEvidenceNames(t *testing.T) { testCases := []struct { name string diff --git a/pkg/attestation/crafter/crafter_test.go b/pkg/attestation/crafter/crafter_test.go index 376baa0b4..c9f9cb5bb 100644 --- a/pkg/attestation/crafter/crafter_test.go +++ b/pkg/attestation/crafter/crafter_test.go @@ -23,6 +23,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "testing" "time" @@ -880,6 +881,21 @@ func buildTarGz(t *testing.T, path string, regular map[string]string, dirs []str require.NoError(t, gw.Close()) } +// buildTar creates an uncompressed .tar archive of regular files. +func buildTar(t *testing.T, path string, regular map[string]string) { + t.Helper() + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + tw := tar.NewWriter(f) + for name, content := range regular { + require.NoError(t, tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeReg, Mode: 0o600, Size: int64(len(content))})) + _, err = tw.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) +} + func (s *crafterSuite) TestAddMaterialsFromArchiveBehavior() { const contract = "testdata/contracts/empty_generic.yaml" backend := &casclient.CASBackend{} @@ -1066,6 +1082,101 @@ func (s *crafterSuite) TestAddMaterialsFromArchiveBehavior() { }) } +// TestAddMaterialsFromArchiveCombinations battle-tests the explode path across +// archive formats, naming scenarios, and failure/rollback paths. +func (s *crafterSuite) TestAddMaterialsFromArchiveCombinations() { + const contract = "testdata/contracts/empty_generic.yaml" + backend := &casclient.CASBackend{} + + // keys returns the sorted material names currently in the crafting state. + keys := func(c *testingCrafter) []string { + mats := c.CraftingState.GetAttestation().GetMaterials() + out := make([]string, 0, len(mats)) + for k := range mats { + out = append(out, k) + } + sort.Strings(out) + return out + } + + s.Run("uncompressed tar explodes named-first in sorted order", func() { + p := filepath.Join(s.T().TempDir(), "u.tar") + buildTar(s.T(), p, map[string]string{"b.txt": "b", "a.txt": "a"}) + c, err := newInitializedCrafter(s.T(), contract, &v1.WorkflowMetadata{}, true, "", runners.NewGeneric()) + require.NoError(s.T(), err) + + mts, err := c.AddMaterialsFromArchive(context.Background(), "", "ARTIFACT", "scan", p, materials.ArchiveTar, backend, nil, materials.DefaultArchiveLimits()) + require.NoError(s.T(), err) + assert.Len(s.T(), mts, 2) + state := c.CraftingState.GetAttestation().GetMaterials() + assert.Equal(s.T(), []string{"scan", "scan-1"}, keys(c)) + // a.txt sorts first → exact name; b.txt → scan-1. + assert.Equal(s.T(), "a.txt", state["scan"].GetArtifact().GetName()) + assert.Equal(s.T(), "b.txt", state["scan-1"].GetArtifact().GetName()) + }) + + s.Run("named with multiple entries: name, name-1, name-2", func() { + p := filepath.Join(s.T().TempDir(), "multi.zip") + buildZip(s.T(), p, map[string]string{"c.json": "3", "a.json": "1", "b.json": "2"}) + c, err := newInitializedCrafter(s.T(), contract, &v1.WorkflowMetadata{}, true, "", runners.NewGeneric()) + require.NoError(s.T(), err) + + mts, err := c.AddMaterialsFromArchive(context.Background(), "", "ARTIFACT", "scan", p, materials.ArchiveZip, backend, nil, materials.DefaultArchiveLimits()) + require.NoError(s.T(), err) + assert.Len(s.T(), mts, 3) + assert.Equal(s.T(), []string{"scan", "scan-1", "scan-2"}, keys(c)) + }) + + s.Run("name collides with an existing material: derived names start at -1", func() { + c, err := newInitializedCrafter(s.T(), contract, &v1.WorkflowMetadata{}, true, "", runners.NewGeneric()) + require.NoError(s.T(), err) + + p1 := filepath.Join(s.T().TempDir(), "first.zip") + buildZip(s.T(), p1, map[string]string{"x.txt": "x"}) + _, err = c.AddMaterialsFromArchive(context.Background(), "", "ARTIFACT", "scan", p1, materials.ArchiveZip, backend, nil, materials.DefaultArchiveLimits()) + require.NoError(s.T(), err) + + // Re-adding under the same --name must not overwrite "scan"; the new + // entry is allocated the next free suffix. + p2 := filepath.Join(s.T().TempDir(), "second.zip") + buildZip(s.T(), p2, map[string]string{"y.txt": "y"}) + _, err = c.AddMaterialsFromArchive(context.Background(), "", "ARTIFACT", "scan", p2, materials.ArchiveZip, backend, nil, materials.DefaultArchiveLimits()) + require.NoError(s.T(), err) + + assert.Equal(s.T(), []string{"scan", "scan-1"}, keys(c)) + }) + + s.Run("max total size exceeded rolls back to empty", func() { + p := filepath.Join(s.T().TempDir(), "big.zip") + buildZip(s.T(), p, map[string]string{"a.txt": "0123456789", "b.txt": "0123456789"}) + c, err := newInitializedCrafter(s.T(), contract, &v1.WorkflowMetadata{}, true, "", runners.NewGeneric()) + require.NoError(s.T(), err) + + tight := materials.ArchiveLimits{MaxEntries: 10000, MaxTotalSize: 4} + _, err = c.AddMaterialsFromArchive(context.Background(), "", "ARTIFACT", "scan", p, materials.ArchiveZip, backend, nil, tight) + require.Error(s.T(), err) + assert.ErrorIs(s.T(), err, materials.ErrArchiveTooLarge) + assert.Empty(s.T(), c.CraftingState.GetAttestation().GetMaterials()) + }) + + s.Run("invalid entry mid-stage rolls back the already-staged entry", func() { + p := filepath.Join(s.T().TempDir(), "mixed-sarif.zip") + buildZip(s.T(), p, map[string]string{ + // a-valid sorts before b-invalid, so the valid entry stages first and + // must be rolled back when the invalid one fails. + "a-valid.sarif": `{"$schema":"https://json.schemastore.org/sarif-2.1.0.json","version":"2.1.0","runs":[{"tool":{"driver":{"name":"t"}},"results":[]}]}`, + "b-invalid.sarif": `{"hello":"world"}`, + }) + c, err := newInitializedCrafter(s.T(), contract, &v1.WorkflowMetadata{}, true, "", runners.NewGeneric()) + require.NoError(s.T(), err) + + _, err = c.AddMaterialsFromArchive(context.Background(), "", "SARIF", "scan", p, materials.ArchiveZip, backend, nil, materials.DefaultArchiveLimits()) + require.Error(s.T(), err) + assert.Empty(s.T(), c.CraftingState.GetAttestation().GetMaterials(), "a staging failure must roll back every entry") + assert.Empty(s.T(), c.CraftingState.GetAttestation().GetPolicyEvaluations()) + }) +} + func loadSchema(path string) (*schemaapi.CraftingSchema, error) { // Extract json formatted data content, err := os.ReadFile(filepath.Clean(path)) From 25b2fbe543af8c33a4dcf6046682ebef89978e81 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 29 Jul 2026 16:17:20 +0200 Subject: [PATCH 07/14] fix(policies): bound PREFIX selector on the dash separator + more edge tests PREFIX now matches the exact name or a "-" derivative (the explode naming scheme) rather than an arbitrary substring, so a selector "scan" matches "scan" and "scan-1" but not "scanner". Adds edge cases: prefix dash-boundary (standalone + group), an archive with no regular files, and user-annotation propagation to every exploded material. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- pkg/attestation/crafter/crafter_test.go | 28 +++++++++++++++++++++++++ pkg/policies/policies.go | 12 ++++++----- pkg/policies/policies_test.go | 12 +++++++++++ pkg/policies/policy_groups_test.go | 1 + 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/pkg/attestation/crafter/crafter_test.go b/pkg/attestation/crafter/crafter_test.go index c9f9cb5bb..8dee569ad 100644 --- a/pkg/attestation/crafter/crafter_test.go +++ b/pkg/attestation/crafter/crafter_test.go @@ -1175,6 +1175,34 @@ func (s *crafterSuite) TestAddMaterialsFromArchiveCombinations() { assert.Empty(s.T(), c.CraftingState.GetAttestation().GetMaterials(), "a staging failure must roll back every entry") assert.Empty(s.T(), c.CraftingState.GetAttestation().GetPolicyEvaluations()) }) + + s.Run("archive with no regular files errors and stages nothing", func() { + p := filepath.Join(s.T().TempDir(), "empty.tar.gz") + // Only a directory entry and a symlink — both skipped by the walker. + buildTarGz(s.T(), p, map[string]string{}, []string{"adir/"}, map[string]string{"link": "adir"}) + c, err := newInitializedCrafter(s.T(), contract, &v1.WorkflowMetadata{}, true, "", runners.NewGeneric()) + require.NoError(s.T(), err) + + _, err = c.AddMaterialsFromArchive(context.Background(), "", "ARTIFACT", "scan", p, materials.ArchiveTarGz, backend, nil, materials.DefaultArchiveLimits()) + require.Error(s.T(), err) + assert.ErrorContains(s.T(), err, "no processable entries") + assert.Empty(s.T(), c.CraftingState.GetAttestation().GetMaterials()) + }) + + s.Run("user annotations propagate to every exploded material", func() { + p := filepath.Join(s.T().TempDir(), "annotated.zip") + buildZip(s.T(), p, map[string]string{"a.txt": "a", "b.txt": "b"}) + c, err := newInitializedCrafter(s.T(), contract, &v1.WorkflowMetadata{}, true, "", runners.NewGeneric()) + require.NoError(s.T(), err) + + annotations := map[string]string{"environment": "ci"} + mts, err := c.AddMaterialsFromArchive(context.Background(), "", "ARTIFACT", "scan", p, materials.ArchiveZip, backend, annotations, materials.DefaultArchiveLimits()) + require.NoError(s.T(), err) + require.Len(s.T(), mts, 2) + for _, mt := range mts { + assert.Equal(s.T(), "ci", mt.GetAnnotations()["environment"], "annotation must be attached to every exploded material") + } + }) } func loadSchema(path string) (*schemaapi.CraftingSchema, error) { diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index abc5b4464..1f028a89b 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -972,13 +972,15 @@ func (pv *PolicyVerifier) shouldApplyPolicy(ctx context.Context, policyAtt *v1.P // nameMatches reports whether a material name satisfies a selector/group-material // name filter under the given match mode. The default (unspecified) mode is an -// exact match, so existing contracts are unaffected; PREFIX matches any material -// whose name begins with the filter (e.g. an archive exploded into "", -// "-1", …). It is the single predicate shared by standalone policy -// attachments (shouldApplyPolicy) and policy groups (VerifyMaterial). +// exact match, so existing contracts are unaffected. PREFIX matches the exact +// name or any "-" derivative — the shape produced by exploding an +// archive into "", "-1", … — and deliberately NOT arbitrary +// substrings, so a selector "scan" matches "scan" and "scan-1" but not "scanner". +// It is the single predicate shared by standalone policy attachments +// (shouldApplyPolicy) and policy groups (VerifyMaterial). func nameMatches(name string, mode v1.PolicyAttachment_MaterialSelector_MatchMode, materialID string) bool { if mode == v1.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX { - return strings.HasPrefix(materialID, name) + return materialID == name || strings.HasPrefix(materialID, name+"-") } return name == materialID } diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index da8588e7f..86cd30782 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -539,6 +539,18 @@ func (s *testSuite) TestMaterialSelectionCriteria() { }, result: 0, }, + { + // Prefix is bounded on the "-" separator, so it must not match a name + // that merely starts with the same characters (no accidental substring). + name: "prefix selector does not match a substring past the dash boundary", + policies: []*v12.PolicyAttachment{attPrefixPolicyTyped}, + material: &v1.Attestation_Material{ + Id: "sbomextra", + M: &v1.Attestation_Material_Artifact_{Artifact: &v1.Attestation_Material_Artifact{}}, + MaterialType: v12.CraftingSchema_Material_SBOM_SPDX_JSON, + }, + result: 0, + }, { name: "exact selector (default) does not match a suffixed name", policies: []*v12.PolicyAttachment{attFilteredPolicyTyped}, diff --git a/pkg/policies/policy_groups_test.go b/pkg/policies/policy_groups_test.go index c78de0291..0d2c27da7 100644 --- a/pkg/policies/policy_groups_test.go +++ b/pkg/policies/policy_groups_test.go @@ -209,6 +209,7 @@ func (s *groupsTestSuite) TestGroupMaterialPrefixMatch() { {"prefix matches the exact base name", "sbom", 1}, {"prefix matches a suffixed name", "sbom-1", 1}, {"prefix does not match a different name", "other", 0}, + {"prefix does not match a substring past the dash boundary", "sbomextra", 0}, } for _, tc := range cases { s.Run(tc.name, func() { From fefbe94b1a046a9aab589838cce9e373799fbac5 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 29 Jul 2026 16:30:28 +0200 Subject: [PATCH 08/14] test(policies): prove multi-role PREFIX selectors never cross-match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors a multi-stage contract with two roles, each guarded by its own name-PREFIX selector: exploding a role into /-1/… routes only that role's policies, never the sibling role's, and unrelated names match neither. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- pkg/policies/policies_test.go | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index 86cd30782..b7203564b 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -578,6 +578,54 @@ func (s *testSuite) TestMaterialSelectionCriteria() { } } +// TestPrefixSelectorMultiRoleSeparation mirrors a multi-stage contract: two +// distinct roles, each guarded by its own name-PREFIX selector. Exploding a +// role's archive into "", "-1", … must route only that role's +// policies and never the sibling role's — the two prefixes never cross-match. +func (s *testSuite) TestPrefixSelectorMultiRoleSeparation() { + buildAtt := &v12.PolicyAttachment{ + Policy: &v12.PolicyAttachment_Ref{Ref: "file://testdata/sbom_syft.yaml"}, + Selector: &v12.PolicyAttachment_MaterialSelector{ + Name: "build-scan", + MatchMode: v12.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX, + }, + } + releaseAtt := &v12.PolicyAttachment{ + Policy: &v12.PolicyAttachment_Ref{Ref: "file://testdata/sbom_syft.yaml"}, + Selector: &v12.PolicyAttachment_MaterialSelector{ + Name: "release-scan", + MatchMode: v12.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX, + }, + } + atts := []*v12.PolicyAttachment{buildAtt, releaseAtt} + + cases := []struct { + name string + id string + want int + }{ + {"build base routes to the build role only", "build-scan", 1}, + {"build suffixed routes to the build role only", "build-scan-1", 1}, + {"release base routes to the release role only", "release-scan", 1}, + {"release suffixed routes to the release role only", "release-scan-2", 1}, + {"unrelated name routes to neither role", "other-scan", 0}, + } + for _, tc := range cases { + s.Run(tc.name, func() { + schema := &v12.CraftingSchema{Policies: &v12.Policies{Materials: atts}} + pv := NewPolicyVerifier(schema.Policies, nil, &s.logger) + material := &v1.Attestation_Material{ + Id: tc.id, + M: &v1.Attestation_Material_Artifact_{Artifact: &v1.Attestation_Material_Artifact{}}, + MaterialType: v12.CraftingSchema_Material_SBOM_SPDX_JSON, + } + got, err := pv.requiredPoliciesForMaterial(context.TODO(), material) + s.Require().NoError(err) + s.Require().Len(got, tc.want) + }) + } +} + func (s *testSuite) TestValidInlineMaterial() { content, err := os.ReadFile("testdata/sbom-spdx.json") s.Require().NoError(err) From 87835c4c749a0619fa91a410ef0910bef91d36de Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 29 Jul 2026 16:37:46 +0200 Subject: [PATCH 09/14] test: simplify explode/selector tests per review - use slices.Sorted(maps.Keys(...)) for the material-name helper (drops the hand-rolled sort + import) - consolidate the two zip builders in the action test package: writeTestZip now delegates to writeZipWithFiles - hoist the loop-invariant verifier in the multi-role selector test Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- app/cli/pkg/action/attestation_add_routing_test.go | 12 +----------- pkg/attestation/crafter/crafter_test.go | 11 +++-------- pkg/policies/policies_test.go | 3 +-- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/app/cli/pkg/action/attestation_add_routing_test.go b/app/cli/pkg/action/attestation_add_routing_test.go index b798d256e..e1d4014ad 100644 --- a/app/cli/pkg/action/attestation_add_routing_test.go +++ b/app/cli/pkg/action/attestation_add_routing_test.go @@ -16,7 +16,6 @@ package action import ( - "archive/zip" "os" "path/filepath" "testing" @@ -31,16 +30,7 @@ import ( func writeTestZip(t *testing.T, dir, name string) string { t.Helper() path := filepath.Join(dir, name) - f, err := os.Create(path) - require.NoError(t, err) - defer f.Close() - - w := zip.NewWriter(f) - entry, err := w.Create("entry.txt") - require.NoError(t, err) - _, err = entry.Write([]byte("hello")) - require.NoError(t, err) - require.NoError(t, w.Close()) + writeZipWithFiles(t, path, map[string]string{"entry.txt": "hello"}) return path } diff --git a/pkg/attestation/crafter/crafter_test.go b/pkg/attestation/crafter/crafter_test.go index 8dee569ad..2b9627391 100644 --- a/pkg/attestation/crafter/crafter_test.go +++ b/pkg/attestation/crafter/crafter_test.go @@ -21,9 +21,10 @@ import ( "compress/gzip" "context" "fmt" + "maps" "os" "path/filepath" - "sort" + "slices" "strings" "testing" "time" @@ -1090,13 +1091,7 @@ func (s *crafterSuite) TestAddMaterialsFromArchiveCombinations() { // keys returns the sorted material names currently in the crafting state. keys := func(c *testingCrafter) []string { - mats := c.CraftingState.GetAttestation().GetMaterials() - out := make([]string, 0, len(mats)) - for k := range mats { - out = append(out, k) - } - sort.Strings(out) - return out + return slices.Sorted(maps.Keys(c.CraftingState.GetAttestation().GetMaterials())) } s.Run("uncompressed tar explodes named-first in sorted order", func() { diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index b7203564b..a32086ef3 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -610,10 +610,9 @@ func (s *testSuite) TestPrefixSelectorMultiRoleSeparation() { {"release suffixed routes to the release role only", "release-scan-2", 1}, {"unrelated name routes to neither role", "other-scan", 0}, } + pv := NewPolicyVerifier(&v12.Policies{Materials: atts}, nil, &s.logger) for _, tc := range cases { s.Run(tc.name, func() { - schema := &v12.CraftingSchema{Policies: &v12.Policies{Materials: atts}} - pv := NewPolicyVerifier(schema.Policies, nil, &s.logger) material := &v1.Attestation_Material{ Id: tc.id, M: &v1.Attestation_Material_Artifact_{Artifact: &v1.Attestation_Material_Artifact{}}, From eefe268d68e2b0efef6f9d3f4187268ad101aa44 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 29 Jul 2026 16:48:37 +0200 Subject: [PATCH 10/14] test(cli): assert exact forward-edge reference set in archive-evidence test Replace substring Contains checks (trivially true) with an ElementsMatch on the split reference list, so the archive's forward references must be exactly the exploded material names. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- app/cli/pkg/action/attestation_add_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/cli/pkg/action/attestation_add_test.go b/app/cli/pkg/action/attestation_add_test.go index edd5484fb..d91f1cba5 100644 --- a/app/cli/pkg/action/attestation_add_test.go +++ b/app/cli/pkg/action/attestation_add_test.go @@ -21,6 +21,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "testing" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" @@ -78,10 +79,9 @@ func TestAddSourceArchiveEvidence(t *testing.T) { require.True(t, ok, "expected scan-archive evidence material") assert.Equal(t, schemaapi.CraftingSchema_Material_EVIDENCE, ev.GetMaterialType()) - // Forward edge: the archive references every exploded material. + // Forward edge: the archive references exactly the exploded materials. fwd := ev.GetAnnotations()[materials.AnnotationMaterialReferences] - assert.Contains(t, fwd, "scan") - assert.Contains(t, fwd, "scan-1") + assert.ElementsMatch(t, []string{"scan", "scan-1"}, strings.Split(fwd, ",")) // Reverse edge: every exploded material references the archive. for _, name := range []string{"scan", "scan-1"} { From dc1ad8a59b471cdcc28e2d4a10b878b447c98e49 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 29 Jul 2026 17:15:36 +0200 Subject: [PATCH 11/14] fix(cli): make archive explode + source-evidence atomic; fix input warning Addresses code-review (cubic) findings: - Fold the source-archive EVIDENCE material and its bidirectional cross-links into AddMaterialsFromArchive's single atomic commit, behind the new WithSourceArchiveEvidence option the CLI opts into. A failed evidence step now rolls the whole set back instead of leaving exploded materials that a retry would duplicate (the allocator seeds from existing names). - Correct the --policy-input-from-file warning: the runtime inputs still apply to policy evaluation of every exploded material; only the per-input evidence materials are not recorded. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- app/cli/pkg/action/attestation_add.go | 71 +++++----------------- app/cli/pkg/action/attestation_add_test.go | 10 +-- pkg/attestation/crafter/crafter.go | 60 +++++++++++++++++- 3 files changed, 79 insertions(+), 62 deletions(-) diff --git a/app/cli/pkg/action/attestation_add.go b/app/cli/pkg/action/attestation_add.go index c835c5d60..68395ed48 100644 --- a/app/cli/pkg/action/attestation_add.go +++ b/app/cli/pkg/action/attestation_add.go @@ -160,26 +160,20 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa } if format != materials.ArchiveNone { if len(policyInputFiles) > 0 { - action.Logger.Warn().Msg("--policy-input-from-file is ignored when expanding an archive") + // The runtime inputs still apply to every exploded material's policy + // evaluation (they flow through addOpts); only the per-input EVIDENCE + // materials are not recorded on the explode path. + action.Logger.Warn().Msg("--policy-input-from-file values apply to policy evaluation but are not recorded as evidence materials when expanding an archive") } limits := materials.ArchiveLimits{MaxEntries: action.maxExtractEntries, MaxTotalSize: action.maxExtractSize} - mts, err := crafter.AddMaterialsFromArchive(ctx, attestationID, materialType, materialName, materialValue, format, casBackend, annotations, limits, addOpts...) + // AddMaterialsFromArchive also records the source archive as an EVIDENCE + // material cross-linked with the exploded materials, all in one atomic + // commit — nothing is persisted unless the whole set succeeds. + mts, err := crafter.AddMaterialsFromArchive(ctx, attestationID, materialType, materialName, materialValue, format, casBackend, annotations, limits, withSourceArchiveEvidence(addOpts)...) if err != nil { return nil, fmt.Errorf("adding materials from archive: %w", err) } - // Record the original archive once as an EVIDENCE material, cross-linked - // with every exploded material so the source bundle itself is attested. - // Note: AddMaterialsFromArchive has already committed the exploded - // materials, so this runs as a second commit. If it fails, the exploded - // materials remain persisted (they are complete and valid); re-running - // the same command is safe because explosion is deterministic — the same - // archive yields the same material names, so the retry overwrites - // identically and re-records the evidence. - if err := action.addSourceArchiveEvidence(ctx, crafter, attestationID, materialName, materialValue, mts, casBackend); err != nil { - return nil, fmt.Errorf("recording source archive evidence: %w", err) - } - results := make([]*AttestationStatusMaterial, 0, len(mts)) for _, mt := range mts { r, err := attMaterialToAction(mt) @@ -261,6 +255,13 @@ func runtimeInputAddOpts(runtimeInputs *policies.RuntimeInputs) []crafter.AddOpt return []crafter.AddOpt{crafter.WithRuntimeInputs(runtimeInputs)} } +// withSourceArchiveEvidence extends opts so an archive explode also records the +// source archive as evidence. Defined at package scope so it can reference the +// crafter package, which the `crafter` local in Run() shadows. +func withSourceArchiveEvidence(opts []crafter.AddOpt) []crafter.AddOpt { + return append(opts, crafter.WithSourceArchiveEvidence()) +} + // buildRuntimeInputs reads each policy input file and returns the extracted // values grouped for the policy engine: unscoped entries under Global and // policy-scoped entries under Scoped[policy]. Values are newline-joined and @@ -326,48 +327,6 @@ func (action *AttestationAdd) addPolicyInputEvidence(ctx context.Context, c *cra return nil } -// addSourceArchiveEvidence records the original archive (the value passed to an -// exploding `att add`) once as an EVIDENCE material and cross-links it with the -// exploded materials in both directions via chainloop.material.references: every -// exploded material points at the archive, and the archive points back at all of -// them. The evidence name is derived deterministically from the material name -// ("-archive"), falling back to "material-archive" when no name was given. -func (action *AttestationAdd) addSourceArchiveEvidence(ctx context.Context, c *crafter.Crafter, attestationID, materialName, archivePath string, exploded []*api.Attestation_Material, casBackend *casclient.CASBackend) error { - base := "material" - if s := materials.SanitizeMaterialName(materialName); s != "" { - base = s - } - // Collision-safe evidence name: seed an allocator with the names already in - // the attestation so "-archive" (or a "-N" variant) never overwrites an - // existing material. - existing := c.CraftingState.GetAttestation().GetMaterials() - existingNames := make([]string, 0, len(existing)) - for k := range existing { - existingNames = append(existingNames, k) - } - archiveName := materials.NewNameAllocator(existingNames).AllocateNamed(base + "-archive") - - // Cross-link both directions via chainloop.material.references. The reverse - // edge mutates each exploded material in place — they are the same objects - // held in the crafting state, so it is persisted when the archive material is - // written below. - explodedNames := make([]string, 0, len(exploded)) - for _, m := range exploded { - explodedNames = append(explodedNames, m.GetId()) - addReference(m, archiveName) - } - - // Forward edge: the archive references every exploded material. - annotations := map[string]string{ - materials.AnnotationMaterialReferences: strings.Join(explodedNames, ","), - } - if _, err := c.AddMaterialContractFree(ctx, attestationID, schemaapi.CraftingSchema_Material_EVIDENCE.String(), archiveName, archivePath, casBackend, annotations); err != nil { - return fmt.Errorf("adding source archive evidence %q: %w", archiveName, err) - } - - return nil -} - // addReference appends the given material names to m's chainloop.material.references // annotation (comma-separated), preserving any existing references and skipping // duplicates. diff --git a/app/cli/pkg/action/attestation_add_test.go b/app/cli/pkg/action/attestation_add_test.go index d91f1cba5..7dcb2f35e 100644 --- a/app/cli/pkg/action/attestation_add_test.go +++ b/app/cli/pkg/action/attestation_add_test.go @@ -43,7 +43,10 @@ var materialNameRe = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) // TestAddSourceArchiveEvidence exercises the Part B cross-link end to end: an // exploded archive is recorded once as an EVIDENCE material and linked with the // exploded materials in both directions. -func TestAddSourceArchiveEvidence(t *testing.T) { +// TestExplodeRecordsSourceArchiveEvidence checks that AddMaterialsFromArchive +// records the source archive once as an EVIDENCE material cross-linked with the +// exploded materials in both directions, all in the one atomic add. +func TestExplodeRecordsSourceArchiveEvidence(t *testing.T) { ctx := context.Background() // A dry-run crafter backed by a local state file (no control plane). @@ -65,13 +68,10 @@ func TestAddSourceArchiveEvidence(t *testing.T) { writeZipWithFiles(t, zipPath, map[string]string{"a.txt": "a", "b.txt": "b"}) backend := &casclient.CASBackend{} - mts, err := c.AddMaterialsFromArchive(ctx, "", "ARTIFACT", "scan", zipPath, materials.ArchiveZip, backend, nil, materials.DefaultArchiveLimits()) + mts, err := c.AddMaterialsFromArchive(ctx, "", "ARTIFACT", "scan", zipPath, materials.ArchiveZip, backend, nil, materials.DefaultArchiveLimits(), crafter.WithSourceArchiveEvidence()) require.NoError(t, err) require.Len(t, mts, 2) - action := &AttestationAdd{} - require.NoError(t, action.addSourceArchiveEvidence(ctx, c, "", "scan", zipPath, mts, backend)) - state := c.CraftingState.GetAttestation().GetMaterials() // The archive is recorded once as EVIDENCE under "scan-archive". diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index d052c3956..08e6c8187 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -591,6 +591,19 @@ type addOpts struct { // the contract arguments when evaluating the standalone material policies, // either globally or scoped to a specific policy. runtimeInputs *policies.RuntimeInputs + // recordSourceArchive, when set on an archive explode, also records the + // source archive as an EVIDENCE material cross-linked with the exploded + // materials, within the same atomic add. + recordSourceArchive bool +} + +// WithSourceArchiveEvidence makes an archive explode also record the source +// archive once as an EVIDENCE material, cross-linked with every exploded +// material, in the same atomic add. +func WithSourceArchiveEvidence() AddOpt { + return func(o *addOpts) { + o.recordSourceArchive = true + } } // WithRuntimeInputs supplies policy input values that are merged additively onto @@ -853,6 +866,11 @@ func (c *Crafter) AddMaterialsFromArchive( return nil, fmt.Errorf("adding materials from archive: %w", err) } + addOptions := &addOpts{} + for _, opt := range opts { + opt(addOptions) + } + // Validate kind up front so we fail fast before touching disk. kindVal, found := schemaapi.CraftingSchema_Material_MaterialType_value[kind] if !found { @@ -959,7 +977,47 @@ func (c *Crafter) AddMaterialsFromArchive( result = append(result, mt) } - // All entries staged successfully; persist once. + // Optionally record the source archive once as an EVIDENCE material, + // cross-linked with every exploded material in both directions via + // chainloop.material.references. Staged inside this same transaction so the + // whole set (exploded materials + archive + cross-links) commits or rolls + // back atomically — a partial commit would otherwise leave orphaned + // materials that a retry duplicates. + if addOptions.recordSourceArchive { + archiveBase := "material" + if s := materials.SanitizeMaterialName(namePrefix); s != "" { + archiveBase = s + } + archiveName := allocator.AllocateNamed(archiveBase + "-archive") + + explodedNames := make([]string, len(result)) + for i, mt := range result { + explodedNames[i] = mt.GetId() + } + + archiveMaterial := &schemaapi.CraftingSchema_Material{ + Optional: true, + Type: schemaapi.CraftingSchema_Material_EVIDENCE, + Name: archiveName, + } + if _, err := c.stageMaterial(ctx, archiveMaterial, archivePath, + casBackend, map[string]string{materials.AnnotationMaterialReferences: strings.Join(explodedNames, ",")}, opts...); err != nil { + rollback() + return nil, fmt.Errorf("recording source archive %q as evidence %q: %w", archivePath, archiveName, err) + } + stagedNames = append(stagedNames, archiveName) + + // Reverse edge: point each exploded material back at the archive. They are + // freshly staged, so there are no prior references to merge. + for _, mt := range result { + if mt.Annotations == nil { + mt.Annotations = map[string]string{} + } + mt.Annotations[materials.AnnotationMaterialReferences] = archiveName + } + } + + // Everything staged successfully; persist the whole set once. if err := c.stateManager.Write(ctx, attestationID, c.CraftingState); err != nil { // Roll back in-memory state including policy evaluations. rollback() From 9bf834e5bc24825b0a7770e9f9d5049006e4e986 Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 30 Jul 2026 08:05:48 +0200 Subject: [PATCH 12/14] fix(crafter): preserve existing material references when linking the archive Follow-up review (cubic): the reverse-edge assignment overwrote any chainloop.material.references a caller set via runtimeAnnotations. Extract a shared materials.AppendReferences that preserves existing entries and dedupes, and route both the crafter reverse edge and the action addReference through it. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- app/cli/pkg/action/attestation_add.go | 19 +-------------- pkg/attestation/crafter/crafter.go | 6 ++--- .../crafter/materials/materials.go | 24 +++++++++++++++++++ .../crafter/materials/materials_test.go | 22 +++++++++++++++++ 4 files changed, 50 insertions(+), 21 deletions(-) diff --git a/app/cli/pkg/action/attestation_add.go b/app/cli/pkg/action/attestation_add.go index 68395ed48..15636f03e 100644 --- a/app/cli/pkg/action/attestation_add.go +++ b/app/cli/pkg/action/attestation_add.go @@ -337,24 +337,7 @@ func addReference(m *api.Attestation_Material, names ...string) { if m.Annotations == nil { m.Annotations = make(map[string]string) } - - existing := []string{} - if v := m.Annotations[materials.AnnotationMaterialReferences]; v != "" { - existing = strings.Split(v, ",") - } - - seen := make(map[string]struct{}, len(existing)) - for _, e := range existing { - seen[e] = struct{}{} - } - for _, n := range names { - if _, ok := seen[n]; !ok { - existing = append(existing, n) - seen[n] = struct{}{} - } - } - - m.Annotations[materials.AnnotationMaterialReferences] = strings.Join(existing, ",") + m.Annotations[materials.AnnotationMaterialReferences] = materials.AppendReferences(m.Annotations[materials.AnnotationMaterialReferences], names...) } // policyInputEvidenceNames derives the evidence material name for each policy diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 08e6c8187..7e719e9ab 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -1007,13 +1007,13 @@ func (c *Crafter) AddMaterialsFromArchive( } stagedNames = append(stagedNames, archiveName) - // Reverse edge: point each exploded material back at the archive. They are - // freshly staged, so there are no prior references to merge. + // Reverse edge: point each exploded material back at the archive, + // preserving any references a caller already set via runtimeAnnotations. for _, mt := range result { if mt.Annotations == nil { mt.Annotations = map[string]string{} } - mt.Annotations[materials.AnnotationMaterialReferences] = archiveName + mt.Annotations[materials.AnnotationMaterialReferences] = materials.AppendReferences(mt.Annotations[materials.AnnotationMaterialReferences], archiveName) } } diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go index bf9b94404..89337c6c0 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -24,6 +24,7 @@ import ( "io" "os" "strconv" + "strings" "time" "buf.build/go/protovalidate" @@ -81,6 +82,29 @@ func IsLegacyAnnotation(key string) bool { return key == AnnotationToolNameKey || key == AnnotationToolVersionKey } +// AppendReferences returns the value for the chainloop.material.references +// annotation with names appended to the existing comma-separated list, +// preserving any existing entries and skipping duplicates. It is the shared +// primitive behind every material cross-link so the append semantics stay +// consistent regardless of the caller. +func AppendReferences(existing string, names ...string) string { + var refs []string + if existing != "" { + refs = strings.Split(existing, ",") + } + seen := make(map[string]struct{}, len(refs)) + for _, r := range refs { + seen[r] = struct{}{} + } + for _, n := range names { + if _, ok := seen[n]; !ok { + refs = append(refs, n) + seen[n] = struct{}{} + } + } + return strings.Join(refs, ",") +} + // Tool represents a tool with name and version type Tool struct { Name string diff --git a/pkg/attestation/crafter/materials/materials_test.go b/pkg/attestation/crafter/materials/materials_test.go index 7b09f7e17..ef9a88be6 100644 --- a/pkg/attestation/crafter/materials/materials_test.go +++ b/pkg/attestation/crafter/materials/materials_test.go @@ -27,6 +27,28 @@ import ( "github.com/stretchr/testify/require" ) +func TestAppendReferences(t *testing.T) { + tests := []struct { + name string + existing string + add []string + want string + }{ + {"append to empty", "", []string{"a"}, "a"}, + {"append multiple to empty", "", []string{"a", "b"}, "a,b"}, + {"preserve existing and append", "existing", []string{"archive"}, "existing,archive"}, + {"preserve a list and append", "a,b", []string{"c"}, "a,b,c"}, + {"skip duplicates against existing", "a,b", []string{"b", "c"}, "a,b,c"}, + {"skip duplicates within added", "", []string{"a", "a"}, "a"}, + {"no names is a no-op on existing", "a,b", nil, "a,b"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, materials.AppendReferences(tc.existing, tc.add...)) + }) + } +} + func TestCraft(t *testing.T) { assert := assert.New(t) schema := &contractAPI.CraftingSchema_Material{ From 063dacac53cc13aba94a9acf7f18a6443fabaa5b Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 30 Jul 2026 08:43:56 +0200 Subject: [PATCH 13/14] refactor(contracts): drop the MATCH_MODE_ enum value prefix for usability Per review (Miguel): unprefixed values read better in contracts (match_mode: PREFIX vs MATCH_MODE_PREFIX) and match the other unprefixed enums in this file (RunnerType, MaterialType). ENUM_VALUE_PREFIX is already ignored file-wide; the bare UNSPECIFIED zero value needs a single inline buf:lint:ignore ENUM_ZERO_VALUE_SUFFIX. Regenerated pb.go/TS/jsonschema and updated the Go references. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- .../workflowcontract/v1/crafting_schema.ts | 36 +++++++++------- ...ttachment.MaterialSelector.jsonschema.json | 12 +++--- ...icyAttachment.MaterialSelector.schema.json | 12 +++--- ...ct.v1.PolicyGroup.Material.jsonschema.json | 12 +++--- ...ntract.v1.PolicyGroup.Material.schema.json | 12 +++--- .../workflowcontract/v1/crafting_schema.pb.go | 41 +++++++++++-------- .../workflowcontract/v1/crafting_schema.proto | 10 +++-- pkg/policies/policies.go | 2 +- pkg/policies/policies_test.go | 6 +-- pkg/policies/policy_groups_test.go | 2 +- 10 files changed, 80 insertions(+), 65 deletions(-) diff --git a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts index c6a8b352f..89cebfbdb 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -661,10 +661,16 @@ export interface PolicyAttachment_MaterialSelector { matchMode: PolicyAttachment_MaterialSelector_MatchMode; } +/** + * Values are intentionally unprefixed for contract-author usability + * ("match_mode: PREFIX" reads better than "MATCH_MODE_PREFIX"), matching the + * other unprefixed enums in this file (RunnerType, MaterialType). + * buf:lint:ignore ENUM_ZERO_VALUE_SUFFIX + */ export enum PolicyAttachment_MaterialSelector_MatchMode { - MATCH_MODE_UNSPECIFIED = 0, - MATCH_MODE_EXACT = 1, - MATCH_MODE_PREFIX = 2, + UNSPECIFIED = 0, + EXACT = 1, + PREFIX = 2, UNRECOGNIZED = -1, } @@ -673,14 +679,14 @@ export function policyAttachment_MaterialSelector_MatchModeFromJSON( ): PolicyAttachment_MaterialSelector_MatchMode { switch (object) { case 0: - case "MATCH_MODE_UNSPECIFIED": - return PolicyAttachment_MaterialSelector_MatchMode.MATCH_MODE_UNSPECIFIED; + case "UNSPECIFIED": + return PolicyAttachment_MaterialSelector_MatchMode.UNSPECIFIED; case 1: - case "MATCH_MODE_EXACT": - return PolicyAttachment_MaterialSelector_MatchMode.MATCH_MODE_EXACT; + case "EXACT": + return PolicyAttachment_MaterialSelector_MatchMode.EXACT; case 2: - case "MATCH_MODE_PREFIX": - return PolicyAttachment_MaterialSelector_MatchMode.MATCH_MODE_PREFIX; + case "PREFIX": + return PolicyAttachment_MaterialSelector_MatchMode.PREFIX; case -1: case "UNRECOGNIZED": default: @@ -692,12 +698,12 @@ export function policyAttachment_MaterialSelector_MatchModeToJSON( object: PolicyAttachment_MaterialSelector_MatchMode, ): string { switch (object) { - case PolicyAttachment_MaterialSelector_MatchMode.MATCH_MODE_UNSPECIFIED: - return "MATCH_MODE_UNSPECIFIED"; - case PolicyAttachment_MaterialSelector_MatchMode.MATCH_MODE_EXACT: - return "MATCH_MODE_EXACT"; - case PolicyAttachment_MaterialSelector_MatchMode.MATCH_MODE_PREFIX: - return "MATCH_MODE_PREFIX"; + case PolicyAttachment_MaterialSelector_MatchMode.UNSPECIFIED: + return "UNSPECIFIED"; + case PolicyAttachment_MaterialSelector_MatchMode.EXACT: + return "EXACT"; + case PolicyAttachment_MaterialSelector_MatchMode.PREFIX: + return "PREFIX"; case PolicyAttachment_MaterialSelector_MatchMode.UNRECOGNIZED: default: return "UNRECOGNIZED"; diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.jsonschema.json index 7b46ecec6..c2c547202 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.jsonschema.json @@ -7,9 +7,9 @@ "anyOf": [ { "enum": [ - "MATCH_MODE_UNSPECIFIED", - "MATCH_MODE_EXACT", - "MATCH_MODE_PREFIX" + "UNSPECIFIED", + "EXACT", + "PREFIX" ], "title": "Match Mode", "type": "string" @@ -28,9 +28,9 @@ "anyOf": [ { "enum": [ - "MATCH_MODE_UNSPECIFIED", - "MATCH_MODE_EXACT", - "MATCH_MODE_PREFIX" + "UNSPECIFIED", + "EXACT", + "PREFIX" ], "title": "Match Mode", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.schema.json index 53ae09fb6..e928c6f89 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.schema.json @@ -7,9 +7,9 @@ "anyOf": [ { "enum": [ - "MATCH_MODE_UNSPECIFIED", - "MATCH_MODE_EXACT", - "MATCH_MODE_PREFIX" + "UNSPECIFIED", + "EXACT", + "PREFIX" ], "title": "Match Mode", "type": "string" @@ -28,9 +28,9 @@ "anyOf": [ { "enum": [ - "MATCH_MODE_UNSPECIFIED", - "MATCH_MODE_EXACT", - "MATCH_MODE_PREFIX" + "UNSPECIFIED", + "EXACT", + "PREFIX" ], "title": "Match Mode", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json index 7462b3555..be4538598 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json @@ -8,9 +8,9 @@ "anyOf": [ { "enum": [ - "MATCH_MODE_UNSPECIFIED", - "MATCH_MODE_EXACT", - "MATCH_MODE_PREFIX" + "UNSPECIFIED", + "EXACT", + "PREFIX" ], "title": "Match Mode", "type": "string" @@ -29,9 +29,9 @@ "anyOf": [ { "enum": [ - "MATCH_MODE_UNSPECIFIED", - "MATCH_MODE_EXACT", - "MATCH_MODE_PREFIX" + "UNSPECIFIED", + "EXACT", + "PREFIX" ], "title": "Match Mode", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json index 88011d12b..d624de007 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json @@ -8,9 +8,9 @@ "anyOf": [ { "enum": [ - "MATCH_MODE_UNSPECIFIED", - "MATCH_MODE_EXACT", - "MATCH_MODE_PREFIX" + "UNSPECIFIED", + "EXACT", + "PREFIX" ], "title": "Match Mode", "type": "string" @@ -29,9 +29,9 @@ "anyOf": [ { "enum": [ - "MATCH_MODE_UNSPECIFIED", - "MATCH_MODE_EXACT", - "MATCH_MODE_PREFIX" + "UNSPECIFIED", + "EXACT", + "PREFIX" ], "title": "Match Mode", "type": "string" diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index 83c5023d9..ecf94cffc 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -373,25 +373,29 @@ func (CraftingSchema_Material_MaterialType) EnumDescriptor() ([]byte, []int) { return file_workflowcontract_v1_crafting_schema_proto_rawDescGZIP(), []int{0, 1, 0} } +// Values are intentionally unprefixed for contract-author usability +// ("match_mode: PREFIX" reads better than "MATCH_MODE_PREFIX"), matching the +// other unprefixed enums in this file (RunnerType, MaterialType). +// buf:lint:ignore ENUM_ZERO_VALUE_SUFFIX type PolicyAttachment_MaterialSelector_MatchMode int32 const ( - PolicyAttachment_MaterialSelector_MATCH_MODE_UNSPECIFIED PolicyAttachment_MaterialSelector_MatchMode = 0 - PolicyAttachment_MaterialSelector_MATCH_MODE_EXACT PolicyAttachment_MaterialSelector_MatchMode = 1 - PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX PolicyAttachment_MaterialSelector_MatchMode = 2 + PolicyAttachment_MaterialSelector_UNSPECIFIED PolicyAttachment_MaterialSelector_MatchMode = 0 + PolicyAttachment_MaterialSelector_EXACT PolicyAttachment_MaterialSelector_MatchMode = 1 + PolicyAttachment_MaterialSelector_PREFIX PolicyAttachment_MaterialSelector_MatchMode = 2 ) // Enum value maps for PolicyAttachment_MaterialSelector_MatchMode. var ( PolicyAttachment_MaterialSelector_MatchMode_name = map[int32]string{ - 0: "MATCH_MODE_UNSPECIFIED", - 1: "MATCH_MODE_EXACT", - 2: "MATCH_MODE_PREFIX", + 0: "UNSPECIFIED", + 1: "EXACT", + 2: "PREFIX", } PolicyAttachment_MaterialSelector_MatchMode_value = map[string]int32{ - "MATCH_MODE_UNSPECIFIED": 0, - "MATCH_MODE_EXACT": 1, - "MATCH_MODE_PREFIX": 2, + "UNSPECIFIED": 0, + "EXACT": 1, + "PREFIX": 2, } ) @@ -1893,7 +1897,7 @@ func (x *PolicyAttachment_MaterialSelector) GetMatchMode() PolicyAttachment_Mate if x != nil { return x.MatchMode } - return PolicyAttachment_MaterialSelector_MATCH_MODE_UNSPECIFIED + return PolicyAttachment_MaterialSelector_UNSPECIFIED } type PolicyGroup_PolicyGroupSpec struct { @@ -2073,7 +2077,7 @@ func (x *PolicyGroup_Material) GetMatchMode() PolicyAttachment_MaterialSelector_ if x != nil { return x.MatchMode } - return PolicyAttachment_MaterialSelector_MATCH_MODE_UNSPECIFIED + return PolicyAttachment_MaterialSelector_UNSPECIFIED } func (x *PolicyGroup_Material) GetPolicies() []*PolicyAttachment { @@ -2192,7 +2196,7 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\tR\x05value\"\x98\x01\n" + "\bPolicies\x12C\n" + "\tmaterials\x18\x01 \x03(\v2%.workflowcontract.v1.PolicyAttachmentR\tmaterials\x12G\n" + - "\vattestation\x18\x02 \x03(\v2%.workflowcontract.v1.PolicyAttachmentR\vattestation\"\xd0\x05\n" + + "\vattestation\x18\x02 \x03(\v2%.workflowcontract.v1.PolicyAttachmentR\vattestation\"\xaf\x05\n" + "\x10PolicyAttachment\x12\x1b\n" + "\x03ref\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\x03ref\x129\n" + "\bembedded\x18\x02 \x01(\v2\x1b.workflowcontract.v1.PolicyH\x00R\bembedded\x12R\n" + @@ -2203,15 +2207,16 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x04gate\x18\a \x01(\bH\x01R\x04gate\x88\x01\x01\x1a7\n" + "\tWithEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a\xdd\x01\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a\xbc\x01\n" + "\x10MaterialSelector\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12_\n" + "\n" + - "match_mode\x18\x02 \x01(\x0e2@.workflowcontract.v1.PolicyAttachment.MaterialSelector.MatchModeR\tmatchMode\"T\n" + - "\tMatchMode\x12\x1a\n" + - "\x16MATCH_MODE_UNSPECIFIED\x10\x00\x12\x14\n" + - "\x10MATCH_MODE_EXACT\x10\x01\x12\x15\n" + - "\x11MATCH_MODE_PREFIX\x10\x02B\x0f\n" + + "match_mode\x18\x02 \x01(\x0e2@.workflowcontract.v1.PolicyAttachment.MaterialSelector.MatchModeR\tmatchMode\"3\n" + + "\tMatchMode\x12\x0f\n" + + "\vUNSPECIFIED\x10\x00\x12\t\n" + + "\x05EXACT\x10\x01\x12\n" + + "\n" + + "\x06PREFIX\x10\x02B\x0f\n" + "\x06policy\x12\x05\xbaH\x02\b\x01B\a\n" + "\x05_gate\"\x88\x02\n" + "\x06Policy\x12[\n" + diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto index 1558c5167..4e662fcbf 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -302,10 +302,14 @@ message PolicyAttachment { // archive exploded into ``, `-1`, `-2`). MatchMode match_mode = 2; + // Values are intentionally unprefixed for contract-author usability + // ("match_mode: PREFIX" reads better than "MATCH_MODE_PREFIX"), matching the + // other unprefixed enums in this file (RunnerType, MaterialType). + // buf:lint:ignore ENUM_ZERO_VALUE_SUFFIX enum MatchMode { - MATCH_MODE_UNSPECIFIED = 0; - MATCH_MODE_EXACT = 1; - MATCH_MODE_PREFIX = 2; + UNSPECIFIED = 0; + EXACT = 1; + PREFIX = 2; } } } diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index 1f028a89b..c71340f32 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -979,7 +979,7 @@ func (pv *PolicyVerifier) shouldApplyPolicy(ctx context.Context, policyAtt *v1.P // It is the single predicate shared by standalone policy attachments // (shouldApplyPolicy) and policy groups (VerifyMaterial). func nameMatches(name string, mode v1.PolicyAttachment_MaterialSelector_MatchMode, materialID string) bool { - if mode == v1.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX { + if mode == v1.PolicyAttachment_MaterialSelector_PREFIX { return materialID == name || strings.HasPrefix(materialID, name+"-") } return name == materialID diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index a32086ef3..5c9c35912 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -418,7 +418,7 @@ func (s *testSuite) TestMaterialSelectionCriteria() { Policy: &v12.PolicyAttachment_Ref{Ref: "file://testdata/sbom_syft.yaml"}, Selector: &v12.PolicyAttachment_MaterialSelector{ Name: "sbom", - MatchMode: v12.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX, + MatchMode: v12.PolicyAttachment_MaterialSelector_PREFIX, }, } @@ -587,14 +587,14 @@ func (s *testSuite) TestPrefixSelectorMultiRoleSeparation() { Policy: &v12.PolicyAttachment_Ref{Ref: "file://testdata/sbom_syft.yaml"}, Selector: &v12.PolicyAttachment_MaterialSelector{ Name: "build-scan", - MatchMode: v12.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX, + MatchMode: v12.PolicyAttachment_MaterialSelector_PREFIX, }, } releaseAtt := &v12.PolicyAttachment{ Policy: &v12.PolicyAttachment_Ref{Ref: "file://testdata/sbom_syft.yaml"}, Selector: &v12.PolicyAttachment_MaterialSelector{ Name: "release-scan", - MatchMode: v12.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX, + MatchMode: v12.PolicyAttachment_MaterialSelector_PREFIX, }, } atts := []*v12.PolicyAttachment{buildAtt, releaseAtt} diff --git a/pkg/policies/policy_groups_test.go b/pkg/policies/policy_groups_test.go index 0d2c27da7..f95cd2b21 100644 --- a/pkg/policies/policy_groups_test.go +++ b/pkg/policies/policy_groups_test.go @@ -192,7 +192,7 @@ func (s *groupsTestSuite) TestGroupMaterialPrefixMatch() { Materials: []*v1.PolicyGroup_Material{ { Name: "sbom", - MatchMode: v1.PolicyAttachment_MaterialSelector_MATCH_MODE_PREFIX, + MatchMode: v1.PolicyAttachment_MaterialSelector_PREFIX, Type: v1.CraftingSchema_Material_SBOM_SPDX_JSON, Policies: []*v1.PolicyAttachment{{Policy: &v1.PolicyAttachment_Ref{Ref: "file://testdata/sbom_syft.yaml"}}}, }, From 327682f2bbb702ff5bb71cd2e3320215a46c047a Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Thu, 30 Jul 2026 10:31:45 +0200 Subject: [PATCH 14/14] fix(policies): make PREFIX selector a literal prefix Per review (jiparis): a name-PREFIX selector should be a literal prefix, not bounded on the "-" separator. The user provides the prefix, so silently matching "-" instead of "" is inconsistent with what they wrote; authors are expected to choose a discriminating prefix. Matching is now strings.HasPrefix(materialID, name). Multi-role separation still holds because distinct full-name prefixes do not cross-match. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 47ceb45b-7e6f-4843-932d-062e50b6419d --- pkg/policies/policies.go | 14 +++++++------- pkg/policies/policies_test.go | 8 ++++---- pkg/policies/policy_groups_test.go | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index c71340f32..d11c81586 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -972,15 +972,15 @@ func (pv *PolicyVerifier) shouldApplyPolicy(ctx context.Context, policyAtt *v1.P // nameMatches reports whether a material name satisfies a selector/group-material // name filter under the given match mode. The default (unspecified) mode is an -// exact match, so existing contracts are unaffected. PREFIX matches the exact -// name or any "-" derivative — the shape produced by exploding an -// archive into "", "-1", … — and deliberately NOT arbitrary -// substrings, so a selector "scan" matches "scan" and "scan-1" but not "scanner". -// It is the single predicate shared by standalone policy attachments -// (shouldApplyPolicy) and policy groups (VerifyMaterial). +// exact match, so existing contracts are unaffected. PREFIX is a literal prefix: +// it matches any material whose name begins with the filter (e.g. "scan-report" +// matches the exploded set "scan-report", "scan-report-1", …). The author is +// expected to choose a discriminating prefix. It is the single predicate shared +// by standalone policy attachments (shouldApplyPolicy) and policy groups +// (VerifyMaterial). func nameMatches(name string, mode v1.PolicyAttachment_MaterialSelector_MatchMode, materialID string) bool { if mode == v1.PolicyAttachment_MaterialSelector_PREFIX { - return materialID == name || strings.HasPrefix(materialID, name+"-") + return strings.HasPrefix(materialID, name) } return name == materialID } diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index 5c9c35912..e68ea63d9 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -540,16 +540,16 @@ func (s *testSuite) TestMaterialSelectionCriteria() { result: 0, }, { - // Prefix is bounded on the "-" separator, so it must not match a name - // that merely starts with the same characters (no accidental substring). - name: "prefix selector does not match a substring past the dash boundary", + // PREFIX is a literal prefix: any name starting with the filter matches, + // including "sbomextra". Authors are expected to pick a discriminating prefix. + name: "prefix selector matches any name starting with the filter", policies: []*v12.PolicyAttachment{attPrefixPolicyTyped}, material: &v1.Attestation_Material{ Id: "sbomextra", M: &v1.Attestation_Material_Artifact_{Artifact: &v1.Attestation_Material_Artifact{}}, MaterialType: v12.CraftingSchema_Material_SBOM_SPDX_JSON, }, - result: 0, + result: 1, }, { name: "exact selector (default) does not match a suffixed name", diff --git a/pkg/policies/policy_groups_test.go b/pkg/policies/policy_groups_test.go index f95cd2b21..89831b269 100644 --- a/pkg/policies/policy_groups_test.go +++ b/pkg/policies/policy_groups_test.go @@ -209,7 +209,7 @@ func (s *groupsTestSuite) TestGroupMaterialPrefixMatch() { {"prefix matches the exact base name", "sbom", 1}, {"prefix matches a suffixed name", "sbom-1", 1}, {"prefix does not match a different name", "other", 0}, - {"prefix does not match a substring past the dash boundary", "sbomextra", 0}, + {"prefix matches any name starting with the filter", "sbomextra", 1}, } for _, tc := range cases { s.Run(tc.name, func() {