diff --git a/internal/platform/ps1/cue.go b/internal/platform/ps1/cue.go index 7729661..a75e1ba 100644 --- a/internal/platform/ps1/cue.go +++ b/internal/platform/ps1/cue.go @@ -131,15 +131,66 @@ func ParseCueFile(path string) (Cue, error) { } c.Path = path dir := filepath.Dir(path) + resolve := fileResolver(dir) for _, n := range c.Files { - c.FilePaths = append(c.FilePaths, filepath.Join(dir, n)) + c.FilePaths = append(c.FilePaths, resolve(n)) } if c.BinName != "" { - c.BinPath = filepath.Join(dir, c.BinName) + c.BinPath = resolve(c.BinName) } return c, nil } +// fileResolver returns a function that turns a FILE name from a cuesheet into +// a path in dir, matching case-insensitively when it has to. +// +// Cuesheets are routinely written on Windows, where the case of a filename is +// not information. A sheet from 2003 says +// +// FILE "FINAL FANTASY VII DISC 1.BIN" BINARY +// +// beside a file actually called "Final Fantasy VII Disc 1.bin". That rip is +// perfectly good and works everywhere except a case-sensitive filesystem, +// where it failed with "references FINAL FANTASY VII DISC 1.BIN, which is +// missing" -- a file the user can plainly see is there. +// +// The exact name always wins, and the directory is only read when it does not +// resolve, so nothing is slower and no ambiguity is introduced where the +// filesystem itself has none. A name that matches nothing is returned +// unchanged, so the error still quotes what the sheet actually said. +func fileResolver(dir string) func(string) string { + var lower map[string]string // lower-cased name -> real name, read once + return func(name string) string { + exact := filepath.Join(dir, name) + if _, err := os.Stat(exact); err == nil { + return exact + } + if lower == nil { + lower = map[string]string{} + entries, err := os.ReadDir(dir) + if err != nil { + return exact + } + for _, e := range entries { + if !e.IsDir() { + // First match wins. Two files differing only in case is + // possible here and there is no way to tell which the + // sheet meant, so the one the directory lists first is + // used rather than guessing. + k := strings.ToLower(e.Name()) + if _, seen := lower[k]; !seen { + lower[k] = e.Name() + } + } + } + } + if real, ok := lower[strings.ToLower(name)]; ok { + return filepath.Join(dir, real) + } + return exact + } +} + // ParseCue parses cuesheet text. // // Only the subset that matters for PS1 images is understood: FILE, TRACK, diff --git a/internal/platform/ps1/cue_test.go b/internal/platform/ps1/cue_test.go index 0af4342..0c97d5c 100644 --- a/internal/platform/ps1/cue_test.go +++ b/internal/platform/ps1/cue_test.go @@ -3,6 +3,7 @@ package ps1_test import ( "errors" "fmt" + "github.com/casmith/ps2hdd/internal/iso9660/isosynth" "os" "path/filepath" "strings" @@ -404,3 +405,96 @@ func TestGapSectorsMatchesTheConversion(t *testing.T) { t.Errorf("a CDRWIN sheet reported %d gap sectors, want 150", got) } } + +// Cuesheets are routinely written on Windows, where the case of a filename is +// not information. This one is from a 2003 rip that works everywhere except a +// case-sensitive filesystem: +// +// FILE "FINAL FANTASY VII DISC 1.BIN" BINARY +// +// beside a file called "Final Fantasy VII Disc 1.bin". +func TestParseCueFileMatchesTheFilenameCaseInsensitively(t *testing.T) { + dir := t.TempDir() + img, err := isosynth.BuildMode2352(isosynth.Image{ + VolumeID: "SCUS_941.63", + CDXA: true, + Files: map[string][]byte{"SYSTEM.CNF": isosynth.PS1SystemCNF("SCUS_941.63")}, + }) + if err != nil { + t.Fatal(err) + } + const real = "Final Fantasy VII Disc 1.bin" + if err := os.WriteFile(filepath.Join(dir, real), img, 0o600); err != nil { + t.Fatal(err) + } + cue := filepath.Join(dir, "Final Fantasy VII Disc 1.cue") + if err := os.WriteFile(cue, []byte( + "FILE \"FINAL FANTASY VII DISC 1.BIN\" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n"), 0o600); err != nil { + t.Fatal(err) + } + + c, err := ps1.ParseCueFile(cue) + if err != nil { + t.Fatalf("ParseCueFile: %v", err) + } + if filepath.Base(c.BinPath) != real { + t.Errorf("BinPath = %q, want the file that is actually there (%q)", c.BinPath, real) + } + if len(c.FilePaths) != 1 || filepath.Base(c.FilePaths[0]) != real { + t.Errorf("FilePaths = %v, want [%q]", c.FilePaths, real) + } + // BinName keeps what the sheet said: it is what an error message should + // quote, and what the archive's own listing will agree with. + if c.BinName != "FINAL FANTASY VII DISC 1.BIN" { + t.Errorf("BinName = %q, want the name as written in the sheet", c.BinName) + } + if err := c.Validate(); err != nil { + t.Errorf("a rip whose only fault is the case of a filename was rejected: %v", err) + } +} + +// An exact match is always preferred, so a directory holding two files that +// differ only in case still resolves to the one the sheet names. +func TestParseCueFilePrefersTheExactFilename(t *testing.T) { + dir := t.TempDir() + sectors := make([]byte, 2352*4) + for _, n := range []string{"game.bin", "GAME.BIN"} { + if err := os.WriteFile(filepath.Join(dir, n), sectors, 0o600); err != nil { + t.Skipf("this filesystem cannot hold two names differing only in case: %v", err) + } + } + cue := filepath.Join(dir, "game.cue") + if err := os.WriteFile(cue, []byte( + "FILE \"GAME.BIN\" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n"), 0o600); err != nil { + t.Fatal(err) + } + c, err := ps1.ParseCueFile(cue) + if err != nil { + t.Fatal(err) + } + if filepath.Base(c.BinPath) != "GAME.BIN" { + t.Errorf("BinPath = %q, want the exactly named GAME.BIN", c.BinPath) + } +} + +// A file that is genuinely absent must still be reported, quoting the name the +// sheet used rather than something invented while looking for it. +func TestParseCueFileStillReportsAMissingTrack(t *testing.T) { + dir := t.TempDir() + cue := filepath.Join(dir, "game.cue") + if err := os.WriteFile(cue, []byte( + "FILE \"absent.bin\" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n"), 0o600); err != nil { + t.Fatal(err) + } + c, err := ps1.ParseCueFile(cue) + if err != nil { + t.Fatal(err) + } + err = c.Validate() + if err == nil { + t.Fatal("a cuesheet naming a file that does not exist was accepted") + } + if !strings.Contains(err.Error(), "absent.bin") { + t.Errorf("the error does not name the missing file: %v", err) + } +}