Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions internal/platform/ps1/cue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions internal/platform/ps1/cue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ps1_test
import (
"errors"
"fmt"
"github.com/casmith/ps2hdd/internal/iso9660/isosynth"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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)
}
}