From 6f51b82991bac3e0bc6be0519538c62f0798d022 Mon Sep 17 00:00:00 2001 From: Clay Smith Date: Fri, 4 Sep 2026 21:44:52 -0500 Subject: [PATCH] fix: verify the POPS runtime by content, not just by presence A drive whose POPS.ELF was a completely different file reported "PS1 READY", and every PS1 title black-screened and returned to the console browser. Nothing in the symptom pointed at the emulator: the VCDs verified byte-perfect against cue2pops, the launchers were byte-identical copies of POPSTARTER.ELF, OPL found and executed them, and stripping a title's name to something trivial changed nothing. The runtime check only listed the directory. POPS.ELF was 137,045 bytes where the real one is 3,166,988, and IOPRP252.IMG was 1,306,134 where the real one is 265,233 -- not corrupt copies, different files -- and "OK" was reported for both. CheckRuntime now hashes what it finds. POPS.ELF, IOPRP252.IMG and the two packages have one published release each that does not vary between POPStarter revisions, so they can be checked exactly. POPSTARTER.ELF changes with every revision and is deliberately left unhashed rather than guessed at. A file that is present but wrong is reported as wrong -- not as missing, which would send someone looking for a file that is already there, and not as OK. It reads WRONG FILE in the table, it makes the status NOT READY, and the explanation says what it means: every PS1 title fails identically with the wrong one, and nothing else in the setup will show why. POPS.PAK and POPS_IOX.PAK join the manifest so they are imported and reported. They are not marked required, because a setup without them is not necessarily broken and calling a working drive NOT READY would be worse than saying nothing. The hashes are the published ones, corroborated independently by the POPStarter documentation site and by PS2-HOME; ps2hdd carries no Sony code, only the means to tell whether yours is the real thing. Claude-Session: https://claude.ai/code/session_018eBAB3V9GRxNwgpQcpGje4 --- internal/app/app_test.go | 81 ++++++++++++++++++++- internal/catalog/installed.go | 4 +- internal/cli/setup.go | 9 +++ internal/platform/ps1/pops.go | 111 +++++++++++++++++++++++++---- internal/platform/ps1/pops_test.go | 96 ++++++++++++++++++++++++- scripts/demo-smoke.sh | 11 ++- 6 files changed, 292 insertions(+), 20 deletions(-) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f7c064a..af020e2 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2,6 +2,8 @@ package app_test import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "os" "path/filepath" @@ -378,11 +380,17 @@ func TestPS1ReadinessAndImport(t *testing.T) { // Importing user-supplied runtime files makes it ready, and files that are // not part of the runtime are left alone. importDir := t.TempDir() + const placeholder = "user supplied" for _, n := range []string{"POPS.ELF", "IOPRP252.IMG"} { - if err := os.WriteFile(filepath.Join(importDir, n), []byte("user supplied"), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(importDir, n), []byte(placeholder), 0o600); err != nil { t.Fatal(err) } } + // The runtime is verified by content, and no test can produce Sony's + // actual binaries. Point the manifest at the placeholder's hash for the + // rest of this test so the ready path is still exercised end to end. + sum := sha256.Sum256([]byte(placeholder)) + defer swapRuntimeHashes(t, hex.EncodeToString(sum[:]))() if err := os.WriteFile(filepath.Join(importDir, "notes.txt"), []byte("x"), 0o600); err != nil { t.Fatal(err) } @@ -397,6 +405,9 @@ func TestPS1ReadinessAndImport(t *testing.T) { if len(rep.Imported) != 2 { t.Errorf("imported %v", rep.Imported) } + if len(rep.Readiness.Wrong) != 0 { + t.Errorf("files that match their published hash were reported wrong: %v", rep.Readiness.Wrong) + } if len(rep.Ignored) != 1 || rep.Ignored[0] != "notes.txt" { t.Errorf("ignored = %v, want just notes.txt", rep.Ignored) } @@ -947,3 +958,71 @@ func TestCatalogWithoutADeviceStillListsSources(t *testing.T) { t.Errorf("no warning that there is no device: %v", warnings) } } + +// swapRuntimeHashes points every hashed runtime file at want for the duration +// of a test, and returns the restore. It exists because the runtime is checked +// by content and a test cannot hold Sony's binaries; the alternative is not +// exercising the ready path at all. +func swapRuntimeHashes(t *testing.T, want string) func() { + t.Helper() + saved := make([]string, len(ps1.RuntimeFiles)) + for i := range ps1.RuntimeFiles { + saved[i] = ps1.RuntimeFiles[i].SHA256 + if ps1.RuntimeFiles[i].SHA256 != "" { + ps1.RuntimeFiles[i].SHA256 = want + } + } + return func() { + for i := range ps1.RuntimeFiles { + ps1.RuntimeFiles[i].SHA256 = saved[i] + } + } +} + +// A runtime file that is present but is not the file it should be must be +// reported. This is the failure that cost an entire debugging session: a +// POPS.ELF that was a different file altogether, with the drive reporting +// PS1 READY, and every single title black-screening identically because the +// emulator was not the emulator. +func TestReadinessRejectsAWrongRuntimeFile(t *testing.T) { + ctx := context.Background() + svc, _ := newTestServices(t) + + importDir := t.TempDir() + for _, n := range []string{"POPS.ELF", "IOPRP252.IMG"} { + if err := os.WriteFile(filepath.Join(importDir, n), []byte("not the real thing"), 0o600); err != nil { + t.Fatal(err) + } + } + // Only POPS.ELF is given the hash it actually has, so IOPRP252.IMG is the + // odd one out and the check has to single it out rather than condemn both. + sum := sha256.Sum256([]byte("not the real thing")) + restore := swapRuntimeHashes(t, hex.EncodeToString(sum[:])) + defer restore() + for i := range ps1.RuntimeFiles { + if ps1.RuntimeFiles[i].Name == "IOPRP252.IMG" { + ps1.RuntimeFiles[i].SHA256 = strings.Repeat("00", 32) + } + } + + rep, err := svc.SetupPS1(ctx, app.SetupPS1Options{ImportDir: importDir}) + if err != nil { + t.Fatalf("SetupPS1: %v", err) + } + if rep.Readiness.Ready() { + t.Error("a drive whose IOPRP252.IMG is the wrong file reported READY") + } + if len(rep.Readiness.Wrong) != 1 || rep.Readiness.Wrong[0] != "IOPRP252.IMG" { + t.Fatalf("wrong = %v, want just IOPRP252.IMG", rep.Readiness.Wrong) + } + if len(rep.Readiness.Missing) != 0 { + t.Errorf("a wrong file was also reported missing: %v", rep.Readiness.Missing) + } + explain := strings.Join(rep.Readiness.Explain(), "\n") + if !strings.Contains(explain, "not the right file") { + t.Errorf("the explanation does not say the file is wrong:\n%s", explain) + } + if !strings.Contains(explain, "every PS1 title fails") && !strings.Contains(explain, "Every PS1 title fails") { + t.Errorf("the explanation does not say what the consequence is:\n%s", explain) + } +} diff --git a/internal/catalog/installed.go b/internal/catalog/installed.go index 70d9e72..1c8c873 100644 --- a/internal/catalog/installed.go +++ b/internal/catalog/installed.go @@ -144,11 +144,11 @@ func (r InstalledReader) Readiness(ctx context.Context) (ps1.Readiness, error) { // than reported as missing; saying "missing" would send the user chasing a // file that may well be there. mountErr := r.Mounts.With(ctx, ps1.CommonPartition, func(mp string) error { - present, missing, err := ps1.CheckRuntime(mp) + present, missing, wrong, err := ps1.CheckRuntime(mp) if err != nil { return err } - out.Runtime, out.Missing, out.RuntimeChecked = present, missing, true + out.Runtime, out.Missing, out.Wrong, out.RuntimeChecked = present, missing, wrong, true return nil }) if mountErr != nil { diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 6418f81..f215214 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -111,10 +111,19 @@ back afterwards.`, {ps1.POPSPartition, boolLabel(rep.Readiness.POPSPartition, "OK", "missing")}, {ps1.CommonPartition, boolLabel(rep.Readiness.CommonPartition, "OK", "missing")}, } + wrong := map[string]bool{} + for _, n := range rep.Readiness.Wrong { + wrong[n] = true + } for _, f := range ps1.RuntimeFiles { switch { case !rep.Readiness.RuntimeChecked: pairs = append(pairs, [2]string{f.Name, dim("unknown")}) + // A file that is present but is not the right file must not + // read as OK: that is precisely the state this check exists to + // expose, and it looks identical to a working one otherwise. + case wrong[f.Name]: + pairs = append(pairs, [2]string{f.Name, red("WRONG FILE")}) case rep.Readiness.Runtime[f.Name]: pairs = append(pairs, [2]string{f.Name, green("OK")}) default: diff --git a/internal/platform/ps1/pops.go b/internal/platform/ps1/pops.go index 794d2fd..01b62a3 100644 --- a/internal/platform/ps1/pops.go +++ b/internal/platform/ps1/pops.go @@ -1,7 +1,10 @@ package ps1 import ( + "crypto/sha256" + "encoding/hex" "fmt" + "io" "os" "path/filepath" "sort" @@ -84,21 +87,35 @@ type RuntimeFile struct { Copyrighted bool // Required marks a file without which PS1 playback cannot work. Required bool + // SHA256 is the file's known content, lower-case hex, or "" when there is + // no single right answer. POPS.ELF and the files beside it are one fixed + // release that does not vary between POPStarter revisions, so they can be + // checked exactly; POPSTARTER.ELF itself changes with every revision and + // cannot be. + SHA256 string } // RuntimeFiles is what a working POPStarter installation needs. +// +// The hashes are the published ones for the POPS release POPStarter is built +// against. They matter more than they look: a POPS.ELF that is the wrong file +// fails every PS1 title identically, whatever is done to the VCD, the launcher +// or its name, and there is nothing in the symptom to say so. Checking only +// that the files exist reports READY for a drive that cannot run anything. var RuntimeFiles = []RuntimeFile{ { Name: "POPS.ELF", Description: "the POPS PlayStation emulator", Copyrighted: true, Required: true, + SHA256: "59df3389c4df88a572daa720b05507c52c34eddfa0031a6fbeec55e0c2d0fcb1", }, { Name: "IOPRP252.IMG", Description: "the IOP replacement image POPS loads", Copyrighted: true, Required: true, + SHA256: "3338b238d84d7d586b716677e3a1c03b2088b882ecfa17f91fc33798931ca3ba", }, { Name: POPStarterELF, @@ -106,6 +123,23 @@ var RuntimeFiles = []RuntimeFile{ Copyrighted: false, Required: true, }, + // The two packages ship with POPS and belong beside it. They are not + // marked required, because a setup missing them is not necessarily broken + // and calling a working drive NOT READY would be worse than saying + // nothing -- but they are imported and reported, which is what makes an + // incomplete runtime visible. + { + Name: "POPS.PAK", + Description: "the POPS support package", + Copyrighted: true, + SHA256: "a3973bc4d177f65dd3201afe508aa9b59dd8a4d3374369bff14fb01f920aacad", + }, + { + Name: "POPS_IOX.PAK", + Description: "the POPS I/O support package", + Copyrighted: true, + SHA256: "9fa120429a73b632029b4f0fd554cd45c1e770f8ec020ecc3120b38a2b983e6e", + }, } // Readiness reports whether PS1 support is usable. @@ -120,11 +154,16 @@ type Readiness struct { RuntimeChecked bool `json:"runtime_checked"` // Missing lists the required runtime files that were not found. Missing []string `json:"missing,omitempty"` + // Wrong lists runtime files that are present but are not the file they + // should be. A wrong POPS.ELF is indistinguishable from a right one until + // a game is launched, and then every game fails the same way. + Wrong []string `json:"wrong,omitempty"` } // Ready reports whether a PS1 game could be launched right now. func (r Readiness) Ready() bool { - return r.POPSPartition && r.CommonPartition && r.RuntimeChecked && len(r.Missing) == 0 + return r.POPSPartition && r.CommonPartition && r.RuntimeChecked && + len(r.Missing) == 0 && len(r.Wrong) == 0 } // Status renders READY or NOT READY. @@ -150,6 +189,23 @@ func (r Readiness) Explain() []string { } return out } + for _, name := range r.Wrong { + f, _ := findRuntimeFile(name) + out = append(out, fmt.Sprintf( + "%s (%s) is present but is not the right file. Every PS1 title fails identically "+ + "with the wrong one, and nothing else in the setup will show why. Replace it and "+ + "re-import with `ps2hdd setup ps1 --import `; the expected SHA-256 is %s.", + name, f.Description, f.SHA256)) + } + for _, f := range RuntimeFiles { + if f.Required || f.SHA256 == "" || r.Runtime[f.Name] { + continue + } + out = append(out, fmt.Sprintf( + "%s (%s) is not installed. It ships with POPS and belongs beside it; "+ + "PS1 support may work without it, but the runtime is incomplete.", + f.Name, f.Description)) + } for _, name := range r.Missing { f, ok := findRuntimeFile(name) if !ok { @@ -181,40 +237,69 @@ func findRuntimeFile(name string) (RuntimeFile, bool) { // CheckRuntime inspects a mounted __common partition and fills in the runtime // half of a Readiness. -func CheckRuntime(commonMount string) (map[string]bool, []string, error) { +func CheckRuntime(commonMount string) (present map[string]bool, missing, wrong []string, err error) { dir := filepath.Join(commonMount, POPSDir) - present := map[string]bool{} + present = map[string]bool{} entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { // No POPS directory at all: everything required is missing, which // is a definite answer rather than an error. - var missing []string for _, f := range RuntimeFiles { present[f.Name] = false if f.Required { missing = append(missing, f.Name) } } - return present, missing, nil + return present, missing, nil, nil } - return nil, nil, fmt.Errorf("read %s: %w", dir, err) + return nil, nil, nil, fmt.Errorf("read %s: %w", dir, err) } - have := map[string]bool{} + // The name on disk may differ in case from the manifest's. + have := map[string]string{} for _, e := range entries { if !e.IsDir() { - have[strings.ToUpper(e.Name())] = true + have[strings.ToUpper(e.Name())] = e.Name() } } - var missing []string for _, f := range RuntimeFiles { - ok := have[strings.ToUpper(f.Name)] + actual, ok := have[strings.ToUpper(f.Name)] present[f.Name] = ok - if !ok && f.Required { - missing = append(missing, f.Name) + if !ok { + if f.Required { + missing = append(missing, f.Name) + } + continue + } + if f.SHA256 == "" { + continue + } + sum, err := fileSHA256(filepath.Join(dir, actual)) + if err != nil { + // Unreadable is not the same as wrong, and saying it is would send + // a user replacing a file that may be fine. + continue } + if !strings.EqualFold(sum, f.SHA256) { + wrong = append(wrong, f.Name) + } + } + return present, missing, wrong, nil +} + +// fileSHA256 hashes a runtime file. The whole runtime is a few megabytes, so +// this is cheap enough to do on every readiness check. +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err } - return present, missing, nil + return hex.EncodeToString(h.Sum(nil)), nil } // VCDName builds the filename an installed disc gets inside __.POPS. diff --git a/internal/platform/ps1/pops_test.go b/internal/platform/ps1/pops_test.go index 5b5bb2a..c936563 100644 --- a/internal/platform/ps1/pops_test.go +++ b/internal/platform/ps1/pops_test.go @@ -1,6 +1,8 @@ package ps1_test import ( + "crypto/sha256" + "encoding/hex" "os" "path/filepath" "strings" @@ -129,7 +131,7 @@ func TestScanPOPSGroupsMultiDisc(t *testing.T) { func TestCheckRuntimeMissing(t *testing.T) { dir := t.TempDir() // no POPS directory at all - present, missing, err := ps1.CheckRuntime(dir) + present, missing, _, err := ps1.CheckRuntime(dir) if err != nil { t.Fatal(err) } @@ -151,18 +153,29 @@ func TestCheckRuntimeComplete(t *testing.T) { } // PFS filenames are conventionally upper case but users copy in whatever // case they have, so matching must ignore it. - for _, f := range []string{"POPS.ELF", "ioprp252.img", "PopStarter.elf"} { + // A complete runtime is all five files: the two Sony binaries, the + // launcher, and the two packages that ship beside POPS. + for _, f := range []string{"POPS.ELF", "ioprp252.img", "PopStarter.elf", "POPS.PAK", "pops_iox.pak"} { if err := os.WriteFile(filepath.Join(pops, f), []byte("x"), 0o600); err != nil { t.Fatal(err) } } - present, missing, err := ps1.CheckRuntime(dir) + // The placeholder bytes are not the real files, so the manifest is pointed + // at their hash: this test is about presence and case matching, and the + // content check has its own tests. + restore := swapRuntimeHashesTo(t, sha256Hex("x")) + defer restore() + + present, missing, wrong, err := ps1.CheckRuntime(dir) if err != nil { t.Fatal(err) } if len(missing) != 0 { t.Errorf("missing = %v, want none", missing) } + if len(wrong) != 0 { + t.Errorf("wrong = %v, want none", wrong) + } if !present["IOPRP252.IMG"] { t.Error("case-insensitive match failed") } @@ -237,3 +250,80 @@ func TestVMCDirContents(t *testing.T) { t.Errorf("VMCDIR.TXT = %q, want %q", got, want) } } + +func sha256Hex(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// swapRuntimeHashesTo points every hashed runtime file at want, and returns the +// restore. A test cannot hold Sony's binaries, so exercising anything past the +// content check means substituting the expectation. +func swapRuntimeHashesTo(t *testing.T, want string) func() { + t.Helper() + saved := make([]string, len(ps1.RuntimeFiles)) + for i := range ps1.RuntimeFiles { + saved[i] = ps1.RuntimeFiles[i].SHA256 + if ps1.RuntimeFiles[i].SHA256 != "" { + ps1.RuntimeFiles[i].SHA256 = want + } + } + return func() { + for i := range ps1.RuntimeFiles { + ps1.RuntimeFiles[i].SHA256 = saved[i] + } + } +} + +// A file whose contents are not the published ones is reported as wrong, not +// as missing, and not as fine. +func TestCheckRuntimeRejectsWrongContents(t *testing.T) { + dir := t.TempDir() + pops := filepath.Join(dir, "POPS") + if err := os.MkdirAll(pops, 0o755); err != nil { + t.Fatal(err) + } + for _, f := range []string{"POPS.ELF", "IOPRP252.IMG", "POPSTARTER.ELF", "POPS.PAK", "POPS_IOX.PAK"} { + if err := os.WriteFile(filepath.Join(pops, f), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + restore := swapRuntimeHashesTo(t, sha256Hex("x")) + defer restore() + // Now make one of them the odd one out. + for i := range ps1.RuntimeFiles { + if ps1.RuntimeFiles[i].Name == "POPS.ELF" { + ps1.RuntimeFiles[i].SHA256 = sha256Hex("the real POPS") + } + } + + present, missing, wrong, err := ps1.CheckRuntime(dir) + if err != nil { + t.Fatal(err) + } + if !present["POPS.ELF"] { + t.Error("a file that exists was reported absent") + } + if len(missing) != 0 { + t.Errorf("missing = %v: a wrong file is present, not missing", missing) + } + if len(wrong) != 1 || wrong[0] != "POPS.ELF" { + t.Fatalf("wrong = %v, want just POPS.ELF", wrong) + } + + r := ps1.Readiness{POPSPartition: true, CommonPartition: true, Runtime: present, + RuntimeChecked: true, Wrong: wrong} + if r.Ready() { + t.Error("READY with the wrong POPS.ELF; every title would fail identically") + } +} + +// POPSTARTER.ELF changes with every POPStarter revision, so there is no single +// right content for it and it must never be called wrong. +func TestCheckRuntimeDoesNotHashThePOPStarterLauncher(t *testing.T) { + for _, f := range ps1.RuntimeFiles { + if f.Name == "POPSTARTER.ELF" && f.SHA256 != "" { + t.Errorf("POPSTARTER.ELF has a fixed expected hash %q, but it differs between releases", f.SHA256) + } + } +} diff --git a/scripts/demo-smoke.sh b/scripts/demo-smoke.sh index c292c26..edf9c61 100755 --- a/scripts/demo-smoke.sh +++ b/scripts/demo-smoke.sh @@ -391,7 +391,16 @@ printf 'user supplied' > "$WORK/pops/POPS.ELF" printf 'user supplied' > "$WORK/pops/IOPRP252.IMG" printf 'unrelated' > "$WORK/pops/notes.txt" ps2hdd setup ps1 --import "$WORK/pops" | tee "$WORK/setup.txt" -grep -q "Status: READY" "$WORK/setup.txt" || fail "PS1 support did not become ready" +# The runtime is checked by content, not merely by presence. Placeholder files +# are not the POPS release, and reporting READY for them is exactly what let a +# drive whose emulator was the wrong file altogether look correctly set up +# while every PS1 title failed identically. +grep -q "Status: NOT READY" "$WORK/setup.txt" \ + || fail "placeholder runtime files were reported READY: $(cat "$WORK/setup.txt")" +grep -q "WRONG FILE" "$WORK/setup.txt" \ + || fail "a file that is not the real runtime was not marked wrong: $(cat "$WORK/setup.txt")" +grep -q "is not the right file" "$WORK/setup.txt" \ + || fail "no explanation of what a wrong runtime file means: $(cat "$WORK/setup.txt")" grep -q "notes.txt" "$WORK/setup.txt" || fail "an unrelated file was not reported as ignored" test ! -f "$DEMO/partitions/common/POPS/notes.txt" \ || fail "an unrelated file was copied onto the HDD"