From 339c4d7f8b8bc03adb9164b800786abe4804e8a1 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 30 May 2026 20:22:48 +0800 Subject: [PATCH 1/4] chore(mister): add AmigaVision launch diagnostics --- pkg/platforms/mister/cores/hooks.go | 79 ++++++++++++++++++++++++++--- pkg/platforms/mister/mgls/mgls.go | 4 +- 2 files changed, 74 insertions(+), 9 deletions(-) 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..e76f2d65 100644 --- a/pkg/platforms/mister/mgls/mgls.go +++ b/pkg/platforms/mister/mgls/mgls.go @@ -166,9 +166,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 } From 9c36efc50fcf3319e051cf5c50a1093a2bd571b9 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 30 May 2026 20:33:46 +0800 Subject: [PATCH 2/4] fix(mister): reject unsafe load_core paths --- pkg/platforms/mister/mgls/mgls.go | 18 +++++++++++++++++ pkg/platforms/mister/mgls/mgls_test.go | 28 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/pkg/platforms/mister/mgls/mgls.go b/pkg/platforms/mister/mgls/mgls.go index e76f2d65..67455a2a 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,6 +145,15 @@ 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 { _, err := os.Stat(misterconfig.CmdInterface) if err != nil { @@ -154,6 +164,10 @@ func launchFile(path string) error { if !s.HasSuffix(lowerPath, ".mgl") && !s.HasSuffix(lowerPath, ".mra") && !s.HasSuffix(lowerPath, ".rbf") { return fmt.Errorf("not a valid launch file: %s", path) } + validationErr := validateLoadCorePath(path) + if validationErr != nil { + return validationErr + } log.Debug().Str("file", path).Msg("sending to command interface") cmd, err := os.OpenFile(misterconfig.CmdInterface, os.O_RDWR, 0) @@ -314,6 +328,10 @@ 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 + } 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..0fd337f0 100644 --- a/pkg/platforms/mister/mgls/mgls_test.go +++ b/pkg/platforms/mister/mgls/mgls_test.go @@ -150,6 +150,34 @@ func TestReadMRA_EmptyFile(t *testing.T) { require.Error(t, err) } +func TestValidateLoadCorePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErr bool + }{ + {name: "valid path", path: "/media/fat/.LASTLAUNCH.mgl"}, + {name: "newline rejected", path: "/media/fat/.LASTLAUNCH.mgl\nload_core /tmp/evil.rbf", wantErr: true}, + {name: "carriage return rejected", path: "/media/fat/.LASTLAUNCH.mgl\r", wantErr: true}, + {name: "tab rejected", path: "/media/fat/.LAST\tLAUNCH.mgl", 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 TestGenerateMgl(t *testing.T) { t.Parallel() From 149d62dd58c982ebede660156bad07e06d059714 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 30 May 2026 20:53:46 +0800 Subject: [PATCH 3/4] test(mister): cover load_core path validation --- pkg/platforms/mister/mgls/mgls.go | 18 +++++----- pkg/platforms/mister/mgls/mgls_test.go | 46 +++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/pkg/platforms/mister/mgls/mgls.go b/pkg/platforms/mister/mgls/mgls.go index 67455a2a..a60d89c4 100644 --- a/pkg/platforms/mister/mgls/mgls.go +++ b/pkg/platforms/mister/mgls/mgls.go @@ -155,6 +155,11 @@ func validateLoadCorePath(path string) error { } 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) @@ -164,10 +169,6 @@ func launchFile(path string) error { if !s.HasSuffix(lowerPath, ".mgl") && !s.HasSuffix(lowerPath, ".mra") && !s.HasSuffix(lowerPath, ".rbf") { return fmt.Errorf("not a valid launch file: %s", path) } - validationErr := validateLoadCorePath(path) - if validationErr != nil { - return validationErr - } log.Debug().Str("file", path).Msg("sending to command interface") cmd, err := os.OpenFile(misterconfig.CmdInterface, os.O_RDWR, 0) @@ -315,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, "") } @@ -333,6 +330,11 @@ func LaunchCore(cfg *config.Instance, _ platforms.Platform, system *cores.Core) 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 { return fmt.Errorf("failed to open command interface: %w", err) diff --git a/pkg/platforms/mister/mgls/mgls_test.go b/pkg/platforms/mister/mgls/mgls_test.go index 0fd337f0..25239915 100644 --- a/pkg/platforms/mister/mgls/mgls_test.go +++ b/pkg/platforms/mister/mgls/mgls_test.go @@ -153,15 +153,16 @@ func TestReadMRA_EmptyFile(t *testing.T) { 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: "/media/fat/.LASTLAUNCH.mgl"}, - {name: "newline rejected", path: "/media/fat/.LASTLAUNCH.mgl\nload_core /tmp/evil.rbf", wantErr: true}, - {name: "carriage return rejected", path: "/media/fat/.LASTLAUNCH.mgl\r", wantErr: true}, - {name: "tab rejected", path: "/media/fat/.LAST\tLAUNCH.mgl", wantErr: true}, + {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 { @@ -178,6 +179,43 @@ func TestValidateLoadCorePath(t *testing.T) { } } +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() + + require.Error(t, launchFile(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.Error(t, err) + }) + } +} + func TestGenerateMgl(t *testing.T) { t.Parallel() From 9938b989514a50f5158a9f020842df75ad853b42 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 31 May 2026 07:39:51 +0800 Subject: [PATCH 4/4] test(mister): assert load_core validation errors --- pkg/platforms/mister/mgls/mgls_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/platforms/mister/mgls/mgls_test.go b/pkg/platforms/mister/mgls/mgls_test.go index 25239915..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" @@ -187,7 +188,8 @@ func TestLaunchFileRejectsControlCharacters(t *testing.T) { t.Run(path, func(t *testing.T) { t.Parallel() - require.Error(t, launchFile(path)) + err := launchFile(path) + require.EqualError(t, err, fmt.Sprintf("load_core path contains control character: %q", path)) }) } } @@ -211,7 +213,7 @@ func TestLaunchCoreRejectsControlCharacters(t *testing.T) { cores.GlobalRBFCache = cache err := LaunchCore(nil, nil, &cores.Core{ID: "NES"}) - require.Error(t, err) + require.EqualError(t, err, fmt.Sprintf("load_core path contains control character: %q", path)) }) } }