diff --git a/README.md b/README.md index f713ceea90a5..b338c3b16c31 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,23 @@ COPY --from=builder /usr/src/app/testresult.xml . buildctl build ... --opt target=testresult --output type=local,dest=path/to/output-dir ``` +Alternatively, `src=` exports only part of the build result, without +needing a dedicated stage: + +```bash +buildctl build ... --output type=local,dest=path/to/output-dir,src=/usr/src/app/reports +``` + +The contents of `src` are written to the root of the destination, so +`src=/usr/src/app/reports` exports `reports/index.html` as `index.html`. Omit +`src` to export the entire filesystem. + +`src` must point to a directory, and is always resolved from the root of the +build result, so `src=app/build` and `src=/app/build` are equivalent. Symlinks +are resolved within the build result: a link pointing at `/etc` refers to `/etc` +inside the result, never on the host running BuildKit. With a multi-platform +build, `src` is applied to each platform. + With a [multi-platform build](docs/multi-platform.md), a subfolder matching each target platform will be created in the destination directory: @@ -379,10 +396,12 @@ buildctl build ... --output type=local,dest=./bin/release,mode=delete ``` Tar exporter is similar to local exporter but transfers the files through a tarball. +It supports `src=` with the same meaning as above. ```bash buildctl build ... --output type=tar,dest=out.tar buildctl build ... --output type=tar > out.tar +buildctl build ... --output type=tar,dest=reports.tar,src=/usr/src/app/reports ``` #### Docker tarball diff --git a/client/client_export_local_test.go b/client/client_export_local_test.go index d29b5a812a9e..c4ab31996e3d 100644 --- a/client/client_export_local_test.go +++ b/client/client_export_local_test.go @@ -16,6 +16,7 @@ import ( "testing" "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/continuity/fs/fstest" "github.com/containerd/platforms" controlapi "github.com/moby/buildkit/api/services/control" "github.com/moby/buildkit/client/llb" @@ -961,3 +962,176 @@ func testTarExporterWithSocketCopy(t *testing.T, sb integration.Sandbox) { _, err = c.Solve(sb.Context(), def, SolveOpt{}, nil) require.NoError(t, err) } + +// sourceTestState returns a state with a nested layout, so a src pointing at +// "sub" can be told apart from the whole result. +// +// top.txt +// sub/nested.txt +// sub/deeper/deep.txt +func sourceTestState() llb.State { + return llb.Scratch(). + File(llb.Mkfile("top.txt", 0600, []byte("top"))). + File(llb.Mkdir("sub", 0755)). + File(llb.Mkfile("sub/nested.txt", 0600, []byte("nested"))). + File(llb.Mkdir("sub/deeper", 0755)). + File(llb.Mkfile("sub/deeper/deep.txt", 0600, []byte("deep"))) +} + +func testExportLocalSource(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + def, err := sourceTestState().Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + Attrs: map[string]string{"src": "/sub"}, + }, + }, + }, nil) + require.NoError(t, err) + + require.NoError(t, fstest.CheckDirectoryEqualWithApplier(destDir, fstest.Apply( + fstest.CreateFile("nested.txt", []byte("nested"), 0600), + fstest.CreateDir("deeper", 0755), + fstest.CreateFile("deeper/deep.txt", []byte("deep"), 0600), + ))) +} + +func testExportLocalSourceNotFound(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + def, err := sourceTestState().Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + Attrs: map[string]string{"src": "/nope"}, + }, + }, + }, nil) + require.Error(t, err) + require.ErrorContains(t, err, "src=/nope no such file or directory") + // the mountpoint of the ref inside the daemon must never reach the client + require.NotContains(t, err.Error(), "buildkit-mount") +} + +func testExportLocalSourceMultiPlatform(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() + + platformsToTest := []string{"linux/amd64", "linux/arm64"} + + // every platform builds the same layout and differs only in file contents, + // so the exported platform directories can be compared against each other + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{ + Platforms: make([]exptypes.Platform, len(platformsToTest)), + } + for i, platform := range platformsToTest { + st := llb.Scratch(). + File(llb.Mkfile("top.txt", 0600, []byte("top"))). + File(llb.Mkdir("sub", 0755)). + File(llb.Mkfile("sub/nested.txt", 0600, []byte(platform))). + File(llb.Mkdir("sub/deeper", 0755)). + File(llb.Mkfile("sub/deeper/deep.txt", 0600, []byte(platform))) + + 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.AddRef(platform, ref) + expPlatforms.Platforms[i] = exptypes.Platform{ + ID: platform, + Platform: platforms.MustParse(platform), + } + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + return res, nil + } + + destDir := t.TempDir() + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + Attrs: map[string]string{"src": "/sub"}, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + // src is applied per platform: every platform directory holds the same + // re-rooted layout, with only the file contents telling them apart + for _, platform := range platformsToTest { + platDir := filepath.Join(destDir, strings.ReplaceAll(platform, "/", "_")) + require.NoError(t, fstest.CheckDirectoryEqualWithApplier(platDir, fstest.Apply( + fstest.CreateFile("nested.txt", []byte(platform), 0600), + fstest.CreateDir("deeper", 0755), + fstest.CreateFile("deeper/deep.txt", []byte(platform), 0600), + )), "unexpected content for %s", platform) + } +} + +func testExportTarSource(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + def, err := sourceTestState().Marshal(sb.Context()) + require.NoError(t, err) + + var buf bytes.Buffer + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterTar, + Output: fixedWriteCloser(&iohelper.NopWriteCloser{Writer: &buf}), + Attrs: map[string]string{"src": "/sub"}, + }, + }, + }, nil) + require.NoError(t, err) + + m, err := testutil.ReadTarToMap(buf.Bytes(), false) + require.NoError(t, err) + + item, ok := m["nested.txt"] + require.True(t, ok, "src contents must be at the root of the tarball") + require.Equal(t, []byte("nested"), item.Data) + + _, ok = m["deeper/deep.txt"] + require.True(t, ok) + + _, ok = m["top.txt"] + require.False(t, ok, "paths outside src must not be in the tarball") +} diff --git a/client/client_test.go b/client/client_test.go index 7407d02541d3..cf3d700734d4 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -90,6 +90,10 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){ testExportLocalModeDeleteMultiPlatformKeepsAllPlatforms, testExportLocalNoPlatformSplit, testExportLocalNoPlatformSplitOverwrite, + testExportLocalSource, + testExportLocalSourceNotFound, + testExportLocalSourceMultiPlatform, + testExportTarSource, testExportTarPlatformIDSanitized, testExporterTargetExists, testMultipleExporters, diff --git a/exporter/local/fs.go b/exporter/local/fs.go index a47e6934539b..4ebd7b03ccc3 100644 --- a/exporter/local/fs.go +++ b/exporter/local/fs.go @@ -5,13 +5,13 @@ import ( "encoding/json" "fmt" "io" - "io/fs" "os" "path" "strconv" "strings" "time" + "github.com/containerd/continuity/fs" intoto "github.com/in-toto/in-toto-golang/in_toto" "github.com/moby/buildkit/cache" "github.com/moby/buildkit/client" @@ -35,12 +35,14 @@ const ( // in subfolders when multiple platform references are exported. keyPlatformSplit = "platform-split" keyMode = "mode" + keySource = "src" ) type CreateFSOpts struct { Epoch *epoch.Epoch AttestationPrefix string PlatformSplit *bool + Source string } func (c *CreateFSOpts) UsePlatformSplit(isMap bool) bool { @@ -73,6 +75,12 @@ func (c *CreateFSOpts) Load(opt map[string]string) (map[string]string, error) { if _, err := client.ParseLocalExporterMode(v); err != nil { return nil, err } + case keySource: + var src = strings.TrimSpace(v) + if src == "" { + return nil, errors.Errorf("empty value for %s omit it to export the entire filesystem", keySource) + } + c.Source = path.Join("/", src) default: rest[k] = v } @@ -81,6 +89,38 @@ func (c *CreateFSOpts) Load(opt map[string]string) (map[string]string, error) { return rest, nil } +// Resolves source inside mountRoot and prevents path traversal +// An empty source returns mountRoot itself. +func resolveSafeSource(mountRoot, source string) (fsutil.FS, error) { + root, err := fs.RootPath(mountRoot, source) + if err != nil { + return nil, sourceError(err, source) + } + + outputFS, err := fsutil.NewFS(root) + if err != nil { + return nil, sourceError(err, source) + } + + return outputFS, nil +} + +// Reports err against the source the client asked for, hiding the +// daemon-side mountpoint that RootPath and NewFS name. +func sourceError(err error, source string) error { + if source == "" { + return err + } + // the innermost *os.PathError carries the bare syscall error, with no path + cause := err + for e := err; e != nil; e = errors.Unwrap(e) { + if pe, ok := e.(*os.PathError); ok { + cause = pe.Err + } + } + return errors.Errorf("%s=%s %v", keySource, source, cause) +} + func CreateFS(ctx context.Context, sessionID string, k string, ref cache.ImmutableRef, attestations []exporter.Attestation, defaultTime time.Time, isMap bool, opt CreateFSOpts) (fsutil.FS, func() error, error) { var cleanup func() error var src string @@ -110,7 +150,7 @@ func CreateFS(ctx context.Context, sessionID string, k string, ref cache.Immutab cleanup = lm.Unmount } - outputFS, err := fsutil.NewFS(src) + outputFS, err := resolveSafeSource(src, opt.Source) if err != nil { return nil, nil, err } @@ -155,7 +195,7 @@ func CreateFS(ctx context.Context, sessionID string, k string, ref cache.Immutab } if len(attestations) > 0 { subjects := []intoto.Subject{} - err = outputFS.Walk(ctx, "", func(path string, entry fs.DirEntry, err error) error { + err = outputFS.Walk(ctx, "", func(path string, entry os.DirEntry, err error) error { if err != nil { return err } diff --git a/exporter/local/fs_test.go b/exporter/local/fs_test.go new file mode 100644 index 000000000000..22963fc74020 --- /dev/null +++ b/exporter/local/fs_test.go @@ -0,0 +1,201 @@ +package local + +import ( + "io/fs" + "sort" + "testing" + + "github.com/containerd/continuity/fs/fstest" + "github.com/stretchr/testify/require" + "github.com/tonistiigi/fsutil" +) + +func TestCreateFSOptsLoadSource(t *testing.T) { + tests := []struct { + name string + attrs map[string]string + want string + wantErr string + }{ + { + name: "unset", + attrs: map[string]string{}, + want: "", + }, + { + name: "empty", + attrs: map[string]string{keySource: ""}, + wantErr: "empty value for src", + }, + { + name: "whitespace only", + attrs: map[string]string{keySource: " "}, + wantErr: "empty value for src", + }, + { + name: "absolute", + attrs: map[string]string{keySource: "/app/build"}, + want: "/app/build", + }, + { + name: "trailing slash", + attrs: map[string]string{keySource: "/app/build/"}, + want: "/app/build", + }, + { + name: "relative is anchored to root", + attrs: map[string]string{keySource: "app/build"}, + want: "/app/build", + }, + { + name: "dot slash prefix", + attrs: map[string]string{keySource: "./app/build"}, + want: "/app/build", + }, + { + name: "redundant separators", + attrs: map[string]string{keySource: "/a//b/./c"}, + want: "/a/b/c", + }, + { + name: "dot is root", + attrs: map[string]string{keySource: "."}, + want: "/", + }, + { + name: "slash is root", + attrs: map[string]string{keySource: "/"}, + want: "/", + }, + { + name: "surrounding whitespace is trimmed", + attrs: map[string]string{keySource: " /app "}, + want: "/app", + }, + { + // Clamping at the root matches what BuildKit already does for + // container-side paths: COPY --from, and both source and target of + // RUN --mount, accept ".." and clamp it the same way. + name: "parent traversal is clamped to root", + attrs: map[string]string{keySource: "../../etc"}, + want: "/etc", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var opts CreateFSOpts + rest, err := opts.Load(tc.attrs) + + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, opts.Source) + require.NotContains(t, rest, keySource) + }) + } +} + +// builds a container filesystem +// -> is a symlink same output that tree command would produce +// +// . +// ├── app -> /etc +// ├── etc +// │ └── inside.txt +// ├── rel -> sub +// ├── sub +// │ └── nested.txt +// └── top.txt +func newRootfs(t *testing.T) string { + t.Helper() + + root := t.TempDir() + require.NoError(t, fstest.Apply( + fstest.CreateFile("top.txt", []byte("top"), 0600), + fstest.CreateDir("sub", 0700), + fstest.CreateFile("sub/nested.txt", []byte("nested"), 0600), + fstest.CreateDir("etc", 0700), + fstest.CreateFile("etc/inside.txt", []byte("inside"), 0600), + fstest.Symlink("/etc", "app"), + fstest.Symlink("sub", "rel"), + ).Apply(root)) + + return root +} + +func walkNames(t *testing.T, f fsutil.FS) []string { + t.Helper() + + var names []string + require.NoError(t, f.Walk(t.Context(), "", func(p string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + names = append(names, p) + return nil + })) + sort.Strings(names) + return names +} + +func TestResolveSafeSource(t *testing.T) { + tests := []struct { + name string + source string + want []string + wantErr string + }{ + { + name: "empty source exports the whole mount", + source: "", + want: []string{"app", "etc", "etc/inside.txt", "rel", "sub", "sub/nested.txt", "top.txt"}, + }, + { + name: "subdirectory is re-rooted", + source: "/sub", + want: []string{"nested.txt"}, + }, + { + source: "/app", + name: "absolute symlink cannot escape the mount", + want: []string{"inside.txt"}, + }, + { + name: "relative symlink resolves inside the mount", + source: "/rel", + want: []string{"nested.txt"}, + }, + { + // same clamping as COPY --from and RUN --mount, see the Load test + name: "parent traversal is clamped to the mount", + source: "../../etc", + want: []string{"inside.txt"}, + }, + { + name: "missing path fails", + source: "/nope", + wantErr: "src=/nope no such file or directory", + }, + { + name: "file is not a directory", + source: "/top.txt", + wantErr: "src=/top.txt not a directory", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + outputFS, err := resolveSafeSource(newRootfs(t), tc.source) + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, walkNames(t, outputFS)) + }) + } +}