From 7af939525b5918edfb7250173074a8ee1c02a97e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Mon, 14 Sep 2026 10:28:12 -0400 Subject: [PATCH 1/2] feat: adopt the image SBOM evidence contract (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This agent inherited a source-only Builder.SBOM, so an image-scope request was answered with a Go module graph — evidence that says nothing about the packages in the Alpine image it ships. Pin service-go v0.0.49 to adopt the shared image-scope override. The scanner stays in core and this repo adds no override of its own. Coverage is measured against the recipe this specialization emits: go-grpc fully overrides Build, so its plan is its own and the image subjects are derived from that plan rather than restated beside it. The release gate asserts only the typed outcome, so it holds whether the agent answers with a source inventory or the precondition failure. Co-Authored-By: Claude Opus 5 (1M context) --- agent.codefly.yaml | 2 +- builder_sbom_coverage_test.go | 206 ++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 4 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 builder_sbom_coverage_test.go diff --git a/agent.codefly.yaml b/agent.codefly.yaml index c8df78d..c81eec9 100644 --- a/agent.codefly.yaml +++ b/agent.codefly.yaml @@ -1,4 +1,4 @@ publisher: codefly.dev kind: codefly:service name: go-grpc -version: 0.1.38 +version: 0.1.39 diff --git a/builder_sbom_coverage_test.go b/builder_sbom_coverage_test.go new file mode 100644 index 0000000..e8c44ee --- /dev/null +++ b/builder_sbom_coverage_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" + + "github.com/codefly-dev/core/agents/services" + "github.com/codefly-dev/core/agents/services/sbom" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// goGrpcServiceFixture writes the on-disk service a Builder.Load accepts, plus a +// self-contained Go module at the source location so the source inventory +// resolves without reaching the network. +func goGrpcServiceFixture(t *testing.T) *basev0.ServiceIdentity { + t.Helper() + + ctx := context.Background() + workspacePath := t.TempDir() + serviceDir := filepath.Join(workspacePath, "mod/svc") + + service := &resources.Service{Name: "svc", Version: "0.0.0"} + require.NoError(t, service.SaveAtDir(ctx, serviceDir)) + service.WithModule("mod") + require.NoError(t, (&resources.Module{Name: "mod"}).SaveToDir(ctx, filepath.Join(workspacePath, "mod"))) + + codeDir := filepath.Join(serviceDir, "code") + require.NoError(t, os.MkdirAll(codeDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(codeDir, "go.mod"), []byte("module example.com/svc\n\ngo "+GoVersion+"\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(codeDir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644)) + + return &basev0.ServiceIdentity{ + Name: service.Name, + Version: service.Version, + Module: "mod", + Workspace: "test", + WorkspacePath: workspacePath, + RelativeToWorkspace: "mod/svc", + } +} + +// startBuilderAgent serves this specialization's Builder over gRPC. Build is +// go-grpc's own and SBOM is inherited from the generic Go builder, so only the +// wire surface proves the composed agent answers both. +func startBuilderAgent(t *testing.T, identity *basev0.ServiceIdentity) *services.BuilderAgent { + t.Helper() + + builder := NewBuilder(NewService()) + _, err := builder.Load(context.Background(), &builderv0.LoadRequest{Identity: identity, CreationMode: &builderv0.CreationMode{}}) + require.NoError(t, err) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + server := grpc.NewServer() + builderv0.RegisterBuilderServer(server, builder) + go func() { + if err := server.Serve(listener); err != nil { + t.Errorf("serve builder: %v", err) + } + }() + t.Cleanup(server.Stop) + + conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + return services.NewBuilderAgentClient(conn) +} + +// buildRecipePlan drives this specialization's own Build and returns the plan it +// emits. The image subjects a caller must cover are derived from this plan, so +// coverage is measured against what go-grpc actually ships rather than a +// restatement of it. +func buildRecipePlan(t *testing.T, ctx context.Context, client *services.BuilderAgent, identity *basev0.ServiceIdentity) *builderv0.DockerBuildPlan { + t.Helper() + + resp, err := client.Build(ctx, &builderv0.BuildRequest{ + OutputDirectory: filepath.Join(identity.GetWorkspacePath(), identity.GetRelativeToWorkspace(), "builder"), + BuildContext: &builderv0.BuildContext{Kind: &builderv0.BuildContext_DockerBuildContext{ + DockerBuildContext: &builderv0.DockerBuildContext{DockerRepository: "registry.example.com"}, + }}, + }) + require.NoError(t, err) + require.Equal(t, builderv0.BuildStatus_SUCCESS, resp.GetState().GetState(), resp.GetState().GetMessage()) + + plan := resp.GetResult().GetDockerBuildPlan() + require.NotNil(t, plan) + return plan +} + +// TestImageSubjectsCoverEveryPlatformGoGrpcShips asserts the coverage +// expectation derived from this specialization's own recipe: one subject per +// shipped platform, carrying the recipe's role and the service identity. A +// recipe narrowed to a single platform, or one that stopped naming its role, +// would silently shrink what image evidence is required. +func TestImageSubjectsCoverEveryPlatformGoGrpcShips(t *testing.T) { + ctx := context.Background() + identity := goGrpcServiceFixture(t) + client := startBuilderAgent(t, identity) + + plan := buildRecipePlan(t, ctx, client, identity) + require.Len(t, plan.GetRecipes(), 1) + recipe := plan.GetRecipes()[0] + platforms := recipe.GetPlatforms() + require.NotEmpty(t, platforms) + + unique := resources.ServiceUnique(identity.GetModule(), identity.GetName()) + expected := sbom.ExpectedFromBuildPlan(unique, plan) + + require.Len(t, expected, len(platforms)) + for i, platform := range platforms { + require.Equal(t, platform, expected[i].GetPlatform()) + require.Equal(t, recipe.GetName(), expected[i].GetRole()) + require.Equal(t, recipe.GetImage(), expected[i].GetReference()) + require.Equal(t, unique, expected[i].GetService()) + } +} + +// TestImageCoverageIsNeverClaimedWithoutImageEvidence is this specialization's +// release gate. The agent emits a recipe and never builds an image, so it holds +// no digest of its own: whatever it answers an image-scope request with, that +// answer must not pass as coverage of the images its recipe produces. +// +// The assertion is deliberately only that coverage validation fails. Which +// failure is correct belongs to the shared contract — a source inventory +// answering an image request, or the precondition failure an agent whose caller +// owns the build returns — and pinning either message here would make this test +// a mirror of the shared implementation rather than a gate on this agent. +func TestImageCoverageIsNeverClaimedWithoutImageEvidence(t *testing.T) { + ctx := context.Background() + identity := goGrpcServiceFixture(t) + client := startBuilderAgent(t, identity) + + plan := buildRecipePlan(t, ctx, client, identity) + expected := sbom.ExpectedFromBuildPlan(resources.ServiceUnique(identity.GetModule(), identity.GetName()), plan) + require.NotEmpty(t, expected) + + resp, err := client.SBOM(ctx, &builderv0.SBOMRequest{Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE}) + require.NoError(t, err) + require.Error(t, sbom.ValidateCoverage(expected, resp), + "an image-scope request answered without digest-bound evidence must not satisfy coverage") +} + +// TestSourceInventoryRemainsAvailable guards the other half of the contract: +// image coverage is separate evidence, and adopting it must not cost the Go +// source inventory this agent already provides. +func TestSourceInventoryRemainsAvailable(t *testing.T) { + ctx := context.Background() + identity := goGrpcServiceFixture(t) + client := startBuilderAgent(t, identity) + + resp, err := client.SBOM(ctx, &builderv0.SBOMRequest{}) + require.NoError(t, err) + require.Equal(t, builderv0.SBOMStatus_COMPLETE, resp.GetState().GetState(), resp.GetState().GetMessage()) + require.Equal(t, builderv0.SBOMScope_SBOM_SCOPE_SOURCE, resp.GetScope()) + require.NotEmpty(t, resp.GetSha256()) +} + +// TestImageScopeWithoutSubjectsIsAPreconditionFailure proves the inherited +// image-scope path is reachable through the composed agent. This specialization +// overrides Load and Build and embeds the generic builder for the rest, so only +// the wire surface distinguishes an image-scope request that reaches the shared +// implementation from one that silently falls back to a source inventory. +func TestImageScopeWithoutSubjectsIsAPreconditionFailure(t *testing.T) { + ctx := context.Background() + identity := goGrpcServiceFixture(t) + client := startBuilderAgent(t, identity) + + resp, err := client.SBOM(ctx, &builderv0.SBOMRequest{Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE}) + require.NoError(t, err) + require.Equal(t, builderv0.SBOMStatus_ERROR, resp.GetState().GetState()) + require.Equal(t, builderv0.SBOMScope_SBOM_SCOPE_IMAGE, resp.GetScope()) + require.Equal(t, basev0.FailureCode_FAILURE_CODE_PRECONDITION_FAILED, resp.GetState().GetFailure().GetCode()) +} + +// TestPlanDerivedSubjectsAreRefusedUntilTheyCarryADigest closes the loop between +// this specialization's recipe and the evidence contract. The recipe names the +// image the caller has yet to build, so subjects derived from the plan carry a +// tag and no digest. Handing them straight back must not yield coverage for +// whatever that tag resolves to when the scan runs. +func TestPlanDerivedSubjectsAreRefusedUntilTheyCarryADigest(t *testing.T) { + ctx := context.Background() + identity := goGrpcServiceFixture(t) + client := startBuilderAgent(t, identity) + + plan := buildRecipePlan(t, ctx, client, identity) + expected := sbom.ExpectedFromBuildPlan(resources.ServiceUnique(identity.GetModule(), identity.GetName()), plan) + require.NotEmpty(t, expected) + require.Empty(t, expected[0].GetDigest()) + + resp, err := client.SBOM(ctx, &builderv0.SBOMRequest{ + Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE, + Subjects: expected, + }) + require.NoError(t, err) + require.Equal(t, builderv0.SBOMStatus_ERROR, resp.GetState().GetState()) + require.Equal(t, builderv0.SBOMScope_SBOM_SCOPE_IMAGE, resp.GetScope()) + require.Error(t, sbom.ValidateCoverage(expected, resp)) +} diff --git a/go.mod b/go.mod index 57e59a6..d5047ef 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( buf.build/go/protovalidate v1.4.0 github.com/bufbuild/protocompile v0.14.1 github.com/codefly-dev/core v0.3.29 - github.com/codefly-dev/service-go v0.0.48 + github.com/codefly-dev/service-go v0.0.49 github.com/stretchr/testify v1.12.1 golang.org/x/mod v0.40.0 golang.org/x/tools v0.49.0 diff --git a/go.sum b/go.sum index 6a9c92a..52cdf39 100644 --- a/go.sum +++ b/go.sum @@ -49,8 +49,8 @@ github.com/codefly-dev/core v0.3.29 h1:S4jgxOt5cnPX1zfv1xPheskOjfY2UR5gVQeb+AP1D github.com/codefly-dev/core v0.3.29/go.mod h1:guGVP8tyWEjpUBwdP588jXb/BwADsrzlhx7WtmZSn20= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= -github.com/codefly-dev/service-go v0.0.48 h1:sUvAbLx4dBGveBjL2s7hRslDlSHGVqkAt65uZyBIdxI= -github.com/codefly-dev/service-go v0.0.48/go.mod h1:jcU+du5HaYDplpTQ1Ej1XXgkCwpJPlJEb82Smb7ogMM= +github.com/codefly-dev/service-go v0.0.49 h1:rh9WhsZkGgsgx9CNqZJ0yXln8jbXoRfalj+GzlePgUo= +github.com/codefly-dev/service-go v0.0.49/go.mod h1:jcU+du5HaYDplpTQ1Ej1XXgkCwpJPlJEb82Smb7ogMM= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= From 5b288bbbdb6c829882a16eedd34434abfc3ab2d1 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Mon, 14 Sep 2026 12:38:46 -0400 Subject: [PATCH 2/2] test: make the image SBOM assertions gate the adoption (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the tests added in 7af9395 passed unchanged against service-go v0.0.48 — the exact pin they were meant to gate. An assertion that holds on both sides of the change it guards is not coverage: reverting the pin would have left the suite green, and one of the two was described in the pull request as the release gate. Delete both. The coverage-validation link they claimed is already exercised by the plan-derived subject test, which does fail on v0.0.48, and the multi-platform assertion worth keeping is folded into it. Verified by re-running every remaining assertion against v0.0.48: both image-scope tests fail there and pass on v0.0.49, while the source inventory test passes on both, which is what a guard should do. Also fixed, each at its cause rather than its symptom: - Collect Serve's error through a channel and report it from cleanup. A t.Errorf from a goroutine that outlives the test panics the whole package instead of failing one test. - Share one gRPC harness rather than two near-identical copies, which removes that same goroutine flaw from the recipe test. - Assert the resolved module purl in the source inventory. The fixture has no dependencies, so a checksum alone would also be produced by an inventory that resolved nothing at all. - Stop writing this agent's GoVersion into fixture go.mod files. Naming a patch release newer than the toolchain running the tests makes go list download a toolchain before it will answer. Co-Authored-By: Claude Opus 5 (1M context) --- builder_recipe_test.go | 43 +---------- builder_sbom_coverage_test.go | 138 ++++++++++++++++------------------ 2 files changed, 66 insertions(+), 115 deletions(-) diff --git a/builder_recipe_test.go b/builder_recipe_test.go index 609236d..f842656 100644 --- a/builder_recipe_test.go +++ b/builder_recipe_test.go @@ -3,20 +3,16 @@ package main import ( "context" "io/fs" - "net" "os" "path/filepath" "testing" "github.com/codefly-dev/core/agents/services" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" "github.com/codefly-dev/core/resources" golanghelpers "github.com/codefly-dev/core/runners/golang" "github.com/codefly-dev/core/templates" "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" ) // renderBuilderTree renders the real builder templates into dir, mirroring what @@ -84,42 +80,9 @@ func TestRecipeBuildPlanRejectsUnrenderedTree(t *testing.T) { func TestBuildRecipeOverGRPC(t *testing.T) { ctx := context.Background() - tmpDir := t.TempDir() - - service := &resources.Service{Name: "svc", Version: "0.0.0"} - require.NoError(t, service.SaveAtDir(ctx, filepath.Join(tmpDir, "mod/svc"))) - service.WithModule("mod") - require.NoError(t, (&resources.Module{Name: "mod"}).SaveToDir(ctx, filepath.Join(tmpDir, "mod"))) - - identity := &basev0.ServiceIdentity{ - Name: service.Name, - Version: service.Version, - Module: "mod", - Workspace: "test", - WorkspacePath: tmpDir, - RelativeToWorkspace: "mod/svc", - } - - builder := NewBuilder(NewService()) - // CreationMode short-circuits endpoint discovery, which the recipe path does - // not need — it renders templates and inventories the tree, never compiling. - _, err := builder.Load(ctx, &builderv0.LoadRequest{Identity: identity, CreationMode: &builderv0.CreationMode{}}) - require.NoError(t, err) - - listener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - server := grpc.NewServer() - builderv0.RegisterBuilderServer(server, builder) - go func() { - if err := server.Serve(listener); err != nil { - t.Errorf("serve builder: %v", err) - } - }() - t.Cleanup(server.Stop) - conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, conn.Close()) }) - client := services.NewBuilderAgentClient(conn) + identity := goGrpcServiceFixture(t) + tmpDir := identity.GetWorkspacePath() + client, builder := startBuilderAgent(t, identity) t.Setenv("PATH", t.TempDir()) // Core's client probes this before dispatching a build that names an explicit diff --git a/builder_sbom_coverage_test.go b/builder_sbom_coverage_test.go index e8c44ee..acd6598 100644 --- a/builder_sbom_coverage_test.go +++ b/builder_sbom_coverage_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "net" "os" "path/filepath" @@ -17,6 +18,12 @@ import ( "google.golang.org/grpc/credentials/insecure" ) +// fixtureGoVersion is the go directive written into fixture modules. It is +// deliberately not this agent's GoVersion: the fixtures assert nothing about the +// toolchain, and naming a patch release newer than the one running the tests +// makes `go list` download a toolchain before it will answer. +const fixtureGoVersion = "1.21" + // goGrpcServiceFixture writes the on-disk service a Builder.Load accepts, plus a // self-contained Go module at the source location so the source inventory // resolves without reaching the network. @@ -34,7 +41,7 @@ func goGrpcServiceFixture(t *testing.T) *basev0.ServiceIdentity { codeDir := filepath.Join(serviceDir, "code") require.NoError(t, os.MkdirAll(codeDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(codeDir, "go.mod"), []byte("module example.com/svc\n\ngo "+GoVersion+"\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(codeDir, "go.mod"), []byte("module example.com/svc\n\ngo "+fixtureGoVersion+"\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(codeDir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644)) return &basev0.ServiceIdentity{ @@ -47,13 +54,17 @@ func goGrpcServiceFixture(t *testing.T) *basev0.ServiceIdentity { } } -// startBuilderAgent serves this specialization's Builder over gRPC. Build is -// go-grpc's own and SBOM is inherited from the generic Go builder, so only the -// wire surface proves the composed agent answers both. -func startBuilderAgent(t *testing.T, identity *basev0.ServiceIdentity) *services.BuilderAgent { +// startBuilderAgent serves this specialization's Builder over gRPC and returns a +// client plus the Builder itself, for callers that need agent state the wire +// does not expose. Build is go-grpc's own and SBOM is inherited from the generic +// Go builder, so only the wire surface proves the composed agent answers both. +func startBuilderAgent(t *testing.T, identity *basev0.ServiceIdentity) (*services.BuilderAgent, *Builder) { t.Helper() builder := NewBuilder(NewService()) + // CreationMode short-circuits endpoint discovery, which neither the recipe + // nor the inventory path needs — they render templates and read the module + // graph, never compiling. _, err := builder.Load(context.Background(), &builderv0.LoadRequest{Identity: identity, CreationMode: &builderv0.CreationMode{}}) require.NoError(t, err) @@ -61,24 +72,32 @@ func startBuilderAgent(t *testing.T, identity *basev0.ServiceIdentity) *services require.NoError(t, err) server := grpc.NewServer() builderv0.RegisterBuilderServer(server, builder) - go func() { - if err := server.Serve(listener); err != nil { + + // Serve's result is handed back through a channel and reported during + // cleanup rather than logged from the goroutine: a t.Errorf that lands after + // the test function returns panics the whole package instead of failing one + // test. Cleanups run last-registered-first, so the client below is closed + // before the server is stopped. + served := make(chan error, 1) + go func() { served <- server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + if err := <-served; err != nil && !errors.Is(err, grpc.ErrServerStopped) { t.Errorf("serve builder: %v", err) } - }() - t.Cleanup(server.Stop) + }) conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, conn.Close()) }) - return services.NewBuilderAgentClient(conn) + return services.NewBuilderAgentClient(conn), builder } // buildRecipePlan drives this specialization's own Build and returns the plan it // emits. The image subjects a caller must cover are derived from this plan, so // coverage is measured against what go-grpc actually ships rather than a // restatement of it. -func buildRecipePlan(t *testing.T, ctx context.Context, client *services.BuilderAgent, identity *basev0.ServiceIdentity) *builderv0.DockerBuildPlan { +func buildRecipePlan(ctx context.Context, t *testing.T, client *services.BuilderAgent, identity *basev0.ServiceIdentity) *builderv0.DockerBuildPlan { t.Helper() resp, err := client.Build(ctx, &builderv0.BuildRequest{ @@ -95,83 +114,35 @@ func buildRecipePlan(t *testing.T, ctx context.Context, client *services.Builder return plan } -// TestImageSubjectsCoverEveryPlatformGoGrpcShips asserts the coverage -// expectation derived from this specialization's own recipe: one subject per -// shipped platform, carrying the recipe's role and the service identity. A -// recipe narrowed to a single platform, or one that stopped naming its role, -// would silently shrink what image evidence is required. -func TestImageSubjectsCoverEveryPlatformGoGrpcShips(t *testing.T) { - ctx := context.Background() - identity := goGrpcServiceFixture(t) - client := startBuilderAgent(t, identity) - - plan := buildRecipePlan(t, ctx, client, identity) - require.Len(t, plan.GetRecipes(), 1) - recipe := plan.GetRecipes()[0] - platforms := recipe.GetPlatforms() - require.NotEmpty(t, platforms) - - unique := resources.ServiceUnique(identity.GetModule(), identity.GetName()) - expected := sbom.ExpectedFromBuildPlan(unique, plan) - - require.Len(t, expected, len(platforms)) - for i, platform := range platforms { - require.Equal(t, platform, expected[i].GetPlatform()) - require.Equal(t, recipe.GetName(), expected[i].GetRole()) - require.Equal(t, recipe.GetImage(), expected[i].GetReference()) - require.Equal(t, unique, expected[i].GetService()) - } -} - -// TestImageCoverageIsNeverClaimedWithoutImageEvidence is this specialization's -// release gate. The agent emits a recipe and never builds an image, so it holds -// no digest of its own: whatever it answers an image-scope request with, that -// answer must not pass as coverage of the images its recipe produces. -// -// The assertion is deliberately only that coverage validation fails. Which -// failure is correct belongs to the shared contract — a source inventory -// answering an image request, or the precondition failure an agent whose caller -// owns the build returns — and pinning either message here would make this test -// a mirror of the shared implementation rather than a gate on this agent. -func TestImageCoverageIsNeverClaimedWithoutImageEvidence(t *testing.T) { - ctx := context.Background() - identity := goGrpcServiceFixture(t) - client := startBuilderAgent(t, identity) - - plan := buildRecipePlan(t, ctx, client, identity) - expected := sbom.ExpectedFromBuildPlan(resources.ServiceUnique(identity.GetModule(), identity.GetName()), plan) - require.NotEmpty(t, expected) - - resp, err := client.SBOM(ctx, &builderv0.SBOMRequest{Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE}) - require.NoError(t, err) - require.Error(t, sbom.ValidateCoverage(expected, resp), - "an image-scope request answered without digest-bound evidence must not satisfy coverage") -} - -// TestSourceInventoryRemainsAvailable guards the other half of the contract: -// image coverage is separate evidence, and adopting it must not cost the Go -// source inventory this agent already provides. +// TestSourceInventoryRemainsAvailable guards the half of the contract that was +// already here: image coverage is separate evidence, and adopting it must not +// cost the Go source inventory. The document's root component is asserted +// because a fixture module with no dependencies yields an empty component list, +// so a checksum alone would also be produced by an inventory that resolved +// nothing at all. func TestSourceInventoryRemainsAvailable(t *testing.T) { ctx := context.Background() identity := goGrpcServiceFixture(t) - client := startBuilderAgent(t, identity) + client, _ := startBuilderAgent(t, identity) resp, err := client.SBOM(ctx, &builderv0.SBOMRequest{}) require.NoError(t, err) require.Equal(t, builderv0.SBOMStatus_COMPLETE, resp.GetState().GetState(), resp.GetState().GetMessage()) require.Equal(t, builderv0.SBOMScope_SBOM_SCOPE_SOURCE, resp.GetScope()) require.NotEmpty(t, resp.GetSha256()) + require.Equal(t, "pkg:golang/example.com/svc", resp.GetBom().GetMetadata().GetComponent().GetPurl()) } // TestImageScopeWithoutSubjectsIsAPreconditionFailure proves the inherited // image-scope path is reachable through the composed agent. This specialization // overrides Load and Build and embeds the generic builder for the rest, so only // the wire surface distinguishes an image-scope request that reaches the shared -// implementation from one that silently falls back to a source inventory. +// implementation from one that silently falls back to a source inventory — which +// is exactly what this agent did before the contract was adopted. func TestImageScopeWithoutSubjectsIsAPreconditionFailure(t *testing.T) { ctx := context.Background() identity := goGrpcServiceFixture(t) - client := startBuilderAgent(t, identity) + client, _ := startBuilderAgent(t, identity) resp, err := client.SBOM(ctx, &builderv0.SBOMRequest{Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE}) require.NoError(t, err) @@ -185,15 +156,32 @@ func TestImageScopeWithoutSubjectsIsAPreconditionFailure(t *testing.T) { // image the caller has yet to build, so subjects derived from the plan carry a // tag and no digest. Handing them straight back must not yield coverage for // whatever that tag resolves to when the scan runs. +// +// It also pins the shape of that subject set: one per shipped platform, since +// evidence for one architecture of a multi-architecture image is not coverage of +// the other, and a recipe narrowed to a single platform would silently shrink +// what a caller is required to produce. func TestPlanDerivedSubjectsAreRefusedUntilTheyCarryADigest(t *testing.T) { ctx := context.Background() identity := goGrpcServiceFixture(t) - client := startBuilderAgent(t, identity) + client, _ := startBuilderAgent(t, identity) + + plan := buildRecipePlan(ctx, t, client, identity) + require.Len(t, plan.GetRecipes(), 1) + recipe := plan.GetRecipes()[0] + require.NotEmpty(t, recipe.GetPlatforms()) + + unique := resources.ServiceUnique(identity.GetModule(), identity.GetName()) + expected := sbom.ExpectedFromBuildPlan(unique, plan) - plan := buildRecipePlan(t, ctx, client, identity) - expected := sbom.ExpectedFromBuildPlan(resources.ServiceUnique(identity.GetModule(), identity.GetName()), plan) - require.NotEmpty(t, expected) - require.Empty(t, expected[0].GetDigest()) + require.Len(t, expected, len(recipe.GetPlatforms())) + for i, platform := range recipe.GetPlatforms() { + require.Equal(t, platform, expected[i].GetPlatform()) + require.Equal(t, recipe.GetName(), expected[i].GetRole()) + require.Equal(t, recipe.GetImage(), expected[i].GetReference()) + require.Equal(t, unique, expected[i].GetService()) + require.Empty(t, expected[i].GetDigest()) + } resp, err := client.SBOM(ctx, &builderv0.SBOMRequest{ Scope: builderv0.SBOMScope_SBOM_SCOPE_IMAGE,