From 14ba4099c8dcdd944142bac917780159d3ad105e Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Tue, 16 Jun 2026 22:37:33 -0700 Subject: [PATCH 1/3] exporter: sanitize platform IDs in paths Replace Windows path separators and drive separators when platform IDs are used as local and tar exporter path components. Add a regression test for tar exporter output generated from frontend-controlled platform metadata. Signed-off-by: Tonis Tiigi --- client/client_export_local_test.go | 85 ++++++++++++++++++++++++++++++ client/client_test.go | 1 + exporter/local/export.go | 3 +- exporter/local/fs.go | 2 +- exporter/local/platform.go | 14 +++++ exporter/local/platform_test.go | 50 ++++++++++++++++++ exporter/tar/export.go | 3 +- 7 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 exporter/local/platform.go create mode 100644 exporter/local/platform_test.go diff --git a/client/client_export_local_test.go b/client/client_export_local_test.go index b162f5b2010a..1cbf98c92b56 100644 --- a/client/client_export_local_test.go +++ b/client/client_export_local_test.go @@ -11,6 +11,7 @@ import ( "path" "path/filepath" "runtime" + "sort" "strings" "testing" @@ -443,6 +444,90 @@ func testExportLocalNoPlatformSplitOverwrite(t *testing.T, sb integration.Sandbo require.ErrorContains(t, err, "when split option is disabled") } +func testExportTarPlatformIDSanitized(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureMultiPlatform) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + const platformID = `..\buildkit-outside` + platform := platforms.DefaultSpec() + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + st := llb.Scratch().File( + llb.Mkfile("payload.txt", 0600, []byte("payload")), + ) + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + + res := gateway.NewResult() + res.AddRef(platformID, ref) + + dt, err := json.Marshal(&exptypes.Platforms{ + Platforms: []exptypes.Platform{{ + ID: platformID, + Platform: platform, + }}, + }) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + outW := bytes.NewBuffer(nil) + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterTar, + Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: outW}), + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(outW.Bytes(), false) + require.NoError(t, err) + + for name := range m { + require.Falsef(t, strings.HasPrefix(name, "../") || + strings.HasPrefix(name, `..\`) || + strings.Contains(name, `/../`) || + strings.Contains(name, `\..\`) || + strings.Contains(name, `\`) || + strings.Contains(name, ":"), + "tar exporter emitted unsafe platform path %q", name) + } + + tarPaths := make([]string, 0, len(m)) + for name := range m { + tarPaths = append(tarPaths, name) + } + sort.Strings(tarPaths) + + expectedPath := path.Join(".._buildkit-outside", integration.UnixOrWindows("payload.txt", "Files/payload.txt")) + item := m[expectedPath] + require.NotNilf(t, item, "expected sanitized tar path %q in %v", expectedPath, tarPaths) + require.Equal(t, "payload", string(item.Data)) +} + func testExporterTargetExists(t *testing.T, sb integration.Sandbox) { workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter) c, err := New(sb.Context(), sb.Address()) diff --git a/client/client_test.go b/client/client_test.go index 53da6223bbc6..f5843dc6ca27 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -89,6 +89,7 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){ testExportLocalModeDeleteMultiPlatformKeepsAllPlatforms, testExportLocalNoPlatformSplit, testExportLocalNoPlatformSplitOverwrite, + testExportTarPlatformIDSanitized, testExporterTargetExists, testMultipleExporters, testSessionExporter, diff --git a/exporter/local/export.go b/exporter/local/export.go index ef1b00ae1645..3cf9218c610c 100644 --- a/exporter/local/export.go +++ b/exporter/local/export.go @@ -3,7 +3,6 @@ package local import ( "context" "os" - "strings" "sync" "time" @@ -127,7 +126,7 @@ func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source platformDirStat := func(k string, opt CreateFSOpts) *fstypes.Stat { st := &fstypes.Stat{ Mode: uint32(os.ModeDir | 0755), - Path: strings.ReplaceAll(k, "/", "_"), + Path: PlatformIDToPath(k), } if opt.Epoch != nil && opt.Epoch.Value != nil { st.ModTime = opt.Epoch.Value.UnixNano() diff --git a/exporter/local/fs.go b/exporter/local/fs.go index 0f5186a299ef..a47e6934539b 100644 --- a/exporter/local/fs.go +++ b/exporter/local/fs.go @@ -199,7 +199,7 @@ func CreateFS(ctx context.Context, sessionID string, k string, ref cache.Immutab if addPlatformToFilename { nameExt := path.Ext(name) namBase := strings.TrimSuffix(name, nameExt) - name = fmt.Sprintf("%s.%s%s", namBase, strings.ReplaceAll(k, "/", "_"), nameExt) + name = fmt.Sprintf("%s.%s%s", namBase, PlatformIDToPath(k), nameExt) } if _, ok := names[name]; ok { return nil, nil, errors.Errorf("duplicate attestation path name %s", name) diff --git a/exporter/local/platform.go b/exporter/local/platform.go new file mode 100644 index 000000000000..c8acd5afaa59 --- /dev/null +++ b/exporter/local/platform.go @@ -0,0 +1,14 @@ +package local + +import "strings" + +// PlatformIDToPath maps an exporter platform ID to a single path component. +func PlatformIDToPath(id string) string { + id = strings.NewReplacer("/", "_", `\`, "_", ":", "_").Replace(id) + switch id { + case ".", "..": + return strings.Repeat("_", len(id)) + default: + return id + } +} diff --git a/exporter/local/platform_test.go b/exporter/local/platform_test.go new file mode 100644 index 000000000000..a258068c6427 --- /dev/null +++ b/exporter/local/platform_test.go @@ -0,0 +1,50 @@ +package local + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPlatformIDToPath(t *testing.T) { + for _, tc := range []struct { + name string + id string + want string + }{ + { + name: "slashes", + id: "linux/amd64", + want: "linux_amd64", + }, + { + name: "windows separators", + id: `..\buildkit-outside`, + want: ".._buildkit-outside", + }, + { + name: "drive separator", + id: `windows/amd64:C`, + want: "windows_amd64_C", + }, + { + name: "dot", + id: ".", + want: "_", + }, + { + name: "dot dot", + id: "..", + want: "__", + }, + { + name: "embedded dots", + id: "linux.v2/amd64", + want: "linux.v2_amd64", + }, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, PlatformIDToPath(tc.id)) + }) + } +} diff --git a/exporter/tar/export.go b/exporter/tar/export.go index ef0e41330160..796bc8e80781 100644 --- a/exporter/tar/export.go +++ b/exporter/tar/export.go @@ -4,7 +4,6 @@ import ( "context" "os" "slices" - "strings" "time" "github.com/moby/buildkit/cache" @@ -109,7 +108,7 @@ func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source st := &fstypes.Stat{ Mode: uint32(os.ModeDir | 0755), - Path: strings.ReplaceAll(k, "/", "_"), + Path: local.PlatformIDToPath(k), } if opt.Epoch != nil && opt.Epoch.Value != nil { st.ModTime = opt.Epoch.Value.UnixNano() From d27e0b7cac7ca96da190d26f3e28c9cd0d8b7664 Mon Sep 17 00:00:00 2001 From: CrazyMax Date: Tue, 18 Aug 2026 15:52:29 +0200 Subject: [PATCH 2/3] vendor: update github.com/tonistiigi/fsutil to ea1b24afbd39 Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- vendor/github.com/tonistiigi/fsutil/tarwriter.go | 3 ++- vendor/modules.txt | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 11117148e313..f47c6617c37b 100644 --- a/go.mod +++ b/go.mod @@ -77,7 +77,7 @@ require ( github.com/spdx/tools-golang v0.5.7 github.com/stretchr/testify v1.12.1 github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 - github.com/tonistiigi/fsutil v0.0.0-20260717003753-6d9dc2ebad62 + github.com/tonistiigi/fsutil v0.0.0-20260818132828-ea1b24afbd39 github.com/tonistiigi/go-actions-cache v0.0.0-20260120203934-54bc28c26fd2 github.com/tonistiigi/go-archvariant v1.0.0 github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 diff --git a/go.sum b/go.sum index cb32837d76f0..3ff334f0afa2 100644 --- a/go.sum +++ b/go.sum @@ -584,8 +584,8 @@ github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 h1:r0p7fK56l8WPequOaR3i9LBqfPtEdXIQbUTzT55iqT4= github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323/go.mod h1:3Iuxbr0P7D3zUzBMAZB+ois3h/et0shEz0qApgHYGpY= -github.com/tonistiigi/fsutil v0.0.0-20260717003753-6d9dc2ebad62 h1:uppBiK+tE8tYG6fc0N8VnsC7FMZcWxnXIyaQ9GcUIU8= -github.com/tonistiigi/fsutil v0.0.0-20260717003753-6d9dc2ebad62/go.mod h1:K5zrLch9UaSGNiek5XHZeqZUf1zPWJHqDfLIcnpquQ4= +github.com/tonistiigi/fsutil v0.0.0-20260818132828-ea1b24afbd39 h1:g12NNHiPRetQ/siMoRWPGhYnAmfoln1ERSal11ll7Pg= +github.com/tonistiigi/fsutil v0.0.0-20260818132828-ea1b24afbd39/go.mod h1:K5zrLch9UaSGNiek5XHZeqZUf1zPWJHqDfLIcnpquQ4= github.com/tonistiigi/go-actions-cache v0.0.0-20260120203934-54bc28c26fd2 h1:5p6hffZeB25G4rhBc3HU6x1aIlyDELfib+/Omq+ZfQA= github.com/tonistiigi/go-actions-cache v0.0.0-20260120203934-54bc28c26fd2/go.mod h1:cD0SB2270BYw6HYKriFn4H6NRLhGj6ytf48YTpsm8LY= github.com/tonistiigi/go-archvariant v1.0.0 h1:5LC1eDWiBNflnTF1prCiX09yfNHIxDC/aukdhCdTyb0= diff --git a/vendor/github.com/tonistiigi/fsutil/tarwriter.go b/vendor/github.com/tonistiigi/fsutil/tarwriter.go index 06b7bda9bec0..e27fb708ed23 100644 --- a/vendor/github.com/tonistiigi/fsutil/tarwriter.go +++ b/vendor/github.com/tonistiigi/fsutil/tarwriter.go @@ -15,7 +15,7 @@ import ( func WriteTar(ctx context.Context, fs FS, w io.Writer) error { tw := tar.NewWriter(w) - err := fs.Walk(ctx, "/", func(path string, entry os.DirEntry, err error) error { + err := fs.Walk(ctx, "", func(path string, entry os.DirEntry, err error) error { if err != nil && !errors.Is(err, os.ErrNotExist) { return err } @@ -33,6 +33,7 @@ func WriteTar(ctx context.Context, fs FS, w io.Writer) error { return err } + // Tar paths use slash separators on the wire. name := filepath.ToSlash(path) if fi.IsDir() && !strings.HasSuffix(name, "/") { name += "/" diff --git a/vendor/modules.txt b/vendor/modules.txt index 66874e245ab7..c775efe9f9f4 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1039,7 +1039,7 @@ github.com/theupdateframework/go-tuf/v2/metadata/updater # github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 ## explicit; go 1.21 github.com/tonistiigi/dchapes-mode -# github.com/tonistiigi/fsutil v0.0.0-20260717003753-6d9dc2ebad62 +# github.com/tonistiigi/fsutil v0.0.0-20260818132828-ea1b24afbd39 ## explicit; go 1.25.0 github.com/tonistiigi/fsutil github.com/tonistiigi/fsutil/copy From d04dd0d2eb9e957ca70116ea72e1cf77cfb4a62d Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:25:20 +0200 Subject: [PATCH 3/3] test: avoid fixed Windows path in tar export check Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- client/client_export_local_test.go | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/client/client_export_local_test.go b/client/client_export_local_test.go index 1cbf98c92b56..d29b5a812a9e 100644 --- a/client/client_export_local_test.go +++ b/client/client_export_local_test.go @@ -522,10 +522,25 @@ func testExportTarPlatformIDSanitized(t *testing.T, sb integration.Sandbox) { } sort.Strings(tarPaths) - expectedPath := path.Join(".._buildkit-outside", integration.UnixOrWindows("payload.txt", "Files/payload.txt")) - item := m[expectedPath] - require.NotNilf(t, item, "expected sanitized tar path %q in %v", expectedPath, tarPaths) - require.Equal(t, "payload", string(item.Data)) + const platformDir = ".._buildkit-outside" + payloadPath := "" + for name, item := range m { + // The tar can contain directory entries and unrelated files; this test + // only verifies that the payload is exported under the sanitized root. + if item.Header.Typeflag != tar.TypeReg { + continue + } + if !strings.HasPrefix(name, platformDir+"/") { + continue + } + if path.Base(name) != "payload.txt" { + continue + } + payloadPath = name + require.Equal(t, "payload", string(item.Data)) + break + } + require.NotEmptyf(t, payloadPath, "expected payload under sanitized tar path %q in %v", platformDir, tarPaths) } func testExporterTargetExists(t *testing.T, sb integration.Sandbox) {