Skip to content

Fixing ‘docker cp’ to allow new target file name in a host symlinked directory #31993

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 19, 2017

Conversation

dccurtis
Copy link
Contributor

@dccurtis dccurtis commented Mar 22, 2017

‘docker cp’ fails with error ‘not a directory’ if a file is copied from a container to a host symlinked directory with a new target filename.

The fix is to use os.Stat instead of os.Lstat since os.Stat follows the linked path.

Fixes Issue #31816

Signed-off-by: Douglas Curtis dougcurtis1@gmail.com

- What I did
pkg/archive/copy.go:: CopyInfoDestinationPath was using os.Lstat to determine whether or not the destination parent folder is a directory or not. After some experimentation with the ‘os’ package I found that FileInfo.IsDir() returns false for a symlinked directory.

I changed os.Lstat to os.Stat in CopyInfoDestinationPath.

- How I did it
Code inspection led me to the CopyInfoDestinationPath. Once I noticed that os.Lstat was being used to determine directory status I performed some local experiments and found that IsDir() does not report symlinked directories as true.

- How to verify it
After manually reproducing the steps provided I found a test gap in TestCpFromSymlinkDestination that missed the case of a new target file name to a symlinked directory. Adding the test case reproduced the failure in the automated testing environment.

After fixing the issue the test case passed as expected.

Manual functional test:

  1. Add a test file to a container
  2. Use ‘docker cp’ to copy the file to a symlink directory as a file name
  3. Verify that the contents of ‘testfile’ match ‘newfilename’

Results: issue-31816-ut.txt

- Description for the changelog

Added isDirectory method to detect symlink directories in CopyInfoDestinationPath to enable copying to a new target file name located in a symlinked host directory.

@thaJeztah
Copy link
Member

ping @jlhawn ptal

@@ -218,11 +252,11 @@ func CopyInfoDestinationPath(path string) (info CopyInfo, err error) {
// Ensure destination parent dir exists.
dstParent, _ := SplitPathDirEntry(path)

parentDirStat, err := os.Lstat(dstParent)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be forgetting something, but wouldn't simply changing this from os.Lstat to os.Stat fix this issue? os.Stat(name) will follow symlinks and you'll get the os.Fileinfo describing the target.

Not only would this be a much shorter change but it also covers the case where dstParent is a symlink to another symlink (to possibly yet another symlink) that finally points to a directory. That is a test case that is not currently covered but looks like would still fail under this current fix.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jlhawn thanks for the suggestion! You are correct, os.Stat follows the link. I updated the PR with the changes and added a test case for symlink -> symlink.

@dccurtis dccurtis force-pushed the issue-31816 branch 3 times, most recently from e90a301 to 61181be Compare March 23, 2017 01:50
@dccurtis
Copy link
Contributor Author

Is there anything I should be doing to resolve the windowsRS1 failure? It looks like an intermitent issue related to a PrepareLayer timeout.

@dccurtis
Copy link
Contributor Author

@thaJeztah Is there anything left for me to do to get this change merged? This is my first contribution and I just want to make sure I didn't miss anything. Thanks!

Copy link
Member

@thaJeztah thaJeztah left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, apologies, this slipped under my radar

LGTM

but looks like the test needs a rebase 😢

@thaJeztah
Copy link
Member

ping @tonistiigi PTAL

@tonistiigi tonistiigi self-assigned this May 17, 2017
@cpuguy83
Copy link
Member

This looks fine to me. AFAICT this is only used by the docker CLI for docker cp

I wonder if it makes sense to just move this over to the cli repo since this function is only using exported functions/types anyway.

@tonistiigi I see you self-assigned this. Does this LGTY?

LGTM

@cpuguy83
Copy link
Member

This does need a rebase, and I think the new tests will likely not work since it would require updating the CLI commit.

@dccurtis
Copy link
Contributor Author

I'm a new contributor and still getting used to the processes. Should I rebase my branch and fix tests as needed?

@cpuguy83
Copy link
Member

cpuguy83 commented Jun 19, 2017 via email

@thaJeztah
Copy link
Member

ping @dccurtis do you have time to work on this?

@thaJeztah
Copy link
Member

Discussing with @tonistiigi, and he wants to have another look at this to check if there's no possible security issue with symlinks being followed where it's not desired.

@dccurtis
Copy link
Contributor Author

@thaJeztah Yes, let me know what I can do to help. A brief situational update would help me to understand what the challenges are for getting this change merged (i.e. architecture and release issues, security, testing, etc).

@GordonTheTurtle GordonTheTurtle added the dco/no Automatically set by a bot when one of the commits lacks proper signature label Jul 21, 2017
@GordonTheTurtle
Copy link

Please sign your commits following these rules:
https://github.com/moby/moby/blob/master/CONTRIBUTING.md#sign-your-work
The easiest way to do this is to amend the last commit:

$ git clone -b "issue-31816" git@github.com:dccurtis/docker.git somewhere
$ cd somewhere
$ git rebase -i HEAD~842353533496
editor opens
change each 'pick' to 'edit'
save the file and quit
$ git commit --amend -s --no-edit
$ git rebase --continue # and repeat the amend for each commit
$ git push -f

Amending updates the existing PR. You DO NOT need to open a new one.

@GordonTheTurtle GordonTheTurtle removed the dco/no Automatically set by a bot when one of the commits lacks proper signature label Jul 21, 2017
@dccurtis
Copy link
Contributor Author

I started to re-base my branch and think I’m hitting the issue that @cpuguy83 was referring to. Commenting out my tests until I figure out how to handle this.

@dccurtis
Copy link
Contributor Author

I don’t have a lot of time to work on this but definitely want to see it through to the end. Is there a desired date to have the code change finalized by?

// Verify that newfile2 has the contents of file3 in the real directory (dir4)
c.Assert(fileContentEquals(c, cpPath(tmpDir, "symlinkToDir4", "newname2"), "file3\n"), checker.IsNil)
*/

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps a unit test for CopyInfoDestinationPath would be enough? A quick draft;

From 5bec8cc8b4f1b89a1326d56d3040da54abf6e604 Mon Sep 17 00:00:00 2001
From: Sebastiaan van Stijn <github@gone.nl>
Date: Tue, 8 Aug 2017 17:20:53 +0200
Subject: [PATCH] Add unit tests

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
---
 pkg/archive/changes_test.go   | 54 +++++++++++++++++++++++--------------------
 pkg/archive/copy_unix_test.go | 27 ++++++++++++++++++++++
 2 files changed, 56 insertions(+), 25 deletions(-)

diff --git a/pkg/archive/changes_test.go b/pkg/archive/changes_test.go
index c5d1629e7..25294652b 100644
--- a/pkg/archive/changes_test.go
+++ b/pkg/archive/changes_test.go
@@ -45,36 +45,40 @@ type FileData struct {
 	path        string
 	contents    string
 	permissions os.FileMode
+	expected    CopyInfo
 }
 
 func createSampleDir(t *testing.T, root string) {
 	files := []FileData{
-		{Regular, "file1", "file1\n", 0600},
-		{Regular, "file2", "file2\n", 0666},
-		{Regular, "file3", "file3\n", 0404},
-		{Regular, "file4", "file4\n", 0600},
-		{Regular, "file5", "file5\n", 0600},
-		{Regular, "file6", "file6\n", 0600},
-		{Regular, "file7", "file7\n", 0600},
-		{Dir, "dir1", "", 0740},
-		{Regular, "dir1/file1-1", "file1-1\n", 01444},
-		{Regular, "dir1/file1-2", "file1-2\n", 0666},
-		{Dir, "dir2", "", 0700},
-		{Regular, "dir2/file2-1", "file2-1\n", 0666},
-		{Regular, "dir2/file2-2", "file2-2\n", 0666},
-		{Dir, "dir3", "", 0700},
-		{Regular, "dir3/file3-1", "file3-1\n", 0666},
-		{Regular, "dir3/file3-2", "file3-2\n", 0666},
-		{Dir, "dir4", "", 0700},
-		{Regular, "dir4/file3-1", "file4-1\n", 0666},
-		{Regular, "dir4/file3-2", "file4-2\n", 0666},
-		{Symlink, "symlink1", "target1", 0666},
-		{Symlink, "symlink2", "target2", 0666},
-		{Symlink, "symlink3", root + "/file1", 0666},
-		{Symlink, "symlink4", root + "/symlink3", 0666},
-		{Symlink, "dirSymlink", root + "/dir1", 0740},
-	}
+		{filetype: Regular, path: "file1", contents: "file1\n", permissions: 0600},
+		{filetype: Regular, path: "file2", contents: "file2\n", permissions: 0666},
+		{filetype: Regular, path: "file3", contents: "file3\n", permissions: 0404},
+		{filetype: Regular, path: "file4", contents: "file4\n", permissions: 0600},
+		{filetype: Regular, path: "file5", contents: "file5\n", permissions: 0600},
+		{filetype: Regular, path: "file6", contents: "file6\n", permissions: 0600},
+		{filetype: Regular, path: "file7", contents: "file7\n", permissions: 0600},
+		{filetype: Dir, path: "dir1", contents: "", permissions: 0740},
+		{filetype: Regular, path: "dir1/file1-1", contents: "file1-1\n", permissions: 01444},
+		{filetype: Regular, path: "dir1/file1-2", contents: "file1-2\n", permissions: 0666},
+		{filetype: Dir, path: "dir2", contents: "", permissions: 0700},
+		{filetype: Regular, path: "dir2/file2-1", contents: "file2-1\n", permissions: 0666},
+		{filetype: Regular, path: "dir2/file2-2", contents: "file2-2\n", permissions: 0666},
+		{filetype: Dir, path: "dir3", contents: "", permissions: 0700},
+		{filetype: Regular, path: "dir3/file3-1", contents: "file3-1\n", permissions: 0666},
+		{filetype: Regular, path: "dir3/file3-2", contents: "file3-2\n", permissions: 0666},
+		{filetype: Dir, path: "dir4", contents: "", permissions: 0700},
+		{filetype: Regular, path: "dir4/file3-1", contents: "file4-1\n", permissions: 0666},
+		{filetype: Regular, path: "dir4/file3-2", contents: "file4-2\n", permissions: 0666},
+		{filetype: Symlink, path: "symlink1", contents: "target1", permissions: 0666},
+		{filetype: Symlink, path: "symlink2", contents: "target2", permissions: 0666},
+		{filetype: Symlink, path: "symlink3", contents: root + "/file1", permissions: 0666},
+		{filetype: Symlink, path: "symlink4", contents: root + "/symlink3", permissions: 0666},
+		{filetype: Symlink, path: "dirSymlink", contents: root + "/dir1", permissions: 0740},
+	}
+	provisionSampleDir(t, root, files)
+}
 
+func provisionSampleDir(t *testing.T, root string, files []FileData) {
 	now := time.Now()
 	for _, info := range files {
 		p := path.Join(root, info.path)
diff --git a/pkg/archive/copy_unix_test.go b/pkg/archive/copy_unix_test.go
index 4d5ae79cd..fc17ae010 100644
--- a/pkg/archive/copy_unix_test.go
+++ b/pkg/archive/copy_unix_test.go
@@ -15,6 +15,8 @@ import (
 	"path/filepath"
 	"strings"
 	"testing"
+
+	"github.com/stretchr/testify/assert"
 )
 
 func removeAllPaths(paths ...string) {
@@ -976,3 +978,28 @@ func TestCopyCaseJFSym(t *testing.T) {
 		t.Fatal(err)
 	}
 }
+
+func TestCopyInfoDestinationPathSymlink(t *testing.T) {
+	tmpDir, _ := getTestTempDirs(t)
+	defer removeAllPaths(tmpDir)
+
+	root := strings.TrimRight(tmpDir, "/") + "/"
+	files := []FileData{
+		{filetype: Regular, path: "file1", permissions: 0600, expected: CopyInfo{Path: root + "file1", Exists: true}},
+		{filetype: Dir, path: "dir1", permissions: 0700, expected: CopyInfo{Path: root + "dir1", Exists: true, IsDir: true}},
+		{filetype: Regular, path: "dir1/file1", permissions: 0600, expected: CopyInfo{Path: root + "dir1/file1", Exists: true}},
+		{filetype: Symlink, path: "symlink1", contents: "noSuchTarget", permissions: 0600, expected: CopyInfo{Path: root + "noSuchTarget", Exists: false}},
+		{filetype: Symlink, path: "symlink2", contents: "file1", permissions: 0600, expected: CopyInfo{Path: root + "file1", Exists: true}},
+		{filetype: Symlink, path: "symlink3", contents: root + "file1", permissions: 0600, expected: CopyInfo{Path: root + "file1", Exists: true}},
+		{filetype: Symlink, path: "symlink4", contents: root + "symlink3", permissions: 0600, expected: CopyInfo{Path: root + "file1", Exists: true}},
+		{filetype: Symlink, path: "dirSymlink", contents: root + "dir1", permissions: 0600, expected: CopyInfo{Path: root + "dir1", Exists: true, IsDir: true}},
+	}
+	provisionSampleDir(t, tmpDir, files)
+
+	for _, info := range files {
+		p := filepath.Join(tmpDir, info.path)
+		ci, err := CopyInfoDestinationPath(p)
+		assert.NoError(t, err)
+		assert.Equal(t, info.expected, ci)
+	}
+}
-- 
2.13.1

@dccurtis
Copy link
Contributor Author

dccurtis commented Aug 9, 2017

@thaJeztah Thanks, I think your test suggestion looks like a good start but first check my understanding of what’s happened since my original PR. I think this will have to go into the docker/cli repo since the moby/moby CopyInfoDestinationPath seems to be dead code.

Let me explain:

  • The pkg/archive/copy.go logic now resides in the docker/cli repo, and not in moby/moby. So there appears to be some dead code in moby/moby that was throwing me off. My guess is that this happened as part of the moby refactoring.
  • The integration-cli test in moby/moby is executing the ‘cp’ command (runDockerCp) using the dockerBinary setup in the environment package.
  • Before the docker/cli split this was ok since the CLI binary was being built in the same repo as the daemon. Now it appears that there is a dependency on having a fixed CLI binary before we can run the integration-cli test cases that I added (‘copy to symlink dir’ testcases).

I manually tested the same os.Lstat -> os.Stat change in docker/cli and it fixes the problem.

As for me declaring the moby/moby copy.go logic dead code. I added asserts all over the moby/moby copy.go file and all of the existing ‘cp’ tests pass. This is what lead me to discover the docker/cli repo in the first place. So I don’t know this with 100% confidence but it seems pretty obvious that its not running in the integration-cli tests anymore.

So if this is how things are working today; I think to solve this and cleanup the code we would have to:

  1. Make the copy.go change in the docker/cli repo
  2. Remove the dead code in moby/moby
  3. Deal with the integration-cli test structure to include CLI and daemon binary versions to know what test cases are expected to pass depending on what version of the CLI or daemon you are using.

Anyway, let me know what you think and I can start picking off some of these items. Also, any news on the security assessment that you mentioned earlier?

@thaJeztah
Copy link
Member

thaJeztah commented Aug 9, 2017

Ah, I see the confusion. Let me try to explain (in a rather schizophrenic way, as I happen to be both a maintainer for the Moby project, and employed by Docker 😅)

What happened with the CLI split, is that the codebase for the CLI is indeed in a separate repository. The Docker CLI is not part of the Moby project, just like many other tools that use the remote API.
For that reason, testing the Docker CLI is not a responsibility for this repository; the API should be tested (because that's the contract with other projects and products), and any packages (the pkg/ directory) should be unit-tested to make sure they work as intended.

All existing integration tests that use the Docker CLI are kept. However, the version of the Docker CLI that is used to run them is fixated to the version that was used at the time that the Docker CLI moved to its own repository.

Basically, those tests are used to verify that what worked keeps working, and that new features that are added, removed or changed in this repository, don't break those tests. It's possible changes break newer versions of the Docker CLI, or break something that isn't tested; it's Docker's responsibility to run integration tests with newer versions of the CLI, and if something broke, to report it here (or provide a fix). Given that those breaking changes would be either due to a change in the API, or due to a change in behavior of a package, testing for that can all be covered by API tests, or unit tests of the package 🎉

The pkg/archive/copy.go logic now resides in the docker/cli repo, and not in moby/moby. So there appears to be some dead code in moby/moby that was throwing me off.

The code from this repository is still used 😄 Here's how:

The Docker CLI uses various packages that are in this repository as a dependency (as well as many other packages from other repositories). You can find all dependencies in the vendor/ directory of the Docker CLI.

For example, the vendored version of the file that's being changed in this pull request can be found in vendor/github.com/docker/docker/pkg/archive/copy.go.

Files are vendored (not downloaded at compile time) so that builds are always reproducible, and don't depend on repositories we don't control (repositories may go down, someone can do a "force push" and mess things up, and repositories may move 😇 (e.g. get renamed from docker to moby)).

A tool (LK4D4/vndr) is used to "vendor" those dependencies that. Vendoring involves fetching the exact git-commit (or tag) of the dependency as specified in the vendor.conf configuration file. After that, the tool analyzes all import statements, and removes all files that are not used, including test files (as described earlier, making sure a package is tested is a responsiblity of the package author / repository).

If you look at the archive package in the Docker CLI repository, you'll see that all the _test.go files are removed.

So how to get this fix into the Docker CLI?

First, the PR needs to be finished 😄 - the package's unit tests need to be updated to check that it works as designed with this change. An API test may not nescessarily be needed, because "copy" in general is already tested, and this PR only changes the client-side code that resolves the target before the actual copy takes place (but I may have overlooked something 😅).

Once the PR is merged, a "vendor bump" PR can be opened against the Docker CLI repository to bring the actual changes into the Docker CLI. A vendor PR basically is;

  • update the vendor.conf configuration file, and change the commit to a commit that has the changes made in this PR
  • check if other dependencies have to be updated as well (it's possible the newer version of the moby/moby codebase expects a different version of its own dependencies)
  • run the vndr tool to fetch the new files
  • commit the results
  • If needed, modify the source code of the Docker CLI itself for the new features / changes, and add unit (or integration) tests accordingly; usually this is done in a separate commit.
  • Open a pull request with the changes 🎉.

You can find an example of a "bump" pull request here: docker/cli#319 (docker/cli@a996206)

Hope this helps!

@dccurtis
Copy link
Contributor Author

@thaJeztah This is exactly what I needed, thanks for clearing this up for me!

@thaJeztah
Copy link
Member

@dccurtis looks like there's a gofmt issue;

07:59:19 These files are not properly gofmt'd:
07:59:19  - pkg/archive/archive_unix_test.go
07:59:19 
07:59:19 Please reformat the above files using "gofmt -s -w" and commit the result.

@thaJeztah
Copy link
Member

otherwise looks good to me

…InfoDestinationPath

Signed-off-by: Douglas Curtis <dougcurtis1@gmail.com>

Commenting out tests for now

Signed-off-by: Doug Curtis <dougcurtis1@gmail.com>

Added unit test for CopyInfoDestionationPath.

Signed-off-by: Doug Curtis <dougcurtis1@gmail.com>

Removing integration-cli test case additions

Signed-off-by: Doug Curtis <dougcurtis1@gmail.com>

Removing extra spaces between archive_unix_test.go test cases

Signed-off-by: Doug Curtis <dougcurtis1@gmail.com>

Fixed gofmt issues in archive_unix_test.go

Signed-off-by: Doug Curtis <dougcurtis1@gmail.com>
@dccurtis
Copy link
Contributor Author

Any updates on when this change should merge?

@yongtang
Copy link
Member

Ping @tonistiigi to see if this is OK

@tonistiigi
Copy link
Member

LGTM

@yongtang
Copy link
Member

👍

@yongtang yongtang merged commit 149f3ac into moby:master Sep 19, 2017
@dccurtis dccurtis deleted the issue-31816 branch September 22, 2017 00:20
@dccurtis
Copy link
Contributor Author

@thaJeztah Can you give me a pointer on how to resolve this error that I'm getting with vndr? Thanks!

....
2017/09/21 22:13:53 Clone https://github.com/dmcgowan/buildkit.git to github.com/moby/buildkit, revision da2b9dc7dab99e824b2b1067ad7d0523e32dd2d9
2017/09/21 22:13:54 Finished clone github.com/Microsoft/go-winio
2017/09/21 22:13:54 Finished clone github.com/gorilla/mux
2017/09/21 22:13:54 Finished clone google.golang.org/genproto
2017/09/21 22:13:54 Finished clone github.com/miekg/pkcs11
2017/09/21 22:13:54 Finished clone google.golang.org/grpc
2017/09/21 22:13:55 Finished clone github.com/golang/protobuf
2017/09/21 22:13:55 Finished clone github.com/mitchellh/mapstructure
2017/09/21 22:13:56 Finished clone github.com/opencontainers/runc
2017/09/21 22:13:57 Finished clone golang.org/x/text
2017/09/21 22:13:57 Finished clone github.com/moby/buildkit
2017/09/21 22:14:02 Finished clone github.com/docker/distribution
2017/09/21 22:14:08 Finished clone github.com/docker/swarmkit
2017/09/21 22:14:09 Finished clone github.com/docker/notary
2017/09/21 22:14:16 Finished clone github.com/gogo/protobuf
2017/09/21 22:14:19 Finished clone github.com/coreos/etcd
2017/09/21 22:14:44 Finished clone github.com/docker/docker
2017/09/21 22:14:44 Errors on clone:
github.com/docker/distribution: Err: exit status 128, out: fatal: reference is not a tree: cd7489f

@thaJeztah
Copy link
Member

@dccurtis looks like a moby commit was entered on the wrong line (docker/distribution)?

liusdu pushed a commit to liusdu/moby that referenced this pull request Oct 30, 2017
…directory

Description:
‘docker cp’ fails with error ‘not a directory’ if a file is copied from
a container to a host symlinked directory with a new target filename.

The fix is to use os.Stat instead of os.Lstat since os.Stat follows the
linked path.

(Backport from moby#31993)

It is not merged yet.
Link: moby#31816

Signed-off-by: Wentao Zhang <zhangwentao234@huawei.com>
liusdu pushed a commit to liusdu/moby that referenced this pull request Oct 30, 2017
Fixing ‘docker cp’ to allow new target file name in a host symlinked directory

Description: 

‘docker cp’ fails with error ‘not a directory’ if a file is copied from
a container to a host symlinked directory with a new target filename.

The fix is to use os.Stat instead of os.Lstat since os.Stat follows the
linked path.

<br>

(Backport from <a href="moby#31993" rel="nofollow noreferrer" target="_blank">https://github.com/moby/moby/pull/31993</a>)

It is not merged yet.

Link:  <a href="moby#31816" rel="nofollow noreferrer" target="_blank">https://github.com/moby/moby/issues/31816</a> 

<br>

Signed-off-by: Wentao Zhang <a href="mailto:zhangwentao234@huawei.com">zhangwentao234@huawei.com</a>



See merge request docker/docker!632
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants