Repository: moby/buildkit

Issue:
Regression with attestations local export since 0.23.0
relates to https://github.com/docker/buildkit-syft-scanner/issues/133

```dockerfile
FROM alpine AS base
ARG BUILDKIT_SBOM_SCAN_STAGE=true
RUN apk add git
COPY <<EOF /empty
EOF

FROM scratch
COPY --from=base /empty /
```

```
$ docker buildx create --name sbomtest --driver-opt "image=moby/buildkit:v0.23.1"
$ docker buildx --builder sbomtest build --sbom=true --output ./bin .
$ tree ./bin
./bin
├── empty
├── sbom-base.spdx.linux_amd64.json
└── sbom.spdx.linux_amd64.json

0 directories, 3 files
```

With BuildKit 0.22.0:

```
$ docker buildx create --name sbomtest --driver-opt "image=moby/buildkit:v0.22.0"
$ docker buildx --builder sbomtest build --sbom=true --output ./bin .
$ tree ./bin
./bin
├── empty
├── sbom-base.spdx.json
└── sbom.spdx.json

0 directories, 3 files
```

Looks related to https://github.com/moby/buildkit/pull/6007

Discussion:

Solution patch:
diff --git a/exporter/local/fs.go b/exporter/local/fs.go
index 6e68b0aa2..ccbbf7e64 100644
--- a/exporter/local/fs.go
+++ b/exporter/local/fs.go
@@ -180,7 +180,7 @@ func CreateFS(ctx context.Context, sessionID string, k string, ref cache.Immutab
 			return nil, nil, err
 		}
 		stmtFS := staticfs.NewFS()
-		split := opt.UsePlatformSplit(isMap)
+		addPlatformToFilename := isMap && !opt.UsePlatformSplit(isMap)
 
 		names := map[string]struct{}{}
 		for i, stmt := range stmts {
@@ -190,7 +190,7 @@ func CreateFS(ctx context.Context, sessionID string, k string, ref cache.Immutab
 			}
 
 			name := opt.AttestationPrefix + path.Base(attestations[i].Path)
-			if !split {
+			if addPlatformToFilename {
 				nameExt := path.Ext(name)
 				namBase := strings.TrimSuffix(name, nameExt)
 				name = fmt.Sprintf("%s.%s%s", namBase, strings.ReplaceAll(k, "/", "_"), nameExt)



Test patch:
diff --git a/frontend/dockerfile/dockerfile_provenance_test.go b/frontend/dockerfile/dockerfile_provenance_test.go
index 46ee5e7b1..bfa859223 100644
--- a/frontend/dockerfile/dockerfile_provenance_test.go
+++ b/frontend/dockerfile/dockerfile_provenance_test.go
@@ -43,6 +43,30 @@ import (
 	"github.com/tonistiigi/fsutil"
 )
 
+var provenanceTests = integration.TestFuncs(
+	testProvenanceAttestation,
+	testGitProvenanceAttestation,
+	testMultiPlatformProvenance,
+	testClientFrontendProvenance,
+	testClientLLBProvenance,
+	testSecretSSHProvenance,
+	testOCILayoutProvenance,
+	testNilProvenance,
+	testDuplicatePlatformProvenance,
+	testDockerIgnoreMissingProvenance,
+	testCommandSourceMapping,
+	testFrontendDeduplicateSources,
+	testDuplicateLayersProvenance,
+	testProvenanceExportLocal,
+	testProvenanceExportLocalForceSplit,
+	testProvenanceExportLocalMultiPlatform,
+	testProvenanceExportLocalMultiPlatformNoSplit,
+)
+
+func init() {
+	allTests = append(allTests, provenanceTests...)
+}
+
 func testProvenanceAttestation(t *testing.T, sb integration.Sandbox) {
 	workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureProvenance)
 	ctx := sb.Context()
@@ -1735,3 +1759,238 @@ RUN date +%s > /b.txt
 	require.NotNil(t, layers)
 	require.Len(t, layers, 1)
 }
+
+func testProvenanceExportLocal(t *testing.T, sb integration.Sandbox) {
+	integration.SkipOnPlatform(t, "windows")
+	workers.CheckFeatureCompat(t, sb, workers.FeatureProvenance)
+	ctx := sb.Context()
+
+	c, err := client.New(ctx, sb.Address())
+	require.NoError(t, err)
+	defer c.Close()
+
+	f := getFrontend(t, sb)
+
+	dockerfile := []byte(`
+FROM busybox:latest AS base
+COPY <<EOF /out/foo
+ok
+EOF
+
+FROM scratch
+COPY --from=base /out /
+`)
+	dir := integration.Tmpdir(
+		t,
+		fstest.CreateFile("Dockerfile", dockerfile, 0600),
+	)
+
+	destDir := t.TempDir()
+	_, err = f.Solve(sb.Context(), c, client.SolveOpt{
+		LocalMounts: map[string]fsutil.FS{
+			dockerui.DefaultLocalNameDockerfile: dir,
+			dockerui.DefaultLocalNameContext:    dir,
+		},
+		FrontendAttrs: map[string]string{
+			"attest:provenance": "mode=max",
+		},
+		Exports: []client.ExportEntry{
+			{
+				Type:      client.ExporterLocal,
+				OutputDir: destDir,
+			},
+		},
+	}, nil)
+	require.NoError(t, err)
+
+	dt, err := os.ReadFile(filepath.Join(destDir, "foo"))
+	require.NoError(t, err)
+	require.Equal(t, "ok\n", string(dt))
+
+	dt, err = os.ReadFile(filepath.Join(destDir, "provenance.json"))
+	require.NoError(t, err)
+	require.NotEqual(t, 0, len(dt))
+
+	var pred provenancetypes.ProvenancePredicateSLSA02
+	require.NoError(t, json.Unmarshal(dt, &pred))
+}
+
+func testProvenanceExportLocalForceSplit(t *testing.T, sb integration.Sandbox) {
+	integration.SkipOnPlatform(t, "windows")
+	workers.CheckFeatureCompat(t, sb, workers.FeatureProvenance)
+	ctx := sb.Context()
+
+	c, err := client.New(ctx, sb.Address())
+	require.NoError(t, err)
+	defer c.Close()
+
+	f := getFrontend(t, sb)
+
+	dockerfile := []byte(`
+FROM busybox:latest AS base
+COPY <<EOF /out/foo
+ok
+EOF
+
+FROM scratch
+COPY --from=base /out /
+`)
+	dir := integration.Tmpdir(
+		t,
+		fstest.CreateFile("Dockerfile", dockerfile, 0600),
+	)
+
+	destDir := t.TempDir()
+	_, err = f.Solve(sb.Context(), c, client.SolveOpt{
+		LocalMounts: map[string]fsutil.FS{
+			dockerui.DefaultLocalNameDockerfile: dir,
+			dockerui.DefaultLocalNameContext:    dir,
+		},
+		FrontendAttrs: map[string]string{
+			"attest:provenance": "mode=max",
+		},
+		Exports: []client.ExportEntry{
+			{
+				Type:      client.ExporterLocal,
+				OutputDir: destDir,
+				Attrs: map[string]string{
+					"platform-split": "true",
+				},
+			},
+		},
+	}, nil)
+	require.NoError(t, err)
+
+	expPlatform := strings.ReplaceAll(platforms.FormatAll(platforms.DefaultSpec()), "/", "_")
+
+	dt, err := os.ReadFile(filepath.Join(destDir, expPlatform, "foo"))
+	require.NoError(t, err)
+	require.Equal(t, "ok\n", string(dt))
+
+	dt, err = os.ReadFile(filepath.Join(destDir, expPlatform, "provenance.json"))
+	require.NoError(t, err)
+	require.NotEqual(t, 0, len(dt))
+
+	var pred provenancetypes.ProvenancePredicateSLSA02
+	require.NoError(t, json.Unmarshal(dt, &pred))
+}
+
+func testProvenanceExportLocalMultiPlatform(t *testing.T, sb integration.Sandbox) {
+	integration.SkipOnPlatform(t, "windows")
+	workers.CheckFeatureCompat(t, sb, workers.FeatureMultiPlatform, workers.FeatureProvenance)
+	ctx := sb.Context()
+
+	c, err := client.New(ctx, sb.Address())
+	require.NoError(t, err)
+	defer c.Close()
+
+	f := getFrontend(t, sb)
+
+	dockerfile := []byte(`
+FROM busybox:latest AS base
+COPY <<EOF /out/foo
+ok
+EOF
+
+FROM scratch
+COPY --from=base /out /
+`)
+	dir := integration.Tmpdir(
+		t,
+		fstest.CreateFile("Dockerfile", dockerfile, 0600),
+	)
+
+	destDir := t.TempDir()
+	_, err = f.Solve(sb.Context(), c, client.SolveOpt{
+		LocalMounts: map[string]fsutil.FS{
+			dockerui.DefaultLocalNameDockerfile: dir,
+			dockerui.DefaultLocalNameContext:    dir,
+		},
+		FrontendAttrs: map[string]string{
+			"attest:provenance": "mode=max",
+			"platform":          "linux/amd64,linux/arm64",
+		},
+		Exports: []client.ExportEntry{
+			{
+				Type:      client.ExporterLocal,
+				OutputDir: destDir,
+			},
+		},
+	}, nil)
+	require.NoError(t, err)
+
+	for _, platform := range []string{"linux_amd64", "linux_arm64"} {
+		dt, err := os.ReadFile(filepath.Join(destDir, platform, "foo"))
+		require.NoError(t, err)
+		require.Equal(t, "ok\n", string(dt))
+
+		dt, err = os.ReadFile(filepath.Join(destDir, platform, "provenance.json"))
+		require.NoError(t, err)
+		require.NotEqual(t, 0, len(dt))
+
+		var pred provenancetypes.ProvenancePredicateSLSA02
+		require.NoError(t, json.Unmarshal(dt, &pred))
+	}
+}
+
+func testProvenanceExportLocalMultiPlatformNoSplit(t *testing.T, sb integration.Sandbox) {
+	integration.SkipOnPlatform(t, "windows")
+	workers.CheckFeatureCompat(t, sb, workers.FeatureMultiPlatform, workers.FeatureProvenance)
+	ctx := sb.Context()
+
+	c, err := client.New(ctx, sb.Address())
+	require.NoError(t, err)
+	defer c.Close()
+
+	f := getFrontend(t, sb)
+
+	dockerfile := []byte(`
+FROM busybox:latest AS base
+ARG TARGETARCH
+COPY <<EOF /out/foo_${TARGETARCH}
+ok
+EOF
+
+FROM scratch
+COPY --from=base /out /
+`)
+	dir := integration.Tmpdir(
+		t,
+		fstest.CreateFile("Dockerfile", dockerfile, 0600),
+	)
+
+	destDir := t.TempDir()
+	_, err = f.Solve(sb.Context(), c, client.SolveOpt{
+		LocalMounts: map[string]fsutil.FS{
+			dockerui.DefaultLocalNameDockerfile: dir,
+			dockerui.DefaultLocalNameContext:    dir,
+		},
+		FrontendAttrs: map[string]string{
+			"attest:provenance": "mode=max",
+			"platform":          "linux/amd64,linux/arm64",
+		},
+		Exports: []client.ExportEntry{
+			{
+				Type:      client.ExporterLocal,
+				OutputDir: destDir,
+				Attrs: map[string]string{
+					"platform-split": "false",
+				},
+			},
+		},
+	}, nil)
+	require.NoError(t, err)
+
+	for _, arch := range []string{"amd64", "arm64"} {
+		dt, err := os.ReadFile(filepath.Join(destDir, "foo_"+arch))
+		require.NoError(t, err)
+		require.Equal(t, "ok\n", string(dt))
+
+		dt, err = os.ReadFile(filepath.Join(destDir, "provenance.linux_"+arch+".json"))
+		require.NoError(t, err)
+		require.NotEqual(t, 0, len(dt))
+
+		var pred provenancetypes.ProvenancePredicateSLSA02
+		require.NoError(t, json.Unmarshal(dt, &pred))
+	}
+}
diff --git a/frontend/dockerfile/dockerfile_test.go b/frontend/dockerfile/dockerfile_test.go
index ec3dbe565..6e25040ba 100644
--- a/frontend/dockerfile/dockerfile_test.go
+++ b/frontend/dockerfile/dockerfile_test.go
@@ -189,17 +189,6 @@ var allTests = integration.TestFuncs(
 	testDockerfileAddChownExpand,
 	testSourceDateEpochWithoutExporter,
 	testSBOMScannerImage,
-	testProvenanceAttestation,
-	testGitProvenanceAttestation,
-	testMultiPlatformProvenance,
-	testClientFrontendProvenance,
-	testClientLLBProvenance,
-	testSecretSSHProvenance,
-	testOCILayoutProvenance,
-	testNilProvenance,
-	testDuplicatePlatformProvenance,
-	testDockerIgnoreMissingProvenance,
-	testCommandSourceMapping,
 	testSBOMScannerArgs,
 	testMultiNilRefsOCIExporter,
 	testNilContextInSolveGateway,