Skip to content

Commit eb4ed8a

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 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 #5777 Signed-off-by: 4RH1T3CT0R7 <iprintercanon@gmail.com>
1 parent 7b93d61 commit eb4ed8a

File tree

3 files changed

+224
-2
lines changed

3 files changed

+224
-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: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,37 @@ 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+
if fi.Mode().IsRegular() {
181+
return fi.Size(), nil
182+
}
183+
return 0, nil
184+
}
185+
var total int64
186+
err = filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
187+
if err != nil {
188+
return err
189+
}
190+
if d.Type().IsRegular() {
191+
info, err := d.Info()
192+
if err != nil {
193+
return err
194+
}
195+
total += info.Size()
196+
}
197+
return nil
198+
})
199+
return total, err
200+
}
201+
171202
func runCopy(ctx context.Context, dockerCli command.Cli, opts copyOptions) error {
172203
srcContainer, srcPath := splitCpArg(opts.source)
173204
destContainer, destPath := splitCpArg(opts.destination)
@@ -295,7 +326,19 @@ func copyFromContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cp
295326
cancel()
296327
<-done
297328
restore()
298-
_, _ = fmt.Fprintln(dockerCLI.Err(), "Successfully copied", progressHumanSize(copiedSize), "to", dstPath)
329+
reportedSize := copiedSize
330+
if !cpRes.Stat.Mode.IsDir() {
331+
reportedSize = cpRes.Stat.Size
332+
}
333+
if reportedSize != copiedSize {
334+
_, _ = fmt.Fprintf(dockerCLI.Err(), "Successfully copied %s (transferred %s) to %s\n",
335+
progressHumanSize(reportedSize), progressHumanSize(copiedSize), dstPath,
336+
)
337+
} else {
338+
_, _ = fmt.Fprintf(dockerCLI.Err(), "Successfully copied %s to %s\n",
339+
progressHumanSize(reportedSize), dstPath,
340+
)
341+
}
299342

300343
return res
301344
}
@@ -354,11 +397,14 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo
354397
content io.ReadCloser
355398
resolvedDstPath string
356399
copiedSize int64
400+
contentSize int64
401+
sizeErr error
357402
)
358403

359404
if srcPath == "-" {
360405
content = os.Stdin
361406
resolvedDstPath = dstInfo.Path
407+
sizeErr = errors.New("content size not available for stdin")
362408
if !dstInfo.IsDir {
363409
return fmt.Errorf(`destination "%s:%s" must be a directory`, copyConfig.container, dstPath)
364410
}
@@ -369,6 +415,8 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo
369415
return err
370416
}
371417

418+
contentSize, sizeErr = localContentSize(srcInfo.Path)
419+
372420
srcArchive, err := archive.TarResource(srcInfo)
373421
if err != nil {
374422
return err
@@ -421,7 +469,19 @@ func copyToContainer(ctx context.Context, dockerCLI command.Cli, copyConfig cpCo
421469
cancel()
422470
<-done
423471
restore()
424-
_, _ = fmt.Fprintln(dockerCLI.Err(), "Successfully copied", progressHumanSize(copiedSize), "to", copyConfig.container+":"+dstInfo.Path)
472+
reportedSize := copiedSize
473+
if sizeErr == nil {
474+
reportedSize = contentSize
475+
}
476+
if reportedSize != copiedSize {
477+
_, _ = fmt.Fprintf(dockerCLI.Err(), "Successfully copied %s (transferred %s) to %s:%s\n",
478+
progressHumanSize(reportedSize), progressHumanSize(copiedSize), copyConfig.container, dstInfo.Path,
479+
)
480+
} else {
481+
_, _ = fmt.Fprintf(dockerCLI.Err(), "Successfully copied %s to %s:%s\n",
482+
progressHumanSize(reportedSize), copyConfig.container, dstInfo.Path,
483+
)
484+
}
425485

426486
return err
427487
}

cli/command/container/cp_test.go

Lines changed: 154 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,156 @@ 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+
// For directories from container, content size is unknown so
365+
// reportedSize == copiedSize and "(transferred ...)" is omitted.
366+
assert.Check(t, !strings.Contains(errOut, "(transferred"))
367+
}

0 commit comments

Comments
 (0)