Skip to content

Commit 5158b8e

Browse files
committed
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. 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. Example output: Successfully copied 0B (transferred 1.54kB) to my-container:/empty Successfully copied 5B (transferred 2.05kB) to my-container:/file Successfully copied 2.01MB (transferred 2.53MB) to my-container:/dir Fixes #5777 Signed-off-by: 4RH1T3CT0R7 <iprintercanon@gmail.com>
1 parent 7b93d61 commit 5158b8e

File tree

3 files changed

+206
-2
lines changed

3 files changed

+206
-2
lines changed

cli/command/container/client_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type fakeClient struct {
3636
infoFunc func() (client.SystemInfoResult, error)
3737
containerStatPathFunc func(containerID, path string) (client.ContainerStatPathResult, error)
3838
containerCopyFromFunc func(containerID, srcPath string) (client.CopyFromContainerResult, error)
39+
containerCopyToFunc func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error)
3940
logFunc func(string, client.ContainerLogsOptions) (client.ContainerLogsResult, error)
4041
waitFunc func(string) client.ContainerWaitResult
4142
containerListFunc func(client.ContainerListOptions) (client.ContainerListResult, error)
@@ -128,6 +129,13 @@ func (f *fakeClient) CopyFromContainer(_ context.Context, containerID string, op
128129
return client.CopyFromContainerResult{}, nil
129130
}
130131

132+
func (f *fakeClient) CopyToContainer(_ context.Context, containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) {
133+
if f.containerCopyToFunc != nil {
134+
return f.containerCopyToFunc(containerID, options)
135+
}
136+
return client.CopyToContainerResult{}, nil
137+
}
138+
131139
func (f *fakeClient) ContainerLogs(_ context.Context, containerID string, options client.ContainerLogsOptions) (client.ContainerLogsResult, error) {
132140
if f.logFunc != nil {
133141
return f.logFunc(containerID, options)

cli/command/container/cp.go

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,34 @@ func progressHumanSize(n int64) string {
168168
return units.HumanSizeWithPrecision(float64(n), 3)
169169
}
170170

171+
// localContentSize returns the total size of regular file content at path.
172+
// For a regular file it returns the file size. For a directory it walks
173+
// the tree and sums sizes of all regular files.
174+
func localContentSize(path string) (int64, error) {
175+
fi, err := os.Lstat(path)
176+
if err != nil {
177+
return -1, err
178+
}
179+
if !fi.IsDir() {
180+
return fi.Size(), nil
181+
}
182+
var total int64
183+
err = filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
184+
if err != nil {
185+
return err
186+
}
187+
if d.Type().IsRegular() {
188+
info, err := d.Info()
189+
if err != nil {
190+
return err
191+
}
192+
total += info.Size()
193+
}
194+
return nil
195+
})
196+
return total, err
197+
}
198+
171199
func runCopy(ctx context.Context, dockerCli command.Cli, opts copyOptions) error {
172200
srcContainer, srcPath := splitCpArg(opts.source)
173201
destContainer, destPath := splitCpArg(opts.destination)
@@ -295,7 +323,13 @@ func copyFromContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cp
295323
cancel()
296324
<-done
297325
restore()
298-
_, _ = fmt.Fprintln(dockerCLI.Err(), "Successfully copied", progressHumanSize(copiedSize), "to", dstPath)
326+
reportedSize := copiedSize
327+
if !cpRes.Stat.Mode.IsDir() {
328+
reportedSize = cpRes.Stat.Size
329+
}
330+
_, _ = fmt.Fprintf(dockerCLI.Err(), "Successfully copied %s (transferred %s) to %s\n",
331+
progressHumanSize(reportedSize), progressHumanSize(copiedSize), dstPath,
332+
)
299333

300334
return res
301335
}
@@ -354,6 +388,8 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo
354388
content io.ReadCloser
355389
resolvedDstPath string
356390
copiedSize int64
391+
contentSize int64
392+
sizeErr error
357393
)
358394

359395
if srcPath == "-" {
@@ -369,6 +405,8 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo
369405
return err
370406
}
371407

408+
contentSize, sizeErr = localContentSize(srcInfo.Path)
409+
372410
srcArchive, err := archive.TarResource(srcInfo)
373411
if err != nil {
374412
return err
@@ -421,7 +459,13 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo
421459
cancel()
422460
<-done
423461
restore()
424-
_, _ = fmt.Fprintln(dockerCLI.Err(), "Successfully copied", progressHumanSize(copiedSize), "to", copyConfig.container+":"+dstInfo.Path)
462+
reportedSize := copiedSize
463+
if sizeErr == nil {
464+
reportedSize = contentSize
465+
}
466+
_, _ = fmt.Fprintf(dockerCLI.Err(), "Successfully copied %s (transferred %s) to %s:%s\n",
467+
progressHumanSize(reportedSize), progressHumanSize(copiedSize), copyConfig.container, dstInfo.Path,
468+
)
425469

426470
return err
427471
}

cli/command/container/cp_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/docker/cli/internal/test"
1212
"github.com/moby/go-archive"
1313
"github.com/moby/go-archive/compression"
14+
"github.com/moby/moby/api/types/container"
1415
"github.com/moby/moby/client"
1516
"gotest.tools/v3/assert"
1617
is "gotest.tools/v3/assert/cmp"
@@ -211,3 +212,154 @@ func TestRunCopyFromContainerToFilesystemIrregularDestination(t *testing.T) {
211212
expected := `"/dev/random" must be a directory or a regular file`
212213
assert.ErrorContains(t, err, expected)
213214
}
215+
216+
func TestCopyFromContainerReportsFileSize(t *testing.T) {
217+
// The file content is "hello" (5 bytes), but the TAR archive wrapping
218+
// it is much larger due to headers and padding. The success message
219+
// should report the actual file size (5B), not the TAR stream size.
220+
srcDir := fs.NewDir(t, "cp-test-from",
221+
fs.WithFile("file1", "hello"))
222+
223+
destDir := fs.NewDir(t, "cp-test-from-dest")
224+
225+
const fileSize int64 = 5
226+
fakeCli := test.NewFakeCli(&fakeClient{
227+
containerCopyFromFunc: func(ctr, srcPath string) (client.CopyFromContainerResult, error) {
228+
readCloser, err := archive.Tar(srcDir.Path(), compression.None)
229+
return client.CopyFromContainerResult{
230+
Content: readCloser,
231+
Stat: container.PathStat{
232+
Name: "file1",
233+
Size: fileSize,
234+
},
235+
}, err
236+
},
237+
})
238+
err := runCopy(context.TODO(), fakeCli, copyOptions{
239+
source: "container:/file1",
240+
destination: destDir.Path(),
241+
})
242+
assert.NilError(t, err)
243+
errOut := fakeCli.ErrBuffer().String()
244+
assert.Check(t, is.Contains(errOut, "Successfully copied 5B"))
245+
assert.Check(t, is.Contains(errOut, "(transferred"))
246+
}
247+
248+
func TestCopyToContainerReportsFileSize(t *testing.T) {
249+
// Create a temp file with known content ("hello" = 5 bytes).
250+
// The TAR archive sent to the container is larger, but the success
251+
// message should report the actual content size.
252+
srcFile := fs.NewFile(t, "cp-test-to", fs.WithContent("hello"))
253+
254+
fakeCli := test.NewFakeCli(&fakeClient{
255+
containerStatPathFunc: func(containerID, path string) (client.ContainerStatPathResult, error) {
256+
return client.ContainerStatPathResult{
257+
Stat: container.PathStat{
258+
Name: "tmp",
259+
Mode: os.ModeDir | 0o755,
260+
},
261+
}, nil
262+
},
263+
containerCopyToFunc: func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) {
264+
_, _ = io.Copy(io.Discard, options.Content)
265+
return client.CopyToContainerResult{}, nil
266+
},
267+
})
268+
err := runCopy(context.TODO(), fakeCli, copyOptions{
269+
source: srcFile.Path(),
270+
destination: "container:/tmp",
271+
})
272+
assert.NilError(t, err)
273+
errOut := fakeCli.ErrBuffer().String()
274+
assert.Check(t, is.Contains(errOut, "Successfully copied 5B"))
275+
assert.Check(t, is.Contains(errOut, "(transferred"))
276+
}
277+
278+
func TestCopyToContainerReportsEmptyFileSize(t *testing.T) {
279+
srcFile := fs.NewFile(t, "cp-test-empty", fs.WithContent(""))
280+
281+
fakeCli := test.NewFakeCli(&fakeClient{
282+
containerStatPathFunc: func(containerID, path string) (client.ContainerStatPathResult, error) {
283+
return client.ContainerStatPathResult{
284+
Stat: container.PathStat{
285+
Name: "tmp",
286+
Mode: os.ModeDir | 0o755,
287+
},
288+
}, nil
289+
},
290+
containerCopyToFunc: func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) {
291+
_, _ = io.Copy(io.Discard, options.Content)
292+
return client.CopyToContainerResult{}, nil
293+
},
294+
})
295+
err := runCopy(context.TODO(), fakeCli, copyOptions{
296+
source: srcFile.Path(),
297+
destination: "container:/tmp",
298+
})
299+
assert.NilError(t, err)
300+
errOut := fakeCli.ErrBuffer().String()
301+
assert.Check(t, is.Contains(errOut, "Successfully copied 0B"))
302+
assert.Check(t, is.Contains(errOut, "(transferred"))
303+
}
304+
305+
func TestCopyToContainerReportsDirectorySize(t *testing.T) {
306+
// Create a temp directory with files "aaa" (3 bytes) + "bbb" (3 bytes) = 6 bytes.
307+
// The TAR archive is much larger, but the success message should report 6B.
308+
srcDir := fs.NewDir(t, "cp-test-dir",
309+
fs.WithFile("aaa", "aaa"),
310+
fs.WithFile("bbb", "bbb"),
311+
)
312+
313+
fakeCli := test.NewFakeCli(&fakeClient{
314+
containerStatPathFunc: func(containerID, path string) (client.ContainerStatPathResult, error) {
315+
return client.ContainerStatPathResult{
316+
Stat: container.PathStat{
317+
Name: "tmp",
318+
Mode: os.ModeDir | 0o755,
319+
},
320+
}, nil
321+
},
322+
containerCopyToFunc: func(containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) {
323+
_, _ = io.Copy(io.Discard, options.Content)
324+
return client.CopyToContainerResult{}, nil
325+
},
326+
})
327+
err := runCopy(context.TODO(), fakeCli, copyOptions{
328+
source: srcDir.Path() + string(os.PathSeparator),
329+
destination: "container:/tmp",
330+
})
331+
assert.NilError(t, err)
332+
errOut := fakeCli.ErrBuffer().String()
333+
assert.Check(t, is.Contains(errOut, "Successfully copied 6B"))
334+
assert.Check(t, is.Contains(errOut, "(transferred"))
335+
}
336+
337+
func TestCopyFromContainerReportsDirectorySize(t *testing.T) {
338+
// When copying a directory from a container, cpRes.Stat.Mode.IsDir() is true,
339+
// so reportedSize falls back to copiedSize (the tar stream bytes).
340+
srcDir := fs.NewDir(t, "cp-test-fromdir",
341+
fs.WithFile("file1", "hello"))
342+
343+
destDir := fs.NewDir(t, "cp-test-fromdir-dest")
344+
345+
fakeCli := test.NewFakeCli(&fakeClient{
346+
containerCopyFromFunc: func(ctr, srcPath string) (client.CopyFromContainerResult, error) {
347+
readCloser, err := archive.Tar(srcDir.Path(), compression.None)
348+
return client.CopyFromContainerResult{
349+
Content: readCloser,
350+
Stat: container.PathStat{
351+
Name: "mydir",
352+
Mode: os.ModeDir | 0o755,
353+
},
354+
}, err
355+
},
356+
})
357+
err := runCopy(context.TODO(), fakeCli, copyOptions{
358+
source: "container:/mydir",
359+
destination: destDir.Path(),
360+
})
361+
assert.NilError(t, err)
362+
errOut := fakeCli.ErrBuffer().String()
363+
assert.Check(t, is.Contains(errOut, "Successfully copied"))
364+
assert.Check(t, is.Contains(errOut, "(transferred"))
365+
}

0 commit comments

Comments
 (0)