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..722b5919c3ae 100644 --- a/cli/command/container/cp.go +++ b/cli/command/container/cp.go @@ -168,6 +168,34 @@ 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() { + return fi.Size(), 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 +323,11 @@ 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 + } + _, _ = fmt.Fprintln(dockerCLI.Err(), "Successfully copied", progressHumanSize(reportedSize), "to", dstPath) return res } @@ -354,6 +386,8 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo content io.ReadCloser resolvedDstPath string copiedSize int64 + contentSize int64 + sizeErr error ) if srcPath == "-" { @@ -369,6 +403,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 +457,11 @@ 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 + } + _, _ = fmt.Fprintln(dockerCLI.Err(), "Successfully copied", progressHumanSize(reportedSize), "to", copyConfig.container+":"+dstInfo.Path) return err } diff --git a/cli/command/container/cp_test.go b/cli/command/container/cp_test.go index cb724f9d9823..1a1576303f03 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,86 @@ 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) + assert.Check(t, is.Contains(fakeCli.ErrBuffer().String(), "5B")) +} + +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) + assert.Check(t, is.Contains(fakeCli.ErrBuffer().String(), "5B")) +} + +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) + assert.Check(t, is.Contains(fakeCli.ErrBuffer().String(), "0B")) +}