diff --git a/fs/copy.go b/fs/copy.go new file mode 100644 index 00000000..e2adde20 --- /dev/null +++ b/fs/copy.go @@ -0,0 +1,131 @@ +package fs + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +// Copy will move a source file or directory to a destination. For directories, +// move will remap relative symlinks ensuring that they align with the +// destination directory. If the destination exists prior to invocation, it +// will be removed. +func Copy(source, destination string) error { + err := os.Remove(destination) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to copy: destination exists: %w", err) + } + } + + info, err := os.Stat(source) + if err != nil { + return err + } + + if info.IsDir() { + err = copyDirectory(source, destination) + if err != nil { + return err + } + } else { + err = copyFile(source, destination) + if err != nil { + return err + } + } + + return nil +} + +func copyFile(source, destination string) error { + sourceFile, err := os.Open(source) + if err != nil { + return err + } + defer sourceFile.Close() + + destinationFile, err := os.Create(destination) + if err != nil { + return err + } + defer destinationFile.Close() + + _, err = io.Copy(destinationFile, sourceFile) + if err != nil { + return err + } + + info, err := sourceFile.Stat() + if err != nil { + return err + } + + err = destinationFile.Chmod(info.Mode()) + if err != nil { + return err + } + + return nil +} + +func copyDirectory(source, destination string) error { + err := filepath.Walk(source, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + path, err = filepath.Rel(source, path) + if err != nil { + return err + } + + switch { + case info.IsDir(): + err = os.Mkdir(filepath.Join(destination, path), os.ModePerm) + if err != nil { + return err + } + + case (info.Mode() & os.ModeSymlink) != 0: + link, err := os.Readlink(filepath.Join(source, path)) + if err != nil { + return err + } + + if filepath.HasPrefix(link, "..") { + link = filepath.Clean(filepath.Join(source, filepath.Base(path), link)) + } + + relativeLink, err := filepath.Rel(source, link) + if err != nil { + return err + } + + if filepath.HasPrefix(relativeLink, "..") { + err = os.Symlink(link, filepath.Join(destination, path)) + } else { + err = os.Symlink(filepath.Join(destination, relativeLink), filepath.Join(destination, path)) + } + if err != nil { + return err + } + + default: + err = copyFile(filepath.Join(source, path), filepath.Join(destination, path)) + if err != nil { + return err + } + } + + return nil + }) + + if err != nil { + return err + } + + return nil +} diff --git a/fs/copy_test.go b/fs/copy_test.go new file mode 100644 index 00000000..cfbc2cc6 --- /dev/null +++ b/fs/copy_test.go @@ -0,0 +1,211 @@ +package fs_test + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/paketo-buildpacks/packit/fs" + "github.com/sclevine/spec" + + . "github.com/onsi/gomega" +) + +func testCopy(t *testing.T, context spec.G, it spec.S) { + var Expect = NewWithT(t).Expect + + context("Copy", func() { + var ( + sourceDir string + destinationDir string + ) + + it.Before(func() { + var err error + sourceDir, err = ioutil.TempDir("", "source") + Expect(err).NotTo(HaveOccurred()) + + destinationDir, err = ioutil.TempDir("", "destination") + Expect(err).NotTo(HaveOccurred()) + }) + + it.After(func() { + Expect(os.RemoveAll(sourceDir)).To(Succeed()) + Expect(os.RemoveAll(destinationDir)).To(Succeed()) + }) + + context("when the source is a file", func() { + var source, destination string + + it.Before(func() { + source = filepath.Join(sourceDir, "source") + destination = filepath.Join(destinationDir, "destination") + + err := ioutil.WriteFile(source, []byte("some-content"), 0644) + Expect(err).NotTo(HaveOccurred()) + }) + + it("copies the source file to the destination", func() { + err := fs.Copy(source, destination) + Expect(err).NotTo(HaveOccurred()) + + content, err := ioutil.ReadFile(destination) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(Equal("some-content")) + + info, err := os.Stat(destination) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode()).To(Equal(os.FileMode(0644))) + + Expect(source).To(BeAnExistingFile()) + }) + + context("failure cases", func() { + context("when the source cannot be read", func() { + it.Before(func() { + Expect(os.Chmod(source, 0000)).To(Succeed()) + }) + + it("returns an error", func() { + err := fs.Copy(source, destination) + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + + context("when the destination cannot be removed", func() { + it.Before(func() { + Expect(os.Chmod(destinationDir, 0000)).To(Succeed()) + }) + + it("returns an error", func() { + err := fs.Copy(source, destination) + Expect(err).To(MatchError(ContainSubstring("failed to copy: destination exists:"))) + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + }) + }) + + context("when the source is a directory", func() { + var source, destination, external string + + it.Before(func() { + var err error + external, err = ioutil.TempDir("", "external") + Expect(err).NotTo(HaveOccurred()) + + err = ioutil.WriteFile(filepath.Join(external, "some-file"), []byte("some-content"), 0644) + Expect(err).NotTo(HaveOccurred()) + + source = filepath.Join(sourceDir, "source") + destination = filepath.Join(destinationDir, "destination") + + Expect(os.MkdirAll(filepath.Join(source, "some-dir"), os.ModePerm)).To(Succeed()) + + err = ioutil.WriteFile(filepath.Join(source, "some-dir", "some-file"), []byte("some-content"), 0644) + Expect(err).NotTo(HaveOccurred()) + + err = ioutil.WriteFile(filepath.Join(source, "some-dir", "readonly-file"), []byte("some-content"), 0444) + Expect(err).NotTo(HaveOccurred()) + + err = os.Symlink(filepath.Join(source, "some-dir", "some-file"), filepath.Join(source, "some-dir", "some-symlink")) + Expect(err).NotTo(HaveOccurred()) + + err = os.Symlink(filepath.Join(external, "some-file"), filepath.Join(source, "some-dir", "external-symlink")) + Expect(err).NotTo(HaveOccurred()) + }) + + context("when the destination does not exist", func() { + it("copies the source directory to the destination", func() { + err := fs.Copy(source, destination) + Expect(err).NotTo(HaveOccurred()) + + Expect(destination).To(BeADirectory()) + + content, err := ioutil.ReadFile(filepath.Join(destination, "some-dir", "some-file")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(Equal("some-content")) + + info, err := os.Stat(filepath.Join(destination, "some-dir", "some-file")) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode()).To(Equal(os.FileMode(0644))) + + info, err = os.Stat(filepath.Join(destination, "some-dir", "readonly-file")) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode()).To(Equal(os.FileMode(0444))) + + path, err := os.Readlink(filepath.Join(destination, "some-dir", "some-symlink")) + Expect(err).NotTo(HaveOccurred()) + Expect(path).To(Equal(filepath.Join(destination, "some-dir", "some-file"))) + + path, err = os.Readlink(filepath.Join(destination, "some-dir", "external-symlink")) + Expect(err).NotTo(HaveOccurred()) + Expect(path).To(Equal(filepath.Join(external, "some-file"))) + + Expect(source).To(BeAnExistingFile()) + }) + }) + + context("when the destination is a file", func() { + it.Before(func() { + Expect(os.RemoveAll(destination)) + Expect(ioutil.WriteFile(destination, []byte{}, os.ModePerm)).To(Succeed()) + }) + + it("copies the source directory to the destination", func() { + err := fs.Copy(source, destination) + Expect(err).NotTo(HaveOccurred()) + + Expect(destination).To(BeADirectory()) + + content, err := ioutil.ReadFile(filepath.Join(destination, "some-dir", "some-file")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(Equal("some-content")) + + info, err := os.Stat(filepath.Join(destination, "some-dir", "some-file")) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode()).To(Equal(os.FileMode(0644))) + + info, err = os.Stat(filepath.Join(destination, "some-dir", "readonly-file")) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode()).To(Equal(os.FileMode(0444))) + + path, err := os.Readlink(filepath.Join(destination, "some-dir", "some-symlink")) + Expect(err).NotTo(HaveOccurred()) + Expect(path).To(Equal(filepath.Join(destination, "some-dir", "some-file"))) + + path, err = os.Readlink(filepath.Join(destination, "some-dir", "external-symlink")) + Expect(err).NotTo(HaveOccurred()) + Expect(path).To(Equal(filepath.Join(external, "some-file"))) + + Expect(source).To(BeAnExistingFile()) + }) + }) + + context("failure cases", func() { + context("when the source does not exist", func() { + it("returns an error", func() { + err := fs.Copy("no-such-source", destination) + Expect(err).To(MatchError(ContainSubstring("no such file or directory"))) + }) + }) + + context("when the source cannot be walked", func() { + it.Before(func() { + Expect(os.Chmod(source, 0000)).To(Succeed()) + }) + + it.After(func() { + Expect(os.Chmod(source, 0777)).To(Succeed()) + }) + + it("returns an error", func() { + err := fs.Copy(source, destination) + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + }) + }) + }) + }) + }) +} diff --git a/fs/init_test.go b/fs/init_test.go index eb0de813..edd077f3 100644 --- a/fs/init_test.go +++ b/fs/init_test.go @@ -10,6 +10,7 @@ import ( func TestUnitFS(t *testing.T) { suite := spec.New("packit/fs", spec.Report(report.Terminal{})) suite("Move", testMove) + suite("Copy", testCopy) suite("IsEmptyDir", testIsEmptyDir) suite("ChecksumCalculator", testChecksumCalculator) suite.Run(t) diff --git a/fs/move.go b/fs/move.go index 045cee44..d2ee3989 100644 --- a/fs/move.go +++ b/fs/move.go @@ -1,11 +1,8 @@ package fs import ( - "errors" "fmt" - "io" "os" - "path/filepath" ) // Move will move a source file or directory to a destination. For directories, @@ -14,28 +11,9 @@ import ( // will be removed. Additionally, the source will be removed once it has been // copied to the destination. func Move(source, destination string) error { - err := os.Remove(destination) + err := Copy(source, destination) if err != nil { - if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("failed to move: destination exists: %w", err) - } - } - - info, err := os.Stat(source) - if err != nil { - return err - } - - if info.IsDir() { - err = copyDirectory(source, destination) - if err != nil { - return err - } - } else { - err = copyFile(source, destination) - if err != nil { - return err - } + return fmt.Errorf("failed to move: %s", err) } err = os.RemoveAll(source) @@ -45,93 +23,3 @@ func Move(source, destination string) error { return nil } - -func copyFile(source, destination string) error { - sourceFile, err := os.Open(source) - if err != nil { - return err - } - defer sourceFile.Close() - - destinationFile, err := os.Create(destination) - if err != nil { - return err - } - defer destinationFile.Close() - - _, err = io.Copy(destinationFile, sourceFile) - if err != nil { - return err - } - - info, err := sourceFile.Stat() - if err != nil { - return err - } - - err = destinationFile.Chmod(info.Mode()) - if err != nil { - return err - } - - return nil -} - -func copyDirectory(source, destination string) error { - err := filepath.Walk(source, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - path, err = filepath.Rel(source, path) - if err != nil { - return err - } - - switch { - case info.IsDir(): - err = os.Mkdir(filepath.Join(destination, path), os.ModePerm) - if err != nil { - return err - } - - case (info.Mode() & os.ModeSymlink) != 0: - link, err := os.Readlink(filepath.Join(source, path)) - if err != nil { - return err - } - - if filepath.HasPrefix(link, "..") { - link = filepath.Clean(filepath.Join(source, filepath.Base(path), link)) - } - - relativeLink, err := filepath.Rel(source, link) - if err != nil { - return err - } - - if filepath.HasPrefix(relativeLink, "..") { - err = os.Symlink(link, filepath.Join(destination, path)) - } else { - err = os.Symlink(filepath.Join(destination, relativeLink), filepath.Join(destination, path)) - } - if err != nil { - return err - } - - default: - err = copyFile(filepath.Join(source, path), filepath.Join(destination, path)) - if err != nil { - return err - } - } - - return nil - }) - - if err != nil { - return err - } - - return nil -} diff --git a/fs/move_test.go b/fs/move_test.go index 0b2be809..6cd6f282 100644 --- a/fs/move_test.go +++ b/fs/move_test.go @@ -80,7 +80,7 @@ func testMove(t *testing.T, context spec.G, it spec.S) { it("returns an error", func() { err := fs.Move(source, destination) - Expect(err).To(MatchError(ContainSubstring("failed to move: destination exists:"))) + Expect(err).To(MatchError(ContainSubstring("failed to move: failed to copy: destination exists:"))) Expect(err).To(MatchError(ContainSubstring("permission denied"))) }) }) diff --git a/go.sum b/go.sum index 636aa0a8..a9f30c1e 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191128015809-6d18c012aee9 h1:ZBzSG/7F4eNKz2L3GE9o300RX0Az1Bw5HF7PDraD+qU= golang.org/x/sys v0.0.0-20191128015809-6d18c012aee9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=