feat(platform): add tar.gz and zip extract helpers with traversal guard

This commit is contained in:
MDietermann
2026-04-14 08:29:42 +02:00
parent 2d73114b5f
commit 3f4223879b
2 changed files with 194 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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")
}
}