package installer import ( "archive/tar" "archive/zip" "bytes" "compress/gzip" "os" "path/filepath" "sort" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ulikunitz/xz" ) func makeZipBytes(t *testing.T, entries map[string][]byte) []byte { t.Helper() var buffer bytes.Buffer writer := zip.NewWriter(&buffer) names := make([]string, 0, len(entries)) for name := range entries { names = append(names, name) } sort.Strings(names) for _, name := range names { entry, err := writer.Create(name) require.NoError(t, err) _, err = entry.Write(entries[name]) require.NoError(t, err) } require.NoError(t, writer.Close()) return buffer.Bytes() } func writeArchiveFixture(t *testing.T, name string, data []byte) string { t.Helper() path := filepath.Join(t.TempDir(), name) require.NoError(t, os.WriteFile(path, data, 0600)) return path } func makeTarBytes(t *testing.T, entries map[string][]byte) []byte { t.Helper() var buffer bytes.Buffer writer := tar.NewWriter(&buffer) names := make([]string, 0, len(entries)) for name := range entries { names = append(names, name) } sort.Strings(names) for _, name := range names { data := entries[name] require.NoError(t, writer.WriteHeader(&tar.Header{Name: name, Mode: 0644, Size: int64(len(data)), Typeflag: tar.TypeReg})) _, err := writer.Write(data) require.NoError(t, err) } require.NoError(t, writer.Close()) return buffer.Bytes() } func compressGzip(t *testing.T, data []byte) []byte { t.Helper() var buffer bytes.Buffer writer := gzip.NewWriter(&buffer) _, err := writer.Write(data) require.NoError(t, err) require.NoError(t, writer.Close()) return buffer.Bytes() } func compressXZ(t *testing.T, data []byte) []byte { t.Helper() var buffer bytes.Buffer writer, err := xz.NewWriter(&buffer) require.NoError(t, err) _, err = writer.Write(data) require.NoError(t, err) require.NoError(t, writer.Close()) return buffer.Bytes() } func TestExtractCompressedFilesNatively(t *testing.T) { for _, test := range []struct { name string extension string compress func(*testing.T, []byte) []byte outputName string }{ {name: "gzip", extension: ".gz", compress: compressGzip, outputName: "payload.bin"}, {name: "xz", extension: ".xz", compress: compressXZ, outputName: "payload.bin"}, } { t.Run(test.name, func(t *testing.T) { source := writeArchiveFixture(t, test.outputName+test.extension, test.compress(t, []byte("decompressed-payload"))) destination := filepath.Join(t.TempDir(), "destination") inst := NewInstaller(ReleaseInfo{}, "", WithExecutor(NewMockCommandExecutor())) require.NoError(t, inst.ExtractArchive(source, destination)) content, err := os.ReadFile(filepath.Join(destination, test.outputName)) require.NoError(t, err) assert.Equal(t, "decompressed-payload", string(content)) assert.Empty(t, inst.executor.(*MockCommandExecutor).GetCommands()) }) } } func TestExtractArchiveNativeFormats(t *testing.T) { tarBytes := makeTarBytes(t, map[string][]byte{"bin/mpv": []byte("payload")}) fixtures := []struct { name string data []byte }{ {name: "payload.zip", data: makeZipBytes(t, map[string][]byte{"bin/mpv": []byte("payload")})}, {name: "payload.tar", data: tarBytes}, {name: "payload.tar.gz", data: compressGzip(t, tarBytes)}, {name: "payload.tar.xz", data: compressXZ(t, tarBytes)}, } for _, fixture := range fixtures { t.Run(fixture.name, func(t *testing.T) { source := writeArchiveFixture(t, fixture.name, fixture.data) destination := filepath.Join(t.TempDir(), "destination") me := NewMockCommandExecutor() inst := NewInstaller(ReleaseInfo{}, "", WithExecutor(me)) require.NoError(t, inst.ExtractArchive(source, destination)) content, err := os.ReadFile(filepath.Join(destination, "bin", "mpv")) require.NoError(t, err) assert.Equal(t, "payload", string(content)) assert.Empty(t, me.GetCommands(), "archive extraction must not invoke a platform command") }) } } func TestArchivePreflightRejectsUnsafeEntriesWithoutLiveWrites(t *testing.T) { t.Run("ZIP traversal", func(t *testing.T) { source := writeArchiveFixture(t, "bad.zip", makeZipBytes(t, map[string][]byte{"../escape": []byte("bad")})) destination := filepath.Join(t.TempDir(), "live") writeTransactionFile(t, filepath.Join(destination, "sentinel"), "keep") err := NewInstaller(ReleaseInfo{}, "").ExtractArchive(source, destination) require.Error(t, err) assert.Contains(t, err.Error(), "unsafe path") assertTransactionFile(t, filepath.Join(destination, "sentinel"), "keep") assert.NoFileExists(t, filepath.Join(filepath.Dir(destination), "escape")) }) t.Run("ZIP symlink", func(t *testing.T) { var buffer bytes.Buffer writer := zip.NewWriter(&buffer) header := &zip.FileHeader{Name: "bin/link"} header.SetMode(os.ModeSymlink | 0777) entry, err := writer.CreateHeader(header) require.NoError(t, err) _, err = entry.Write([]byte("../../outside")) require.NoError(t, err) require.NoError(t, writer.Close()) source := writeArchiveFixture(t, "link.zip", buffer.Bytes()) destination := filepath.Join(t.TempDir(), "live") err = NewInstaller(ReleaseInfo{}, "").ExtractArchive(source, destination) require.Error(t, err) assert.Contains(t, err.Error(), "unsupported special file") assert.NoDirExists(t, destination) }) t.Run("ZIP case collision", func(t *testing.T) { source := writeArchiveFixture(t, "collision.zip", makeZipBytes(t, map[string][]byte{ "Bin/mpv.exe": []byte("one"), "bin/MPV.exe": []byte("two"), })) err := NewInstaller(ReleaseInfo{}, "").ExtractArchive(source, filepath.Join(t.TempDir(), "live")) require.Error(t, err) assert.Contains(t, err.Error(), "collide on Windows") }) t.Run("ZIP file used as parent", func(t *testing.T) { source := writeArchiveFixture(t, "parent.zip", makeZipBytes(t, map[string][]byte{ "bin": []byte("not a directory"), "bin/mpv": []byte("payload"), })) destination := filepath.Join(t.TempDir(), "live") err := NewInstaller(ReleaseInfo{}, "").ExtractArchive(source, destination) require.Error(t, err) assert.Contains(t, err.Error(), "uses file") assert.NoDirExists(t, destination) }) t.Run("tar hard link", func(t *testing.T) { var buffer bytes.Buffer writer := tar.NewWriter(&buffer) require.NoError(t, writer.WriteHeader(&tar.Header{Name: "bin/link", Linkname: "../../outside", Typeflag: tar.TypeLink})) require.NoError(t, writer.Close()) source := writeArchiveFixture(t, "link.tar", buffer.Bytes()) destination := filepath.Join(t.TempDir(), "live") err := NewInstaller(ReleaseInfo{}, "").ExtractArchive(source, destination) require.Error(t, err) assert.Contains(t, err.Error(), "unsupported type") assert.NoDirExists(t, destination) }) } func TestArchiveInventoryEnforcesExpandedSizeLimit(t *testing.T) { _, err := validateArchiveInventory("test", []archiveInventoryEntry{{name: "huge.bin", size: maxSevenZipExpandedSize + 1}}) require.Error(t, err) assert.Contains(t, err.Error(), "expands beyond") } func TestUOSCArchiveInventoryRejectsUnrelatedFiles(t *testing.T) { source := writeArchiveFixture(t, "uosc.zip", makeZipBytes(t, map[string][]byte{ "scripts/uosc.lua": []byte("-- uosc"), "mpv.conf": []byte("malicious replacement"), })) destination := filepath.Join(t.TempDir(), "live") writeTransactionFile(t, filepath.Join(destination, "mpv.conf"), "user config") cr, _ := newBufferedRunner() err := NewInstaller(ReleaseInfo{}, "").extractUOSCArchive(cr, source, destination, true) require.Error(t, err) assert.Contains(t, err.Error(), "unexpected path") assertTransactionFile(t, filepath.Join(destination, "mpv.conf"), "user config") assert.NoFileExists(t, filepath.Join(destination, "scripts", "uosc.lua")) } func TestExtractArchiveManualAndUnsupportedFormats(t *testing.T) { for source, want := range map[string]string{ "a.dmg": "DMG files must be opened manually", "a.exe": "EXE files must be executed manually", "a.rar": "unsupported archive format: .rar", } { err := NewInstaller(ReleaseInfo{}, "").ExtractArchive(source, t.TempDir()) require.Error(t, err) assert.Contains(t, err.Error(), want) } } func TestUOSCArchiveLayout(t *testing.T) { for _, entrypoint := range []string{"scripts/uosc/main.lua", "scripts/uosc.lua"} { t.Run(entrypoint, func(t *testing.T) { source := writeArchiveFixture(t, "uosc.zip", makeZipBytes(t, map[string][]byte{ entrypoint: []byte("-- uosc"), "fonts/uosc_icons.otf": []byte("icons"), "fonts/uosc_textures.ttf": []byte("textures"), })) dest := filepath.Join(t.TempDir(), "live") cr, _ := newBufferedRunner() require.NoError(t, NewInstaller(ReleaseInfo{}, "").extractUOSCArchive(cr, source, dest, true)) assertTransactionFile(t, filepath.Join(dest, "fonts", "uosc_icons.otf"), "icons") assertTransactionFile(t, filepath.Join(dest, "fonts", "uosc_textures.ttf"), "textures") }) } for _, unexpected := range []string{"fonts/unrelated.ttf", "fonts/uosc_icons.otf.exe", "scripts/other.lua", "mpv.conf"} { t.Run(unexpected, func(t *testing.T) { require.ErrorContains(t, validateUOSCArchiveInventory([]archiveInventoryEntry{{name: "scripts/uosc/main.lua"}, {name: unexpected}}), "unexpected path") }) } require.ErrorContains(t, validateUOSCArchiveInventory([]archiveInventoryEntry{{name: "scripts/uosc/lib/utils.lua"}}), "does not contain") }