From c4e94088a84fe4023f10059f2a12c9f4bd9c78c0 Mon Sep 17 00:00:00 2001 From: Leo Di Donato <120051+leodido@users.noreply.github.com> Date: Thu, 20 Nov 2025 18:51:42 +0000 Subject: [PATCH] fix: extract digest from OCI layout for SLSA provenance When exportToCache is enabled, Docker images are exported as OCI layout and not loaded into the Docker daemon. This causes 'docker inspect' to fail when generating SLSA provenance subjects. Problem: - OCI layout export creates image.tar without loading to Docker daemon - createDockerSubjectsFunction calls 'docker inspect ' - Fails with 'No such object: ' - Blocks SLSA provenance generation Root Cause: - Integration test for OCI layout doesn't enable SLSA - Subjects function never called during testing - Bug went undetected Solution: - Split into two separate functions for clarity: * createDockerInspectSubjectsFunction() - for legacy push workflow * createOCILayoutSubjectsFunction() - for OCI layout export - Add extractDigestFromOCILayout() to read digest from OCI index.json - Extract image.tar and parse index.json for manifest digest - Use digest for SLSA provenance subjects Changes: - Separate functions for Docker inspect vs OCI layout paths - Extract image.tar to temporary directory - Read index.json from OCI layout - Parse manifest digest from index - Use digest for SLSA provenance subjects Testing: - Unit tests for extractDigestFromOCILayout() (6 test cases) - Integration test for OCI layout + SLSA (TestDockerPackage_OCILayout_SLSA_Integration) - All tests pass - Maintains backward compatibility Fixes: gitpod-io/gitpod-next#11869 Co-authored-by: Ona --- pkg/leeway/build.go | 105 +++++++- pkg/leeway/build_integration_test.go | 217 +++++++++++++++ pkg/leeway/build_oci_test.go | 377 +++++++++++++++++++++++++++ 3 files changed, 688 insertions(+), 11 deletions(-) create mode 100644 pkg/leeway/build_oci_test.go diff --git a/pkg/leeway/build.go b/pkg/leeway/build.go index 3d00a26..908619b 100644 --- a/pkg/leeway/build.go +++ b/pkg/leeway/build.go @@ -2171,8 +2171,8 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p Commands: commands, } - // Add subjects function for provenance generation - res.Subjects = createDockerSubjectsFunction(version, cfg) + // Add subjects function for provenance generation (image in Docker daemon) + res.Subjects = createDockerInspectSubjectsFunction(version, cfg) } else if len(cfg.Image) > 0 && *cfg.ExportToCache { // Export to cache for signing log.WithField("package", p.FullName()).Debug("Exporting Docker image to cache (OCI layout)") @@ -2228,17 +2228,22 @@ func (p *Package) buildDocker(buildctx *buildContext, wd, result string) (res *p Commands: commands, } - // Add PostProcess to create structured metadata file + // Add PostProcess to create structured metadata file and set up subjects function res.PostProcess = func(buildCtx *buildContext, pkg *Package, buildDir string) error { mtime, err := pkg.getDeterministicMtime() if err != nil { return fmt.Errorf("failed to get deterministic mtime: %w", err) } - return createDockerExportMetadata(buildDir, version, cfg, mtime) + + // Create metadata + if err := createDockerExportMetadata(buildDir, version, cfg, mtime); err != nil { + return err + } + + // Set up subjects function with buildDir for OCI layout extraction + res.Subjects = createOCILayoutSubjectsFunction(version, cfg, buildDir) + return nil } - - // Add subjects function for provenance generation - res.Subjects = createDockerSubjectsFunction(version, cfg) } return res, nil @@ -2298,13 +2303,49 @@ func extractImageNameFromCache(pkgName, cacheBundleFN string) (imgname string, e return "", nil } -// createDockerSubjectsFunction creates a function that generates SLSA provenance subjects for Docker images -func createDockerSubjectsFunction(version string, cfg DockerPkgConfig) func() ([]in_toto.Subject, error) { +// extractDigestFromOCILayout extracts the image digest from an OCI layout directory +func extractDigestFromOCILayout(ociLayoutPath string) (common.DigestSet, error) { + // Read index.json from OCI layout + indexPath := filepath.Join(ociLayoutPath, "index.json") + indexData, err := os.ReadFile(indexPath) + if err != nil { + return nil, xerrors.Errorf("failed to read OCI index.json: %w", err) + } + + // Parse index.json to get manifest digest + var index struct { + Manifests []struct { + Digest string `json:"digest"` + } `json:"manifests"` + } + + if err := json.Unmarshal(indexData, &index); err != nil { + return nil, xerrors.Errorf("failed to parse OCI index.json: %w", err) + } + + if len(index.Manifests) == 0 { + return nil, xerrors.Errorf("no manifests found in OCI index.json") + } + + // Extract digest from first manifest (format: "sha256:abc123...") + digestStr := index.Manifests[0].Digest + parts := strings.Split(digestStr, ":") + if len(parts) != 2 { + return nil, xerrors.Errorf("invalid digest format in OCI index: %s", digestStr) + } + + return common.DigestSet{ + parts[0]: parts[1], + }, nil +} + +// createDockerInspectSubjectsFunction creates a function that generates SLSA provenance subjects +// by inspecting the Docker image in the daemon (legacy push workflow) +func createDockerInspectSubjectsFunction(version string, cfg DockerPkgConfig) func() ([]in_toto.Subject, error) { return func() ([]in_toto.Subject, error) { subjectLogger := log.WithField("operation", "provenance-subjects") - subjectLogger.Debug("Calculating provenance subjects for Docker images") + subjectLogger.Debug("Extracting digest from Docker daemon") - // Get image digest with improved error handling out, err := exec.Command("docker", "inspect", version).CombinedOutput() if err != nil { return nil, xerrors.Errorf("failed to inspect image %s: %w\nOutput: %s", @@ -2370,6 +2411,48 @@ func createDockerSubjectsFunction(version string, cfg DockerPkgConfig) func() ([ } } +// createOCILayoutSubjectsFunction creates a function that generates SLSA provenance subjects +// by extracting the digest from OCI layout files (exportToCache workflow) +func createOCILayoutSubjectsFunction(version string, cfg DockerPkgConfig, buildDir string) func() ([]in_toto.Subject, error) { + return func() ([]in_toto.Subject, error) { + subjectLogger := log.WithField("operation", "provenance-subjects") + subjectLogger.Debug("Extracting digest from OCI layout") + + ociLayoutPath := filepath.Join(buildDir, "image.tar.extracted") + + // Extract image.tar to temporary directory + if err := os.MkdirAll(ociLayoutPath, 0755); err != nil { + return nil, xerrors.Errorf("failed to create OCI layout extraction directory: %w", err) + } + defer os.RemoveAll(ociLayoutPath) + + // Extract image.tar + imageTarPath := filepath.Join(buildDir, "image.tar") + extractCmd := exec.Command("tar", "-xf", imageTarPath, "-C", ociLayoutPath) + if out, err := extractCmd.CombinedOutput(); err != nil { + return nil, xerrors.Errorf("failed to extract OCI layout: %w\nOutput: %s", err, string(out)) + } + + digest, err := extractDigestFromOCILayout(ociLayoutPath) + if err != nil { + return nil, xerrors.Errorf("failed to extract digest from OCI layout: %w", err) + } + + subjectLogger.WithField("digest", digest).Debug("Found image digest from OCI layout") + + // Create subjects for each image + result := make([]in_toto.Subject, 0, len(cfg.Image)) + for _, tag := range cfg.Image { + result = append(result, in_toto.Subject{ + Name: tag, + Digest: digest, + }) + } + + return result, nil + } +} + // DockerImageMetadata holds metadata for exported Docker images type DockerImageMetadata struct { ImageNames []string `json:"image_names" yaml:"image_names"` diff --git a/pkg/leeway/build_integration_test.go b/pkg/leeway/build_integration_test.go index 4975c42..607d2d6 100644 --- a/pkg/leeway/build_integration_test.go +++ b/pkg/leeway/build_integration_test.go @@ -572,6 +572,28 @@ func TestDockerPackage_OCILayout_Determinism_Integration(t *testing.T) { t.Skip("Docker buildx not available, skipping integration test") } + // Create docker-container builder for OCI export + builderName := "leeway-slsa-test-builder" + createBuilder := exec.Command("docker", "buildx", "create", "--name", builderName, "--driver", "docker-container", "--bootstrap") + if err := createBuilder.Run(); err != nil { + // Builder might already exist, try to use it + t.Logf("Warning: failed to create builder (might already exist): %v", err) + } + defer func() { + // Cleanup builder + exec.Command("docker", "buildx", "rm", builderName).Run() + }() + + // Use the builder + useBuilder := exec.Command("docker", "buildx", "use", builderName) + if err := useBuilder.Run(); err != nil { + t.Fatalf("Failed to use builder: %v", err) + } + defer func() { + // Switch back to default + exec.Command("docker", "buildx", "use", "default").Run() + }() + // Create test workspace tmpDir := t.TempDir() wsDir := filepath.Join(tmpDir, "workspace") @@ -752,3 +774,198 @@ func checksumFile(path string) (string, error) { return fmt.Sprintf("%x", h.Sum(nil)), nil } + +// TestDockerPackage_OCILayout_SLSA_Integration tests that SLSA provenance generation +// works correctly with OCI layout export (regression test for docker inspect bug) +func TestDockerPackage_OCILayout_SLSA_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Ensure Docker is available + if err := exec.Command("docker", "version").Run(); err != nil { + t.Skip("Docker not available, skipping integration test") + } + + // Ensure buildx is available + if err := exec.Command("docker", "buildx", "version").Run(); err != nil { + t.Skip("Docker buildx not available, skipping integration test") + } + + // Create docker-container builder for OCI export + builderName := "leeway-slsa-test-builder" + createBuilder := exec.Command("docker", "buildx", "create", "--name", builderName, "--driver", "docker-container", "--bootstrap") + if err := createBuilder.Run(); err != nil { + // Builder might already exist, try to use it + t.Logf("Warning: failed to create builder (might already exist): %v", err) + } + defer func() { + // Cleanup builder + exec.Command("docker", "buildx", "rm", builderName).Run() + }() + + // Use the builder + useBuilder := exec.Command("docker", "buildx", "use", builderName) + if err := useBuilder.Run(); err != nil { + t.Fatalf("Failed to use builder: %v", err) + } + defer func() { + // Switch back to default + exec.Command("docker", "buildx", "use", "default").Run() + }() + + // Create test workspace + tmpDir := t.TempDir() + wsDir := filepath.Join(tmpDir, "workspace") + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatal(err) + } + + // Create WORKSPACE.yaml with SLSA enabled + workspaceYAML := `defaultTarget: ":test-image" +provenance: + slsa: true +` + if err := os.WriteFile(filepath.Join(wsDir, "WORKSPACE.yaml"), []byte(workspaceYAML), 0644); err != nil { + t.Fatal(err) + } + + // Create Dockerfile + dockerfile := `FROM alpine:3.18 +ARG SOURCE_DATE_EPOCH +RUN echo "Build time: $SOURCE_DATE_EPOCH" > /build-time.txt +CMD ["cat", "/build-time.txt"] +` + if err := os.WriteFile(filepath.Join(wsDir, "Dockerfile"), []byte(dockerfile), 0644); err != nil { + t.Fatal(err) + } + + // Create BUILD.yaml with exportToCache + buildYAML := `packages: + - name: test-image + type: docker + config: + dockerfile: Dockerfile + image: + - localhost/leeway-slsa-test:latest + exportToCache: true +` + if err := os.WriteFile(filepath.Join(wsDir, "BUILD.yaml"), []byte(buildYAML), 0644); err != nil { + t.Fatal(err) + } + + // Initialize git repo + gitInit := exec.Command("git", "init") + gitInit.Dir = wsDir + if err := gitInit.Run(); err != nil { + t.Fatal(err) + } + + gitConfigName := exec.Command("git", "config", "user.name", "Test User") + gitConfigName.Dir = wsDir + if err := gitConfigName.Run(); err != nil { + t.Fatal(err) + } + + gitConfigEmail := exec.Command("git", "config", "user.email", "test@example.com") + gitConfigEmail.Dir = wsDir + if err := gitConfigEmail.Run(); err != nil { + t.Fatal(err) + } + + gitAdd := exec.Command("git", "add", ".") + gitAdd.Dir = wsDir + if err := gitAdd.Run(); err != nil { + t.Fatal(err) + } + + gitCommit := exec.Command("git", "commit", "-m", "initial") + gitCommit.Dir = wsDir + gitCommit.Env = append(os.Environ(), + "GIT_AUTHOR_DATE=2021-01-01T00:00:00Z", + "GIT_COMMITTER_DATE=2021-01-01T00:00:00Z", + ) + if err := gitCommit.Run(); err != nil { + t.Fatal(err) + } + + // Build with SLSA enabled + cacheDir := filepath.Join(tmpDir, "cache") + cache, err := local.NewFilesystemCache(cacheDir) + if err != nil { + t.Fatal(err) + } + + buildCtx, err := newBuildContext(buildOptions{ + LocalCache: cache, + DockerExportToCache: true, + DockerExportSet: true, + Reporter: NewConsoleReporter(), + }) + if err != nil { + t.Fatal(err) + } + + ws, err := FindWorkspace(wsDir, Arguments{}, "", "") + if err != nil { + t.Fatal(err) + } + + pkg, ok := ws.Packages["//:test-image"] + if !ok { + t.Fatal("package //:test-image not found") + } + + // Build the package - this should trigger SLSA provenance generation + // which calls the Subjects function that extracts digest from OCI layout + if err := pkg.build(buildCtx); err != nil { + t.Fatalf("build failed: %v", err) + } + + // Verify that the build succeeded and created the cache artifact + // Find the cache file (it might have a different name) + cacheFiles, err := filepath.Glob(filepath.Join(cacheDir, "*.tar.gz")) + if err != nil || len(cacheFiles) == 0 { + t.Fatal("No cache file found after build") + } + cachePath := cacheFiles[0] + t.Logf("Found cache artifact: %s", cachePath) + + // Verify the OCI layout was created (image.tar inside the cache) + // This confirms that OCI export worked + f, err := os.Open(cachePath) + if err != nil { + t.Fatalf("failed to open cache file: %v", err) + } + defer f.Close() + + gzr, err := gzip.NewReader(f) + if err != nil { + t.Fatalf("failed to create gzip reader: %v", err) + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + foundImageTar := false + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("failed to read tar: %v", err) + } + if hdr.Name == "./image.tar" { + foundImageTar = true + break + } + } + + if !foundImageTar { + t.Fatal("image.tar not found in cache artifact (OCI layout not created)") + } + + t.Logf("✅ Build succeeded with OCI layout export") + t.Logf("✅ No 'docker inspect' error occurred") + t.Logf("✅ This confirms the fix works: digest extracted from OCI layout instead of Docker daemon") +} diff --git a/pkg/leeway/build_oci_test.go b/pkg/leeway/build_oci_test.go new file mode 100644 index 0000000..a6c1168 --- /dev/null +++ b/pkg/leeway/build_oci_test.go @@ -0,0 +1,377 @@ +package leeway + +import ( + "archive/tar" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" +) + +func TestExtractDigestFromOCILayout(t *testing.T) { + tests := []struct { + name string + indexJSON string + wantDigest common.DigestSet + wantErr bool + errContains string + }{ + { + name: "valid OCI index with single manifest", + indexJSON: `{ + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:abc123def456", + "size": 1234 + } + ] + }`, + wantDigest: common.DigestSet{ + "sha256": "abc123def456", + }, + wantErr: false, + }, + { + name: "valid OCI index with multiple manifests (uses first)", + indexJSON: `{ + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:first123", + "size": 1234 + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:second456", + "size": 5678 + } + ] + }`, + wantDigest: common.DigestSet{ + "sha256": "first123", + }, + wantErr: false, + }, + { + name: "empty manifests array", + indexJSON: `{ + "schemaVersion": 2, + "manifests": [] + }`, + wantErr: true, + errContains: "no manifests found", + }, + { + name: "invalid digest format (no colon)", + indexJSON: `{ + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256abc123", + "size": 1234 + } + ] + }`, + wantErr: true, + errContains: "invalid digest format", + }, + { + name: "invalid JSON", + indexJSON: `{invalid json`, + wantErr: true, + errContains: "failed to parse OCI index.json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directory with index.json + tmpDir := t.TempDir() + indexPath := filepath.Join(tmpDir, "index.json") + + if err := os.WriteFile(indexPath, []byte(tt.indexJSON), 0644); err != nil { + t.Fatalf("failed to write test index.json: %v", err) + } + + // Test extraction + digest, err := extractDigestFromOCILayout(tmpDir) + + if tt.wantErr { + if err == nil { + t.Errorf("extractDigestFromOCILayout() expected error, got nil") + return + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("extractDigestFromOCILayout() error = %v, want error containing %q", err, tt.errContains) + } + return + } + + if err != nil { + t.Errorf("extractDigestFromOCILayout() unexpected error: %v", err) + return + } + + if len(digest) != len(tt.wantDigest) { + t.Errorf("extractDigestFromOCILayout() digest length = %d, want %d", len(digest), len(tt.wantDigest)) + return + } + + for algo, hash := range tt.wantDigest { + if digest[algo] != hash { + t.Errorf("extractDigestFromOCILayout() digest[%s] = %s, want %s", algo, digest[algo], hash) + } + } + }) + } +} + +func TestExtractDigestFromOCILayout_MissingFile(t *testing.T) { + tmpDir := t.TempDir() + // Don't create index.json + + _, err := extractDigestFromOCILayout(tmpDir) + if err == nil { + t.Error("extractDigestFromOCILayout() expected error for missing index.json, got nil") + } + if !strings.Contains(err.Error(), "failed to read OCI index.json") { + t.Errorf("extractDigestFromOCILayout() error = %v, want error containing 'failed to read OCI index.json'", err) + } +} + +// TestCreateOCILayoutSubjectsFunction tests the OCI layout subjects function +// Note: This function is set up regardless of SLSA being enabled, but is only +// called when SLSA provenance generation is active. This test verifies the +// function works correctly when called. +func TestCreateOCILayoutSubjectsFunction(t *testing.T) { + // Create temporary directory with OCI layout structure + tmpDir := t.TempDir() + buildDir := filepath.Join(tmpDir, "build") + if err := os.MkdirAll(buildDir, 0755); err != nil { + t.Fatal(err) + } + + // Create OCI layout directory + ociLayoutDir := filepath.Join(tmpDir, "oci-layout") + if err := os.MkdirAll(ociLayoutDir, 0755); err != nil { + t.Fatal(err) + } + + // Create index.json + indexJSON := `{ + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:abc123def456", + "size": 1234 + } + ] + }` + if err := os.WriteFile(filepath.Join(ociLayoutDir, "index.json"), []byte(indexJSON), 0644); err != nil { + t.Fatal(err) + } + + // Create image.tar containing the OCI layout + imageTarPath := filepath.Join(buildDir, "image.tar") + if err := createTarFromDir(ociLayoutDir, imageTarPath); err != nil { + t.Fatal(err) + } + + // Test configuration + version := "test-image:v1.0.0" + cfg := DockerPkgConfig{ + Image: []string{ + "localhost/test-image:latest", + "registry.example.com/test-image:v1.0.0", + }, + } + + // Create subjects function + subjectsFunc := createOCILayoutSubjectsFunction(version, cfg, buildDir) + + // Call the function + subjects, err := subjectsFunc() + if err != nil { + t.Fatalf("createOCILayoutSubjectsFunction() error = %v", err) + } + + // Verify results + if len(subjects) != 2 { + t.Errorf("createOCILayoutSubjectsFunction() returned %d subjects, want 2", len(subjects)) + } + + // Verify first subject + if subjects[0].Name != "localhost/test-image:latest" { + t.Errorf("subjects[0].Name = %s, want localhost/test-image:latest", subjects[0].Name) + } + + // Verify digest + expectedDigest := common.DigestSet{"sha256": "abc123def456"} + if subjects[0].Digest["sha256"] != expectedDigest["sha256"] { + t.Errorf("subjects[0].Digest = %v, want %v", subjects[0].Digest, expectedDigest) + } + + // Verify second subject + if subjects[1].Name != "registry.example.com/test-image:v1.0.0" { + t.Errorf("subjects[1].Name = %s, want registry.example.com/test-image:v1.0.0", subjects[1].Name) + } + + if subjects[1].Digest["sha256"] != expectedDigest["sha256"] { + t.Errorf("subjects[1].Digest = %v, want %v", subjects[1].Digest, expectedDigest) + } +} + +// TestCreateOCILayoutSubjectsFunction_MissingImageTar tests error handling +func TestCreateOCILayoutSubjectsFunction_MissingImageTar(t *testing.T) { + tmpDir := t.TempDir() + buildDir := filepath.Join(tmpDir, "build") + if err := os.MkdirAll(buildDir, 0755); err != nil { + t.Fatal(err) + } + + // Don't create image.tar + + version := "test-image:v1.0.0" + cfg := DockerPkgConfig{ + Image: []string{"localhost/test-image:latest"}, + } + + subjectsFunc := createOCILayoutSubjectsFunction(version, cfg, buildDir) + + _, err := subjectsFunc() + if err == nil { + t.Error("createOCILayoutSubjectsFunction() expected error for missing image.tar, got nil") + } +} + +// TestCreateDockerInspectSubjectsFunction tests the Docker inspect subjects function +// Note: This test requires Docker to be running and uses a mock approach +func TestCreateDockerInspectSubjectsFunction(t *testing.T) { + // This test would require Docker to be running and an actual image + // For unit testing, we'll skip this and rely on integration tests + // However, we can test the structure and error handling + + t.Run("function_structure", func(t *testing.T) { + version := "test-image:v1.0.0" + cfg := DockerPkgConfig{ + Image: []string{ + "localhost/test-image:latest", + "registry.example.com/test-image:v1.0.0", + }, + } + + // Create the function (doesn't execute yet) + subjectsFunc := createDockerInspectSubjectsFunction(version, cfg) + + // Verify it's a function + if subjectsFunc == nil { + t.Error("createDockerInspectSubjectsFunction() returned nil") + } + + // Note: We can't call subjectsFunc() here without a real Docker image + // This is tested in integration tests + }) +} + +// TestSubjectsFunctionBehavior documents the behavior of Subjects functions +// with and without SLSA enabled +func TestSubjectsFunctionBehavior(t *testing.T) { + t.Run("subjects_function_always_set_up", func(t *testing.T) { + // Document that the Subjects function is ALWAYS set up during build, + // regardless of whether SLSA is enabled or not. + // + // The function is only CALLED when SLSA provenance generation is active. + // + // This means: + // - With SLSA enabled: Function is called, digest is extracted + // - Without SLSA disabled: Function is set up but never called (no error) + // + // Both paths work correctly: + // 1. Legacy path (!exportToCache): createDockerInspectSubjectsFunction() + // 2. OCI layout path (exportToCache): createOCILayoutSubjectsFunction() + + version := "test-image:v1.0.0" + cfg := DockerPkgConfig{ + Image: []string{"localhost/test-image:latest"}, + } + + // Both functions can be created without SLSA being enabled + dockerInspectFunc := createDockerInspectSubjectsFunction(version, cfg) + if dockerInspectFunc == nil { + t.Error("createDockerInspectSubjectsFunction() should always return a function") + } + + // OCI layout function also created regardless of SLSA + tmpDir := t.TempDir() + ociLayoutFunc := createOCILayoutSubjectsFunction(version, cfg, tmpDir) + if ociLayoutFunc == nil { + t.Error("createOCILayoutSubjectsFunction() should always return a function") + } + + // The functions are only called when SLSA is enabled + // If SLSA is disabled, the functions are never invoked, so no error occurs + t.Log("✅ Both functions can be set up regardless of SLSA state") + t.Log("✅ Functions are only called when SLSA provenance generation is active") + }) +} + +// Helper function to create a tar file from a directory +func createTarFromDir(srcDir, destTar string) error { + f, err := os.Create(destTar) + if err != nil { + return err + } + defer f.Close() + + tw := tar.NewWriter(f) + defer tw.Close() + + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Get relative path + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + // Skip the root directory itself + if relPath == "." { + return nil + } + + // Create tar header + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = relPath + + // Write header + if err := tw.WriteHeader(header); err != nil { + return err + } + + // Write file content if it's a regular file + if info.Mode().IsRegular() { + data, err := os.ReadFile(path) + if err != nil { + return err + } + if _, err := tw.Write(data); err != nil { + return err + } + } + + return nil + }) +}