package installer import ( "os" "os/exec" "path/filepath" "runtime" "testing" "github.com/stretchr/testify/require" ) // Review regression probe: a framework's relative, internal link is legitimate // app-bundle content. Model interruption after live->backup and before activation. func TestAppBundleSymlinkRecovery(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("macOS bundle links; native Windows symlink privileges are unrelated") } root := t.TempDir() live := filepath.Join(root, "Player.app") backupRoot := filepath.Join(root, ".mpv-manager-swap-backup-review") backup := filepath.Join(backupRoot, "Player.app") framework := filepath.Join(backup, "Contents", "Frameworks", "Player.framework") version := filepath.Join(framework, "Versions", "A") if err := os.MkdirAll(version, 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(version, "Player"), []byte("old framework payload"), 0700); err != nil { t.Fatal(err) } if err := os.Symlink("A", filepath.Join(framework, "Versions", "Current")); err != nil { t.Fatal(err) } if err := os.Symlink("Versions/Current/Player", filepath.Join(framework, "Player")); err != nil { t.Fatal(err) } txn := &installerTransaction{journalPath: backupRoot + installerJournalSuffix, journal: installerTransactionJournal{Schema: installerTransactionSchema, Kind: installerTransactionSwap, State: "applying", Destination: live, BackupRoot: backupRoot, Changes: []installerJournalChange{ {Target: live, Backup: backup, HadOriginal: true}, }}} if err := txn.write(); err != nil { t.Fatal(err) } if runtime.GOOS == "darwin" { require.NoError(t, exec.Command("xattr", "-w", "com.mpv-manager.recovery-test", "preserve metadata", filepath.Join(version, "Player")).Run()) } // Simulate a second interruption after the first restore but before journal retirement. require.NoError(t, rollbackInstallerJournal(txn.journal)) require.NoError(t, rollbackInstallerJournal(txn.journal)) if runtime.GOOS == "darwin" { output, err := exec.Command("xattr", "-p", "com.mpv-manager.recovery-test", filepath.Join(live, "Contents", "Frameworks", "Player.framework", "Versions", "A", "Player")).Output() require.NoError(t, err) require.Equal(t, "preserve metadata\n", string(output)) } err := RecoverInstallerTransactions(live) t.Logf("recovery error=%v", err) if err != nil { t.Errorf("valid app bundle backup should be recoverable: %v", err) } if _, err := os.Stat(filepath.Join(live, "Contents", "Frameworks", "Player.framework", "Player")); err != nil { t.Errorf("application remains unavailable after recovery: %v", err) } } func TestAppBundleRecoveryRejectsUnsafeLinksBeforeMutation(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("macOS bundle symlinks") } for _, kind := range []string{"absolute", "escaping", "broken", "cycle", "sibling-prefix"} { t.Run(kind, func(t *testing.T) { root := t.TempDir() live := filepath.Join(root, "Player.app") backupRoot := filepath.Join(root, ".mpv-manager-swap-backup-unsafe") backup := filepath.Join(backupRoot, "Player.app") require.NoError(t, os.MkdirAll(backup, 0700)) require.NoError(t, os.MkdirAll(live, 0700)) require.NoError(t, os.WriteFile(filepath.Join(live, "current"), []byte("preserve live"), 0600)) outside := filepath.Join(root, "outside") require.NoError(t, os.WriteFile(outside, []byte("preserve outside"), 0600)) target := outside switch kind { case "escaping": target = "../../outside" case "broken": target = "missing" case "cycle": target = "link" case "sibling-prefix": sibling := backup + "-other" require.NoError(t, os.MkdirAll(sibling, 0700)) require.NoError(t, os.WriteFile(filepath.Join(sibling, "file"), []byte("sibling"), 0600)) target = "../Player.app-other/file" } require.NoError(t, os.Symlink(target, filepath.Join(backup, "link"))) txn := &installerTransaction{journalPath: backupRoot + installerJournalSuffix, journal: installerTransactionJournal{Schema: installerTransactionSchema, Kind: installerTransactionSwap, State: "applying", Destination: live, BackupRoot: backupRoot, Changes: []installerJournalChange{{Target: live, Backup: backup, HadOriginal: true}}}} require.NoError(t, txn.write()) for range 2 { require.Error(t, RecoverInstallerTransactions(live)) } data, err := os.ReadFile(filepath.Join(live, "current")) require.NoError(t, err) require.Equal(t, "preserve live", string(data)) require.FileExists(t, txn.journalPath) require.DirExists(t, backup) data, err = os.ReadFile(outside) require.NoError(t, err) require.Equal(t, "preserve outside", string(data)) }) } }