From 99f185203ac3e31d82b170df261c150d8ff24c14 Mon Sep 17 00:00:00 2001 From: ramonskie Date: Tue, 2 Dec 2025 23:38:48 +0100 Subject: [PATCH] Add extraction functions with strip components support Add ExtractZipWithStrip, ExtractTarGzWithStrip, and ExtractTarXzWithStrip functions to enable stripping leading path components during archive extraction, similar to tar's --strip-components flag. This feature allows buildpacks to extract nested archives without requiring custom post-extraction directory traversal logic. --- util.go | 168 +++++++++++++++++++++++++++++++++++++++++++++++++++ util_test.go | 92 ++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+) diff --git a/util.go b/util.go index db0b08fc..68610326 100644 --- a/util.go +++ b/util.go @@ -150,6 +150,53 @@ func ExtractZip(zipfile, destDir string) error { return nil } +// ExtractZipWithStrip extracts zipfile to destDir, optionally stripping N leading path components +// stripComponents works like tar's --strip-components flag: +// +// 0 = extract as-is (default) +// 1 = remove top-level directory +// 2 = remove two levels, etc. +func ExtractZipWithStrip(zipfile, destDir string, stripComponents int) error { + r, err := zip.OpenReader(zipfile) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + // Strip leading path components + name := filepath.Clean(f.Name) + if stripComponents > 0 { + parts := strings.Split(name, string(filepath.Separator)) + if len(parts) <= stripComponents { + // Skip files/dirs that would be completely stripped away + continue + } + name = filepath.Join(parts[stripComponents:]...) + } + + path := filepath.Join(destDir, name) + + rc, err := f.Open() + if err != nil { + return err + } + + if f.FileInfo().IsDir() { + err = os.MkdirAll(path, f.Mode()) + } else { + err = writeToFile(rc, path, f.Mode()) + } + + rc.Close() + if err != nil { + return err + } + } + + return nil +} + func ExtractTarXz(tarfile, destDir string) error { file, err := os.Open(tarfile) if err != nil { @@ -161,6 +208,23 @@ func ExtractTarXz(tarfile, destDir string) error { return extractTar(xz, destDir) } +// ExtractTarXzWithStrip extracts tar.xz to destDir, optionally stripping N leading path components +// stripComponents works like tar's --strip-components flag: +// +// 0 = extract as-is (default) +// 1 = remove top-level directory +// 2 = remove two levels, etc. +func ExtractTarXzWithStrip(tarfile, destDir string, stripComponents int) error { + file, err := os.Open(tarfile) + if err != nil { + return err + } + defer file.Close() + xz := xzReader(file) + defer xz.Close() + return extractTarWithStrip(xz, destDir, stripComponents) +} + func xzReader(r io.Reader) io.ReadCloser { rpipe, wpipe := io.Pipe() @@ -208,6 +272,26 @@ func ExtractTarGz(tarfile, destDir string) error { return extractTar(gz, destDir) } +// ExtractTarGzWithStrip extracts tar.gz to destDir, optionally stripping N leading path components +// stripComponents works like tar's --strip-components flag: +// +// 0 = extract as-is (default) +// 1 = remove top-level directory +// 2 = remove two levels, etc. +func ExtractTarGzWithStrip(tarfile, destDir string, stripComponents int) error { + file, err := os.Open(tarfile) + if err != nil { + return err + } + defer file.Close() + gz, err := gzip.NewReader(file) + if err != nil { + return err + } + defer gz.Close() + return extractTarWithStrip(gz, destDir, stripComponents) +} + // CopyFile copies source file to destFile, creating all intermediate directories in destFile func CopyFile(source, destFile string) error { fh, err := os.Open(source) @@ -309,6 +393,90 @@ func extractTar(src io.Reader, destDir string) error { return nil } +func extractTarWithStrip(src io.Reader, destDir string, stripComponents int) error { + tr := tar.NewReader(src) + + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + + // Strip leading path components + name := cleanPath(hdr.Name) + if stripComponents > 0 { + parts := strings.Split(name, string(filepath.Separator)) + if len(parts) <= stripComponents { + // Skip files/dirs that would be completely stripped away + continue + } + name = filepath.Join(parts[stripComponents:]...) + } + + path := filepath.Join(destDir, name) + + fi := hdr.FileInfo() + if fi.IsDir() { + if err := os.MkdirAll(path, hdr.FileInfo().Mode()); err != nil { + return err + } + } else if hdr.Typeflag == tar.TypeSymlink { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + if filepath.IsAbs(hdr.Linkname) { + return fmt.Errorf("cannot link to an absolute path when extracting archives") + } + + fullLink, err := filepath.Abs(filepath.Join(filepath.Dir(path), hdr.Linkname)) + if err != nil { + return err + } + + fullDest, err := filepath.Abs(destDir) + if err != nil { + return err + } + + // check that the relative link does not escape the destination dir + if !strings.HasPrefix(fullLink, fullDest) { + return fmt.Errorf("cannot link outside of the destination diretory when extracting archives") + } + + if err = os.Symlink(hdr.Linkname, path); err != nil { + return err + } + } else if hdr.Typeflag == tar.TypeLink { + // For hard links, also strip the link target path + linkname := cleanPath(hdr.Linkname) + if stripComponents > 0 { + parts := strings.Split(linkname, string(filepath.Separator)) + if len(parts) <= stripComponents { + // Skip if link target would be stripped away + continue + } + linkname = filepath.Join(parts[stripComponents:]...) + } + originalPath := filepath.Join(destDir, linkname) + file, err := os.Open(originalPath) + if err != nil { + return err + } + + if err := writeToFile(file, path, hdr.FileInfo().Mode()); err != nil { + return err + } + + } else { + if err := writeToFile(tr, path, hdr.FileInfo().Mode()); err != nil { + return err + } + } + } + return nil +} + func filterURI(rawURL string) (string, error) { unsafeURL, err := url.Parse(rawURL) diff --git a/util_test.go b/util_test.go index ca60c888..e4c95070 100644 --- a/util_test.go +++ b/util_test.go @@ -87,6 +87,41 @@ var _ = Describe("Util", func() { }) }) + Describe("ExtractZipWithStrip", func() { + var ( + tmpdir string + err error + ) + BeforeEach(func() { + tmpdir, err = ioutil.TempDir("", "exploded") + Expect(err).To(BeNil()) + }) + AfterEach(func() { err = os.RemoveAll(tmpdir); Expect(err).To(BeNil()) }) + + Context("with stripComponents=0", func() { + It("behaves like ExtractZip", func() { + err = libbuildpack.ExtractZipWithStrip("fixtures/thing.zip", tmpdir, 0) + Expect(err).To(BeNil()) + + Expect(filepath.Join(tmpdir, "root.txt")).To(BeAnExistingFile()) + Expect(filepath.Join(tmpdir, "thing", "bin", "file2.exe")).To(BeAnExistingFile()) + }) + }) + + Context("with stripComponents=1", func() { + It("strips the top-level directory", func() { + err = libbuildpack.ExtractZipWithStrip("fixtures/thing.zip", tmpdir, 1) + Expect(err).To(BeNil()) + + // root.txt should be gone (only 1 component) + Expect(filepath.Join(tmpdir, "root.txt")).ToNot(BeAnExistingFile()) + // thing/bin/file2.exe should become bin/file2.exe + Expect(filepath.Join(tmpdir, "bin", "file2.exe")).To(BeAnExistingFile()) + Expect(ioutil.ReadFile(filepath.Join(tmpdir, "bin", "file2.exe"))).To(Equal([]byte("progam2\n"))) + }) + }) + }) + Describe("GetBuildpackDir", func() { var ( parentDir string @@ -254,6 +289,63 @@ var _ = Describe("Util", func() { }) }) + Describe("ExtractTarGzWithStrip", func() { + var ( + tmpdir string + err error + ) + BeforeEach(func() { + tmpdir, err = ioutil.TempDir("", "exploded") + Expect(err).To(BeNil()) + }) + AfterEach(func() { err = os.RemoveAll(tmpdir); Expect(err).To(BeNil()) }) + + Context("with stripComponents=0", func() { + It("behaves like ExtractTarGz", func() { + err = libbuildpack.ExtractTarGzWithStrip("fixtures/thing.tgz", tmpdir, 0) + Expect(err).To(BeNil()) + + Expect(filepath.Join(tmpdir, "root.txt")).To(BeAnExistingFile()) + Expect(filepath.Join(tmpdir, "thing", "bin", "file2.exe")).To(BeAnExistingFile()) + }) + }) + + Context("with stripComponents=1", func() { + It("strips the top-level directory", func() { + err = libbuildpack.ExtractTarGzWithStrip("fixtures/thing.tgz", tmpdir, 1) + Expect(err).To(BeNil()) + + // root.txt should be gone (only 1 component) + Expect(filepath.Join(tmpdir, "root.txt")).ToNot(BeAnExistingFile()) + // thing/bin/file2.exe should become bin/file2.exe + Expect(filepath.Join(tmpdir, "bin", "file2.exe")).To(BeAnExistingFile()) + Expect(ioutil.ReadFile(filepath.Join(tmpdir, "bin", "file2.exe"))).To(Equal([]byte("progam2\n"))) + }) + }) + }) + + Describe("ExtractTarXzWithStrip", func() { + var ( + tmpdir string + err error + ) + BeforeEach(func() { + tmpdir, err = ioutil.TempDir("", "exploded") + Expect(err).To(BeNil()) + }) + AfterEach(func() { err = os.RemoveAll(tmpdir); Expect(err).To(BeNil()) }) + + Context("with stripComponents=0", func() { + It("behaves like ExtractTarXz", func() { + err = libbuildpack.ExtractTarXzWithStrip("fixtures/xzarchive.tar.xz", tmpdir, 0) + Expect(err).To(BeNil()) + + Expect(filepath.Join(tmpdir, "innerDir")).To(BeADirectory()) + Expect(filepath.Join(tmpdir, "innerDir", "inner_file.txt")).To(BeAnExistingFile()) + }) + }) + }) + Describe("CopyFile", func() { var ( tmpdir string