diff --git a/pkg/platforms/mister/cores/hooks.go b/pkg/platforms/mister/cores/hooks.go index 5f92c109..bc4fc1c5 100644 --- a/pkg/platforms/mister/cores/hooks.go +++ b/pkg/platforms/mister/cores/hooks.go @@ -175,12 +175,32 @@ func hookAmiga(cfg *config.Instance, _ *Core, path string) (string, error) { } gameName := filepath.Base(path) + listingName := filepath.Base(filepath.Dir(path)) installPath := filepath.Clean(filepath.Join(filepath.Dir(path), "..", "..")) - if !hasAmigaVisionBootImage(installPath) { - listingName := filepath.Base(filepath.Dir(path)) + bootImagePath := amigaVisionBootImagePath(installPath) + bootImageFound := bootImagePath != "" + log.Debug(). + Str("path", path). + Str("game", gameName). + Str("listing", listingName). + Str("candidate_install_path", installPath). + Str("candidate_boot_image", bootImagePath). + Bool("candidate_has_boot_image", bootImageFound). + Msg("AmigaVision launch hook detected listing entry") + if !bootImageFound { activeInstallPath := findAmigaVisionInstallPath(cfg, listingName, gameName) if activeInstallPath != "" { + log.Debug(). + Str("candidate_install_path", installPath). + Str("active_install_path", activeInstallPath). + Msg("AmigaVision launch hook using active install path") installPath = activeInstallPath + } else { + log.Warn(). + Str("candidate_install_path", installPath). + Str("game", gameName). + Str("listing", listingName). + Msg("AmigaVision launch hook could not find install path with boot image") } } @@ -191,31 +211,64 @@ func hookAmiga(cfg *config.Instance, _ *Core, path string) (string, error) { bootFile := filepath.Join(sharedPath, "ags_boot") if err := os.WriteFile(bootFile, []byte(gameName+"\n"), 0o600); err != nil { + log.Error().Err(err). + Str("install_path", installPath). + Str("shared_path", sharedPath). + Str("boot_file", bootFile). + Str("game", gameName). + Msg("failed to write AmigaVision boot file") return "", fmt.Errorf("failed to write boot file: %w", err) } + log.Info(). + Str("install_path", installPath). + Str("shared_path", sharedPath). + Str("boot_file", bootFile). + Str("game", gameName). + Msg("AmigaVision boot file written") return "\tAmiga\n", nil } -func hasAmigaVisionBootImage(path string) bool { +func amigaVisionBootImagePath(path string) string { for _, image := range []string{"AmigaVision.hdf", "MegaAGS.hdf"} { - if _, err := os.Stat(filepath.Join(path, image)); err == nil { - return true + imagePath := filepath.Join(path, image) + if _, err := os.Stat(imagePath); err == nil { + return imagePath } } - return false + return "" } func findAmigaVisionInstallPath(cfg *config.Instance, listingName, gameName string) string { if cfg == nil { + log.Debug().Msg("AmigaVision install search skipped: config is nil") return "" } var preferred []string var other []string - for _, root := range misterconfig.RootDirs(cfg) { + roots := misterconfig.RootDirs(cfg) + log.Debug(). + Strs("roots", roots). + Str("listing", listingName). + Str("game", gameName). + Msg("searching for AmigaVision install path") + for _, root := range roots { candidate := filepath.Join(root, "Amiga") - if !hasAmigaVisionBootImage(candidate) || !amigaListingContains(candidate, listingName, gameName) { + bootImagePath := amigaVisionBootImagePath(candidate) + hasBootImage := bootImagePath != "" + listingContainsGame := false + if hasBootImage { + listingContainsGame = amigaListingContains(candidate, listingName, gameName) + } + log.Debug(). + Str("root", root). + Str("candidate", candidate). + Str("boot_image", bootImagePath). + Bool("has_boot_image", hasBootImage). + Bool("listing_contains_game", listingContainsGame). + Msg("checked AmigaVision install candidate") + if !hasBootImage || !listingContainsGame { continue } if strings.HasSuffix(strings.ToLower(filepath.Clean(candidate)), filepath.Join("games", "amiga")) { @@ -225,11 +278,17 @@ func findAmigaVisionInstallPath(cfg *config.Instance, listingName, gameName stri other = append(other, candidate) } if len(preferred) > 0 { + log.Debug().Str("install_path", preferred[0]).Msg("selected preferred AmigaVision install path") return preferred[0] } if len(other) > 0 { + log.Debug().Str("install_path", other[0]).Msg("selected fallback AmigaVision install path") return other[0] } + log.Warn(). + Str("listing", listingName). + Str("game", gameName). + Msg("no AmigaVision install path matched listing entry") return "" } @@ -237,6 +296,7 @@ func amigaListingContains(installPath, listingName, gameName string) bool { listingPath := filepath.Join(installPath, "listings", listingName) f, err := os.Open(listingPath) //nolint:gosec // Internal AmigaVision listing path if err != nil { + log.Debug().Err(err).Str("listing_path", listingPath).Msg("failed to open AmigaVision listing") return false } defer func() { @@ -251,6 +311,9 @@ func amigaListingContains(installPath, listingName, gameName string) bool { return true } } + if err := scanner.Err(); err != nil { + log.Warn().Err(err).Str("listing_path", listingPath).Msg("failed to scan AmigaVision listing") + } return false } diff --git a/pkg/platforms/mister/mgls/mgls.go b/pkg/platforms/mister/mgls/mgls.go index 34a8f7d5..a60d89c4 100644 --- a/pkg/platforms/mister/mgls/mgls.go +++ b/pkg/platforms/mister/mgls/mgls.go @@ -29,6 +29,7 @@ import ( "os" "path/filepath" s "strings" + "unicode" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" @@ -144,7 +145,21 @@ func writeTempFile(content string) (string, error) { return tmpFile.Name(), nil } +func validateLoadCorePath(path string) error { + for _, r := range path { + if unicode.IsControl(r) { + return fmt.Errorf("load_core path contains control character: %q", path) + } + } + return nil +} + func launchFile(path string) error { + validationErr := validateLoadCorePath(path) + if validationErr != nil { + return validationErr + } + _, err := os.Stat(misterconfig.CmdInterface) if err != nil { return fmt.Errorf("command interface not accessible: %w", err) @@ -166,9 +181,11 @@ func launchFile(path string) error { } }() - if _, err := fmt.Fprintf(cmd, "load_core %s\n", path); err != nil { + command := "load_core " + path + if _, err := fmt.Fprintln(cmd, command); err != nil { return fmt.Errorf("failed to write to command interface: %w", err) } + log.Info().Str("command", command).Msg("command interface launch request sent") return nil } @@ -299,10 +316,6 @@ func LaunchGame(cfg *config.Instance, system *cores.Core, path string) error { // LaunchCore Launch a core given a possibly partial path, as per MGL files. func LaunchCore(cfg *config.Instance, _ platforms.Platform, system *cores.Core) error { - if _, err := os.Stat(misterconfig.CmdInterface); err != nil { - return fmt.Errorf("command interface not accessible: %w", err) - } - if system.SetName != "" { return LaunchGame(cfg, system, "") } @@ -312,6 +325,15 @@ func LaunchCore(cfg *config.Instance, _ platforms.Platform, system *cores.Core) return fmt.Errorf("resolving core RBF: %w", err) } path := rbfInfo.Path + validationErr := validateLoadCorePath(path) + if validationErr != nil { + return validationErr + } + + _, statErr := os.Stat(misterconfig.CmdInterface) + if statErr != nil { + return fmt.Errorf("command interface not accessible: %w", statErr) + } cmd, err := os.OpenFile(misterconfig.CmdInterface, os.O_RDWR, 0) if err != nil { diff --git a/pkg/platforms/mister/mgls/mgls_test.go b/pkg/platforms/mister/mgls/mgls_test.go index 967c5bdc..51ae2f2c 100644 --- a/pkg/platforms/mister/mgls/mgls_test.go +++ b/pkg/platforms/mister/mgls/mgls_test.go @@ -22,6 +22,7 @@ package mgls import ( + "fmt" "os" "path/filepath" "testing" @@ -150,6 +151,73 @@ func TestReadMRA_EmptyFile(t *testing.T) { require.Error(t, err) } +func TestValidateLoadCorePath(t *testing.T) { + t.Parallel() + + basePath := filepath.Join("media", "fat", ".LASTLAUNCH.mgl") + tests := []struct { + name string + path string + wantErr bool + }{ + {name: "valid path", path: basePath}, + {name: "newline rejected", path: basePath + "\nload_core " + filepath.Join("tmp", "evil.rbf"), wantErr: true}, + {name: "carriage return rejected", path: basePath + "\r", wantErr: true}, + {name: "tab rejected", path: basePath + "\t", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateLoadCorePath(tt.path) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestLaunchFileRejectsControlCharacters(t *testing.T) { + t.Parallel() + + basePath := filepath.Join("media", "fat", ".LASTLAUNCH.mgl") + for _, path := range []string{basePath + "\n", basePath + "\r", basePath + "\t"} { + t.Run(path, func(t *testing.T) { + t.Parallel() + + err := launchFile(path) + require.EqualError(t, err, fmt.Sprintf("load_core path contains control character: %q", path)) + }) + } +} + +func TestLaunchCoreRejectsControlCharacters(t *testing.T) { + oldCache := cores.GlobalRBFCache + t.Cleanup(func() { + cores.GlobalRBFCache = oldCache + }) + + basePath := filepath.Join("media", "fat", "_Console", "NES.rbf") + for _, path := range []string{basePath + "\n", basePath + "\r", basePath + "\t"} { + t.Run(path, func(t *testing.T) { + cache := &cores.RBFCache{} + cache.BuildFromRBFs([]cores.RBFInfo{{ + Path: path, + Filename: "NES.rbf", + ShortName: "NES", + MglName: filepath.Join("_Console", "NES"), + }}) + cores.GlobalRBFCache = cache + + err := LaunchCore(nil, nil, &cores.Core{ID: "NES"}) + require.EqualError(t, err, fmt.Sprintf("load_core path contains control character: %q", path)) + }) + } +} + func TestGenerateMgl(t *testing.T) { t.Parallel()