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", archiveSize(t, archivePath), ) 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, }, { 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, }, { 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, }, { 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, }, { 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, MaxArchiveBytes: 16 * 1024, MaxCentralDirectoryBytes: 1024, 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, MaxArchiveBytes: 16 * 1024, MaxCentralDirectoryBytes: 1024, 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, MaxArchiveBytes: 16 * 1024, MaxCentralDirectoryBytes: 1024, MaxUncompressedBytes: 8192, MaxCompressionRatio: 2, }, wantErr: ErrCompressionRatio, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { archivePath := writeTestZIP(t, test.entries) root := t.TempDir() destination := filepath.Join(root, "staging") extractor := mustExtractor(t, test.limits) _, err := extractor.ExtractFile( archivePath, destination, test.entrypoint, archiveSize(t, archivePath), ) 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) } if _, statErr := os.Stat(filepath.Join(root, "escape.exe")); !os.IsNotExist(statErr) { t.Fatalf("rejected archive wrote outside staging, stat error = %v", statErr) } }) } } 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}, {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}, {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, archiveSize(t, archivePath), ) 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) } }) } } 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", archiveSize(t, archivePath), ) 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) } } 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", archiveSize(t, archivePath), ) 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", archiveSize(t, archivePath), ) 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, MaxArchiveBytes: 64 * 1024, MaxCentralDirectoryBytes: 1024, MaxUncompressedBytes: 16 * 1024, MaxCompressionRatio: 100, } } func archiveSize(t *testing.T, archivePath string) int64 { t.Helper() info, err := os.Stat(archivePath) if err != nil { t.Fatalf("stat archive: %v", err) } return info.Size() } 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, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 1}, {MaxEntries: 1, MaxArchiveBytes: 0, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 1}, {MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 0, MaxUncompressedBytes: 1, MaxCompressionRatio: 1}, {MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 2, MaxUncompressedBytes: 1, MaxCompressionRatio: 1}, {MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 0, MaxCompressionRatio: 1}, {MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: 0}, {MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 1, MaxUncompressedBytes: 1, MaxCompressionRatio: math.NaN()}, {MaxEntries: 1, MaxArchiveBytes: 1, MaxCentralDirectoryBytes: 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) } } }