diff --git a/app/cli/pkg/action/attestation_add.go b/app/cli/pkg/action/attestation_add.go index b6b8fe219..15636f03e 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") + // 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) } + results := make([]*AttestationStatusMaterial, 0, len(mts)) for _, mt := range mts { r, err := attMaterialToAction(mt) @@ -248,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 @@ -323,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/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/app/cli/pkg/action/attestation_add_test.go b/app/cli/pkg/action/attestation_add_test.go index 0b35052e4..7dcb2f35e 100644 --- a/app/cli/pkg/action/attestation_add_test.go +++ b/app/cli/pkg/action/attestation_add_test.go @@ -16,13 +16,21 @@ package action import ( + "archive/zip" + "context" "os" "path/filepath" "regexp" + "strings" "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 +40,71 @@ 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. +// 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). + 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(), crafter.WithSourceArchiveEvidence()) + require.NoError(t, err) + require.Len(t, mts, 2) + + 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 exactly the exploded materials. + fwd := ev.GetAnnotations()[materials.AnnotationMaterialReferences] + 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"} { + 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/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts index ea16ec84b..89cebfbdb 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -652,6 +652,62 @@ 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; +} + +/** + * 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 { + UNSPECIFIED = 0, + EXACT = 1, + PREFIX = 2, + UNRECOGNIZED = -1, +} + +export function policyAttachment_MaterialSelector_MatchModeFromJSON( + object: any, +): PolicyAttachment_MaterialSelector_MatchMode { + switch (object) { + case 0: + case "UNSPECIFIED": + return PolicyAttachment_MaterialSelector_MatchMode.UNSPECIFIED; + case 1: + case "EXACT": + return PolicyAttachment_MaterialSelector_MatchMode.EXACT; + case 2: + case "PREFIX": + return PolicyAttachment_MaterialSelector_MatchMode.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.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"; + } } /** Represents a policy to be applied to a material or attestation */ @@ -820,6 +876,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[]; } @@ -1830,7 +1892,7 @@ export const PolicyAttachment_WithEntry = { }; function createBasePolicyAttachment_MaterialSelector(): PolicyAttachment_MaterialSelector { - return { name: "" }; + return { name: "", matchMode: 0 }; } export const PolicyAttachment_MaterialSelector = { @@ -1838,6 +1900,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; }, @@ -1855,6 +1920,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; @@ -1865,12 +1937,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; }, @@ -1885,6 +1962,7 @@ export const PolicyAttachment_MaterialSelector = { ): PolicyAttachment_MaterialSelector { const message = createBasePolicyAttachment_MaterialSelector(); message.name = object.name ?? ""; + message.matchMode = object.matchMode ?? 0; return message; }, }; @@ -3087,7 +3165,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 = { @@ -3101,6 +3179,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(); } @@ -3135,6 +3216,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; @@ -3156,6 +3244,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)) : [], }; }, @@ -3165,6 +3254,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 { @@ -3182,6 +3273,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.PolicyAttachment.MaterialSelector.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyAttachment.MaterialSelector.jsonschema.json index c21464a07..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 @@ -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": [ + "UNSPECIFIED", + "EXACT", + "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": [ + "UNSPECIFIED", + "EXACT", + "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..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 @@ -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": [ + "UNSPECIFIED", + "EXACT", + "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": [ + "UNSPECIFIED", + "EXACT", + "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.PolicyGroup.Material.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json index 34c9a1bec..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 @@ -3,7 +3,47 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "Policy group materials", + "patternProperties": { + "^(match_mode)$": { + "anyOf": [ + { + "enum": [ + "UNSPECIFIED", + "EXACT", + "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": [ + "UNSPECIFIED", + "EXACT", + "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..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 @@ -3,7 +3,47 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "Policy group materials", + "patternProperties": { + "^(matchMode)$": { + "anyOf": [ + { + "enum": [ + "UNSPECIFIED", + "EXACT", + "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": [ + "UNSPECIFIED", + "EXACT", + "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 1d81067b7..ecf94cffc 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -373,6 +373,59 @@ 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_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: "UNSPECIFIED", + 1: "EXACT", + 2: "PREFIX", + } + PolicyAttachment_MaterialSelector_MatchMode_value = map[string]int32{ + "UNSPECIFIED": 0, + "EXACT": 1, + "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 // @@ -1793,7 +1846,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 } @@ -1835,6 +1893,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_UNSPECIFIED +} + type PolicyGroup_PolicyGroupSpec struct { state protoimpl.MessageState `protogen:"open.v1"` Policies *PolicyGroup_PolicyGroupPolicies `protobuf:"bytes,1,opt,name=policies,proto3" json:"policies,omitempty"` @@ -1947,6 +2012,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 @@ -2004,6 +2073,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_UNSPECIFIED +} + func (x *PolicyGroup_Material) GetPolicies() []*PolicyAttachment { if x != nil { return x.Policies @@ -2120,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\"\x98\x04\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" + @@ -2131,9 +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&\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a\xbc\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\"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" + @@ -2194,7 +2277,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" + @@ -2207,11 +2290,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" + @@ -2231,80 +2316,83 @@ 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 + 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() } @@ -2337,7 +2425,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 d43c19409..4e662fcbf 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -295,6 +295,22 @@ 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; + + // 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 { + UNSPECIFIED = 0; + EXACT = 1; + PREFIX = 2; + } } } @@ -489,6 +505,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/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 15f8f1a25..7e719e9ab 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" @@ -590,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 @@ -852,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 { @@ -885,19 +904,25 @@ 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. 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, for error messages + sortKey string // normalized path, precomputed once to order entries deterministically + 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,37 +941,83 @@ func (c *Crafter) AddMaterialsFromArchive( return fmt.Errorf("closing temp file for entry %q: %w", name, err) } + entries = append(entries, archiveEntry{name: name, sortKey: materials.NormalizeArchivePath(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 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 entries[i].sortKey < entries[j].sortKey + }) + + // 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) + // 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, + // 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] = materials.AppendReferences(mt.Annotations[materials.AnnotationMaterialReferences], archiveName) + } } - // All entries staged successfully; persist once. + // 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() diff --git a/pkg/attestation/crafter/crafter_test.go b/pkg/attestation/crafter/crafter_test.go index f9b1f68e9..2b9627391 100644 --- a/pkg/attestation/crafter/crafter_test.go +++ b/pkg/attestation/crafter/crafter_test.go @@ -21,8 +21,10 @@ import ( "compress/gzip" "context" "fmt" + "maps" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -773,17 +775,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() { @@ -879,6 +882,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{} @@ -906,14 +924,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 +954,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 +982,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 +1039,164 @@ 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") + }) +} + +// 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 { + return slices.Sorted(maps.Keys(c.CraftingState.GetAttestation().GetMaterials())) + } + + 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()) + }) + + 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") + } }) } diff --git a/pkg/attestation/crafter/materials/archive.go b/pkg/attestation/crafter/materials/archive.go index 7518ec80f..72668770c 100644 --- a/pkg/attestation/crafter/materials/archive.go +++ b/pkg/attestation/crafter/materials/archive.go @@ -111,7 +111,15 @@ func IsExplodableKind(kind string) bool { // on Windows resolve to the same basename everywhere (filepath.Base would treat // "\\" as a separator only on Windows, yielding OS-dependent results). func ArchiveEntryBaseName(name string) string { - return path.Base(strings.ReplaceAll(name, "\\", "/")) + return path.Base(NormalizeArchivePath(name)) +} + +// 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. +func NormalizeArchivePath(name string) string { + return strings.ReplaceAll(name, "\\", "/") } // defaultMaterialName is the fallback base used when a name cannot be derived @@ -139,12 +147,14 @@ 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 } // NewNameAllocator seeds the allocator with existing material names. @@ -156,19 +166,28 @@ 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 +// 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 { - candidate := fmt.Sprintf("%s-%d", base, a.seq) - a.seq++ + 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 32b3c277c..113be80c9 100644 --- a/pkg/attestation/crafter/materials/archive_test.go +++ b/pkg/attestation/crafter/materials/archive_test.go @@ -222,29 +222,30 @@ func TestSanitizeMaterialName(t *testing.T) { } } -func TestNameAllocatorSequential(t *testing.T) { - t.Run("default prefix numbers from 0", func(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, "material-0", a.AllocateSequential("")) - assert.Equal(t, "material-1", a.AllocateSequential("")) - assert.Equal(t, "material-2", a.AllocateSequential("")) + 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("custom prefix is sanitized and numbered", func(t *testing.T) { + t.Run("name is sanitized", 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")) + assert.Equal(t, "q3-scans", a.AllocateNamed("Q3 Scans")) + assert.Equal(t, "q3-scans-1", a.AllocateNamed("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("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("symbol-only prefix falls back to material", func(t *testing.T) { + t.Run("empty/symbol-only name falls back to material", func(t *testing.T) { a := NewNameAllocator(nil) - assert.Equal(t, "material-0", a.AllocateSequential("!!!")) + assert.Equal(t, "material", a.AllocateNamed("")) + assert.Equal(t, "material-1", a.AllocateNamed("!!!")) }) } 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{ diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index 00ecfab93..d11c81586 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 != "" && !nameMatches(filteredName, policyAtt.GetSelector().GetMatchMode(), material.GetId()) { + // a filter exists and doesn't match return false, nil } @@ -970,6 +970,21 @@ func (pv *PolicyVerifier) shouldApplyPolicy(ctx context.Context, policyAtt *v1.P return true, nil } +// 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 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 strings.HasPrefix(materialID, name) + } + return name == 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..e68ea63d9 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_PREFIX, + }, + } testcases := []struct { name string @@ -502,6 +509,58 @@ 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, + }, + { + // 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: 1, + }, + { + 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 { @@ -519,6 +578,53 @@ 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_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_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}, + } + pv := NewPolicyVerifier(&v12.Policies{Materials: atts}, nil, &s.logger) + for _, tc := range cases { + s.Run(tc.name, func() { + 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) 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..89831b269 100644 --- a/pkg/policies/policy_groups_test.go +++ b/pkg/policies/policy_groups_test.go @@ -181,6 +181,51 @@ 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_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}, + {"prefix matches any name starting with the filter", "sbomextra", 1}, + } + 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