From eb4ed8a2da945c3ee2bd8600cbc9bb506e9360df Mon Sep 17 00:00:00 2001 From: 4RH1T3CT0R7 Date: Sat, 14 Feb 2026 00:54:31 +0300 Subject: [PATCH] docker cp: report both content size and transferred size Report the actual file/content size in the success message, with the transferred (tar stream) size shown in parentheses when it differs from the content size. For copyToContainer, use localContentSize() which performs fast stat-only metadata lookups on local files. For copyFromContainer, use the PathStat.Size from the container API response for regular files, falling back to the tar stream size for directories. When content size equals transferred size (e.g. directory downloads or stdin input where content size is unknown), only the single size is shown to avoid redundant output. Example output: Successfully copied 0B (transferred 1.54kB) to my-container:/empty Successfully copied 5B (transferred 2.05kB) to my-container:/file Successfully copied 10.5kB to /local/dir Fixes docker#5777 Signed-off-by: 4RH1T3CT0R7 --- cli/command/container/client_test.go | 8 ++ cli/command/container/cp.go | 64 ++++++++++- cli/command/container/cp_test.go | 154 +++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 2 deletions(-) diff --git a/cli/command/container/client_test.go b/cli/command/container/client_test.go index 9b66f713cb59..7c38e7bec8d6 100644 --- a/cli/command/container/client_test.go +++ b/cli/command/container/client_test.go @@ -36,6 +36,7 @@ type fakeClient struct { infoFunc func() (client.SystemInfoResult, error) containerStatPathFunc func(containerID, path string) (client.ContainerStatPathResult, error) containerCopyFromFunc func(containerID, srcPath string) (client.CopyFromContainerResult, error) + containerCopyToFunc func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) logFunc func(string, client.ContainerLogsOptions) (client.ContainerLogsResult, error) waitFunc func(string) client.ContainerWaitResult containerListFunc func(client.ContainerListOptions) (client.ContainerListResult, error) @@ -128,6 +129,13 @@ func (f *fakeClient) CopyFromContainer(_ context.Context, containerID string, op return client.CopyFromContainerResult{}, nil } +func (f *fakeClient) CopyToContainer(_ context.Context, containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) { + if f.containerCopyToFunc != nil { + return f.containerCopyToFunc(containerID, options) + } + return client.CopyToContainerResult{}, nil +} + func (f *fakeClient) ContainerLogs(_ context.Context, containerID string, options client.ContainerLogsOptions) (client.ContainerLogsResult, error) { if f.logFunc != nil { return f.logFunc(containerID, options) diff --git a/cli/command/container/cp.go b/cli/command/container/cp.go index 5121cb88a594..a46419abc57b 100644 --- a/cli/command/container/cp.go +++ b/cli/command/container/cp.go @@ -168,6 +168,37 @@ func progressHumanSize(n int64) string { return units.HumanSizeWithPrecision(float64(n), 3) } +// localContentSize returns the total size of regular file content at path. +// For a regular file it returns the file size. For a directory it walks +// the tree and sums sizes of all regular files. +func localContentSize(path string) (int64, error) { + fi, err := os.Lstat(path) + if err != nil { + return -1, err + } + if !fi.IsDir() { + if fi.Mode().IsRegular() { + return fi.Size(), nil + } + return 0, nil + } + var total int64 + err = filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.Type().IsRegular() { + info, err := d.Info() + if err != nil { + return err + } + total += info.Size() + } + return nil + }) + return total, err +} + func runCopy(ctx context.Context, dockerCli command.Cli, opts copyOptions) error { srcContainer, srcPath := splitCpArg(opts.source) destContainer, destPath := splitCpArg(opts.destination) @@ -295,7 +326,19 @@ func copyFromContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cp cancel() <-done restore() - _, _ = fmt.Fprintln(dockerCLI.Err(), "Successfully copied", progressHumanSize(copiedSize), "to", dstPath) + reportedSize := copiedSize + if !cpRes.Stat.Mode.IsDir() { + reportedSize = cpRes.Stat.Size + } + if reportedSize != copiedSize { + _, _ = fmt.Fprintf(dockerCLI.Err(), "Successfully copied %s (transferred %s) to %s\n", + progressHumanSize(reportedSize), progressHumanSize(copiedSize), dstPath, + ) + } else { + _, _ = fmt.Fprintf(dockerCLI.Err(), "Successfully copied %s to %s\n", + progressHumanSize(reportedSize), dstPath, + ) + } return res } @@ -354,11 +397,14 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo content io.ReadCloser resolvedDstPath string copiedSize int64 + contentSize int64 + sizeErr error ) if srcPath == "-" { content = os.Stdin resolvedDstPath = dstInfo.Path + sizeErr = errors.New("content size not available for stdin") if !dstInfo.IsDir { return fmt.Errorf(`destination "%s:%s" must be a directory`, copyConfig.container, dstPath) } @@ -369,6 +415,8 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo return err } + contentSize, sizeErr = localContentSize(srcInfo.Path) + srcArchive, err := archive.TarResource(srcInfo) if err != nil { return err @@ -421,7 +469,19 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo cancel() <-done restore() - _, _ = fmt.Fprintln(dockerCLI.Err(), "Successfully copied", progressHumanSize(copiedSize), "to", copyConfig.container+":"+dstInfo.Path) + reportedSize := copiedSize + if sizeErr == nil { + reportedSize = contentSize + } + if reportedSize != copiedSize { + _, _ = fmt.Fprintf(dockerCLI.Err(), "Successfully copied %s (transferred %s) to %s:%s\n", + progressHumanSize(reportedSize), progressHumanSize(copiedSize), copyConfig.container, dstInfo.Path, + ) + } else { + _, _ = fmt.Fprintf(dockerCLI.Err(), "Successfully copied %s to %s:%s\n", + progressHumanSize(reportedSize), copyConfig.container, dstInfo.Path, + ) + } return err } diff --git a/cli/command/container/cp_test.go b/cli/command/container/cp_test.go index cb724f9d9823..f054a50af886 100644 --- a/cli/command/container/cp_test.go +++ b/cli/command/container/cp_test.go @@ -11,6 +11,7 @@ import ( "github.com/docker/cli/internal/test" "github.com/moby/go-archive" "github.com/moby/go-archive/compression" + "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" @@ -211,3 +212,156 @@ func TestRunCopyFromContainerToFilesystemIrregularDestination(t *testing.T) { expected := `"/dev/random" must be a directory or a regular file` assert.ErrorContains(t, err, expected) } + +func TestCopyFromContainerReportsFileSize(t *testing.T) { + // The file content is "hello" (5 bytes), but the TAR archive wrapping + // it is much larger due to headers and padding. The success message + // should report the actual file size (5B), not the TAR stream size. + srcDir := fs.NewDir(t, "cp-test-from", + fs.WithFile("file1", "hello")) + + destDir := fs.NewDir(t, "cp-test-from-dest") + + const fileSize int64 = 5 + fakeCli := test.NewFakeCli(&fakeClient{ + containerCopyFromFunc: func(ctr, srcPath string) (client.CopyFromContainerResult, error) { + readCloser, err := archive.Tar(srcDir.Path(), compression.None) + return client.CopyFromContainerResult{ + Content: readCloser, + Stat: container.PathStat{ + Name: "file1", + Size: fileSize, + }, + }, err + }, + }) + err := runCopy(context.TODO(), fakeCli, copyOptions{ + source: "container:/file1", + destination: destDir.Path(), + }) + assert.NilError(t, err) + errOut := fakeCli.ErrBuffer().String() + assert.Check(t, is.Contains(errOut, "Successfully copied 5B")) + assert.Check(t, is.Contains(errOut, "(transferred")) +} + +func TestCopyToContainerReportsFileSize(t *testing.T) { + // Create a temp file with known content ("hello" = 5 bytes). + // The TAR archive sent to the container is larger, but the success + // message should report the actual content size. + srcFile := fs.NewFile(t, "cp-test-to", fs.WithContent("hello")) + + fakeCli := test.NewFakeCli(&fakeClient{ + containerStatPathFunc: func(containerID, path string) (client.ContainerStatPathResult, error) { + return client.ContainerStatPathResult{ + Stat: container.PathStat{ + Name: "tmp", + Mode: os.ModeDir | 0o755, + }, + }, nil + }, + containerCopyToFunc: func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) { + _, _ = io.Copy(io.Discard, options.Content) + return client.CopyToContainerResult{}, nil + }, + }) + err := runCopy(context.TODO(), fakeCli, copyOptions{ + source: srcFile.Path(), + destination: "container:/tmp", + }) + assert.NilError(t, err) + errOut := fakeCli.ErrBuffer().String() + assert.Check(t, is.Contains(errOut, "Successfully copied 5B")) + assert.Check(t, is.Contains(errOut, "(transferred")) +} + +func TestCopyToContainerReportsEmptyFileSize(t *testing.T) { + srcFile := fs.NewFile(t, "cp-test-empty", fs.WithContent("")) + + fakeCli := test.NewFakeCli(&fakeClient{ + containerStatPathFunc: func(containerID, path string) (client.ContainerStatPathResult, error) { + return client.ContainerStatPathResult{ + Stat: container.PathStat{ + Name: "tmp", + Mode: os.ModeDir | 0o755, + }, + }, nil + }, + containerCopyToFunc: func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) { + _, _ = io.Copy(io.Discard, options.Content) + return client.CopyToContainerResult{}, nil + }, + }) + err := runCopy(context.TODO(), fakeCli, copyOptions{ + source: srcFile.Path(), + destination: "container:/tmp", + }) + assert.NilError(t, err) + errOut := fakeCli.ErrBuffer().String() + assert.Check(t, is.Contains(errOut, "Successfully copied 0B")) + assert.Check(t, is.Contains(errOut, "(transferred")) +} + +func TestCopyToContainerReportsDirectorySize(t *testing.T) { + // Create a temp directory with files "aaa" (3 bytes) + "bbb" (3 bytes) = 6 bytes. + // The TAR archive is much larger, but the success message should report 6B. + srcDir := fs.NewDir(t, "cp-test-dir", + fs.WithFile("aaa", "aaa"), + fs.WithFile("bbb", "bbb"), + ) + + fakeCli := test.NewFakeCli(&fakeClient{ + containerStatPathFunc: func(containerID, path string) (client.ContainerStatPathResult, error) { + return client.ContainerStatPathResult{ + Stat: container.PathStat{ + Name: "tmp", + Mode: os.ModeDir | 0o755, + }, + }, nil + }, + containerCopyToFunc: func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) { + _, _ = io.Copy(io.Discard, options.Content) + return client.CopyToContainerResult{}, nil + }, + }) + err := runCopy(context.TODO(), fakeCli, copyOptions{ + source: srcDir.Path() + string(os.PathSeparator), + destination: "container:/tmp", + }) + assert.NilError(t, err) + errOut := fakeCli.ErrBuffer().String() + assert.Check(t, is.Contains(errOut, "Successfully copied 6B")) + assert.Check(t, is.Contains(errOut, "(transferred")) +} + +func TestCopyFromContainerReportsDirectorySize(t *testing.T) { + // When copying a directory from a container, cpRes.Stat.Mode.IsDir() is true, + // so reportedSize falls back to copiedSize (the tar stream bytes). + srcDir := fs.NewDir(t, "cp-test-fromdir", + fs.WithFile("file1", "hello")) + + destDir := fs.NewDir(t, "cp-test-fromdir-dest") + + fakeCli := test.NewFakeCli(&fakeClient{ + containerCopyFromFunc: func(ctr, srcPath string) (client.CopyFromContainerResult, error) { + readCloser, err := archive.Tar(srcDir.Path(), compression.None) + return client.CopyFromContainerResult{ + Content: readCloser, + Stat: container.PathStat{ + Name: "mydir", + Mode: os.ModeDir | 0o755, + }, + }, err + }, + }) + err := runCopy(context.TODO(), fakeCli, copyOptions{ + source: "container:/mydir", + destination: destDir.Path(), + }) + assert.NilError(t, err) + errOut := fakeCli.ErrBuffer().String() + assert.Check(t, is.Contains(errOut, "Successfully copied")) + // For directories from container, content size is unknown so + // reportedSize == copiedSize and "(transferred ...)" is omitted. + assert.Check(t, !strings.Contains(errOut, "(transferred")) +}