From 3f4223879b1a89836879f6416f7ac1405dbc4fa6 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 08:29:42 +0200 Subject: [PATCH] feat(platform): add tar.gz and zip extract helpers with traversal guard --- internal/platform/extract.go | 114 ++++++++++++++++++++++++++++++ internal/platform/extract_test.go | 80 +++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 internal/platform/extract.go create mode 100644 internal/platform/extract_test.go diff --git a/internal/platform/extract.go b/internal/platform/extract.go new file mode 100644 index 0000000..d205530 --- /dev/null +++ b/internal/platform/extract.go @@ -0,0 +1,114 @@ +package platform + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func safeJoin(dest, name string) (string, error) { + p := filepath.Join(dest, name) + cleanDest := filepath.Clean(dest) + string(os.PathSeparator) + if !strings.HasPrefix(filepath.Clean(p)+string(os.PathSeparator), cleanDest) { + return "", fmt.Errorf("unsafe path %q escapes %q", name, dest) + } + return p, nil +} + +func ExtractTarGz(ctx context.Context, archivePath, destDir string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + if err := ctx.Err(); err != nil { + return err + } + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + target, err := safeJoin(destDir, h.Name) + if err != nil { + return err + } + switch h.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, os.FileMode(h.Mode)); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(w, tr); err != nil { + w.Close() + return err + } + w.Close() + } + } + return nil +} + +func ExtractZip(ctx context.Context, archivePath, destDir string) error { + zr, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer zr.Close() + for _, zf := range zr.File { + if err := ctx.Err(); err != nil { + return err + } + target, err := safeJoin(destDir, zf.Name) + if err != nil { + return err + } + if zf.FileInfo().IsDir() { + if err := os.MkdirAll(target, zf.Mode()); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := zf.Open() + if err != nil { + return err + } + w, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, zf.Mode()) + if err != nil { + rc.Close() + return err + } + _, err = io.Copy(w, rc) + rc.Close() + w.Close() + if err != nil { + return err + } + } + return nil +} diff --git a/internal/platform/extract_test.go b/internal/platform/extract_test.go new file mode 100644 index 0000000..7a652a1 --- /dev/null +++ b/internal/platform/extract_test.go @@ -0,0 +1,80 @@ +package platform + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "os" + "path/filepath" + "testing" +) + +func makeTarGz(t *testing.T, entries map[string]string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "a.tar.gz") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + for name, body := range entries { + tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))}) + tw.Write([]byte(body)) + } + tw.Close() + gz.Close() + f.Close() + return p +} + +func makeZip(t *testing.T, entries map[string]string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "a.zip") + f, _ := os.Create(p) + zw := zip.NewWriter(f) + for name, body := range entries { + w, _ := zw.Create(name) + w.Write([]byte(body)) + } + zw.Close() + f.Close() + return p +} + +func TestExtractTarGz(t *testing.T) { + src := makeTarGz(t, map[string]string{"hello.txt": "world", "sub/x.txt": "y"}) + dest := t.TempDir() + if err := ExtractTarGz(context.Background(), src, dest); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(filepath.Join(dest, "hello.txt")) + if string(got) != "world" { + t.Fatalf("got %q", got) + } + got, _ = os.ReadFile(filepath.Join(dest, "sub", "x.txt")) + if string(got) != "y" { + t.Fatalf("got %q", got) + } +} + +func TestExtractZip(t *testing.T) { + src := makeZip(t, map[string]string{"a.txt": "A"}) + dest := t.TempDir() + if err := ExtractZip(context.Background(), src, dest); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(filepath.Join(dest, "a.txt")) + if string(got) != "A" { + t.Fatalf("got %q", got) + } +} + +func TestExtractRejectsPathTraversal(t *testing.T) { + src := makeTarGz(t, map[string]string{"../evil.txt": "x"}) + dest := t.TempDir() + if err := ExtractTarGz(context.Background(), src, dest); err == nil { + t.Fatal("want error on path traversal") + } +}