2026-07-16 16:22:45 +08:00
|
|
|
|
package installer
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"archive/zip"
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"math"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"testing"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorExtractsPayloadOnly(t *testing.T) {
|
|
|
|
|
|
archivePath := writeTestZIP(t, []testZIPEntry{
|
|
|
|
|
|
{name: "app.json", body: []byte(`{"entrypoint":"bin/App.exe"}`)},
|
|
|
|
|
|
{name: "files.json", body: []byte(`{"files":[]}`)},
|
|
|
|
|
|
{name: "payload/bin/", mode: os.ModeDir | 0o755},
|
|
|
|
|
|
{name: "payload/bin/App.exe", body: []byte("executable"), mode: 0o755},
|
|
|
|
|
|
{name: "payload/readme.txt", body: []byte("hello")},
|
|
|
|
|
|
})
|
|
|
|
|
|
destination := filepath.Join(t.TempDir(), "staging")
|
|
|
|
|
|
extractor := mustExtractor(t, testLimits())
|
|
|
|
|
|
|
|
|
|
|
|
result, err := extractor.ExtractFile(archivePath, destination, "bin/App.exe")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ExtractFile() error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.Files != 2 {
|
|
|
|
|
|
t.Fatalf("Files = %d, want 2", result.Files)
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.Bytes != int64(len("executable")+len("hello")) {
|
|
|
|
|
|
t.Fatalf("Bytes = %d, want %d", result.Bytes, len("executable")+len("hello"))
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := os.Stat(result.EntrypointPath); err != nil {
|
|
|
|
|
|
t.Fatalf("entrypoint stat error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := os.Stat(filepath.Join(destination, "app.json")); !os.IsNotExist(err) {
|
|
|
|
|
|
t.Fatalf("app.json should not be extracted, stat error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorRejectsAttackArchives(t *testing.T) {
|
|
|
|
|
|
base := []testZIPEntry{
|
|
|
|
|
|
{name: "app.json", body: []byte(`{}`)},
|
|
|
|
|
|
{name: "payload/App.exe", body: []byte("ok")},
|
|
|
|
|
|
}
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
entries []testZIPEntry
|
|
|
|
|
|
entrypoint string
|
|
|
|
|
|
limits Limits
|
|
|
|
|
|
wantErr error
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "absolute path",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "/payload/evil.exe", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "drive path",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "C:/payload/evil.exe", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "ADS path",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/App.exe:stream", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "dot dot traversal",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/../evil.exe", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
2026-07-16 23:56:19 +08:00
|
|
|
|
{
|
|
|
|
|
|
name: "Windows normalized dot dot traversal",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/.. /escape.exe", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "trailing period",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/evil.exe.", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "trailing space",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/evil.exe ", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "leading space",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/ evil.exe", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "reserved device",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/NUL.txt", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "reserved device with space before extension",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/NUL .txt", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "reserved device superscript",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/COM¹.log", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "forbidden Windows character",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/evil?.exe", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
2026-07-16 16:22:45 +08:00
|
|
|
|
{
|
|
|
|
|
|
name: "backslash traversal",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: `payload\..\evil.exe`, body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrPathEscape,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "encrypted entry",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/secret.bin", body: []byte("x"), flags: 0x1}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrEncryptedEntry,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "symlink",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{
|
|
|
|
|
|
name: "payload/link",
|
|
|
|
|
|
body: []byte("../../outside"),
|
|
|
|
|
|
mode: os.ModeSymlink | 0o777,
|
|
|
|
|
|
}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrUnsupportedEntry,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "special file",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/pipe", mode: os.ModeNamedPipe | 0o600}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrUnsupportedEntry,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "case folded duplicate",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/app.exe", body: []byte("duplicate")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrDuplicateEntry,
|
|
|
|
|
|
},
|
2026-07-16 23:56:19 +08:00
|
|
|
|
{
|
|
|
|
|
|
name: "Unicode folded duplicate",
|
|
|
|
|
|
entries: []testZIPEntry{
|
|
|
|
|
|
{name: "app.json", body: []byte(`{}`)},
|
|
|
|
|
|
{name: "payload/K.exe", body: []byte("one")},
|
|
|
|
|
|
{name: "payload/K.exe", body: []byte("two")},
|
|
|
|
|
|
},
|
|
|
|
|
|
entrypoint: "K.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrDuplicateEntry,
|
|
|
|
|
|
},
|
2026-07-16 16:22:45 +08:00
|
|
|
|
{
|
|
|
|
|
|
name: "unexpected top level entry",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "install.bat", body: []byte("echo unsafe")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: testLimits(),
|
|
|
|
|
|
wantErr: ErrUnexpectedEntry,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "too many entries",
|
|
|
|
|
|
entries: appendEntries(base,
|
|
|
|
|
|
testZIPEntry{name: "payload/extra.txt", body: []byte("x")}),
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: Limits{
|
|
|
|
|
|
MaxEntries: 2,
|
|
|
|
|
|
MaxUncompressedBytes: 1024,
|
|
|
|
|
|
MaxCompressionRatio: 100,
|
|
|
|
|
|
},
|
|
|
|
|
|
wantErr: ErrTooManyEntries,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "expanded size",
|
|
|
|
|
|
entries: []testZIPEntry{
|
|
|
|
|
|
{name: "app.json", body: []byte(`{}`)},
|
|
|
|
|
|
{name: "payload/App.exe", body: []byte("0123456789")},
|
|
|
|
|
|
},
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: Limits{
|
|
|
|
|
|
MaxEntries: 10,
|
|
|
|
|
|
MaxUncompressedBytes: 8,
|
|
|
|
|
|
MaxCompressionRatio: 100,
|
|
|
|
|
|
},
|
|
|
|
|
|
wantErr: ErrExpandedTooLarge,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "compression ratio",
|
|
|
|
|
|
entries: []testZIPEntry{
|
|
|
|
|
|
{name: "app.json", body: []byte(`{}`)},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "payload/App.exe",
|
|
|
|
|
|
body: bytes.Repeat([]byte("A"), 4096),
|
|
|
|
|
|
method: zip.Deflate,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
entrypoint: "App.exe",
|
|
|
|
|
|
limits: Limits{
|
|
|
|
|
|
MaxEntries: 10,
|
|
|
|
|
|
MaxUncompressedBytes: 8192,
|
|
|
|
|
|
MaxCompressionRatio: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
wantErr: ErrCompressionRatio,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, test := range tests {
|
|
|
|
|
|
t.Run(test.name, func(t *testing.T) {
|
|
|
|
|
|
archivePath := writeTestZIP(t, test.entries)
|
2026-07-16 23:56:19 +08:00
|
|
|
|
root := t.TempDir()
|
|
|
|
|
|
destination := filepath.Join(root, "staging")
|
2026-07-16 16:22:45 +08:00
|
|
|
|
extractor := mustExtractor(t, test.limits)
|
|
|
|
|
|
|
|
|
|
|
|
_, err := extractor.ExtractFile(archivePath, destination, test.entrypoint)
|
|
|
|
|
|
if !errors.Is(err, test.wantErr) {
|
|
|
|
|
|
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
|
|
|
|
|
t.Fatalf("rejected archive left staging, stat error = %v", statErr)
|
|
|
|
|
|
}
|
2026-07-16 23:56:19 +08:00
|
|
|
|
if _, statErr := os.Stat(filepath.Join(root, "escape.exe")); !os.IsNotExist(statErr) {
|
|
|
|
|
|
t.Fatalf("rejected archive wrote outside staging, stat error = %v", statErr)
|
|
|
|
|
|
}
|
2026-07-16 16:22:45 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorRejectsInvalidEntrypoints(t *testing.T) {
|
|
|
|
|
|
archivePath := writeTestZIP(t, []testZIPEntry{
|
|
|
|
|
|
{name: "app.json", body: []byte(`{}`)},
|
|
|
|
|
|
{name: "payload/App.exe", body: []byte("ok")},
|
|
|
|
|
|
})
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
entrypoint string
|
|
|
|
|
|
wantErr error
|
|
|
|
|
|
}{
|
|
|
|
|
|
{entrypoint: "../App.exe", wantErr: ErrEntrypointInvalid},
|
|
|
|
|
|
{entrypoint: "/App.exe", wantErr: ErrEntrypointInvalid},
|
|
|
|
|
|
{entrypoint: `..\App.exe`, wantErr: ErrEntrypointInvalid},
|
2026-07-16 23:56:19 +08:00
|
|
|
|
{entrypoint: ".. /App.exe", wantErr: ErrEntrypointInvalid},
|
|
|
|
|
|
{entrypoint: "App.exe.", wantErr: ErrEntrypointInvalid},
|
|
|
|
|
|
{entrypoint: "App.exe ", wantErr: ErrEntrypointInvalid},
|
|
|
|
|
|
{entrypoint: " App.exe", wantErr: ErrEntrypointInvalid},
|
|
|
|
|
|
{entrypoint: "NUL", wantErr: ErrEntrypointInvalid},
|
|
|
|
|
|
{entrypoint: "bad?.exe", wantErr: ErrEntrypointInvalid},
|
2026-07-16 16:22:45 +08:00
|
|
|
|
{entrypoint: "payload/App.exe", wantErr: ErrEntrypointInvalid},
|
|
|
|
|
|
{entrypoint: "Missing.exe", wantErr: ErrEntrypointMissing},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, test := range tests {
|
|
|
|
|
|
t.Run(test.entrypoint, func(t *testing.T) {
|
|
|
|
|
|
destination := filepath.Join(t.TempDir(), "staging")
|
|
|
|
|
|
extractor := mustExtractor(t, testLimits())
|
|
|
|
|
|
_, err := extractor.ExtractFile(archivePath, destination, test.entrypoint)
|
|
|
|
|
|
if !errors.Is(err, test.wantErr) {
|
|
|
|
|
|
t.Fatalf("ExtractFile() error = %v, want %v", err, test.wantErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
|
|
|
|
|
t.Fatalf("invalid entrypoint left staging, stat error = %v", statErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 23:56:19 +08:00
|
|
|
|
func TestExtractorAcceptsUnicodeNestedPaths(t *testing.T) {
|
|
|
|
|
|
archivePath := writeTestZIP(t, []testZIPEntry{
|
|
|
|
|
|
{name: "app.json", body: []byte(`{}`)},
|
|
|
|
|
|
{name: "payload/工具/解析器.exe", body: []byte("executable")},
|
|
|
|
|
|
{name: "payload/资源/说明.txt", body: []byte("说明")},
|
|
|
|
|
|
})
|
|
|
|
|
|
destination := filepath.Join(t.TempDir(), "staging")
|
|
|
|
|
|
extractor := mustExtractor(t, testLimits())
|
|
|
|
|
|
|
|
|
|
|
|
result, err := extractor.ExtractFile(archivePath, destination, "工具/解析器.exe")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ExtractFile() error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.Files != 2 {
|
|
|
|
|
|
t.Fatalf("Files = %d, want 2", result.Files)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := os.Stat(result.EntrypointPath); err != nil {
|
|
|
|
|
|
t.Fatalf("entrypoint stat error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 16:22:45 +08:00
|
|
|
|
func TestExtractorRejectsExistingDestination(t *testing.T) {
|
|
|
|
|
|
archivePath := writeTestZIP(t, []testZIPEntry{
|
|
|
|
|
|
{name: "app.json", body: []byte(`{}`)},
|
|
|
|
|
|
{name: "payload/App.exe", body: []byte("ok")},
|
|
|
|
|
|
})
|
|
|
|
|
|
destination := filepath.Join(t.TempDir(), "staging")
|
|
|
|
|
|
if err := os.Mkdir(destination, 0o700); err != nil {
|
|
|
|
|
|
t.Fatalf("Mkdir() error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
extractor := mustExtractor(t, testLimits())
|
|
|
|
|
|
|
|
|
|
|
|
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
|
|
|
|
|
|
if !errors.Is(err, ErrDestinationExists) {
|
|
|
|
|
|
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrDestinationExists)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorRemovesDestinationAfterCopyFailure(t *testing.T) {
|
|
|
|
|
|
archivePath := writeTestZIP(t, []testZIPEntry{
|
|
|
|
|
|
{name: "app.json", body: []byte(`{}`)},
|
|
|
|
|
|
{name: "payload/App.exe", body: []byte("verified bytes"), method: zip.Store},
|
|
|
|
|
|
})
|
|
|
|
|
|
corruptZIPEntryData(t, archivePath, "payload/App.exe")
|
|
|
|
|
|
destination := filepath.Join(t.TempDir(), "staging")
|
|
|
|
|
|
extractor := mustExtractor(t, testLimits())
|
|
|
|
|
|
|
|
|
|
|
|
_, err := extractor.ExtractFile(archivePath, destination, "App.exe")
|
|
|
|
|
|
if !errors.Is(err, ErrArchiveCorrupt) {
|
|
|
|
|
|
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrArchiveCorrupt)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
|
|
|
|
|
t.Fatalf("copy failure left staging, stat error = %v", statErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type testZIPEntry struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
body []byte
|
|
|
|
|
|
mode os.FileMode
|
|
|
|
|
|
method uint16
|
|
|
|
|
|
flags uint16
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func testLimits() Limits {
|
|
|
|
|
|
return Limits{
|
|
|
|
|
|
MaxEntries: 20,
|
|
|
|
|
|
MaxUncompressedBytes: 16 * 1024,
|
|
|
|
|
|
MaxCompressionRatio: 100,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func mustExtractor(t *testing.T, limits Limits) Extractor {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
extractor, err := NewExtractor(limits)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractor() error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return extractor
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func appendEntries(base []testZIPEntry, extra ...testZIPEntry) []testZIPEntry {
|
|
|
|
|
|
result := append([]testZIPEntry(nil), base...)
|
|
|
|
|
|
return append(result, extra...)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func writeTestZIP(t *testing.T, entries []testZIPEntry) string {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
path := filepath.Join(t.TempDir(), "package.zip")
|
|
|
|
|
|
file, err := os.Create(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("create ZIP: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
writer := zip.NewWriter(file)
|
|
|
|
|
|
for _, entry := range entries {
|
|
|
|
|
|
header := &zip.FileHeader{
|
|
|
|
|
|
Name: entry.name,
|
|
|
|
|
|
Method: entry.method,
|
|
|
|
|
|
Flags: entry.flags,
|
|
|
|
|
|
}
|
|
|
|
|
|
mode := entry.mode
|
|
|
|
|
|
if mode == 0 {
|
|
|
|
|
|
mode = 0o600
|
|
|
|
|
|
}
|
|
|
|
|
|
header.SetMode(mode)
|
|
|
|
|
|
part, err := writer.CreateHeader(header)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
writer.Close()
|
|
|
|
|
|
file.Close()
|
|
|
|
|
|
t.Fatalf("create ZIP entry %s: %v", entry.name, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := part.Write(entry.body); err != nil {
|
|
|
|
|
|
writer.Close()
|
|
|
|
|
|
file.Close()
|
|
|
|
|
|
t.Fatalf("write ZIP entry %s: %v", entry.name, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := writer.Close(); err != nil {
|
|
|
|
|
|
file.Close()
|
|
|
|
|
|
t.Fatalf("close ZIP writer: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := file.Close(); err != nil {
|
|
|
|
|
|
t.Fatalf("close ZIP file: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return path
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func corruptZIPEntryData(t *testing.T, archivePath, entryName string) {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
reader, err := zip.OpenReader(archivePath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("open ZIP for corruption: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
var offset int64 = -1
|
|
|
|
|
|
for _, file := range reader.File {
|
|
|
|
|
|
if file.Name == entryName {
|
|
|
|
|
|
offset, err = file.DataOffset()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
reader.Close()
|
|
|
|
|
|
t.Fatalf("entry data offset: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := reader.Close(); err != nil {
|
|
|
|
|
|
t.Fatalf("close ZIP reader: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if offset < 0 {
|
|
|
|
|
|
t.Fatalf("entry %s not found", entryName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data, err := os.ReadFile(archivePath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("read ZIP for corruption: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
data[offset] ^= 0xff
|
|
|
|
|
|
if err := os.WriteFile(archivePath, data, 0o600); err != nil {
|
|
|
|
|
|
t.Fatalf("write corrupted ZIP: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestDefaultLimitsAreValid(t *testing.T) {
|
|
|
|
|
|
if _, err := NewExtractor(DefaultLimits()); err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractor(DefaultLimits()) error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorRejectsInvalidLimits(t *testing.T) {
|
|
|
|
|
|
tests := []Limits{
|
|
|
|
|
|
{MaxEntries: 0, MaxUncompressedBytes: 1, MaxCompressionRatio: 1},
|
|
|
|
|
|
{MaxEntries: 1, MaxUncompressedBytes: 0, MaxCompressionRatio: 1},
|
|
|
|
|
|
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 0},
|
|
|
|
|
|
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.NaN()},
|
|
|
|
|
|
{MaxEntries: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.Inf(1)},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for index, limits := range tests {
|
|
|
|
|
|
if _, err := NewExtractor(limits); !errors.Is(err, ErrInvalidLimits) {
|
|
|
|
|
|
t.Errorf("case %d NewExtractor() error = %v, want %v", index, err, ErrInvalidLimits)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|