From 7d17a1044fcc2a70763a245db3cd3c97dd0cc42f Mon Sep 17 00:00:00 2001 From: apostasie Date: Sat, 17 Aug 2024 14:11:00 -0700 Subject: [PATCH] Rewrite cp Signed-off-by: apostasie --- cmd/nerdctl/container_cp_acid_linux_test.go | 170 +++ cmd/nerdctl/container_cp_linux.go | 8 +- cmd/nerdctl/container_cp_linux_test.go | 1277 +++++++++++++------ pkg/cmd/container/cp_linux.go | 20 +- pkg/containerutil/cp_linux.go | 379 +++--- pkg/containerutil/cp_resolve.go | 436 +++++++ pkg/containerutil/cp_resolve_other.go | 24 + pkg/imgutil/filtering_test.go | 3 +- pkg/testutil/testutil.go | 2 + 9 files changed, 1756 insertions(+), 563 deletions(-) create mode 100644 cmd/nerdctl/container_cp_acid_linux_test.go create mode 100644 pkg/containerutil/cp_resolve.go create mode 100644 pkg/containerutil/cp_resolve_other.go diff --git a/cmd/nerdctl/container_cp_acid_linux_test.go b/cmd/nerdctl/container_cp_acid_linux_test.go new file mode 100644 index 00000000000..9d34c863a72 --- /dev/null +++ b/cmd/nerdctl/container_cp_acid_linux_test.go @@ -0,0 +1,170 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" + "gotest.tools/v3/icmd" + + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" +) + +// This is a separate set of tests for cp specifically meant to test corner or extreme cases that do not fit in the normal testing rig +// because of their complexity + +func TestAcidCopy(t *testing.T) { + t.Parallel() + + t.Run("Travelling along volumes w/o read-only", func(t *testing.T) { + t.Parallel() + testID := testutil.Identifier(t) + tempDir := t.TempDir() + base := testutil.NewBase(t) + base.Dir = tempDir + + sourceFile := filepath.Join(tempDir, "hostfile") + sourceFileContent := []byte(testID) + + roContainer := testID + "-ro" + rwContainer := testID + "-rw" + + setup := func() { + base.Cmd("volume", "create", testID+"-1-ro").AssertOK() + base.Cmd("volume", "create", testID+"-2-rw").AssertOK() + base.Cmd("volume", "create", testID+"-3-rw").AssertOK() + base.Cmd("run", "-d", "-w", containerCwd, "--name", roContainer, "--read-only", + "-v", fmt.Sprintf("%s:%s:ro", testID+"-1-ro", "/vol1/dir1/ro"), + "-v", fmt.Sprintf("%s:%s", testID+"-2-rw", "/vol2/dir2/rw"), + testutil.CommonImage, "sleep", "Inf", + ).AssertOK() + base.Cmd("run", "-d", "-w", containerCwd, "--name", rwContainer, + "-v", fmt.Sprintf("%s:%s:ro", testID+"-1-ro", "/vol1/dir1/ro"), + "-v", fmt.Sprintf("%s:%s", testID+"-3-rw", "/vol3/dir3/rw"), + testutil.CommonImage, "sleep", "Inf", + ).AssertOK() + + base.Cmd("exec", rwContainer, "sh", "-euxc", "cd /vol3/dir3/rw; ln -s ../../../ relativelinktoroot").AssertOK() + base.Cmd("exec", rwContainer, "sh", "-euxc", "cd /vol3/dir3/rw; ln -s / absolutelinktoroot").AssertOK() + base.Cmd("exec", roContainer, "sh", "-euxc", "cd /vol2/dir2/rw; ln -s ../../../ relativelinktoroot").AssertOK() + base.Cmd("exec", roContainer, "sh", "-euxc", "cd /vol2/dir2/rw; ln -s / absolutelinktoroot").AssertOK() + // Create file on the host + err := os.WriteFile(sourceFile, sourceFileContent, filePerm) + assert.NilError(t, err) + } + + tearDown := func() { + base.Cmd("rm", "-f", roContainer).Run() + base.Cmd("rm", "-f", rwContainer).Run() + base.Cmd("volume", "rm", testID+"-1-ro").Run() + base.Cmd("volume", "rm", testID+"-2-rw").Run() + base.Cmd("volume", "rm", testID+"-3-rw").Run() + } + + t.Cleanup(tearDown) + tearDown() + + setup() + + t.Run("Cannot copy into a read-only root", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, roContainer+":/").Assert(icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrTargetIsReadOnly.Error(), + }) + }) + + t.Run("Cannot copy into a read-only mount, in a rw container", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, rwContainer+":/vol1/dir1/ro").Assert(icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrTargetIsReadOnly.Error(), + }) + }) + + t.Run("Can copy into a read-write mount in a read-only container", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, roContainer+":/vol2/dir2/rw").Assert(icmd.Expected{ + ExitCode: 0, + }) + }) + + t.Run("Traverse read-only locations to a read-write location", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, roContainer+":/vol1/dir1/ro/../../../vol2/dir2/rw").Assert(icmd.Expected{ + ExitCode: 0, + }) + }) + + t.Run("Follow an absolute symlink inside a read-write mount to a read-only root", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, roContainer+":/vol2/dir2/rw/absolutelinktoroot").Assert(icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrTargetIsReadOnly.Error(), + }) + }) + + t.Run("Follow am absolute symlink inside a read-write mount to a read-only mount", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, rwContainer+":/vol3/dir3/rw/absolutelinktoroot/vol1/dir1/ro").Assert(icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrTargetIsReadOnly.Error(), + }) + }) + + t.Run("Follow a relative symlink inside a read-write location to a read-only root", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, roContainer+":/vol2/dir2/rw/relativelinktoroot").Assert(icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrTargetIsReadOnly.Error(), + }) + }) + + t.Run("Follow a relative symlink inside a read-write location to a read-only mount", func(t *testing.T) { + t.Parallel() + + base.Cmd("cp", sourceFile, rwContainer+":/vol3/dir3/rw/relativelinktoroot/vol1/dir1/ro").Assert(icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrTargetIsReadOnly.Error(), + }) + }) + + t.Run("Cannot copy into a HOST read-only location", func(t *testing.T) { + t.Parallel() + + err := os.MkdirAll(filepath.Join(tempDir, "rotest"), 0o000) + assert.NilError(t, err) + base.Cmd("cp", roContainer+":/etc/issue", filepath.Join(tempDir, "rotest")).Assert(icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrTargetIsReadOnly.Error(), + }) + }) + + }) +} diff --git a/cmd/nerdctl/container_cp_linux.go b/cmd/nerdctl/container_cp_linux.go index 3a2c2aa84e1..824c6f7ec04 100644 --- a/cmd/nerdctl/container_cp_linux.go +++ b/cmd/nerdctl/container_cp_linux.go @@ -111,16 +111,16 @@ func processCpOptions(cmd *cobra.Command, args []string) (types.ContainerCpOptio } container2host := srcSpec.Container != nil - var container string + var containerReq string if container2host { - container = *srcSpec.Container + containerReq = *srcSpec.Container } else { - container = *destSpec.Container + containerReq = *destSpec.Container } return types.ContainerCpOptions{ GOptions: globalOptions, Container2Host: container2host, - ContainerReq: container, + ContainerReq: containerReq, DestPath: destSpec.Path, SrcPath: srcSpec.Path, FollowSymLink: flagL, diff --git a/cmd/nerdctl/container_cp_linux_test.go b/cmd/nerdctl/container_cp_linux_test.go index 33f59ba45d3..cf9034f916c 100644 --- a/cmd/nerdctl/container_cp_linux_test.go +++ b/cmd/nerdctl/container_cp_linux_test.go @@ -25,396 +25,933 @@ import ( "testing" "gotest.tools/v3/assert" + "gotest.tools/v3/icmd" + "github.com/containerd/nerdctl/v2/pkg/containerutil" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" "github.com/containerd/nerdctl/v2/pkg/testutil" ) +// For the test matrix, see https://docs.docker.com/engine/reference/commandline/cp/ +// Obviously, none of this is fully windows ready - obviously `nerdctl cp` itself is not either, so, ok for now. +const ( + // Use this to poke the testing rig for improper path handling + // TODO: fuzz this more seriously + // FIXME: the following will break the test (anything that will evaluate on the shell, obviously): + // - ` + // - $a, ${a}, etc + complexify = "" // = "-~a0-_.(){}[]*#! \"'∞" + + pathDoesNotExistRelative = "does-not-exist" + complexify + pathDoesNotExistAbsolute = string(os.PathSeparator) + "does-not-exist" + complexify + pathIsAFileRelative = "is-a-file" + complexify + pathIsAFileAbsolute = string(os.PathSeparator) + "is-a-file" + complexify + pathIsADirRelative = "is-a-dir" + complexify + pathIsADirAbsolute = string(os.PathSeparator) + "is-a-dir" + complexify + pathIsAVolumeMount = string(os.PathSeparator) + "is-a-volume-mount" + complexify + + srcFileName = "test-file" + complexify + + // Since nerdctl cp must NOT obey container wd, but instead resolve paths against the root, we set this + // explicitly to ensure we do the right thing wrt that. + containerCwd = "/nerdctl/cp/test" + + dirPerm = 0o755 + filePerm = 0o644 +) + +var srcDirName = filepath.Join("three-levels-src-dir", "test-dir", "dir"+complexify) + +type testgroup struct { + description string // parent test description + toContainer bool // copying to, or from container + + // sourceSpec as specified by the user (without the container: part) - can be relative or absolute - + // if sourceSpec points to a file, you must use srcFileName for filename + sourceSpec string + sourceIsAFile bool // whether the provided sourceSpec points to a file or a dir + testCases []testcases // testcases +} + +type testcases struct { + description string // textual description of what the test is doing + destinationSpec string // destination path as specified by the user (without the container: part) - can be relative or absolute + expect icmd.Expected // expectation + + // Optional + catFile string // path that we "cat" - defaults to destinationSpec if not specified + setup func(base *testutil.Base, container string, destPath string) // additional test setup if needed + tearDown func() // additional cleanup if needed + volume func(base *testutil.Base, id string) (string, string, bool) // volume creation function if needed (should return the volume name, mountPoint, readonly flag) +} + func TestCopyToContainer(t *testing.T) { t.Parallel() - base := testutil.NewBase(t) - testContainer := testutil.Identifier(t) - testStoppedContainer := "stopped-container-" + testutil.Identifier(t) - - base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", "1h").AssertOK() - defer base.Cmd("rm", "-f", testContainer).Run() - - base.Cmd("run", "-d", "--name", testStoppedContainer, testutil.CommonImage, "sleep", "1h").AssertOK() - defer base.Cmd("rm", "-f", testStoppedContainer).Run() - // Stop container immediately after starting for testing copying into stopped container - base.Cmd("stop", testStoppedContainer).AssertOK() - srcUID := os.Geteuid() - srcDir := t.TempDir() - srcFile := filepath.Join(srcDir, "test-file") - srcFileContent := []byte("test-file-content") - err := os.WriteFile(srcFile, srcFileContent, 0o644) - assert.NilError(t, err) - - assertCat := func(catPath string, testContainer string, stopped bool) { - if stopped { - base.Cmd("start", testContainer).AssertOK() - defer base.Cmd("stop", testContainer).AssertOK() - } - t.Logf("catPath=%q", catPath) - base.Cmd("exec", testContainer, "cat", catPath).AssertOutExactly(string(srcFileContent)) - base.Cmd("exec", testContainer, "stat", "-c", "%u", catPath).AssertOutExactly(fmt.Sprintf("%d\n", srcUID)) + + testGroups := []*testgroup{ + { + description: "Copying to container, SRC_PATH is a file, absolute", + sourceSpec: filepath.Join(string(os.PathSeparator), srcDirName, srcFileName), + sourceIsAFile: true, + toContainer: true, + testCases: []testcases{ + { + description: "DEST_PATH does not exist, relative", + destinationSpec: pathDoesNotExistRelative, + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, absolute", + destinationSpec: pathDoesNotExistAbsolute, + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, relative, and ends with " + string(os.PathSeparator), + destinationSpec: pathDoesNotExistRelative + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationDirMustExist.Error(), + }, + }, + { + description: "DEST_PATH does not exist, absolute, and ends with " + string(os.PathSeparator), + destinationSpec: pathDoesNotExistAbsolute + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationDirMustExist.Error(), + }, + }, + + { + description: "DEST_PATH is a file, relative", + destinationSpec: pathIsAFileRelative, + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a file, absolute", + destinationSpec: pathIsAFileAbsolute, + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a file, relative, ends with improper " + string(os.PathSeparator), + destinationSpec: pathIsAFileRelative + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a file, absolute, ends with improper " + string(os.PathSeparator), + destinationSpec: pathIsAFileAbsolute + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + // FIXME: it is unclear why the code path with absolute (this test) versus relative (just above) + // yields a different error. Both should ideally be ErrCannotCopyDirToFile + // This is probably happening somewhere in resolve. + // This is not a deal killer, as both DO error with a reasonable explanation, but a bit + // frustrating + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, relative", + destinationSpec: pathIsADirRelative, + catFile: filepath.Join(pathIsADirRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, absolute", + destinationSpec: pathIsADirAbsolute, + catFile: filepath.Join(pathIsADirAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, relative, ends with " + string(os.PathSeparator), + destinationSpec: pathIsADirRelative + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, absolute, ends with " + string(os.PathSeparator), + destinationSpec: pathIsADirAbsolute + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a volume mount-point", + destinationSpec: pathIsAVolumeMount, + catFile: filepath.Join(pathIsAVolumeMount, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + // FIXME the way we handle volume is not right - too complicated for the test author + volume: func(base *testutil.Base, id string) (string, string, bool) { + base.Cmd("volume", "create", id).Run() + return id, pathIsAVolumeMount, false + }, + }, + { + description: "DEST_PATH is a read-only volume mount-point", + destinationSpec: pathIsAVolumeMount, + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrTargetIsReadOnly.Error(), + }, + volume: func(base *testutil.Base, id string) (string, string, bool) { + base.Cmd("volume", "create", id).Run() + return id, pathIsAVolumeMount, true + }, + }, + }, + }, + { + description: "Copying to container, SRC_PATH is a directory", + sourceSpec: srcDirName, + toContainer: true, + testCases: []testcases{ + { + description: "DEST_PATH does not exist, relative", + destinationSpec: pathDoesNotExistRelative, + catFile: filepath.Join(pathDoesNotExistRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, absolute", + destinationSpec: pathDoesNotExistAbsolute, + catFile: filepath.Join(pathDoesNotExistAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, relative, and ends with " + string(os.PathSeparator), + destinationSpec: pathDoesNotExistRelative + string(os.PathSeparator), + catFile: filepath.Join(pathDoesNotExistRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, absolute, and ends with " + string(os.PathSeparator), + destinationSpec: pathDoesNotExistAbsolute + string(os.PathSeparator), + catFile: filepath.Join(pathDoesNotExistAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH is a file, relative", + destinationSpec: pathIsAFileRelative, + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrCannotCopyDirToFile.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a file, absolute", + destinationSpec: pathIsAFileAbsolute, + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrCannotCopyDirToFile.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a file, relative, ends with improper " + string(os.PathSeparator), + destinationSpec: pathIsAFileRelative + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a file, absolute, ends with improper " + string(os.PathSeparator), + destinationSpec: pathIsAFileAbsolute + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + // FIXME: it is unclear why the code path with absolute (this test) versus relative (just above) + // yields a different error. Both should ideally be ErrCannotCopyDirToFile + // This is probably happening somewhere in resolve. + // This is not a deal killer, as both DO error with a reasonable explanation, but a bit + // frustrating + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "touch", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, relative", + destinationSpec: pathIsADirRelative, + catFile: filepath.Join(pathIsADirRelative, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, absolute", + destinationSpec: pathIsADirAbsolute, + catFile: filepath.Join(pathIsADirAbsolute, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, relative, ends with " + string(os.PathSeparator), + destinationSpec: pathIsADirRelative + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirRelative, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, absolute, ends with " + string(os.PathSeparator), + destinationSpec: pathIsADirAbsolute + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirAbsolute, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + }, + }, + { + description: "Copying to container, SRC_PATH is a directory ending with /.", + sourceSpec: srcDirName + string(os.PathSeparator) + ".", + toContainer: true, + testCases: []testcases{ + { + description: "DEST_PATH is a directory, relative", + destinationSpec: pathIsADirRelative, + catFile: filepath.Join(pathIsADirRelative, srcFileName), + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + { + description: "DEST_PATH is a directory, absolute", + destinationSpec: pathIsADirAbsolute, + catFile: filepath.Join(pathIsADirAbsolute, srcFileName), + setup: func(base *testutil.Base, container string, destPath string) { + base.Cmd("exec", container, "mkdir", "-p", destPath).AssertOK() + }, + }, + }, + }, } - // For the test matrix, see https://docs.docker.com/engine/reference/commandline/cp/ - t.Run("SRC_PATH specifies a file", func(t *testing.T) { - srcPath := srcFile - t.Run("DEST_PATH does not exist", func(t *testing.T) { - destPath := "/dest-no-exist-no-slash" - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertOK() - catPath := destPath - assertCat(catPath, testContainer, false) - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("cp", srcPath, testStoppedContainer+":"+destPath).AssertOK() - assertCat(catPath, testStoppedContainer, true) - }) - t.Run("DEST_PATH does not exist and ends with /", func(t *testing.T) { - destPath := "/dest-no-exist-with-slash/" - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertFail() - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("cp", srcPath, testStoppedContainer+":"+destPath).AssertFail() - }) - t.Run("DEST_PATH exists and is a file", func(t *testing.T) { - destPath := "/dest-file-exists" - base.Cmd("exec", testContainer, "touch", destPath).AssertOK() - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertOK() - catPath := destPath - assertCat(catPath, testContainer, false) - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("cp", srcPath, testStoppedContainer+":"+destPath).AssertOK() - assertCat(catPath, testStoppedContainer, true) - }) - t.Run("DEST_PATH exists and is a directory", func(t *testing.T) { - destPath := "/dest-dir-exists" - base.Cmd("exec", testContainer, "mkdir", "-p", destPath).AssertOK() - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertOK() - catPath := filepath.Join(destPath, filepath.Base(srcFile)) - assertCat(catPath, testContainer, false) - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("start", testStoppedContainer).AssertOK() - base.Cmd("exec", testStoppedContainer, "mkdir", "-p", destPath).AssertOK() - base.Cmd("stop", testStoppedContainer).AssertOK() - base.Cmd("cp", srcPath, testStoppedContainer+":"+destPath).AssertOK() - assertCat(catPath, testStoppedContainer, true) - }) - t.Run("DEST_PATH is in a volume", func(t *testing.T) { - // Create a volume - vol := "somevol" - base.Cmd("volume", "create", vol).AssertOK() - defer base.Cmd("volume", "rm", vol).Run() - con := fmt.Sprintf("%s-with-volume", testContainer) - mountDir := "/some_dir" - base.Cmd("run", "-d", "--name", con, "-v", fmt.Sprintf("%s:%s", vol, mountDir), testutil.CommonImage, "sleep", "1h").AssertOK() - defer base.Cmd("rm", "-f", con).Run() - catPath := filepath.Join(mountDir, filepath.Base(srcFile)) - // Running container test - base.Cmd("cp", srcPath, con+":"+mountDir).AssertOK() - assertCat(catPath, con, false) - - // Skip for rootless - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - // Stopped container test - // Delete previously copied file - base.Cmd("exec", con, "rm", catPath).AssertOK() - base.Cmd("stop", con).AssertOK() - base.Cmd("cp", srcPath, con+":"+mountDir).AssertOK() - assertCat(catPath, con, true) - }) - t.Run("Destination path is a read-only", func(t *testing.T) { - vol := "somevol" - base.Cmd("volume", "create", vol).AssertOK() - defer base.Cmd("volume", "rm", vol).Run() - con := fmt.Sprintf("%s-with-read-only-volume", testContainer) - mountDir := "/some_dir" - // Create container with read-only volume mounted - base.Cmd("run", "-d", "--name", con, "-v", fmt.Sprintf("%s:%s:ro", vol, mountDir), testutil.CommonImage, "sleep", "1h").AssertOK() - defer base.Cmd("rm", "-f", con).Run() - base.Cmd("cp", srcPath, con+":"+mountDir).AssertFail() - - // Skip for rootless - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - - // Stopped container test - // Delete previously copied file - base.Cmd("stop", con).AssertOK() - base.Cmd("cp", srcPath, con+":"+mountDir).AssertFail() - }) - t.Run("Destination path is a read-only and default tmpfs mount point", func(t *testing.T) { - vol := "somevol" - base.Cmd("volume", "create", vol).AssertOK() - defer base.Cmd("volume", "rm", vol).Run() - con := fmt.Sprintf("%s-with-read-only-volume", testContainer) - - // /tmp is from rootfs of alpine - mountDir := "/tmp" - // Create container with read-only mounted volume mounted at /tmp - base.Cmd("run", "-d", "--name", con, "-v", fmt.Sprintf("%s:%s:ro", vol, mountDir), testutil.CommonImage, "sleep", "1h").AssertOK() - defer base.Cmd("rm", "-f", con).Run() - base.Cmd("cp", srcPath, con+":"+mountDir).AssertFail() - - // Skip for rootless - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - - // Stopped container test - // Delete previously copied file - base.Cmd("stop", con).AssertOK() - base.Cmd("cp", srcPath, con+":"+mountDir).AssertFail() - }) - }) - t.Run("SRC_PATH specifies a directory", func(t *testing.T) { - srcPath := srcDir - t.Run("DEST_PATH does not exist", func(t *testing.T) { - destPath := "/dest2-no-exist" - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertOK() - catPath := filepath.Join(destPath, filepath.Base(srcFile)) - assertCat(catPath, testContainer, false) - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("cp", srcPath, testStoppedContainer+":"+destPath).AssertOK() - assertCat(catPath, testStoppedContainer, true) - }) - t.Run("DEST_PATH exists and is a file", func(t *testing.T) { - destPath := "/dest2-file-exists" - base.Cmd("exec", testContainer, "touch", destPath).AssertOK() - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertFail() - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("start", testStoppedContainer).AssertOK() - base.Cmd("exec", testStoppedContainer, "touch", destPath).AssertOK() - base.Cmd("stop", testStoppedContainer).AssertOK() - base.Cmd("cp", srcPath, testStoppedContainer+":"+destPath).AssertFail() - }) - t.Run("DEST_PATH exists and is a directory", func(t *testing.T) { - t.Run("SRC_PATH does not end with `/.`", func(t *testing.T) { - destPath := "/dest2-dir-exists" - base.Cmd("exec", testContainer, "mkdir", "-p", destPath).AssertOK() - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertOK() - catPath := filepath.Join(destPath, strings.TrimPrefix(srcFile, filepath.Dir(srcDir)+"/")) - assertCat(catPath, testContainer, false) - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("start", testStoppedContainer).AssertOK() - base.Cmd("exec", testStoppedContainer, "mkdir", "-p", destPath).AssertOK() - base.Cmd("stop", testStoppedContainer).AssertOK() - base.Cmd("cp", srcPath, testStoppedContainer+":"+destPath).AssertOK() - assertCat(catPath, testStoppedContainer, true) - }) - t.Run("SRC_PATH does end with `/.`", func(t *testing.T) { - srcPath += "/." - destPath := "/dest2-dir2-exists" - base.Cmd("exec", testContainer, "mkdir", "-p", destPath).AssertOK() - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertOK() - catPath := filepath.Join(destPath, filepath.Base(srcFile)) - t.Logf("catPath=%q", catPath) - assertCat(catPath, testContainer, false) - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("start", testStoppedContainer).AssertOK() - base.Cmd("exec", testStoppedContainer, "mkdir", "-p", destPath).AssertOK() - base.Cmd("stop", testStoppedContainer).AssertOK() - base.Cmd("cp", srcPath, testStoppedContainer+":"+destPath).AssertOK() - assertCat(catPath, testStoppedContainer, true) - }) - }) - }) + for _, tg := range testGroups { + cpTestHelper(t, tg) + } } func TestCopyFromContainer(t *testing.T) { t.Parallel() - base := testutil.NewBase(t) - testContainer := testutil.Identifier(t) - testStoppedContainer := "stopped-container-" + testutil.Identifier(t) - base.Cmd("run", "-d", "--name", testContainer, testutil.CommonImage, "sleep", "1h").AssertOK() - defer base.Cmd("rm", "-f", testContainer).Run() - - base.Cmd("run", "-d", "--name", testStoppedContainer, testutil.CommonImage, "sleep", "1h").AssertOK() - defer base.Cmd("rm", "-f", testStoppedContainer).Run() - - euid := os.Geteuid() - srcUID := 42 - srcDir := "/test-dir" - srcFile := filepath.Join(srcDir, "test-file") - srcFileContent := []byte("test-file-content") - mkSrcScript := fmt.Sprintf("mkdir -p %q && echo -n %q >%q && chown %d %q", srcDir, srcFileContent, srcFile, srcUID, srcFile) - base.Cmd("exec", testContainer, "sh", "-euc", mkSrcScript).AssertOK() - base.Cmd("exec", testStoppedContainer, "sh", "-euc", mkSrcScript).AssertOK() - // Stop container for testing copying out of stopped container - base.Cmd("stop", testStoppedContainer) - - assertCat := func(catPath string) { - t.Logf("catPath=%q", catPath) + + testGroups := []*testgroup{ + { + description: "Copying from container, SRC_PATH specifies a file", + sourceSpec: srcFileName, + sourceIsAFile: true, + testCases: []testcases{ + { + description: "DEST_PATH does not exist, relative", + destinationSpec: pathDoesNotExistRelative, + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, absolute", + destinationSpec: pathDoesNotExistAbsolute, + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, relative, and ends with a path separator", + destinationSpec: pathDoesNotExistRelative + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationDirMustExist.Error(), + }, + }, + { + description: "DEST_PATH does not exist, absolute, and ends with a path separator", + destinationSpec: pathDoesNotExistAbsolute + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationDirMustExist.Error(), + }, + }, + { + description: "DEST_PATH is a file, relative", + destinationSpec: pathIsAFileRelative, + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a file, absolute", + destinationSpec: pathIsAFileAbsolute, + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a file, relative, improperly ends with a separator", + destinationSpec: pathIsAFileRelative + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a file, absolute, improperly ends with a separator", + destinationSpec: pathIsAFileAbsolute + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, relative", + destinationSpec: pathIsADirRelative, + catFile: filepath.Join(pathIsADirRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, absolute", + destinationSpec: pathIsADirAbsolute, + catFile: filepath.Join(pathIsADirAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, relative, ending with a path separator", + destinationSpec: pathIsADirRelative + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, absolute, ending with a path separator", + destinationSpec: pathIsADirAbsolute + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + }, + }, + { + description: "Copying from container, SRC_PATH specifies a dir", + sourceSpec: srcDirName, + testCases: []testcases{ + { + description: "DEST_PATH does not exist, relative", + destinationSpec: pathDoesNotExistRelative, + catFile: filepath.Join(pathDoesNotExistRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, absolute", + destinationSpec: pathDoesNotExistAbsolute, + catFile: filepath.Join(pathDoesNotExistAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, relative, ends with path separator", + destinationSpec: pathDoesNotExistRelative + string(os.PathSeparator), + catFile: filepath.Join(pathDoesNotExistRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH does not exist, absolute, ends with path separator", + destinationSpec: pathDoesNotExistAbsolute + string(os.PathSeparator), + catFile: filepath.Join(pathDoesNotExistAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + }, + { + description: "DEST_PATH is a file, relative", + destinationSpec: pathIsAFileRelative, + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrCannotCopyDirToFile.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(filepath.Dir(destPath), dirPerm) + assert.NilError(t, err) + err = os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a file, absolute", + destinationSpec: pathIsAFileAbsolute, + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrCannotCopyDirToFile.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(filepath.Dir(destPath), dirPerm) + assert.NilError(t, err) + err = os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a file, relative, improperly ends with path separator", + destinationSpec: pathIsAFileRelative + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(filepath.Dir(destPath), dirPerm) + assert.NilError(t, err) + err = os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a file, absolute, improperly ends with path separator", + destinationSpec: pathIsAFileAbsolute + string(os.PathSeparator), + expect: icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrDestinationIsNotADir.Error(), + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(filepath.Dir(destPath), dirPerm) + assert.NilError(t, err) + err = os.WriteFile(destPath, []byte(""), filePerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, relative", + destinationSpec: pathIsADirRelative, + catFile: filepath.Join(pathIsADirRelative, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, absolute", + destinationSpec: pathIsADirAbsolute, + catFile: filepath.Join(pathIsADirAbsolute, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, relative, ends with path separator", + destinationSpec: pathIsADirRelative + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirRelative, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, absolute, ends with path separator", + destinationSpec: pathIsADirAbsolute + string(os.PathSeparator), + catFile: filepath.Join(pathIsADirAbsolute, filepath.Base(srcDirName), srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + }, + }, + + { + description: "SRC_PATH is a dir, with a trailing slash/dot", + sourceSpec: srcDirName + string(os.PathSeparator) + ".", + testCases: []testcases{ + { + description: "DEST_PATH is a directory, relative", + destinationSpec: pathIsADirRelative, + catFile: filepath.Join(pathIsADirRelative, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + { + description: "DEST_PATH is a directory, absolute", + destinationSpec: pathIsADirAbsolute, + catFile: filepath.Join(pathIsADirAbsolute, srcFileName), + expect: icmd.Expected{ + ExitCode: 0, + }, + setup: func(base *testutil.Base, container string, destPath string) { + err := os.MkdirAll(destPath, dirPerm) + assert.NilError(t, err) + }, + }, + }, + }, + } + + for _, tg := range testGroups { + cpTestHelper(t, tg) + } +} + +func assertCatHelper(base *testutil.Base, catPath string, fileContent []byte, container string, expectedUID int, containerIsStopped bool) { + base.T.Logf("catPath=%q", catPath) + if container != "" && containerIsStopped { + base.Cmd("start", container).AssertOK() + defer base.Cmd("stop", container).AssertOK() + } + + if container == "" { got, err := os.ReadFile(catPath) - assert.NilError(t, err) - assert.DeepEqual(t, srcFileContent, got) + assert.NilError(base.T, err, "Failed reading from file") + assert.DeepEqual(base.T, fileContent, got) st, err := os.Stat(catPath) - assert.NilError(t, err) + assert.NilError(base.T, err) stSys := st.Sys().(*syscall.Stat_t) - // stSys.Uid matches euid, not srcUID - assert.DeepEqual(t, uint32(euid), stSys.Uid) + expected := uint32(expectedUID) + actual := stSys.Uid + assert.DeepEqual(base.T, expected, actual) + } else { + base.Cmd("exec", container, "sh", "-c", "--", fmt.Sprintf("ls -lA /; echo %q; cat %q", catPath, catPath)).AssertOutContains(string(fileContent)) + base.Cmd("exec", container, "stat", "-c", "%u", catPath).AssertOutExactly(fmt.Sprintf("%d\n", expectedUID)) } +} - td := t.TempDir() - // For the test matrix, see https://docs.docker.com/engine/reference/commandline/cp/ - t.Run("SRC_PATH specifies a file", func(t *testing.T) { - srcPath := srcFile - t.Run("DEST_PATH does not exist", func(t *testing.T) { - destPath := filepath.Join(td, "dest-no-exist-no-slash") - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertOK() - catPath := destPath - assertCat(catPath) - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("cp", testStoppedContainer+":"+srcPath, destPath).AssertOK() - assertCat(catPath) - }) - t.Run("DEST_PATH does not exist and ends with /", func(t *testing.T) { - destPath := td + "/dest-no-exist-with-slash/" // Avoid filepath.Join, to forcibly append "/" - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertFail() - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("cp", testStoppedContainer+":"+srcPath, destPath).AssertFail() - }) - t.Run("DEST_PATH exists and is a file", func(t *testing.T) { - destPath := filepath.Join(td, "dest-file-exists") - err := os.WriteFile(destPath, []byte(""), 0o644) - assert.NilError(t, err) - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertOK() - catPath := destPath - assertCat(catPath) - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("cp", testStoppedContainer+":"+srcPath, destPath).AssertOK() - assertCat(catPath) - }) - t.Run("DEST_PATH exists and is a directory", func(t *testing.T) { - destPath := filepath.Join(td, "dest-dir-exists") - err := os.Mkdir(destPath, 0o755) - assert.NilError(t, err) - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertOK() - catPath := filepath.Join(destPath, filepath.Base(srcFile)) - assertCat(catPath) - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("cp", testStoppedContainer+":"+srcPath, destPath).AssertOK() - assertCat(catPath) - }) - t.Run("SRC_PATH is in a volume", func(t *testing.T) { - // Setup - // Create a volume - vol := "somevol" - base.Cmd("volume", "create", vol).AssertOK() - defer base.Cmd("volume", "rm", "-f", vol).Run() - - // Create container for test - con := fmt.Sprintf("%s-with-volume", testContainer) - - mountDir := "/some_dir" - base.Cmd("run", "-d", "--name", con, "-v", fmt.Sprintf("%s:%s", vol, mountDir), testutil.CommonImage, "sleep", "1h").AssertOK() - defer base.Cmd("rm", "-f", con).Run() - - // Create a file to mounted volume - mountedVolFile := filepath.Join(mountDir, "test-file") - mkSrcScript = fmt.Sprintf("echo -n %q >%q && chown %d %q", srcFileContent, mountedVolFile, srcUID, mountedVolFile) - base.Cmd("exec", con, "sh", "-euc", mkSrcScript).AssertOK() - - // Create destination directory on host for copy - destPath := filepath.Join(td, "dest-dir") - err := os.Mkdir(destPath, 0o700) - assert.NilError(t, err) - - catPath := filepath.Join(destPath, filepath.Base(mountedVolFile)) - - // Running container test - base.Cmd("cp", con+":"+mountedVolFile, destPath).AssertOK() - assertCat(catPath) - - // Skip for rootless - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - // Stopped container test - base.Cmd("stop", con).AssertOK() - base.Cmd("cp", con+":"+mountedVolFile, destPath).AssertOK() - assertCat(catPath) - }) - }) - t.Run("SRC_PATH specifies a directory", func(t *testing.T) { - srcPath := srcDir - t.Run("DEST_PATH does not exist", func(t *testing.T) { - destPath := filepath.Join(td, "dest2-no-exist") - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertOK() - catPath := filepath.Join(destPath, filepath.Base(srcFile)) - assertCat(catPath) - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("cp", testStoppedContainer+":"+srcPath, destPath).AssertOK() - assertCat(catPath) - }) - t.Run("DEST_PATH exists and is a file", func(t *testing.T) { - destPath := filepath.Join(td, "dest2-file-exists") - err := os.WriteFile(destPath, []byte(""), 0o644) - assert.NilError(t, err) - base.Cmd("cp", srcPath, testContainer+":"+destPath).AssertFail() - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") - } - base.Cmd("cp", srcPath, testStoppedContainer+":"+destPath).AssertFail() - }) - t.Run("DEST_PATH exists and is a directory", func(t *testing.T) { - t.Run("SRC_PATH does not end with `/.`", func(t *testing.T) { - destPath := filepath.Join(td, "dest2-dir-exists") - err := os.Mkdir(destPath, 0o755) - assert.NilError(t, err) - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertOK() - catPath := filepath.Join(destPath, strings.TrimPrefix(srcFile, filepath.Dir(srcDir)+"/")) - assertCat(catPath) - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") +func cpTestHelper(t *testing.T, tg *testgroup) { + // Get the source path + groupSourceSpec := tg.sourceSpec + groupSourceDir := groupSourceSpec + if tg.sourceIsAFile { + groupSourceDir = filepath.Dir(groupSourceSpec) + } + + // Copy direction + copyToContainer := tg.toContainer + // Description + description := tg.description + // Test cases + testCases := tg.testCases + + // Compute UIDs dependent on cp direction + var srcUID, destUID int + if copyToContainer { + srcUID = os.Geteuid() + destUID = srcUID + } else { + srcUID = 42 + destUID = os.Geteuid() + } + + t.Run(description, func(t *testing.T) { + t.Parallel() + + for _, tc := range testCases { + testCase := tc + + t.Run(testCase.description, func(t *testing.T) { + t.Parallel() + + // Compute test-specific values + testID := testutil.Identifier(t) + containerRunning := testID + "-r" + containerStopped := testID + "-s" + sourceFileContent := []byte(testID) + tempDir := t.TempDir() + + base := testutil.NewBase(t) + // Change working directory for commands to execute to the newly created temp directory on the host + // Note that ChDir won't do in a parallel context - and that setup func on the host below + // has to deal with that problem separately by making sure relative paths are resolved against temp + base.Dir = tempDir + + // Prepare the specs and derived variables + sourceSpec := groupSourceSpec + destinationSpec := testCase.destinationSpec + + // If the test case does not specify a catFile, start with the destination spec + catFile := testCase.catFile + if catFile == "" { + catFile = destinationSpec } - base.Cmd("cp", testStoppedContainer+":"+srcPath, destPath).AssertOK() - assertCat(catPath) - }) - t.Run("SRC_PATH does end with `/.`", func(t *testing.T) { - srcPath += "/." - destPath := filepath.Join(td, "dest2-dir2-exists") - err := os.Mkdir(destPath, 0o755) - assert.NilError(t, err) - base.Cmd("cp", testContainer+":"+srcPath, destPath).AssertOK() - catPath := filepath.Join(destPath, filepath.Base(srcFile)) - assertCat(catPath) - if rootlessutil.IsRootless() { - t.Skip("Test skipped in rootless mode for testStoppedContainer") + + sourceFile := filepath.Join(groupSourceDir, srcFileName) + if copyToContainer { + // Use an absolute path for evaluation + if !filepath.IsAbs(catFile) { + catFile = filepath.Join(string(os.PathSeparator), catFile) + } + // If the sourceFile is still relative, make it absolute to the temp + sourceFile = filepath.Join(tempDir, sourceFile) + // If the spec path for source on the host was absolute, make sure we put that under tempDir + if filepath.IsAbs(sourceSpec) { + sourceSpec = tempDir + sourceSpec + } + } else { + // If we are copying to host, we need to make sure we have an absolute path to cat, relative to temp, + // whether it is relative, or "absolute" + catFile = filepath.Join(tempDir, catFile) + // If the spec for destination on the host was absolute, make sure we put that under tempDir + if filepath.IsAbs(destinationSpec) { + destinationSpec = tempDir + destinationSpec + } + } + + // Teardown: clean-up containers and optional volume + tearDown := func() { + base.Cmd("rm", "-f", containerRunning).Run() + base.Cmd("rm", "-f", containerStopped).Run() + if testCase.volume != nil { + volID, _, _ := testCase.volume(base, testID) + base.Cmd("volume", "rm", volID).Run() + } + } + + createFileOnHost := func() { + // Create file on the host + err := os.MkdirAll(filepath.Dir(sourceFile), dirPerm) + assert.NilError(t, err) + err = os.WriteFile(sourceFile, sourceFileContent, filePerm) + assert.NilError(t, err) + } + + // Setup: create volume, containers, create the source file + setup := func() { + args := []string{"run", "-d", "-w", containerCwd} + if testCase.volume != nil { + vol, mount, ro := testCase.volume(base, testID) + volArg := fmt.Sprintf("%s:%s", vol, mount) + if ro { + volArg += ":ro" + } + args = append(args, "-v", volArg) + } + base.Cmd(append(args, "--name", containerRunning, testutil.CommonImage, "sleep", "Inf")...).AssertOK() + base.Cmd(append(args, "--name", containerStopped, testutil.CommonImage, "sleep", "Inf")...).AssertOK() + + if copyToContainer { + createFileOnHost() + } else { + // Create file content in the container + // Note: cd /, otherwise we end-up in the container cwd, which is NOT obeyed by cp + mkSrcScript := fmt.Sprintf("cd /; mkdir -p %q && echo -n %q >%q && chown %d %q", filepath.Dir(sourceFile), sourceFileContent, sourceFile, srcUID, sourceFile) + base.Cmd("exec", containerRunning, "sh", "-euc", mkSrcScript).AssertOK() + base.Cmd("exec", containerStopped, "sh", "-euc", mkSrcScript).AssertOK() + } + + // If we have optional setup, run that now + if testCase.setup != nil { + // Some specs may come with a trailing slash (proper or improper) + // Setup should still work in all cases (including if its a file), and get through to the actual test + setupDest := destinationSpec + setupDest = strings.TrimSuffix(setupDest, string(os.PathSeparator)) + if !filepath.IsAbs(setupDest) { + if copyToContainer { + setupDest = filepath.Join(string(os.PathSeparator), setupDest) + } else { + setupDest = filepath.Join(tempDir, setupDest) + } + } + testCase.setup(base, containerRunning, setupDest) + testCase.setup(base, containerStopped, setupDest) + } + + // Stop the "stopped" container + base.Cmd("stop", containerStopped).AssertOK() + } + + tearDown() + t.Cleanup(tearDown) + // If we have custom teardown, do that + if testCase.tearDown != nil { + testCase.tearDown() + t.Cleanup(testCase.tearDown) + } + + // Do the setup + setup() + + // If Docker, removes the err part of expectation + if testutil.GetTarget() == testutil.Docker { + testCase.expect.Err = "" + } + + // Build the final src and dest specifiers, including `containerXYZ:` + container := "" + if copyToContainer { + container = containerRunning + base.Cmd("cp", sourceSpec, containerRunning+":"+destinationSpec).Assert(testCase.expect) + } else { + base.Cmd("cp", containerRunning+":"+sourceSpec, destinationSpec).Assert(testCase.expect) + } + + // Run the actual test for the running container + // If we expect the op to be a success, also check the destination file + if testCase.expect.ExitCode == 0 { + assertCatHelper(base, catFile, sourceFileContent, container, destUID, false) + } + + // When copying container > host, we get shadowing from the previous container, possibly hiding failures + // Solution: clear-up the tempDir + if copyToContainer { + err := os.RemoveAll(tempDir) + assert.NilError(t, err) + err = os.MkdirAll(tempDir, dirPerm) + assert.NilError(t, err) + createFileOnHost() + defer os.RemoveAll(tempDir) + } + + // ... and for the stopped container + container = "" + var cmd *testutil.Cmd + if copyToContainer { + container = containerStopped + cmd = base.Cmd("cp", sourceSpec, containerStopped+":"+destinationSpec) + } else { + cmd = base.Cmd("cp", containerStopped+":"+sourceSpec, destinationSpec) + } + + if rootlessutil.IsRootless() && testutil.GetTarget() == testutil.Nerdctl { + cmd.Assert( + icmd.Expected{ + ExitCode: 1, + Err: containerutil.ErrRootlessCannotCp.Error(), + }) + return + } + + cmd.Assert(testCase.expect) + if testCase.expect.ExitCode == 0 { + assertCatHelper(base, catFile, sourceFileContent, container, destUID, true) } - base.Cmd("cp", testStoppedContainer+":"+srcPath, destPath).AssertOK() - assertCat(catPath) }) - }) + } }) } diff --git a/pkg/cmd/container/cp_linux.go b/pkg/cmd/container/cp_linux.go index 5d883a47565..58135c4f0c3 100644 --- a/pkg/cmd/container/cp_linux.go +++ b/pkg/cmd/container/cp_linux.go @@ -18,6 +18,7 @@ package container import ( "context" + "errors" "fmt" containerd "github.com/containerd/containerd/v2/client" @@ -39,17 +40,22 @@ func Cp(ctx context.Context, client *containerd.Client, options types.ContainerC ctx, client, found.Container, - options.Container2Host, - options.DestPath, - options.SrcPath, - options.GOptions.Snapshotter, - options.FollowSymLink) + options) }, } count, err := walker.Walk(ctx, options.ContainerReq) - if count < 1 { - err = fmt.Errorf("could not find container: %s, with error: %w", options.ContainerReq, err) + if count == -1 { + if err == nil { + panic("nil error and count == -1 from ContainerWalker.Walk should never happen") + } + err = errors.Join(errors.New("unable to copy"), err) + } else if count == 0 { + if err != nil { + err = errors.Join(errors.New("unable to retrieve containers with error"), err) + } else { + err = fmt.Errorf("no container found for: %s", options.ContainerReq) + } } return err diff --git a/pkg/containerutil/cp_linux.go b/pkg/containerutil/cp_linux.go index 2036b677630..2709839d40c 100644 --- a/pkg/containerutil/cp_linux.go +++ b/pkg/containerutil/cp_linux.go @@ -17,163 +17,200 @@ package containerutil import ( + "bytes" "context" "errors" "fmt" - "io/fs" "os" "os/exec" - "path" "path/filepath" "strconv" "strings" - securejoin "github.com/cyphar/filepath-securejoin" - containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/containers" "github.com/containerd/containerd/v2/core/mount" "github.com/containerd/errdefs" "github.com/containerd/log" + "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" "github.com/containerd/nerdctl/v2/pkg/tarutil" ) -// CopyFiles implements `nerdctl cp`. // See https://docs.docker.com/engine/reference/commandline/cp/ for the specification. -func CopyFiles(ctx context.Context, client *containerd.Client, container containerd.Container, container2host bool, dst, src string, snapshotter string, followSymlink bool) error { + +var ( + // Generic and system errors + ErrFilesystem = errors.New("filesystem error") // lstat hard errors, etc + ErrShouldNeverHappen = errors.New("an unexpected error happened - please report this as a bug") // unexpected conditions + ErrRootlessCannotCp = errors.New("cannot use cp with stopped containers in rootless mode") // rootless cp with a stopped container + ErrFailedMountingSnapshot = errors.New("failed mounting snapshot") // failure to mount a stopped container snapshot + + // CP specific errors + ErrTargetIsReadOnly = errors.New("cannot copy into read-only location") // ... + ErrSourceIsNotADir = errors.New("source is not a directory") // cp SOMEFILE/ foo:/ + ErrDestinationIsNotADir = errors.New("destination is not a directory") // * cp ./ foo:/etc/issue/bah + ErrSourceDoesNotExist = errors.New("source does not exist") // cp NONEXISTENT foo:/ + ErrDestinationParentMustExist = errors.New("destination parent does not exist") // nerdctl cp VALID_PATH foo:/NONEXISTENT/NONEXISTENT + ErrDestinationDirMustExist = errors.New("the destination directory must exist to be able to copy a file") // * cp SOMEFILE foo:/NONEXISTENT/ + ErrCannotCopyDirToFile = errors.New("cannot copy a directory to a file") // cp SOMEDIR foo:/etc/issue +) + +// getRoot will tentatively return the root of the container on the host (/proc/pid/root), along with the pid, +// (eg: doable when the container is running) +func getRoot(ctx context.Context, container containerd.Container) (string, int, error) { + task, err := container.Task(ctx, nil) + if err != nil { + return "", 0, err + } + + status, err := task.Status(ctx) + if err != nil { + return "", 0, err + } + + if status.Status != containerd.Running { + return "", 0, nil + } + pid := int(task.Pid()) + + return fmt.Sprintf("/proc/%d/root", pid), pid, nil +} + +// CopyFiles implements `nerdctl cp` +// It currently depends on the following assumptions: +// - linux only +// - tar binary exists on the system +// - nsenter binary exists on the system +// - if rootless, the container is running (aka: /proc/pid/root) +func CopyFiles(ctx context.Context, client *containerd.Client, container containerd.Container, options types.ContainerCpOptions) (err error) { + // We do rely on the tar binary as a shortcut - could also be replaced by archive/tar, though that would mean + // we need to replace nsenter calls with re-exec tarBinary, isGNUTar, err := tarutil.FindTarBinary() if err != nil { return err } + log.G(ctx).Debugf("Detected tar binary %q (GNU=%v)", tarBinary, isGNUTar) - var srcFull, dstFull, root, mountDestination, containerPath string - var cleanup func() - task, err := container.Task(ctx, nil) + + // Actually, this can happen if the container being passed has been deleted since in a racy way + conSpec, err := container.Spec(ctx) if err != nil { - // FIXME: Rootless does not support copying into/out of stopped/created containers as we need to nsenter into the user namespace of the - // pid of the running container with --preserve-credentials to preserve uid/gid mapping and copy files into the container. + return errors.Join(ErrShouldNeverHappen, err) + } + + // Try to get a running container root + root, pid, err := getRoot(ctx, container) + // If the task is "not found" (for example, if the container stopped), we will try to mount the snapshot + // Any other type of error from Task() is fatal here. + if err != nil && !errdefs.IsNotFound(err) { + return errors.Join(ErrShouldNeverHappen, err) + } + + log.G(ctx).Debugf("We have root %s and pid %d", root, pid) + + // If we have no root: + // - bail out for rootless + // - mount the snapshot for rootful + if root == "" { + // FIXME: Rootless does not support copying into/out of stopped/created containers as we need to nsenter into + // the user namespace of the pid of the running container with --preserve-credentials to preserve uid/gid + // mapping and copy files into the container. if rootlessutil.IsRootless() { - return errors.New("cannot use cp with stopped containers in rootless mode") - } - // if the task is simply not found, we should try to mount the snapshot. any other type of error from Task() is fatal here. - if !errdefs.IsNotFound(err) { - return err + return ErrRootlessCannotCp } - if container2host { - containerPath = src - } else { - containerPath = dst - } - // Check if containerPath is in a volume - root, mountDestination, err = getContainerMountInfo(ctx, container, containerPath, container2host) + + // See similar situation above. This may happen if we are racing against container deletion + var conInfo containers.Container + conInfo, err = container.Info(ctx) if err != nil { - return err + return errors.Join(ErrShouldNeverHappen, err) } - // if containerPath is in a volume and not read-only in case of host2container copy then handle volume paths, - // else containerPath is not in volume so mount container snapshot for copy - if root != "" { - dst, src = handleVolumePaths(container2host, dst, src, mountDestination) - } else { - root, cleanup, err = mountSnapshotForContainer(ctx, client, container, snapshotter) - if cleanup != nil { - defer cleanup() - } - if err != nil { - return err - } + + var cleanup func() error + root, cleanup, err = mountSnapshotForContainer(ctx, client, conInfo, options.GOptions.Snapshotter) + if cleanup != nil { + defer func() { + err = errors.Join(err, cleanup()) + }() } - } else { - status, err := task.Status(ctx) + if err != nil { - return err - } - if status.Status == containerd.Running { - root = fmt.Sprintf("/proc/%d/root", task.Pid()) - } else { - if rootlessutil.IsRootless() { - return fmt.Errorf("cannot use cp with stopped containers in rootless mode") - } - if container2host { - containerPath = src - } else { - containerPath = dst - } - root, mountDestination, err = getContainerMountInfo(ctx, container, containerPath, container2host) - if err != nil { - return err - } - // if containerPath is in a volume and not read-only in case of host2container copy then handle volume paths, - // else containerPath is not in volume so mount container snapshot for copy - if root != "" { - dst, src = handleVolumePaths(container2host, dst, src, mountDestination) - } else { - root, cleanup, err = mountSnapshotForContainer(ctx, client, container, snapshotter) - if cleanup != nil { - defer cleanup() - } - if err != nil { - return err - } - } + return errors.Join(ErrFailedMountingSnapshot, err) } + + log.G(ctx).Debugf("Got new root %s", root) } - if container2host { - srcFull, err = securejoin.SecureJoin(root, src) - dstFull = dst + + var sourceSpec, destinationSpec *pathSpecifier + var sourceErr, destErr error + if options.Container2Host { + sourceSpec, sourceErr = getPathSpecFromContainer(options.SrcPath, conSpec, root) + destinationSpec, destErr = getPathSpecFromHost(options.DestPath) } else { - srcFull = src - dstFull, err = securejoin.SecureJoin(root, dst) + sourceSpec, sourceErr = getPathSpecFromHost(options.SrcPath) + destinationSpec, destErr = getPathSpecFromContainer(options.DestPath, conSpec, root) } - if err != nil { - return err + + if destErr != nil { + if errors.Is(destErr, errDoesNotExist) { + return ErrDestinationParentMustExist + } else if errors.Is(destErr, errIsNotADir) { + return ErrDestinationIsNotADir + } + + return errors.Join(ErrFilesystem, destErr) } - var ( - srcIsDir bool - dstExists bool - dstExistsAsDir bool - st fs.FileInfo - ) - st, err = os.Stat(srcFull) - if err != nil { - return err + + if sourceErr != nil { + if errors.Is(sourceErr, errDoesNotExist) { + return ErrSourceDoesNotExist + } else if errors.Is(sourceErr, errIsNotADir) { + return ErrSourceIsNotADir + } + + return errors.Join(ErrFilesystem, sourceErr) } - srcIsDir = st.IsDir() - // dst may not exist yet, so err is negligible - if st, err := os.Stat(dstFull); err == nil { - dstExists = true - dstExistsAsDir = st.IsDir() + // Now, resolve cp shenanigans + // First, cannot copy a non-existent resource + if !sourceSpec.exists { + return ErrSourceDoesNotExist } - dstEndsWithSep := strings.HasSuffix(dst, string(os.PathSeparator)) - srcEndsWithSlashDot := strings.HasSuffix(src, string(os.PathSeparator)+".") - if !srcIsDir && dstEndsWithSep && !dstExistsAsDir { - // The error is specified in https://docs.docker.com/engine/reference/commandline/cp/ - // See the `DEST_PATH does not exist and ends with /` case. - return fmt.Errorf("the destination directory must exists: %w", err) + + // Second, cannot copy into a readonly destination + if destinationSpec.readOnly { + return ErrTargetIsReadOnly } - if !srcIsDir && srcEndsWithSlashDot { - return fmt.Errorf("the source is not a directory") + + // Cannot copy a dir into a file + if sourceSpec.isADir && destinationSpec.exists && !destinationSpec.isADir { + return ErrCannotCopyDirToFile } - if srcIsDir && dstExists && !dstExistsAsDir { - return fmt.Errorf("cannot copy a directory to a file") + + // A file cannot be copied inside a non-existent directory with a trailing slash, or slash+dot + if !sourceSpec.isADir && !destinationSpec.exists && (destinationSpec.endsWithSeparator || destinationSpec.endsWithSeparatorDot) { + return ErrDestinationDirMustExist } - if srcIsDir && !dstExists { - if err := os.MkdirAll(dstFull, 0o755); err != nil { - return err + + // XXX FIXME: this seems wrong. What about ownership? We could be doing that inside a container + if !destinationSpec.exists { + if err = os.Mkdir(destinationSpec.resolvedPath, 0o755); err != nil { + return errors.Join(ErrFilesystem, err) } } var tarCDir, tarCArg string - if srcIsDir { - if !dstExists || srcEndsWithSlashDot { + if sourceSpec.isADir { + if !destinationSpec.exists || sourceSpec.endsWithSeparatorDot { // the content of the source directory is copied into this directory - tarCDir = srcFull + tarCDir = sourceSpec.resolvedPath tarCArg = "." } else { // the source directory is copied into this directory - tarCDir = filepath.Dir(srcFull) - tarCArg = filepath.Base(srcFull) + tarCDir = filepath.Dir(sourceSpec.resolvedPath) + tarCArg = filepath.Base(sourceSpec.resolvedPath) } } else { // Prepare a single-file directory to create an archive of the source file @@ -184,16 +221,16 @@ func CopyFiles(ctx context.Context, client *containerd.Client, container contain defer os.RemoveAll(td) tarCDir = td cp := []string{"cp", "-a"} - if followSymlink { + if options.FollowSymLink { cp = append(cp, "-L") } - if dstEndsWithSep || dstExistsAsDir { - tarCArg = filepath.Base(srcFull) + if destinationSpec.endsWithSeparator || (destinationSpec.exists && destinationSpec.isADir) { + tarCArg = filepath.Base(sourceSpec.resolvedPath) } else { // Handle `nerdctl cp /path/to/file some-container:/path/to/file-with-another-name` - tarCArg = filepath.Base(dstFull) + tarCArg = filepath.Base(destinationSpec.resolvedPath) } - cp = append(cp, srcFull, filepath.Join(td, tarCArg)) + cp = append(cp, sourceSpec.resolvedPath, filepath.Join(td, tarCArg)) cpCmd := exec.CommandContext(ctx, cp[0], cp[1:]...) log.G(ctx).Debugf("executing %v", cpCmd.Args) if out, err := cpCmd.CombinedOutput(); err != nil { @@ -201,30 +238,33 @@ func CopyFiles(ctx context.Context, client *containerd.Client, container contain } } tarC := []string{tarBinary} - if followSymlink { + if options.FollowSymLink { tarC = append(tarC, "-h") } tarC = append(tarC, "-c", "-f", "-", tarCArg) - tarXDir := dstFull - if !srcIsDir && !dstEndsWithSep && !dstExistsAsDir { - tarXDir = filepath.Dir(dstFull) + tarXDir := destinationSpec.resolvedPath + if !sourceSpec.isADir && !destinationSpec.endsWithSeparator && !(destinationSpec.exists && destinationSpec.isADir) { + tarXDir = filepath.Dir(destinationSpec.resolvedPath) } tarX := []string{tarBinary, "-x"} - if container2host && isGNUTar { + if options.Container2Host && isGNUTar { tarX = append(tarX, "--no-same-owner") } tarX = append(tarX, "-f", "-") if rootlessutil.IsRootless() { - nsenter := []string{"nsenter", "-t", strconv.Itoa(int(task.Pid())), "-U", "--preserve-credentials", "--"} - if container2host { + nsenter := []string{"nsenter", "-t", strconv.Itoa(pid), "-U", "--preserve-credentials", "--"} + if options.Container2Host { tarC = append(nsenter, tarC...) } else { tarX = append(nsenter, tarX...) } } + // FIXME: moving to archive/tar should allow better error management than this + // WARNING: some of our testing on stderr might not be portable across different versions of tar + // In these cases (readonly target), we will just get the straight tar output instead tarCCmd := exec.CommandContext(ctx, tarC[0], tarC[1:]...) tarCCmd.Dir = tarCDir tarCCmd.Stdin = nil @@ -237,97 +277,74 @@ func CopyFiles(ctx context.Context, client *containerd.Client, container contain return err } tarXCmd.Stdout = os.Stderr - tarXCmd.Stderr = os.Stderr + var tarErr bytes.Buffer + tarXCmd.Stderr = &tarErr log.G(ctx).Debugf("executing %v in %q", tarCCmd.Args, tarCCmd.Dir) if err := tarCCmd.Start(); err != nil { - return fmt.Errorf("failed to execute %v: %w", tarCCmd.Args, err) + return errors.Join(fmt.Errorf("failed to execute %v", tarCCmd.Args), err) } + log.G(ctx).Debugf("executing %v in %q", tarXCmd.Args, tarXCmd.Dir) if err := tarXCmd.Start(); err != nil { - return fmt.Errorf("failed to execute %v: %w", tarXCmd.Args, err) + if strings.Contains(err.Error(), "permission denied") { + return ErrTargetIsReadOnly + } + + // Other errors, just put them back on stderr + _, fpErr := fmt.Fprint(os.Stderr, tarErr.String()) + if fpErr != nil { + return errors.Join(fpErr, err) + } + + return errors.Join(fmt.Errorf("failed to execute %v", tarXCmd.Args), err) } + if err := tarCCmd.Wait(); err != nil { return fmt.Errorf("failed to wait %v: %w", tarCCmd.Args, err) } + if err := tarXCmd.Wait(); err != nil { - return fmt.Errorf("failed to wait %v: %w", tarXCmd.Args, err) + if strings.Contains(tarErr.String(), "Read-only file system") { + return ErrTargetIsReadOnly + } + + // Other errors, just put them back on stderr + _, fpErr := fmt.Fprint(os.Stderr, tarErr.String()) + if fpErr != nil { + return errors.Join(fpErr, err) + } + + return errors.Join(fmt.Errorf("failed to wait %v", tarXCmd.Args), err) } + return nil } -func mountSnapshotForContainer(ctx context.Context, client *containerd.Client, container containerd.Container, snapshotter string) (string, func(), error) { - cinfo, err := container.Info(ctx) - if err != nil { - return "", nil, err - } - snapKey := cinfo.SnapshotKey +func mountSnapshotForContainer(ctx context.Context, client *containerd.Client, conInfo containers.Container, snapshotter string) (string, func() error, error) { + snapKey := conInfo.SnapshotKey resp, err := client.SnapshotService(snapshotter).Mounts(ctx, snapKey) if err != nil { return "", nil, err } + tempDir, err := os.MkdirTemp("", "nerdctl-cp-") if err != nil { return "", nil, err } + err = mount.All(resp, tempDir) if err != nil { - return "", nil, fmt.Errorf("failed to mount snapshot with error %s", err.Error()) + return "", nil, errors.Join(errors.New("failed to mount snapshot"), err) } - cleanup := func() { + + cleanup := func() error { err = mount.Unmount(tempDir, 0) if err != nil { - log.G(ctx).Warnf("failed to unmount %s with error %s", tempDir, err.Error()) - return - } - os.RemoveAll(tempDir) - } - return tempDir, cleanup, nil -} - -func getContainerMountInfo(ctx context.Context, con containerd.Container, containerPath string, container2host bool) (string, string, error) { - filePath := filepath.Clean(containerPath) - spec, err := con.Spec(ctx) - if err != nil { - return "", "", err - } - // read-only applies only while copying into container from host - if !container2host && spec.Root.Readonly { - return "", "", fmt.Errorf("container rootfs: %s is marked read-only", spec.Root.Path) - } - - for _, mount := range spec.Mounts { - if isSelfOrAscendant(filePath, mount.Destination) { - // read-only applies only while copying into container from host - if !container2host { - for _, option := range mount.Options { - if option == "ro" { - return "", "", fmt.Errorf("mount point %s is marked read-only", filePath) - } - } - } - return mount.Source, mount.Destination, nil + return err } + return os.RemoveAll(tempDir) } - return "", "", nil -} -func isSelfOrAscendant(filePath, potentialAncestor string) bool { - if filePath == "/" || filePath == "" || potentialAncestor == "" { - return false - } - filePath = filepath.Clean(filePath) - potentialAncestor = filepath.Clean(potentialAncestor) - if filePath == potentialAncestor { - return true - } - return isSelfOrAscendant(path.Dir(filePath), potentialAncestor) -} - -// When the path is in volume remove directory that volume is mounted on from the path -func handleVolumePaths(container2host bool, dst string, src string, mountDestination string) (string, string) { - if container2host { - return dst, strings.TrimPrefix(filepath.Clean(src), mountDestination) - } - return strings.TrimPrefix(filepath.Clean(dst), mountDestination), src + return tempDir, cleanup, nil } diff --git a/pkg/containerutil/cp_resolve.go b/pkg/containerutil/cp_resolve.go new file mode 100644 index 00000000000..070a036c330 --- /dev/null +++ b/pkg/containerutil/cp_resolve.go @@ -0,0 +1,436 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package containerutil + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "syscall" + + "github.com/opencontainers/runtime-spec/specs-go" + + "github.com/containerd/containerd/v2/pkg/oci" +) + +var ( + errDoesNotExist = errors.New("resource does not exist") // when a path parent dir does not exist + errIsNotADir = errors.New("is not a dir") // when a path is a file, ending with path separator + errCannotResolvePathNoCwd = errors.New("unable to resolve path against undefined current working directory") // relative host path, no cwd +) + +// pathSpecifier represents a path to be used by cp +// besides exposing relevant properties (endsWithSeparator, etc), it also provides a fully resolved *host* path to +// access the resource +type pathSpecifier struct { + originalPath string + endsWithSeparator bool + endsWithSeparatorDot bool + exists bool + isADir bool + readOnly bool + resolvedPath string +} + +// getPathSpecFromHost builds a pathSpecifier from a host location +// errors with errDoesNotExist, errIsNotADir, "EvalSymlinks: too many links", or other hard filesystem errors from lstat/stat +func getPathSpecFromHost(originalPath string) (*pathSpecifier, error) { + pathSpec := &pathSpecifier{ + originalPath: originalPath, + endsWithSeparator: strings.HasSuffix(originalPath, string(os.PathSeparator)), + endsWithSeparatorDot: filepath.Base(originalPath) == ".", + } + + path := originalPath + + // Path may still be relative at this point. If it is, figure out getwd. + if !filepath.IsAbs(path) { + cwd, err := os.Getwd() + if err != nil { + return nil, errors.Join(errCannotResolvePathNoCwd, err) + } + path = cwd + string(os.PathSeparator) + path + } + + // Try to fully resolve the path + resolvedPath, err := filepath.EvalSymlinks(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + if errors.Is(err, syscall.ENOTDIR) { + return nil, errors.Join(errIsNotADir, err) + } + + // Other errors: + // - "EvalSymlinks: too many links" + // - any other error coming from lstat + return nil, err + } + + pathSpec.exists = err == nil + + // Ensure the parent exists if the path itself does not + if !pathSpec.exists { + // Try the parent - obtain it by removing any trailing / or /., then the base + cleaned := strings.TrimRight(strings.TrimSuffix(path, string(os.PathSeparator)+"."), string(os.PathSeparator)) + for len(cleaned) < len(path) { + path = cleaned + cleaned = strings.TrimRight(strings.TrimSuffix(path, string(os.PathSeparator)+"."), string(os.PathSeparator)) + } + + base := filepath.Base(path) + path = strings.TrimSuffix(path, string(os.PathSeparator)+base) + + // Resolve it + resolvedPath, err = filepath.EvalSymlinks(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, errors.Join(errDoesNotExist, err) + } else if errors.Is(err, syscall.ENOTDIR) { + return nil, errors.Join(errIsNotADir, err) + } + + return nil, err + } + + resolvedPath = filepath.Join(resolvedPath, base) + } else { + // If it exists, we can check if it is a dir + var st os.FileInfo + st, err = os.Stat(path) + if err != nil { + return nil, err + } + pathSpec.isADir = st.IsDir() + } + + pathSpec.resolvedPath = resolvedPath + + return pathSpec, nil +} + +// getPathSpecFromHost builds a pathSpecifier from a container location +func getPathSpecFromContainer(originalPath string, conSpec *oci.Spec, containerHostRoot string) (*pathSpecifier, error) { + pathSpec := &pathSpecifier{ + originalPath: originalPath, + endsWithSeparator: strings.HasSuffix(originalPath, string(os.PathSeparator)), + endsWithSeparatorDot: filepath.Base(originalPath) == ".", + } + + path := originalPath + + // Path may still be relative at this point. If it is, join it to the root + // NOTE: this is specifically called out in the docker reference. Paths in the container are assumed + // relative to the root, and not to the current (container) working directory. + // Though this seems like a questionable decision, it is set. + if !filepath.IsAbs(path) { + path = string(os.PathSeparator) + path + } + + // Now, fully resolve the path - resolving all symlinks and cleaning-up the end result, following across mounts + pathResolver := newResolver(conSpec, containerHostRoot) + resolvedContainerPath, err := pathResolver.resolvePath(path) + + // Errors we get from that are from Lstat or Readlink + // Either the object does not exist, or we have a dangling symlink, or otherwise hosed filesystem entries + if err != nil && !errors.Is(err, os.ErrNotExist) { + if errors.Is(err, syscall.ENOTDIR) { + return nil, errors.Join(errIsNotADir, err) + } + + // errors.New("EvalSymlinks: too many links") + // other errors would come from lstat + return nil, err + } + + pathSpec.exists = err == nil + + // If the resource does not exist + if !pathSpec.exists { + // Try the parent + cleaned := strings.TrimRight(strings.TrimSuffix(path, string(os.PathSeparator)+"."), string(os.PathSeparator)) + for len(cleaned) < len(path) { + path = cleaned + cleaned = strings.TrimRight(strings.TrimSuffix(path, string(os.PathSeparator)+"."), string(os.PathSeparator)) + } + + base := filepath.Base(path) + path = strings.TrimSuffix(path, string(os.PathSeparator)+base) + + resolvedContainerPath, err = pathResolver.resolvePath(path) + + // Error? That is the end + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, errors.Join(errDoesNotExist, err) + } else if errors.Is(err, syscall.ENOTDIR) { + return nil, errors.Join(errIsNotADir, err) + } + + return nil, err + } + + resolvedContainerPath = filepath.Join(resolvedContainerPath, base) + } + + // Now, finally get the location of the fully resolved containerPath (in the root? in a volume?) + containerMount, relativePath := pathResolver.getMount(resolvedContainerPath) + pathSpec.resolvedPath = filepath.Join(containerMount.hostPath, relativePath) + // If the endpoint is readonly, flag it as such + if containerMount.readonly { + pathSpec.readOnly = true + } + + // If it exists, we can check if it is a dir + if pathSpec.exists { + var st os.FileInfo + st, err = os.Stat(pathSpec.resolvedPath) + if err != nil { + return nil, err + } + + pathSpec.isADir = st.IsDir() + } + + return pathSpec, nil +} + +// resolver provides methods to fully resolve any given container given path to a host location +// accounting for rootfs and mounts location +type resolver struct { + root *specs.Root + mounts []specs.Mount + hostRoot string +} + +// locator represents a container mount +type locator struct { + containerPath string + hostPath string + readonly bool +} + +func isParent(child []string, candidate []string) (bool, []string) { + if len(child) < len(candidate) { + return false, child + } + return slices.Equal(child[0:len(candidate)], candidate), child[len(candidate):] +} + +// newResolver returns a resolver struct +func newResolver(conSpec *oci.Spec, hostRoot string) *resolver { + return &resolver{ + root: conSpec.Root, + mounts: conSpec.Mounts, + hostRoot: hostRoot, + } +} + +// pathOnHost will return the *host* location of a container path, accounting for volumes. +// The provided path must be fully resolved, as returned by `resolvePath`. +func (res *resolver) pathOnHost(path string) string { + hostRoot := res.hostRoot + path = filepath.Clean(path) + itemized := strings.Split(path, string(os.PathSeparator)) + + containerRoot := "/" + sub := itemized + + for _, mnt := range res.mounts { + if candidateIsParent, subPath := isParent(itemized, strings.Split(mnt.Destination, string(os.PathSeparator))); candidateIsParent { + if len(mnt.Destination) > len(containerRoot) { + containerRoot = mnt.Destination + hostRoot = mnt.Source + sub = subPath + } + } + } + + return filepath.Join(append([]string{hostRoot}, sub...)...) +} + +// getMount returns the mount locator for a given fully-resolved path, along with the corresponding subpath of the path +// relative to the locator +func (res *resolver) getMount(path string) (*locator, string) { + itemized := strings.Split(path, string(os.PathSeparator)) + + loc := &locator{ + containerPath: "/", + hostPath: res.hostRoot, + readonly: res.root.Readonly, + } + + sub := itemized + + for _, mnt := range res.mounts { + if candidateIsParent, subPath := isParent(itemized, strings.Split(mnt.Destination, string(os.PathSeparator))); candidateIsParent { + if len(mnt.Destination) > len(loc.containerPath) { + loc.readonly = false + for _, option := range mnt.Options { + if option == "ro" { + loc.readonly = true + } + } + loc.containerPath = mnt.Destination + loc.hostPath = mnt.Source + sub = subPath + } + } + } + + return loc, filepath.Join(sub...) +} + +// resolvePath is adapted from https://cs.opensource.google/go/go/+/go1.23.0:src/path/filepath/path.go;l=147 +// The (only) changes are on Lstat and ReadLink, which are fed the actual host path, that is computed by `res.pathOnHost` +func (res *resolver) resolvePath(path string) (string, error) { + volLen := volumeNameLen(path) + pathSeparator := string(os.PathSeparator) + + if volLen < len(path) && os.IsPathSeparator(path[volLen]) { + volLen++ + } + vol := path[:volLen] + dest := vol + linksWalked := 0 + //nolint:ineffassign + for start, end := volLen, volLen; start < len(path); start = end { + for start < len(path) && os.IsPathSeparator(path[start]) { + start++ + } + end = start + for end < len(path) && !os.IsPathSeparator(path[end]) { + end++ + } + + // On Windows, "." can be a symlink. + // We look it up, and use the value if it is absolute. + // If not, we just return ".". + isWindowsDot := runtime.GOOS == "windows" && path[volumeNameLen(path):] == "." + + // The next path component is in path[start:end]. + if end == start { + // No more path components. + break + } else if path[start:end] == "." && !isWindowsDot { + // Ignore path component ".". + continue + } else if path[start:end] == ".." { + // Back up to previous component if possible. + // Note that volLen includes any leading slash. + + // Set r to the index of the last slash in dest, + // after the volume. + var r int + for r = len(dest) - 1; r >= volLen; r-- { + if os.IsPathSeparator(dest[r]) { + break + } + } + if r < volLen || dest[r+1:] == ".." { + // Either path has no slashes + // (it's empty or just "C:") + // or it ends in a ".." we had to keep. + // Either way, keep this "..". + if len(dest) > volLen { + dest += pathSeparator + } + dest += ".." + } else { + // Discard everything since the last slash. + dest = dest[:r] + } + continue + } + + // Ordinary path component. Add it to result. + + if len(dest) > volumeNameLen(dest) && !os.IsPathSeparator(dest[len(dest)-1]) { + dest += pathSeparator + } + + dest += path[start:end] + + // Resolve symlink. + hostPath := res.pathOnHost(dest) + fi, err := os.Lstat(hostPath) + if err != nil { + return "", err + } + + if fi.Mode()&fs.ModeSymlink == 0 { + if !fi.Mode().IsDir() && end < len(path) { + return "", syscall.ENOTDIR + } + continue + } + + // Found symlink. + linksWalked++ + if linksWalked > 255 { + return "", errors.New("EvalSymlinks: too many links") + } + + link, err := os.Readlink(hostPath) + if err != nil { + return "", err + } + + if isWindowsDot && !filepath.IsAbs(link) { + // On Windows, if "." is a relative symlink, + // just return ".". + break + } + + path = link + path[end:] + + v := volumeNameLen(link) + if v > 0 { + // Symlink to drive name is an absolute path. + if v < len(link) && os.IsPathSeparator(link[v]) { + v++ + } + vol = link[:v] + dest = vol + end = len(vol) + } else if len(link) > 0 && os.IsPathSeparator(link[0]) { + // Symlink to absolute path. + dest = link[:1] + end = 1 + vol = link[:1] + volLen = 1 + } else { + // Symlink to relative path; replace last + // path component in dest. + var r int + for r = len(dest) - 1; r >= volLen; r-- { + if os.IsPathSeparator(dest[r]) { + break + } + } + if r < volLen { + dest = vol + } else { + dest = dest[:r] + } + end = 0 + } + } + return filepath.Clean(dest), nil +} diff --git a/pkg/containerutil/cp_resolve_other.go b/pkg/containerutil/cp_resolve_other.go new file mode 100644 index 00000000000..58219aadd9a --- /dev/null +++ b/pkg/containerutil/cp_resolve_other.go @@ -0,0 +1,24 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package containerutil + +// volumeNameLen returns length of the leading volume name on Windows. +// It returns 0 elsewhere. +// FIXME: whenever we will want to port cp to windows, we will need the windows implementation of volumeNameLen +func volumeNameLen(_ string) int { + return 0 +} diff --git a/pkg/imgutil/filtering_test.go b/pkg/imgutil/filtering_test.go index 70125ee6012..7d82cb2ce60 100644 --- a/pkg/imgutil/filtering_test.go +++ b/pkg/imgutil/filtering_test.go @@ -20,8 +20,9 @@ import ( "testing" "time" - "github.com/containerd/containerd/v2/core/images" "gotest.tools/v3/assert" + + "github.com/containerd/containerd/v2/core/images" ) func TestApplyFilters(t *testing.T) { diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go index 4d93fc4c915..eeccf508b17 100644 --- a/pkg/testutil/testutil.go +++ b/pkg/testutil/testutil.go @@ -58,6 +58,7 @@ type Base struct { Binary string Args []string Env []string + Dir string } // WithStdin sets the standard input of Cmd to the specified reader @@ -70,6 +71,7 @@ func WithStdin(r io.Reader) func(*Cmd) { func (b *Base) Cmd(args ...string) *Cmd { icmdCmd := icmd.Command(b.Binary, append(b.Args, args...)...) icmdCmd.Env = b.Env + icmdCmd.Dir = b.Dir cmd := &Cmd{ Cmd: icmdCmd, Base: b,