Skip to content

Commit 8eed026

Browse files
authored
feat: explode archive materials into named contract slots with a prefix policy selector (#3314)
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
1 parent 3782eb7 commit 8eed026

21 files changed

Lines changed: 1106 additions & 190 deletions

app/cli/pkg/action/attestation_add.go

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -160,13 +160,20 @@ func (action *AttestationAdd) Run(ctx context.Context, attestationID, materialNa
160160
}
161161
if format != materials.ArchiveNone {
162162
if len(policyInputFiles) > 0 {
163-
action.Logger.Warn().Msg("--policy-input-from-file is ignored when expanding an archive; evidence cross-links are not recorded for exploded materials")
163+
// The runtime inputs still apply to every exploded material's policy
164+
// evaluation (they flow through addOpts); only the per-input EVIDENCE
165+
// materials are not recorded on the explode path.
166+
action.Logger.Warn().Msg("--policy-input-from-file values apply to policy evaluation but are not recorded as evidence materials when expanding an archive")
164167
}
165168
limits := materials.ArchiveLimits{MaxEntries: action.maxExtractEntries, MaxTotalSize: action.maxExtractSize}
166-
mts, err := crafter.AddMaterialsFromArchive(ctx, attestationID, materialType, materialName, materialValue, format, casBackend, annotations, limits, addOpts...)
169+
// AddMaterialsFromArchive also records the source archive as an EVIDENCE
170+
// material cross-linked with the exploded materials, all in one atomic
171+
// commit — nothing is persisted unless the whole set succeeds.
172+
mts, err := crafter.AddMaterialsFromArchive(ctx, attestationID, materialType, materialName, materialValue, format, casBackend, annotations, limits, withSourceArchiveEvidence(addOpts)...)
167173
if err != nil {
168174
return nil, fmt.Errorf("adding materials from archive: %w", err)
169175
}
176+
170177
results := make([]*AttestationStatusMaterial, 0, len(mts))
171178
for _, mt := range mts {
172179
r, err := attMaterialToAction(mt)
@@ -248,6 +255,13 @@ func runtimeInputAddOpts(runtimeInputs *policies.RuntimeInputs) []crafter.AddOpt
248255
return []crafter.AddOpt{crafter.WithRuntimeInputs(runtimeInputs)}
249256
}
250257

258+
// withSourceArchiveEvidence extends opts so an archive explode also records the
259+
// source archive as evidence. Defined at package scope so it can reference the
260+
// crafter package, which the `crafter` local in Run() shadows.
261+
func withSourceArchiveEvidence(opts []crafter.AddOpt) []crafter.AddOpt {
262+
return append(opts, crafter.WithSourceArchiveEvidence())
263+
}
264+
251265
// buildRuntimeInputs reads each policy input file and returns the extracted
252266
// values grouped for the policy engine: unscoped entries under Global and
253267
// policy-scoped entries under Scoped[policy]. Values are newline-joined and
@@ -323,24 +337,7 @@ func addReference(m *api.Attestation_Material, names ...string) {
323337
if m.Annotations == nil {
324338
m.Annotations = make(map[string]string)
325339
}
326-
327-
existing := []string{}
328-
if v := m.Annotations[materials.AnnotationMaterialReferences]; v != "" {
329-
existing = strings.Split(v, ",")
330-
}
331-
332-
seen := make(map[string]struct{}, len(existing))
333-
for _, e := range existing {
334-
seen[e] = struct{}{}
335-
}
336-
for _, n := range names {
337-
if _, ok := seen[n]; !ok {
338-
existing = append(existing, n)
339-
seen[n] = struct{}{}
340-
}
341-
}
342-
343-
m.Annotations[materials.AnnotationMaterialReferences] = strings.Join(existing, ",")
340+
m.Annotations[materials.AnnotationMaterialReferences] = materials.AppendReferences(m.Annotations[materials.AnnotationMaterialReferences], names...)
344341
}
345342

346343
// policyInputEvidenceNames derives the evidence material name for each policy

app/cli/pkg/action/attestation_add_routing_test.go

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
package action
1717

1818
import (
19-
"archive/zip"
2019
"os"
2120
"path/filepath"
2221
"testing"
@@ -31,16 +30,7 @@ import (
3130
func writeTestZip(t *testing.T, dir, name string) string {
3231
t.Helper()
3332
path := filepath.Join(dir, name)
34-
f, err := os.Create(path)
35-
require.NoError(t, err)
36-
defer f.Close()
37-
38-
w := zip.NewWriter(f)
39-
entry, err := w.Create("entry.txt")
40-
require.NoError(t, err)
41-
_, err = entry.Write([]byte("hello"))
42-
require.NoError(t, err)
43-
require.NoError(t, w.Close())
33+
writeZipWithFiles(t, path, map[string]string{"entry.txt": "hello"})
4434
return path
4535
}
4636

app/cli/pkg/action/attestation_add_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,21 @@
1616
package action
1717

1818
import (
19+
"archive/zip"
20+
"context"
1921
"os"
2022
"path/filepath"
2123
"regexp"
24+
"strings"
2225
"testing"
2326

27+
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
28+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
2429
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2530
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
31+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners"
32+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/statemanager/filesystem"
33+
"github.com/chainloop-dev/chainloop/pkg/casclient"
2634
"github.com/chainloop-dev/chainloop/pkg/policies"
2735
"github.com/stretchr/testify/assert"
2836
"github.com/stretchr/testify/require"
@@ -32,6 +40,71 @@ import (
3240
// names by the proto validation (name.dns-1123).
3341
var materialNameRe = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
3442

43+
// TestAddSourceArchiveEvidence exercises the Part B cross-link end to end: an
44+
// exploded archive is recorded once as an EVIDENCE material and linked with the
45+
// exploded materials in both directions.
46+
// TestExplodeRecordsSourceArchiveEvidence checks that AddMaterialsFromArchive
47+
// records the source archive once as an EVIDENCE material cross-linked with the
48+
// exploded materials in both directions, all in the one atomic add.
49+
func TestExplodeRecordsSourceArchiveEvidence(t *testing.T) {
50+
ctx := context.Background()
51+
52+
// A dry-run crafter backed by a local state file (no control plane).
53+
statePath := filepath.Join(t.TempDir(), "attestation.json")
54+
sm, err := filesystem.New(statePath)
55+
require.NoError(t, err)
56+
c, err := crafter.NewCrafter(sm, nil)
57+
require.NoError(t, err)
58+
require.NoError(t, c.Init(ctx, &crafter.InitOpts{
59+
SchemaV1: &schemaapi.CraftingSchema{SchemaVersion: "v1"},
60+
WfInfo: &api.WorkflowMetadata{},
61+
DryRun: true,
62+
AttestationID: "",
63+
Runner: runners.NewGeneric(),
64+
}))
65+
66+
// A zip of two files exploded into "scan" / "scan-1".
67+
zipPath := filepath.Join(t.TempDir(), "bundle.zip")
68+
writeZipWithFiles(t, zipPath, map[string]string{"a.txt": "a", "b.txt": "b"})
69+
70+
backend := &casclient.CASBackend{}
71+
mts, err := c.AddMaterialsFromArchive(ctx, "", "ARTIFACT", "scan", zipPath, materials.ArchiveZip, backend, nil, materials.DefaultArchiveLimits(), crafter.WithSourceArchiveEvidence())
72+
require.NoError(t, err)
73+
require.Len(t, mts, 2)
74+
75+
state := c.CraftingState.GetAttestation().GetMaterials()
76+
77+
// The archive is recorded once as EVIDENCE under "scan-archive".
78+
ev, ok := state["scan-archive"]
79+
require.True(t, ok, "expected scan-archive evidence material")
80+
assert.Equal(t, schemaapi.CraftingSchema_Material_EVIDENCE, ev.GetMaterialType())
81+
82+
// Forward edge: the archive references exactly the exploded materials.
83+
fwd := ev.GetAnnotations()[materials.AnnotationMaterialReferences]
84+
assert.ElementsMatch(t, []string{"scan", "scan-1"}, strings.Split(fwd, ","))
85+
86+
// Reverse edge: every exploded material references the archive.
87+
for _, name := range []string{"scan", "scan-1"} {
88+
assert.Contains(t, state[name].GetAnnotations()[materials.AnnotationMaterialReferences], "scan-archive",
89+
"exploded material %q must reference the archive", name)
90+
}
91+
}
92+
93+
func writeZipWithFiles(t *testing.T, path string, files map[string]string) {
94+
t.Helper()
95+
f, err := os.Create(path)
96+
require.NoError(t, err)
97+
defer f.Close()
98+
zw := zip.NewWriter(f)
99+
for name, content := range files {
100+
w, err := zw.Create(name)
101+
require.NoError(t, err)
102+
_, err = w.Write([]byte(content))
103+
require.NoError(t, err)
104+
}
105+
require.NoError(t, zw.Close())
106+
}
107+
35108
func TestPolicyInputEvidenceNames(t *testing.T) {
36109
testCases := []struct {
37110
name string

app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts

Lines changed: 95 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)