Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cli/command/container/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
44 changes: 42 additions & 2 deletions cli/command/container/cp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 == "-" {
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
84 changes: 84 additions & 0 deletions cli/command/container/cp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"))
}