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"