From 6d42b2759eccdddff7466009344e301f9b180f70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 14:07:57 +0000 Subject: [PATCH 1/7] Add zst archive decompression support Agent-Logs-Url: https://github.com/version-fox/vfox/sessions/abcce404-2e68-4df1-aad4-2d6d0295b4f0 Co-authored-by: bytemain <13938334+bytemain@users.noreply.github.com> --- docs/plugins/library/archiver.md | 4 +- docs/zh-hans/plugins/library/archiver.md | 4 +- go.mod | 2 +- internal/shared/util/decompressor.go | 81 +++++++++++++++++++++++ internal/shared/util/decompressor_test.go | 68 +++++++++++++++++++ 5 files changed, 154 insertions(+), 5 deletions(-) diff --git a/docs/plugins/library/archiver.md b/docs/plugins/library/archiver.md index 0db914e7..51642689 100644 --- a/docs/plugins/library/archiver.md +++ b/docs/plugins/library/archiver.md @@ -1,6 +1,6 @@ # Archiver Library -`vfox` provides a decompression tool that supports `tar.gz`, `tgz`, `tar.xz`, `zip`, and `7z`. In Lua scripts, you can +`vfox` provides a decompression tool that supports `tar.gz`, `tgz`, `tar.xz`, `tar.zst`, `tzst`, `zip`, and `7z`. In Lua scripts, you can use `require("vfox.archiver")` to access it. **Usage** @@ -8,4 +8,4 @@ use `require("vfox.archiver")` to access it. ```lua local archiver = require("vfox.archiver") local err = archiver.decompress("testdata/test.zip", "testdata/test") -``` \ No newline at end of file +``` diff --git a/docs/zh-hans/plugins/library/archiver.md b/docs/zh-hans/plugins/library/archiver.md index f14e60ce..f62a59ed 100644 --- a/docs/zh-hans/plugins/library/archiver.md +++ b/docs/zh-hans/plugins/library/archiver.md @@ -1,10 +1,10 @@ # Archiver 标准库 -`vfox` 提供了解压工具, 支持`tar.gz`、`tgz`、`tar.xz`、`zip`、`7z`。在Lua脚本中,你可以使用`require("vfox.archiver")`来访问它。 +`vfox` 提供了解压工具, 支持`tar.gz`、`tgz`、`tar.xz`、`tar.zst`、`tzst`、`zip`、`7z`。在Lua脚本中,你可以使用`require("vfox.archiver")`来访问它。 例如: **Usage** ```shell local archiver = require("vfox.archiver") local err = archiver.decompress("testdata/test.zip", "testdata/test") -``` \ No newline at end of file +``` diff --git a/go.mod b/go.mod index 31ded9d8..90a4d583 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 github.com/PuerkitoBio/goquery v1.9.3 github.com/bodgit/sevenzip v1.5.1 + github.com/klauspost/compress v1.17.7 github.com/lithammer/fuzzysearch v1.1.8 github.com/pterm/pterm v0.12.79 github.com/schollz/progressbar/v3 v3.14.2 @@ -36,7 +37,6 @@ require ( github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/klauspost/compress v1.17.7 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect diff --git a/internal/shared/util/decompressor.go b/internal/shared/util/decompressor.go index 6a057ccc..6dad614b 100644 --- a/internal/shared/util/decompressor.go +++ b/internal/shared/util/decompressor.go @@ -29,6 +29,7 @@ import ( "strings" "github.com/bodgit/sevenzip" + "github.com/klauspost/compress/zstd" "github.com/ulikunitz/xz" ) @@ -262,6 +263,81 @@ loop: return nil } +type ZstdTarDecompressor struct { + src string +} + +func (z *ZstdTarDecompressor) Decompress(dest string) error { + file, err := os.Open(z.src) + if err != nil { + return err + } + defer file.Close() + + zr, err := zstd.NewReader(file) + if err != nil { + return err + } + defer zr.Close() + + tr := tar.NewReader(zr) + var symlinks []symlink + +loop: + for { + header, err := tr.Next() + switch { + case err == io.EOF: + break loop + case err != nil: + return err + case header == nil: + continue + } + + parts := strings.Split(header.Name, "/") + if len(parts) > 1 { + parts = parts[1:] + } + fname := strings.Join(parts, "/") + target := filepath.Join(dest, fname) + + switch header.Typeflag { + case tar.TypeDir: + if _, err := os.Stat(target); err != nil { + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + } + case tar.TypeReg: + _ = os.MkdirAll(filepath.Dir(target), 0755) + f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { + return err + } + f.Close() + case tar.TypeSymlink: + symlinks = append(symlinks, symlink{header.Linkname, target}) + } + } + + for _, s := range symlinks { + dir := filepath.Dir(s.newname) + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + } + if err = os.Symlink(s.oldname, s.newname); err != nil { + return err + } + } + return nil +} + type ZipDecompressor struct { src string } @@ -525,6 +601,11 @@ func NewDecompressor(src string) Decompressor { src: src, } } + if strings.HasSuffix(filename, ".tar.zst") || strings.HasSuffix(filename, ".tzst") { + return &ZstdTarDecompressor{ + src: src, + } + } if strings.HasSuffix(filename, ".zip") { return &ZipDecompressor{ src: src, diff --git a/internal/shared/util/decompressor_test.go b/internal/shared/util/decompressor_test.go index b2ab93ed..63728ca1 100644 --- a/internal/shared/util/decompressor_test.go +++ b/internal/shared/util/decompressor_test.go @@ -17,7 +17,13 @@ package util import ( + "archive/tar" + "os" + "path/filepath" + "strings" "testing" + + "github.com/klauspost/compress/zstd" ) func TestNewDecompressor(t *testing.T) { @@ -31,12 +37,74 @@ func TestNewDecompressor(t *testing.T) { t.Errorf("Expected ZipDecompressor, got %T", zipDecompressor) } + zstdTarDecompressor := NewDecompressor("test.tar.zst") + if _, ok := zstdTarDecompressor.(*ZstdTarDecompressor); !ok { + t.Errorf("Expected ZstdTarDecompressor, got %T", zstdTarDecompressor) + } + + tzstDecompressor := NewDecompressor("test.tzst") + if _, ok := tzstDecompressor.(*ZstdTarDecompressor); !ok { + t.Errorf("Expected ZstdTarDecompressor, got %T", tzstDecompressor) + } + unknownDecompressor := NewDecompressor("test.unknown") if unknownDecompressor != nil { t.Errorf("Expected nil, got %T", unknownDecompressor) } } +func TestZstdTarDecompressor(t *testing.T) { + tempDir := t.TempDir() + archivePath := filepath.Join(tempDir, "test.tar.zst") + dest := filepath.Join(tempDir, "dest") + + file, err := os.Create(archivePath) + if err != nil { + t.Fatal(err) + } + + zw, err := zstd.NewWriter(file) + if err != nil { + t.Fatal(err) + } + tw := tar.NewWriter(zw) + + body := "Hello, zstd!" + err = tw.WriteHeader(&tar.Header{ + Name: "root/test.txt", + Mode: 0600, + Size: int64(len(body)), + }) + if err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + + decompressor := NewDecompressor(archivePath) + if err := decompressor.Decompress(dest); err != nil { + t.Fatalf("Failed to decompress: %v", err) + } + + decompressedFile, err := os.ReadFile(filepath.Join(dest, "test.txt")) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(decompressedFile)) != body { + t.Errorf("Expected %q, got %q", body, string(decompressedFile)) + } +} + //func TestDecompress(t *testing.T) { // // Create a temporary directory for testing // tempDir, err := os.MkdirTemp("", "decompress_test") From 4c12b850ac93b039a50dbaa3fa33c4c68acabc73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 14:11:40 +0000 Subject: [PATCH 2/7] Secure zst archive extraction Agent-Logs-Url: https://github.com/version-fox/vfox/sessions/abcce404-2e68-4df1-aad4-2d6d0295b4f0 Co-authored-by: bytemain <13938334+bytemain@users.noreply.github.com> --- internal/shared/util/decompressor.go | 37 +++++++++++++--- internal/shared/util/decompressor_test.go | 52 ++++++++++++++++------- 2 files changed, 68 insertions(+), 21 deletions(-) diff --git a/internal/shared/util/decompressor.go b/internal/shared/util/decompressor.go index 6dad614b..01e9fec9 100644 --- a/internal/shared/util/decompressor.go +++ b/internal/shared/util/decompressor.go @@ -295,12 +295,10 @@ loop: continue } - parts := strings.Split(header.Name, "/") - if len(parts) > 1 { - parts = parts[1:] + target, err := safeTarTarget(dest, header.Name) + if err != nil { + return err } - fname := strings.Join(parts, "/") - target := filepath.Join(dest, fname) switch header.Typeflag { case tar.TypeDir: @@ -310,7 +308,9 @@ loop: } } case tar.TypeReg: - _ = os.MkdirAll(filepath.Dir(target), 0755) + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) if err != nil { return err @@ -338,6 +338,31 @@ loop: return nil } +func safeTarTarget(dest string, name string) (string, error) { + normalizedPath := strings.ReplaceAll(name, "\\", "/") + parts := strings.Split(normalizedPath, "/") + if len(parts) > 1 { + parts = parts[1:] + } + fname := filepath.Clean(strings.Join(parts, "/")) + if fname == "." { + return dest, nil + } + if !filepath.IsLocal(fname) { + return "", fmt.Errorf("archive entry %q is outside destination", name) + } + + target := filepath.Join(dest, fname) + rel, err := filepath.Rel(dest, target) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("archive entry %q is outside destination", name) + } + return target, nil +} + type ZipDecompressor struct { src string } diff --git a/internal/shared/util/decompressor_test.go b/internal/shared/util/decompressor_test.go index 63728ca1..7cca2c15 100644 --- a/internal/shared/util/decompressor_test.go +++ b/internal/shared/util/decompressor_test.go @@ -57,6 +57,42 @@ func TestZstdTarDecompressor(t *testing.T) { tempDir := t.TempDir() archivePath := filepath.Join(tempDir, "test.tar.zst") dest := filepath.Join(tempDir, "dest") + body := "Hello, zstd!" + + writeZstdTar(t, archivePath, "test.txt", body) + + decompressor := NewDecompressor(archivePath) + if err := decompressor.Decompress(dest); err != nil { + t.Fatalf("Failed to decompress: %v", err) + } + + decompressedFile, err := os.ReadFile(filepath.Join(dest, "test.txt")) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(decompressedFile)) != body { + t.Errorf("Expected %q, got %q", body, string(decompressedFile)) + } +} + +func TestZstdTarDecompressorRejectsPathTraversal(t *testing.T) { + tempDir := t.TempDir() + archivePath := filepath.Join(tempDir, "test.tar.zst") + dest := filepath.Join(tempDir, "dest") + + writeZstdTar(t, archivePath, "root/../../evil.txt", "evil") + + decompressor := NewDecompressor(archivePath) + if err := decompressor.Decompress(dest); err == nil { + t.Fatal("Expected path traversal archive entry to fail") + } + if _, err := os.Stat(filepath.Join(tempDir, "evil.txt")); !os.IsNotExist(err) { + t.Fatalf("Expected no file outside destination, got err %v", err) + } +} + +func writeZstdTar(t *testing.T, archivePath string, name string, body string) { + t.Helper() file, err := os.Create(archivePath) if err != nil { @@ -69,9 +105,8 @@ func TestZstdTarDecompressor(t *testing.T) { } tw := tar.NewWriter(zw) - body := "Hello, zstd!" err = tw.WriteHeader(&tar.Header{ - Name: "root/test.txt", + Name: name, Mode: 0600, Size: int64(len(body)), }) @@ -90,19 +125,6 @@ func TestZstdTarDecompressor(t *testing.T) { if err := file.Close(); err != nil { t.Fatal(err) } - - decompressor := NewDecompressor(archivePath) - if err := decompressor.Decompress(dest); err != nil { - t.Fatalf("Failed to decompress: %v", err) - } - - decompressedFile, err := os.ReadFile(filepath.Join(dest, "test.txt")) - if err != nil { - t.Fatal(err) - } - if strings.TrimSpace(string(decompressedFile)) != body { - t.Errorf("Expected %q, got %q", body, string(decompressedFile)) - } } //func TestDecompress(t *testing.T) { From 37ed69d2b8cc885c60178782cf9c0777e08210bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 14:13:00 +0000 Subject: [PATCH 3/7] Tighten zst archive target normalization Agent-Logs-Url: https://github.com/version-fox/vfox/sessions/abcce404-2e68-4df1-aad4-2d6d0295b4f0 Co-authored-by: bytemain <13938334+bytemain@users.noreply.github.com> --- internal/shared/util/decompressor.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/shared/util/decompressor.go b/internal/shared/util/decompressor.go index 01e9fec9..ab06e2cf 100644 --- a/internal/shared/util/decompressor.go +++ b/internal/shared/util/decompressor.go @@ -340,13 +340,21 @@ loop: func safeTarTarget(dest string, name string) (string, error) { normalizedPath := strings.ReplaceAll(name, "\\", "/") + if strings.HasPrefix(normalizedPath, "/") { + return "", fmt.Errorf("archive entry %q is outside destination", name) + } + normalizedPath = strings.Trim(normalizedPath, "/") + if normalizedPath == "" { + return "", fmt.Errorf("archive entry %q is empty", name) + } + parts := strings.Split(normalizedPath, "/") if len(parts) > 1 { parts = parts[1:] } fname := filepath.Clean(strings.Join(parts, "/")) if fname == "." { - return dest, nil + return "", fmt.Errorf("archive entry %q is empty", name) } if !filepath.IsLocal(fname) { return "", fmt.Errorf("archive entry %q is outside destination", name) From a313c32c480b25adde31fc0b214cea90b9dd9629 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 14:14:31 +0000 Subject: [PATCH 4/7] Make zst root extraction explicit Agent-Logs-Url: https://github.com/version-fox/vfox/sessions/abcce404-2e68-4df1-aad4-2d6d0295b4f0 Co-authored-by: bytemain <13938334+bytemain@users.noreply.github.com> --- internal/shared/util/decompressor.go | 48 +++++++++++++++++++++-- internal/shared/util/decompressor_test.go | 29 ++++++++++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/internal/shared/util/decompressor.go b/internal/shared/util/decompressor.go index ab06e2cf..99764f9c 100644 --- a/internal/shared/util/decompressor.go +++ b/internal/shared/util/decompressor.go @@ -268,6 +268,7 @@ type ZstdTarDecompressor struct { } func (z *ZstdTarDecompressor) Decompress(dest string) error { + rootFolderInTar := findRootFolderInZstdTar(z.src) file, err := os.Open(z.src) if err != nil { return err @@ -295,7 +296,7 @@ loop: continue } - target, err := safeTarTarget(dest, header.Name) + target, err := safeTarTarget(dest, header.Name, rootFolderInTar) if err != nil { return err } @@ -338,7 +339,48 @@ loop: return nil } -func safeTarTarget(dest string, name string) (string, error) { +func findRootFolderInZstdTar(tarFilePath string) string { + file, err := os.Open(tarFilePath) + if err != nil { + return "" + } + defer file.Close() + + zr, err := zstd.NewReader(file) + if err != nil { + return "" + } + defer zr.Close() + + tr := tar.NewReader(zr) + var firstElement string + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil || header == nil { + return "" + } + + normalizedPath := strings.Trim(strings.ReplaceAll(header.Name, "\\", "/"), "/") + if normalizedPath == "" || strings.HasPrefix(normalizedPath, ".DS_Store") || strings.HasPrefix(normalizedPath, "__MACOSX") { + continue + } + + currentFirstElement := strings.Split(normalizedPath, "/")[0] + if firstElement != "" && firstElement != currentFirstElement { + return "" + } + if firstElement == "" { + firstElement = currentFirstElement + } + } + return firstElement +} + +func safeTarTarget(dest string, name string, rootFolderInTar string) (string, error) { normalizedPath := strings.ReplaceAll(name, "\\", "/") if strings.HasPrefix(normalizedPath, "/") { return "", fmt.Errorf("archive entry %q is outside destination", name) @@ -349,7 +391,7 @@ func safeTarTarget(dest string, name string) (string, error) { } parts := strings.Split(normalizedPath, "/") - if len(parts) > 1 { + if len(parts) > 1 && rootFolderInTar != "" && parts[0] == rootFolderInTar { parts = parts[1:] } fname := filepath.Clean(strings.Join(parts, "/")) diff --git a/internal/shared/util/decompressor_test.go b/internal/shared/util/decompressor_test.go index 7cca2c15..d6c04057 100644 --- a/internal/shared/util/decompressor_test.go +++ b/internal/shared/util/decompressor_test.go @@ -75,6 +75,28 @@ func TestZstdTarDecompressor(t *testing.T) { } } +func TestZstdTarDecompressorStripsCommonRootFolder(t *testing.T) { + tempDir := t.TempDir() + archivePath := filepath.Join(tempDir, "test.tar.zst") + dest := filepath.Join(tempDir, "dest") + body := "Hello from root!" + + writeZstdTar(t, archivePath, "root/test.txt", body) + + decompressor := NewDecompressor(archivePath) + if err := decompressor.Decompress(dest); err != nil { + t.Fatalf("Failed to decompress: %v", err) + } + + decompressedFile, err := os.ReadFile(filepath.Join(dest, "test.txt")) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(decompressedFile)) != body { + t.Errorf("Expected %q, got %q", body, string(decompressedFile)) + } +} + func TestZstdTarDecompressorRejectsPathTraversal(t *testing.T) { tempDir := t.TempDir() archivePath := filepath.Join(tempDir, "test.tar.zst") @@ -106,9 +128,10 @@ func writeZstdTar(t *testing.T, archivePath string, name string, body string) { tw := tar.NewWriter(zw) err = tw.WriteHeader(&tar.Header{ - Name: name, - Mode: 0600, - Size: int64(len(body)), + Name: name, + Mode: 0600, + Size: int64(len(body)), + Typeflag: tar.TypeReg, }) if err != nil { t.Fatal(err) From 365893a983c43aa58cabcaa39369ec3350afe298 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 14:15:39 +0000 Subject: [PATCH 5/7] Handle zst extraction file close errors Agent-Logs-Url: https://github.com/version-fox/vfox/sessions/abcce404-2e68-4df1-aad4-2d6d0295b4f0 Co-authored-by: bytemain <13938334+bytemain@users.noreply.github.com> --- internal/shared/util/decompressor.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/shared/util/decompressor.go b/internal/shared/util/decompressor.go index 99764f9c..6953f0ec 100644 --- a/internal/shared/util/decompressor.go +++ b/internal/shared/util/decompressor.go @@ -296,7 +296,7 @@ loop: continue } - target, err := safeTarTarget(dest, header.Name, rootFolderInTar) + target, err := safeZstdTarTarget(dest, header.Name, rootFolderInTar) if err != nil { return err } @@ -317,9 +317,12 @@ loop: return err } if _, err := io.Copy(f, tr); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { return err } - f.Close() case tar.TypeSymlink: symlinks = append(symlinks, symlink{header.Linkname, target}) } @@ -380,7 +383,7 @@ func findRootFolderInZstdTar(tarFilePath string) string { return firstElement } -func safeTarTarget(dest string, name string, rootFolderInTar string) (string, error) { +func safeZstdTarTarget(dest string, name string, rootFolderInTar string) (string, error) { normalizedPath := strings.ReplaceAll(name, "\\", "/") if strings.HasPrefix(normalizedPath, "/") { return "", fmt.Errorf("archive entry %q is outside destination", name) From ce3ed6e3c64176390f8d16c7970edc8927145418 Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Fri, 1 May 2026 22:27:38 +0800 Subject: [PATCH 6/7] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/zh-hans/plugins/library/archiver.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/zh-hans/plugins/library/archiver.md b/docs/zh-hans/plugins/library/archiver.md index f62a59ed..9c084567 100644 --- a/docs/zh-hans/plugins/library/archiver.md +++ b/docs/zh-hans/plugins/library/archiver.md @@ -4,7 +4,6 @@ 例如: **Usage** -```shell local archiver = require("vfox.archiver") local err = archiver.decompress("testdata/test.zip", "testdata/test") ``` From daa537a8f638fcf190183fd3c0432c16b2c69fce Mon Sep 17 00:00:00 2001 From: Jiacheng Date: Fri, 1 May 2026 22:28:13 +0800 Subject: [PATCH 7/7] Update archiver documentation with usage example --- docs/zh-hans/plugins/library/archiver.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/zh-hans/plugins/library/archiver.md b/docs/zh-hans/plugins/library/archiver.md index 9c084567..e1f8a3a2 100644 --- a/docs/zh-hans/plugins/library/archiver.md +++ b/docs/zh-hans/plugins/library/archiver.md @@ -4,6 +4,8 @@ 例如: **Usage** + +```lua local archiver = require("vfox.archiver") local err = archiver.decompress("testdata/test.zip", "testdata/test") ```