package installer import ( "context" "debug/pe" "encoding/binary" "errors" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestTransactionalOverlayPreservesUnrelatedAndProtectedFiles(t *testing.T) { root := t.TempDir() staging := filepath.Join(root, "staging") destination := filepath.Join(root, "mpv") writeTransactionFile(t, filepath.Join(staging, "mpv.exe"), "new-mpv") writeTransactionFile(t, filepath.Join(staging, "new.dll"), "new-dll") writeTransactionFile(t, filepath.Join(staging, "PORTABLE_CONFIG", "mpv.conf"), "archive-config") writeTransactionFile(t, filepath.Join(staging, "mpv-manager", "uninstall.bat"), "archive-manager") writeTransactionFile(t, filepath.Join(destination, "mpv.exe"), "old-mpv") writeTransactionFile(t, filepath.Join(destination, "portable_config", "mpv.conf"), "user-config") writeTransactionFile(t, filepath.Join(destination, "mpv-manager", "manager.exe"), "manager") writeTransactionFile(t, filepath.Join(destination, "user-file.txt"), "keep-me") err := transactionalOverlay( context.Background(), staging, destination, []string{"portable_config", "mpv-manager"}, os.Rename, ) require.NoError(t, err) assertTransactionFile(t, filepath.Join(destination, "mpv.exe"), "new-mpv") assertTransactionFile(t, filepath.Join(destination, "new.dll"), "new-dll") assertTransactionFile(t, filepath.Join(destination, "portable_config", "mpv.conf"), "user-config") assertTransactionFile(t, filepath.Join(destination, "mpv-manager", "manager.exe"), "manager") assertTransactionFile(t, filepath.Join(destination, "user-file.txt"), "keep-me") } func TestTransactionalOverlayRollsBackAfterPartialCommit(t *testing.T) { root := t.TempDir() staging := filepath.Join(root, "staging") destination := filepath.Join(root, "mpv") writeTransactionFile(t, filepath.Join(staging, "a.exe"), "new-a") writeTransactionFile(t, filepath.Join(staging, "b.dll"), "new-b") writeTransactionFile(t, filepath.Join(destination, "a.exe"), "old-a") injected := errors.New("injected commit failure") renameCalls := 0 rename := func(oldPath, newPath string) error { renameCalls++ if renameCalls == 3 { return injected } return os.Rename(oldPath, newPath) } err := transactionalOverlay(context.Background(), staging, destination, nil, rename) require.ErrorIs(t, err, injected) assertTransactionFile(t, filepath.Join(destination, "a.exe"), "old-a") _, statErr := os.Lstat(filepath.Join(destination, "b.dll")) assert.ErrorIs(t, statErr, os.ErrNotExist) } func TestTransactionalOverlayRollsBackCancellationAfterBackup(t *testing.T) { root := t.TempDir() staging := filepath.Join(root, "staging") destination := filepath.Join(root, "mpv") writeTransactionFile(t, filepath.Join(staging, "mpv.exe"), "new") writeTransactionFile(t, filepath.Join(destination, "mpv.exe"), "old") ctx, cancel := context.WithCancel(context.Background()) renameCalls := 0 rename := func(oldPath, newPath string) error { renameCalls++ err := os.Rename(oldPath, newPath) if renameCalls == 1 { cancel() } return err } err := transactionalOverlay(ctx, staging, destination, nil, rename) require.ErrorIs(t, err, context.Canceled) assertTransactionFile(t, filepath.Join(destination, "mpv.exe"), "old") } func TestTransactionalPathReplaceRestoresLiveBundle(t *testing.T) { root := t.TempDir() stageRoot := filepath.Join(root, ".stage") staged := filepath.Join(stageRoot, "MPV.app") live := filepath.Join(root, "MPV.app") writeTransactionFile(t, filepath.Join(staged, "Contents", "MacOS", "mpv"), "new") writeTransactionFile(t, filepath.Join(live, "Contents", "MacOS", "mpv"), "old") injected := errors.New("injected swap failure") renameCalls := 0 rename := func(oldPath, newPath string) error { renameCalls++ if renameCalls == 2 { return injected } return os.Rename(oldPath, newPath) } err := transactionalPathReplace(context.Background(), staged, live, rename) require.ErrorIs(t, err, injected) assertTransactionFile(t, filepath.Join(live, "Contents", "MacOS", "mpv"), "old") assertTransactionFile(t, filepath.Join(staged, "Contents", "MacOS", "mpv"), "new") } func TestTransactionalPathReplaceRestoresOnCancellation(t *testing.T) { root := t.TempDir() staged := filepath.Join(root, ".stage", "IINA.app") live := filepath.Join(root, "IINA.app") writeTransactionFile(t, filepath.Join(staged, "Contents", "MacOS", "IINA"), "new") writeTransactionFile(t, filepath.Join(live, "Contents", "MacOS", "IINA"), "old") ctx, cancel := context.WithCancel(context.Background()) renameCalls := 0 rename := func(oldPath, newPath string) error { renameCalls++ err := os.Rename(oldPath, newPath) if renameCalls == 1 { cancel() } return err } err := transactionalPathReplace(ctx, staged, live, rename) require.ErrorIs(t, err, context.Canceled) assertTransactionFile(t, filepath.Join(live, "Contents", "MacOS", "IINA"), "old") } func TestTransactionalPathReplaceKeepsBackupIfRestoreFails(t *testing.T) { root := t.TempDir() staged := filepath.Join(root, ".stage", "MPV.app") live := filepath.Join(root, "MPV.app") writeTransactionFile(t, filepath.Join(staged, "Contents", "MacOS", "mpv"), "new") writeTransactionFile(t, filepath.Join(live, "Contents", "MacOS", "mpv"), "old") renameCalls := 0 rename := func(oldPath, newPath string) error { renameCalls++ if renameCalls == 2 { return errors.New("injected activation failure") } if renameCalls == 3 { return errors.New("injected restore failure") } return os.Rename(oldPath, newPath) } err := transactionalPathReplace(context.Background(), staged, live, rename) require.Error(t, err) assert.Contains(t, err.Error(), "backup retained") backups, globErr := filepath.Glob(filepath.Join(root, ".mpv-manager-swap-backup-*", "MPV.app", "Contents", "MacOS", "mpv")) require.NoError(t, globErr) require.Len(t, backups, 1) assertTransactionFile(t, backups[0], "old") } func TestTransactionalRegularFileReplaceRollsBackPostActivationFailure(t *testing.T) { root := t.TempDir() staged := filepath.Join(root, ".ffmpeg.exe.stage") live := filepath.Join(root, "ffmpeg.exe") writeTransactionFile(t, staged, "new") writeTransactionFile(t, live, "old") injected := errors.New("persist version failed") err := transactionalRegularFileReplace( context.Background(), staged, live, func(path string) error { return validateNonEmptyRegularFile(path, false) }, func() error { return injected }, os.Rename, ) require.ErrorIs(t, err, injected) assertTransactionFile(t, live, "old") assert.NoFileExists(t, staged) } func TestTransactionalRegularFileReplaceRetainsBackupWhenRestoreFails(t *testing.T) { root := t.TempDir() staged := filepath.Join(root, ".ffmpeg.exe.stage") live := filepath.Join(root, "ffmpeg.exe") writeTransactionFile(t, staged, "new") writeTransactionFile(t, live, "old") renameCalls := 0 rename := func(oldPath, newPath string) error { renameCalls++ if renameCalls == 3 { return errors.New("injected restore failure") } return os.Rename(oldPath, newPath) } err := transactionalRegularFileReplace( context.Background(), staged, live, func(path string) error { return validateNonEmptyRegularFile(path, false) }, func() error { return errors.New("injected metadata failure") }, rename, ) require.Error(t, err) assert.Contains(t, err.Error(), "backup retained") backups, globErr := filepath.Glob(filepath.Join(root, ".mpv-manager-file-backup-*", "ffmpeg.exe")) require.NoError(t, globErr) require.Len(t, backups, 1) assertTransactionFile(t, backups[0], "old") } func TestTransactionalRegularFileReplaceRejectsNonSiblingStage(t *testing.T) { root := t.TempDir() staged := filepath.Join(root, "stage", "ffmpeg.exe") live := filepath.Join(root, "live", "ffmpeg.exe") writeTransactionFile(t, staged, "new") writeTransactionFile(t, live, "old") err := transactionalRegularFileReplace(context.Background(), staged, live, nil, nil, os.Rename) require.Error(t, err) assert.Contains(t, err.Error(), "must be beside") assertTransactionFile(t, live, "old") } func TestStageRegularFileBesideCreatesVerifiedSibling(t *testing.T) { root := t.TempDir() source := filepath.Join(root, "extract", "ffmpeg.exe") live := filepath.Join(root, "install", "ffmpeg.exe") writeTransactionFile(t, source, "authenticated replacement bytes") staged, err := stageRegularFileBeside(context.Background(), source, live, 0755) require.NoError(t, err) t.Cleanup(func() { _ = os.Remove(staged) }) assert.Equal(t, filepath.Dir(live), filepath.Dir(staged)) assertTransactionFile(t, staged, "authenticated replacement bytes") assert.NoFileExists(t, live) } func TestValidateFFmpegExecutableRequiresAMD64PE(t *testing.T) { valid := filepath.Join(t.TempDir(), "ffmpeg.exe") data := make([]byte, 0x98) copy(data, []byte{'M', 'Z'}) binary.LittleEndian.PutUint32(data[0x3c:], 0x80) copy(data[0x80:], []byte{'P', 'E', 0, 0}) binary.LittleEndian.PutUint16(data[0x84:], 0x8664) require.NoError(t, os.WriteFile(valid, data, 0755)) require.NoError(t, validateFFmpegExecutableForMachine(valid, pe.IMAGE_FILE_MACHINE_AMD64)) invalid := filepath.Join(t.TempDir(), "ffmpeg.exe") require.NoError(t, os.WriteFile(invalid, []byte("not a PE"), 0755)) require.Error(t, validateFFmpegExecutableForMachine(invalid, pe.IMAGE_FILE_MACHINE_AMD64)) } func TestTransactionalOverlayRejectsSymlinkWithoutChangingDestination(t *testing.T) { root := t.TempDir() staging := filepath.Join(root, "staging") destination := filepath.Join(root, "mpv") outside := filepath.Join(root, "outside") require.NoError(t, os.MkdirAll(staging, 0755)) require.NoError(t, os.MkdirAll(destination, 0755)) writeTransactionFile(t, outside, "outside") require.NoError(t, os.Symlink(outside, filepath.Join(staging, "mpv.exe"))) err := transactionalOverlay(context.Background(), staging, destination, nil, os.Rename) require.Error(t, err) assertTransactionFile(t, outside, "outside") entries, readErr := os.ReadDir(destination) require.NoError(t, readErr) assert.Empty(t, entries) } func TestRecoverInstallerTransactionsRollsBackInterruptedOverlay(t *testing.T) { root := t.TempDir() destination := filepath.Join(root, "mpv") target := filepath.Join(destination, "mpv.exe") writeTransactionFile(t, target, "old") backupRoot, err := os.MkdirTemp(root, ".mpv-manager-overlay-backup-") require.NoError(t, err) backup := filepath.Join(backupRoot, "mpv.exe") transaction, err := beginInstallerTransaction(installerTransactionOverlay, destination, backupRoot, nil, nil) require.NoError(t, err) require.NoError(t, transaction.record(installerJournalChange{Target: target, Backup: backup, HadOriginal: true})) require.NoError(t, os.Rename(target, backup)) writeTransactionFile(t, target, "new") journalPath := transaction.journalPath transaction.retain() // Simulate process death without resolving the journal. require.NoError(t, RecoverInstallerTransactions(destination)) assertTransactionFile(t, target, "old") assert.NoDirExists(t, backupRoot) assert.NoFileExists(t, journalPath) } func TestRecoverInstallerTransactionsRemovesInterruptedNewPath(t *testing.T) { root := t.TempDir() destination := filepath.Join(root, "mpv") target := filepath.Join(destination, "new.dll") backupRoot, err := os.MkdirTemp(root, ".mpv-manager-overlay-backup-") require.NoError(t, err) transaction, err := beginInstallerTransaction(installerTransactionOverlay, destination, backupRoot, nil, nil) require.NoError(t, err) require.NoError(t, transaction.record(installerJournalChange{Target: target, Backup: filepath.Join(backupRoot, "new.dll")})) writeTransactionFile(t, target, "partial") transaction.retain() require.NoError(t, RecoverInstallerTransactions(destination)) assert.NoFileExists(t, target) } func TestRecoverInstallerTransactionsPreservesCommittedReplacement(t *testing.T) { root := t.TempDir() live := filepath.Join(root, "ffmpeg.exe") backupRoot, err := os.MkdirTemp(root, ".mpv-manager-file-backup-") require.NoError(t, err) backup := filepath.Join(backupRoot, "ffmpeg.exe") writeTransactionFile(t, live, "old") transaction, err := beginInstallerTransaction(installerTransactionFile, live, backupRoot, nil, nil) require.NoError(t, err) require.NoError(t, transaction.record(installerJournalChange{Target: live, Backup: backup, HadOriginal: true})) require.NoError(t, os.Rename(live, backup)) writeTransactionFile(t, live, "new") transaction.journal.State = "committed" require.NoError(t, transaction.write()) transaction.retain() require.NoError(t, RecoverInstallerTransactions(live)) assertTransactionFile(t, live, "new") assert.NoDirExists(t, backupRoot) } func TestRecoverInstallerTransactionsRestoresInterruptedUI(t *testing.T) { root := t.TempDir() configDir := filepath.Join(root, "portable_config") script := filepath.Join(configDir, "scripts", "uosc.lua") writeTransactionFile(t, script, "old UI") transaction, err := beginUITransaction(OSFileSystem{}, configDir) require.NoError(t, err) require.NoError(t, os.Remove(script)) writeTransactionFile(t, script, "new UI") writeTransactionFile(t, filepath.Join(configDir, "uosc.zip"), "partial download") transaction.journal.retain() require.NoError(t, RecoverInstallerTransactions(configDir)) assertTransactionFile(t, script, "old UI") assert.NoFileExists(t, filepath.Join(configDir, "uosc.zip")) assert.NoDirExists(t, transaction.backupDir) } func TestRecoverInstallerTransactionsRejectsTamperedJournal(t *testing.T) { root := t.TempDir() destination := filepath.Join(root, "mpv") outside := filepath.Join(root, "outside.txt") writeTransactionFile(t, outside, "keep") backupRoot, err := os.MkdirTemp(root, ".mpv-manager-overlay-backup-") require.NoError(t, err) transaction, err := beginInstallerTransaction(installerTransactionOverlay, destination, backupRoot, nil, nil) require.NoError(t, err) transaction.journal.Changes = []installerJournalChange{{Target: outside}} require.NoError(t, transaction.write()) transaction.retain() err = RecoverInstallerTransactions(destination) require.Error(t, err) assert.Contains(t, err.Error(), "unsafe target") assertTransactionFile(t, outside, "keep") assert.DirExists(t, backupRoot, "untrusted recovery evidence must be retained") } func writeTransactionFile(t *testing.T, path, content string) { t.Helper() require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) require.NoError(t, os.WriteFile(path, []byte(content), 0755)) } func assertTransactionFile(t *testing.T, path, expected string) { t.Helper() content, err := os.ReadFile(path) require.NoError(t, err) assert.Equal(t, expected, string(content)) }