From dc767e24ccf84ff110addcd51d877412a10bbbd9 Mon Sep 17 00:00:00 2001 From: terafin Date: Mon, 8 Jun 2026 11:37:06 -0700 Subject: [PATCH] fix(validators): libretro/RetroDECK format compat (GBA signature + GB rtc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related libretro-vs-standalone validator gaps in one batch — both fix real data-loss paths reported by SGM-Helper users on Steam Deck (RetroDECK) and SS1 (libretro cores), both stay strict for anonymous uploads, both ship with happy-path + regression-guard tests. ## Part 1 — GBA library-signature advisory under helper trust (was PR #8, fixes #7) Standalone mGBA / VBA-M write a library version footer (`EEPROM_V`, `SRAM_V`, `FLASH_V`, `FLASH1M_V`, `FLASH512_V`) into the .srm. RetroArch's libretro-mGBA core does NOT — confirmed across both EA Sports games (007 - Everything or Nothing, header `3500DNOB`) and canonical mGBA-targeted titles (Pokemon Emerald, sparse FRAM data). RSM's `hasGBASignature()` rejects every libretro GBA save → HTTP 422. Adds `SignatureAdvisoryWithHelperTrust bool` to `strictRawSaveValidationProfile`. When true, a failing `RequireSignature` check is downgraded from reject → warning IFF (a) `HelperTrusted` AND (b) `rom_sha1` present AND (c) payload non-blank. Anonymous uploads or uploads without `rom_sha1` still hard-reject. GBA's profile opts in. NES / SNES / Master System / Genesis are unchanged because their `RequireSignature` is nil (no signature required at all). ## Part 2 — Game Boy .rtc sidecar size validation (was PR #9) RetroArch / libretro Game Boy cores write a small `.rtc` sidecar next to the canonical `.srm` for cartridges with real-time clock state (Pokemon Crystal, Pokemon Gold/Silver, Harvest Moon GBC). Observed sizes 8–48 bytes. The strict gameboy raw-save profile's `AllowedSizes` only listed canonical SRAM sizes (512..65536), so every `.rtc` upload rejected with "game boy raw save size N is not recognized" → silent data loss for clock state. Adds optional `AllowedSizesByExt map[string]func(int) bool` to `strictRawSaveValidationProfile`. When the incoming extension matches a registered predicate, that predicate replaces the canonical size-set check for that extension only. Other extensions fall through to `AllowedSizes` unchanged. Gameboy opts in for `.rtc` with `1..=64` bytes. `.sav` / `.srm` / `.ram` / `.gme` still validate against `strictRawGBSizes` exactly as before — scoped relaxation, not a blanket loosening. ## Tests - `TestNormalizeSaveInputAcceptsLibretroGBASaveUnderHelperTrust` — accepts libretro GBA save without library footer when HelperTrusted + rom_sha1 - `TestNormalizeSaveInputRejectsLibretroGBASaveWithoutHelperTrust` — regression guard: anonymous upload still rejected - `TestNormalizeSaveInputRejectsLibretroGBASaveWithoutROMSHA1` — same - `TestNormalizeSaveInputRejectsBlankLibretroGBASave` — blank payload still rejected even under helper trust - `TestNormalizeSaveInputAcceptsTinyGameBoyRTCFile` — accepts 8/13/32/48/64 byte gameboy `.rtc` payloads - `TestNormalizeSaveInputRejectsTinyGameBoySRMFile` — regression guard: 8-byte `.srm` still rejected, proves relaxation is `.rtc`-scoped - `TestNormalizeSaveInputRejectsOversizedGameBoyRTCFile` — 65-byte `.rtc` rejected with new per-ext message ## Scope - Only GBA opts into `SignatureAdvisoryWithHelperTrust`; only gameboy opts into `AllowedSizesByExt`. Every other system's behavior is byte-identical to before. - Pre-existing trust / blank / executable-payload / text-noise checks still run — only the size predicate and signature requirement get the per-extension / per-trust-context overrides. Consolidates and supersedes PR #8 (GBA advisory) + PR #9 (gameboy .rtc). Both are scanner.rs/validator-relaxation fixes from the same lens (libretro-vs-standalone format gaps). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cmd/server/gba_signature_advisory_test.go | 169 ++++++++++++++++++ backend/cmd/server/nintendo_validation.go | 38 ++-- .../cmd/server/nintendo_validation_test.go | 66 +++++++ backend/cmd/server/raw_save_validation.go | 60 ++++++- 4 files changed, 307 insertions(+), 26 deletions(-) create mode 100644 backend/cmd/server/gba_signature_advisory_test.go diff --git a/backend/cmd/server/gba_signature_advisory_test.go b/backend/cmd/server/gba_signature_advisory_test.go new file mode 100644 index 0000000..2236301 --- /dev/null +++ b/backend/cmd/server/gba_signature_advisory_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "strings" + "testing" +) + +// buildGBAPayloadWithSignature returns a 32K FLASH-erased buffer with mGBA's +// SRAM_V signature embedded at a low offset, mimicking what standalone mGBA +// would write. +func buildGBAPayloadWithSignature(size int, signature string) []byte { + payload := make([]byte, size) + for idx := range payload { + payload[idx] = 0xFF + } + // Embed signature at byte 64 — mGBA writes its version string at a + // well-defined offset, but for validator purposes any location within + // the first 512KB scan window works. + copy(payload[64:], []byte(signature)) + // Add some non-zero bytes so the blank check passes. + for i := 0; i < 32; i++ { + payload[i] = 0x5a + } + return payload +} + +// buildGBAPayloadNoSignature returns a same-size buffer with NO library +// signature and NO AGB cartridge header — simulating what RetroArch's +// libretro-mGBA core writes for Pokemon Emerald, EA-Sports 007 games, etc. +func buildGBAPayloadNoSignature(size int) []byte { + payload := make([]byte, size) + for idx := range payload { + payload[idx] = 0xFF + } + // Real-world EA Sports 007 save header (BOND0053 backwards) at offset 0 — + // guarantees no signature/AGB header collisions. + copy(payload[0:8], []byte("3500DNOB")) + // Some non-zero bytes scattered through so the blank check passes. + for i := 0; i < 32; i++ { + payload[i] = 0x5a + } + return payload +} + +// Regression guard: GBA saves WITH the standard library signature must +// continue to pass without any advisory downgrade (no warning emitted). +func TestGBARawSaveAcceptedWithLibrarySignature(t *testing.T) { + a := &app{} + result := a.normalizeSaveInputDetailed(saveCreateInput{ + Filename: "Some Standalone mGBA Save.srm", + Payload: buildGBAPayloadWithSignature(32768, "SRAM_V113"), + Game: game{Name: "Some Standalone mGBA Save"}, + Format: "sram", + ROMSHA1: "abc123", + SlotName: "default", + SystemSlug: "gba", + TrustedHelperSystem: true, + }) + if result.Rejected { + t.Fatalf("expected GBA save with SRAM_V signature to be accepted, got reject=%q", result.RejectReason) + } + if result.Input.Inspection == nil { + t.Fatal("expected GBA inspection metadata") + } + for _, w := range result.Input.Inspection.Warnings { + if strings.Contains(w, "without the standard library signature footer") { + t.Errorf("did NOT expect advisory-downgrade warning when signature is present: %q", w) + } + } +} + +// THE FIX: a GBA save from a trusted helper (e.g. RetroArch via SGM-Helper), +// with rom_sha1 present and non-blank payload, but NO library signature, +// must be accepted — with a warning explaining the advisory downgrade. +func TestGBARawSaveAcceptedWithoutSignatureUnderHelperTrust(t *testing.T) { + a := &app{} + result := a.normalizeSaveInputDetailed(saveCreateInput{ + Filename: "Pokemon - Emerald Version (USA, Europe).srm", + Payload: buildGBAPayloadNoSignature(131072), + Game: game{Name: "Pokemon - Emerald Version"}, + Format: "flash", + ROMSHA1: "pokemon-emerald-rom-sha1", + SlotName: "default", + SystemSlug: "gba", + TrustedHelperSystem: true, + }) + if result.Rejected { + t.Fatalf("expected GBA save from trusted helper without signature to be ACCEPTED, got reject=%q", result.RejectReason) + } + if result.Input.Inspection == nil { + t.Fatal("expected GBA inspection metadata") + } + foundWarning := false + for _, w := range result.Input.Inspection.Warnings { + if strings.Contains(w, "without the standard library signature footer") { + foundWarning = true + } + } + if !foundWarning { + t.Errorf("expected advisory-downgrade warning, got warnings: %+v", result.Input.Inspection.Warnings) + } +} + +// Security guard: same scenario as above BUT without rom_sha1 — must reject. +// The advisory downgrade requires all three trust conditions; missing any +// one of them keeps the original reject behavior. +func TestGBARawSaveRejectedWithoutROMSHA1EvenWithHelperTrust(t *testing.T) { + a := &app{} + result := a.normalizeSaveInputDetailed(saveCreateInput{ + Filename: "Pokemon - Emerald Version (USA, Europe).srm", + Payload: buildGBAPayloadNoSignature(131072), + Game: game{Name: "Pokemon - Emerald Version"}, + Format: "flash", + SlotName: "default", + SystemSlug: "gba", + TrustedHelperSystem: true, + // rom_sha1 deliberately absent + }) + if !result.Rejected { + t.Fatal("expected GBA save without rom_sha1 to be rejected even with helper trust") + } + // Pre-signature check (RequireROMSHA1) fires first. + if !strings.Contains(result.RejectReason, "rom_sha1") { + t.Errorf("expected rom_sha1 rejection reason, got %q", result.RejectReason) + } +} + +// Security guard: helper-trust signal absent — even with rom_sha1 and +// non-blank payload, must reject. The advisory downgrade is opt-in via +// trusted authenticated channel. +func TestGBARawSaveRejectedWithoutHelperTrustEvenWithROMSHA1(t *testing.T) { + a := &app{} + result := a.normalizeSaveInputDetailed(saveCreateInput{ + Filename: "Pokemon - Emerald Version (USA, Europe).srm", + Payload: buildGBAPayloadNoSignature(131072), + Game: game{Name: "Pokemon - Emerald Version"}, + Format: "flash", + ROMSHA1: "pokemon-emerald-rom-sha1", + SlotName: "default", + SystemSlug: "gba", + // TrustedHelperSystem deliberately false + }) + if !result.Rejected { + t.Fatal("expected GBA save without helper trust to be rejected even with rom_sha1") + } +} + +// Security guard: blank payload must still be rejected — the advisory +// downgrade explicitly requires non-blank. +func TestGBARawSaveRejectedWhenBlankEvenUnderHelperTrust(t *testing.T) { + blankFF := make([]byte, 131072) + for idx := range blankFF { + blankFF[idx] = 0xFF + } + a := &app{} + result := a.normalizeSaveInputDetailed(saveCreateInput{ + Filename: "freshly-launched-game.srm", + Payload: blankFF, + Game: game{Name: "freshly launched game"}, + Format: "flash", + ROMSHA1: "some-rom-sha1", + SlotName: "default", + SystemSlug: "gba", + TrustedHelperSystem: true, + }) + if !result.Rejected { + t.Fatal("expected blank GBA save to be rejected even under helper trust") + } +} diff --git a/backend/cmd/server/nintendo_validation.go b/backend/cmd/server/nintendo_validation.go index 121f474..ab80eba 100644 --- a/backend/cmd/server/nintendo_validation.go +++ b/backend/cmd/server/nintendo_validation.go @@ -53,11 +53,14 @@ func validateNintendoRawSave(input saveCreateInput, detection saveSystemDetectio switch systemSlug { case "gameboy": return validateStrictRawSaveClass(input, detection, strictRawSaveValidationProfile{ - SystemSlug: "gameboy", - DisplayName: "game boy", - ParserID: "gameboy-raw-sram", - AllowedExts: map[string]struct{}{"sav": {}, "srm": {}, "ram": {}, "rtc": {}, "gme": {}}, - AllowedSizes: strictRawGBSizes, + SystemSlug: "gameboy", + DisplayName: "game boy", + ParserID: "gameboy-raw-sram", + AllowedExts: map[string]struct{}{"sav": {}, "srm": {}, "ram": {}, "rtc": {}, "gme": {}}, + AllowedSizes: strictRawGBSizes, + AllowedSizesByExt: map[string]func(int) bool{ + "rtc": func(n int) bool { return n >= 1 && n <= 64 }, + }, RequireROMSHA1: true, RequireTrustedMatch: true, RejectBlank: true, @@ -66,18 +69,19 @@ func validateNintendoRawSave(input saveCreateInput, detection saveSystemDetectio }) case "gba": return validateStrictRawSaveClass(input, detection, strictRawSaveValidationProfile{ - SystemSlug: "gba", - DisplayName: "gba", - ParserID: "gba-raw-backup", - AllowedExts: map[string]struct{}{"sav": {}, "srm": {}, "sa1": {}}, - AllowedSizes: strictRawGBASizes, - RequireROMSHA1: true, - RequireTrustedMatch: true, - RequireSignature: hasGBASignature, - SignatureReason: "gba validated payload signature", - RejectBlank: true, - SparseWarningCutoff: 16, - Warning: "No structural GBA save decoder is available yet beyond backup-library signature validation", + SystemSlug: "gba", + DisplayName: "gba", + ParserID: "gba-raw-backup", + AllowedExts: map[string]struct{}{"sav": {}, "srm": {}, "sa1": {}}, + AllowedSizes: strictRawGBASizes, + RequireROMSHA1: true, + RequireTrustedMatch: true, + RequireSignature: hasGBASignature, + SignatureReason: "gba validated payload signature", + SignatureAdvisoryWithHelperTrust: true, + RejectBlank: true, + SparseWarningCutoff: 16, + Warning: "No structural GBA save decoder is available yet beyond backup-library signature validation", }) case "nes": return validateStrictRawSaveClass(input, detection, strictRawSaveValidationProfile{ diff --git a/backend/cmd/server/nintendo_validation_test.go b/backend/cmd/server/nintendo_validation_test.go index 6eca4cd..e1aeb35 100644 --- a/backend/cmd/server/nintendo_validation_test.go +++ b/backend/cmd/server/nintendo_validation_test.go @@ -245,6 +245,72 @@ func TestNormalizeSaveInputRejectsBlankTrustedNeoGeoSave(t *testing.T) { } } +func TestNormalizeSaveInputAcceptsTinyGameBoyRTCFile(t *testing.T) { + a := &app{} + for _, size := range []int{8, 13, 32, 48, 64} { + payload := buildNonBlankPayload(size, 0xA5) + result := a.normalizeSaveInputDetailed(saveCreateInput{ + Filename: "Pokemon - Crystal Version (USA, Europe) (Rev 1).rtc", + Payload: payload, + Game: game{Name: "Pokemon - Crystal Version"}, + Format: "sram", + ROMSHA1: "pokemon-crystal-rom", + SlotName: "default", + SystemSlug: "gameboy", + TrustedHelperSystem: true, + }) + if result.Rejected { + t.Fatalf("expected gameboy .rtc payload of %d bytes to be accepted, got reject=%q", size, result.RejectReason) + } + if result.Input.Inspection == nil || result.Input.Inspection.ParserID != "gameboy-raw-sram" { + t.Fatalf("expected gameboy-raw-sram inspection for %d-byte .rtc, got %+v", size, result.Input.Inspection) + } + if got := result.Input.Inspection.SemanticFields["rawSaveKind"]; got != "Game Boy real-time clock data" { + t.Fatalf("expected RTC raw save kind for %d-byte .rtc, got %+v", size, result.Input.Inspection.SemanticFields) + } + } +} + +func TestNormalizeSaveInputRejectsTinyGameBoySRMFile(t *testing.T) { + a := &app{} + result := a.normalizeSaveInputDetailed(saveCreateInput{ + Filename: "Pokemon - Crystal Version (USA, Europe) (Rev 1).srm", + Payload: buildNonBlankPayload(8, 0xA5), + Game: game{Name: "Pokemon - Crystal Version"}, + Format: "sram", + ROMSHA1: "pokemon-crystal-rom", + SlotName: "default", + SystemSlug: "gameboy", + TrustedHelperSystem: true, + }) + if !result.Rejected { + t.Fatal("expected 8-byte gameboy .srm payload to still be rejected (regression guard)") + } + if result.RejectReason != "game boy raw save size 8 is not recognized" { + t.Fatalf("unexpected reject reason: %q", result.RejectReason) + } +} + +func TestNormalizeSaveInputRejectsOversizedGameBoyRTCFile(t *testing.T) { + a := &app{} + result := a.normalizeSaveInputDetailed(saveCreateInput{ + Filename: "Pokemon - Crystal Version (USA, Europe) (Rev 1).rtc", + Payload: buildNonBlankPayload(65, 0xA5), + Game: game{Name: "Pokemon - Crystal Version"}, + Format: "sram", + ROMSHA1: "pokemon-crystal-rom", + SlotName: "default", + SystemSlug: "gameboy", + TrustedHelperSystem: true, + }) + if !result.Rejected { + t.Fatal("expected 65-byte gameboy .rtc payload to be rejected (oversized for RTC)") + } + if result.RejectReason != "game boy raw save size 65 is not recognized for .rtc" { + t.Fatalf("unexpected reject reason: %q", result.RejectReason) + } +} + func buildNSMBNDSSaveFixture() []byte { payload := make([]byte, 8192) positions := []int{2, 258, 898, 1538, 2178, 4098, 4354, 4994, 5634, 6274} diff --git a/backend/cmd/server/raw_save_validation.go b/backend/cmd/server/raw_save_validation.go index e1598fc..257ca9c 100644 --- a/backend/cmd/server/raw_save_validation.go +++ b/backend/cmd/server/raw_save_validation.go @@ -25,15 +25,26 @@ type strictRawSaveValidationProfile struct { ParserID string AllowedExts map[string]struct{} AllowedSizes map[int]struct{} + AllowedSizesByExt map[string]func(int) bool RequireROMSHA1 bool RequireTrustedMatch bool RequireDeclared bool RequireHelperOrStore bool RequireSignature func([]byte) bool SignatureReason string - RejectBlank bool - SparseWarningCutoff int - Warning string + // SignatureAdvisoryWithHelperTrust: when true, a failing RequireSignature check + // is downgraded from reject to warning IF (a) detection.Evidence.HelperTrusted + // is true AND (b) rom_sha1 is present AND (c) the payload is non-blank. This + // enables saves from authenticated helpers whose underlying emulators don't + // embed the library signature footer the validator would prefer to see — most + // notably RetroArch's libretro-mGBA core, which is the dominant modern GBA + // emulation path (Steam Deck / RetroDECK) and does NOT write the + // EEPROM_V/SRAM_V/FLASH_V/FLASH1M_V/FLASH512_V strings that standalone + // mGBA and VBA-M do. See issue #7. + SignatureAdvisoryWithHelperTrust bool + RejectBlank bool + SparseWarningCutoff int + Warning string } func validateStrictRawSaveClass(input saveCreateInput, detection saveSystemDetectionResult, profile strictRawSaveValidationProfile) consoleValidationResult { @@ -62,7 +73,14 @@ func validateStrictRawSaveClass(input saveCreateInput, detection saveSystemDetec RejectReason: "payload looks like text/noise", } } - if _, ok := profile.AllowedSizes[len(input.Payload)]; !ok { + if extSizeFn, ok := profile.AllowedSizesByExt[ext]; ok { + if !extSizeFn(len(input.Payload)) { + return consoleValidationResult{ + Rejected: true, + RejectReason: fmt.Sprintf("%s raw save size %d is not recognized for .%s", profile.DisplayName, len(input.Payload), ext), + } + } + } else if _, ok := profile.AllowedSizes[len(input.Payload)]; !ok { return consoleValidationResult{ Rejected: true, RejectReason: fmt.Sprintf("%s raw save size %d is not recognized", profile.DisplayName, len(input.Payload)), @@ -92,14 +110,27 @@ func validateStrictRawSaveClass(input saveCreateInput, detection saveSystemDetec RejectReason: fmt.Sprintf("%s raw saves require trusted helper or stored system evidence", profile.SystemSlug), } } + signatureAdvisoryDowngraded := false + stats := analyzeRawSavePayload(input.Payload, profile.SparseWarningCutoff) if profile.RequireSignature != nil && !profile.RequireSignature(input.Payload) { - return consoleValidationResult{ - Rejected: true, - RejectReason: fmt.Sprintf("%s raw save is missing a validated payload signature", profile.DisplayName), + // Optional escape hatch: if the profile allows the signature requirement + // to be advisory under sufficient trust, and ALL of the trust conditions + // hold (HelperTrusted + rom_sha1 present + non-blank payload), downgrade + // the rejection to a warning instead. The signature stays a hard reject + // for anonymous uploads or uploads without rom_sha1. + canDowngrade := profile.SignatureAdvisoryWithHelperTrust && + detection.Evidence.HelperTrusted && + strings.TrimSpace(input.ROMSHA1) != "" && + !stats.BlankZero && !stats.BlankFF + if !canDowngrade { + return consoleValidationResult{ + Rejected: true, + RejectReason: fmt.Sprintf("%s raw save is missing a validated payload signature", profile.DisplayName), + } } + signatureAdvisoryDowngraded = true } - stats := analyzeRawSavePayload(input.Payload, profile.SparseWarningCutoff) if profile.RejectBlank && stats.BlankZero { return consoleValidationResult{ Rejected: true, @@ -135,13 +166,24 @@ func validateStrictRawSaveClass(input saveCreateInput, detection saveSystemDetec evidence = append(evidence, "declared system") } if profile.RequireSignature != nil && strings.TrimSpace(profile.SignatureReason) != "" { - evidence = append(evidence, profile.SignatureReason) + if signatureAdvisoryDowngraded { + evidence = append(evidence, profile.SignatureReason+" (advisory: missing — accepted under helper trust)") + } else { + evidence = append(evidence, profile.SignatureReason) + } } warnings := []string(nil) if strings.TrimSpace(profile.Warning) != "" { warnings = append(warnings, profile.Warning) } + if signatureAdvisoryDowngraded { + warnings = append(warnings, + fmt.Sprintf("%s save accepted without the standard library signature footer; "+ + "trust derived from authenticated helper + rom_sha1 + non-blank payload. "+ + "Common for RetroArch / libretro cores that don't write the footer.", + profile.DisplayName)) + } if stats.SparseCutoff > 0 && stats.NonZero <= stats.SparseCutoff { warnings = append(warnings, "Payload is extremely sparse and only raw media validation is available") }