diff --git a/identifier/ps2_test.go b/identifier/ps2_test.go
index c3c3498..5e914ea 100644
--- a/identifier/ps2_test.go
+++ b/identifier/ps2_test.go
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU General Public License
// along with go-gameid. If not, see .
-//nolint:dupl // PS2/PSP tests have similar structure but test different identifiers
package identifier
import (
diff --git a/identifier/psp.go b/identifier/psp.go
index ca4a5ea..0185c97 100644
--- a/identifier/psp.go
+++ b/identifier/psp.go
@@ -74,17 +74,17 @@ func identifyPSPFromISO(iso *iso9660.ISO9660, database Database) (*Result, error
result := NewResult(ConsolePSP)
// Look for UMD_DATA.BIN in root
- files, err := iso.IterFiles(true)
- if err != nil {
- return nil, fmt.Errorf("iterate files: %w", err)
- }
-
var umdDataInfo *iso9660.FileInfo
- for _, f := range files {
- if strings.ToUpper(filepath.Base(f.Path)) == "UMD_DATA.BIN" {
- umdDataInfo = &f
- break
+ err := iso.WalkFiles(true, func(file iso9660.FileInfo) bool {
+ fileName := strings.ToUpper(filepath.Base(cleanISOFileName(file.Path)))
+ if fileName == "UMD_DATA.BIN" {
+ umdDataInfo = &file
+ return false
}
+ return true
+ })
+ if err != nil {
+ return nil, fmt.Errorf("iterate files: %w", err)
}
if umdDataInfo == nil {
diff --git a/identifier/psp_test.go b/identifier/psp_test.go
index 998c46b..85c260a 100644
--- a/identifier/psp_test.go
+++ b/identifier/psp_test.go
@@ -16,12 +16,16 @@
// You should have received a copy of the GNU General Public License
// along with go-gameid. If not, see .
-//nolint:dupl // PS2/PSP tests have similar structure but test different identifiers
package identifier
import (
"errors"
+ "os"
+ "path/filepath"
+ "strings"
"testing"
+
+ "github.com/ZaparooProject/go-gameid/internal/testiso"
)
func TestPSPIdentifier_Console(t *testing.T) {
@@ -54,3 +58,28 @@ func TestPSPIdentifier_IdentifyFromPath_NonExistent(t *testing.T) {
t.Error("IdentifyFromPath() should error for non-existent file")
}
}
+
+func TestPSPIdentifier_IdentifyFromPath_UMDData(t *testing.T) {
+ t.Parallel()
+
+ isoData := testiso.CreateMinimal(t, "PSPTEST", "PLAYSTATION", "", []testiso.File{
+ {Name: "UMD_DATA.BIN;1", Data: []byte("UCUS-98765|Example Game")},
+ })
+ isoPath := filepath.Join(t.TempDir(), "game.iso")
+ if err := os.WriteFile(isoPath, isoData, 0o600); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ id := NewPSPIdentifier()
+ result, err := id.IdentifyFromPath(isoPath, nil)
+ if err != nil {
+ t.Fatalf("IdentifyFromPath() error = %v", err)
+ }
+
+ if result.ID != "UCUS-98765" {
+ t.Errorf("result.ID = %q, want %q", result.ID, "UCUS-98765")
+ }
+ if !strings.HasPrefix(result.Metadata["volume_ID"], "PSPTEST") {
+ t.Errorf("volume_ID metadata = %q, want prefix %q", result.Metadata["volume_ID"], "PSPTEST")
+ }
+}
diff --git a/identifier/psx.go b/identifier/psx.go
index 89a9f19..340ec24 100644
--- a/identifier/psx.go
+++ b/identifier/psx.go
@@ -51,6 +51,14 @@ type playstationISO interface {
Close() error
}
+type rootFileWalker interface {
+ WalkFiles(onlyRootDir bool, fn func(iso9660.FileInfo) bool) error
+}
+
+type isoFileReader interface {
+ ReadFile(info iso9660.FileInfo) ([]byte, error)
+}
+
// openPlayStationISO opens an ISO from a path, handling CUE and CHD files.
func openPlayStationISO(path string) (playstationISO, error) {
ext := strings.ToLower(filepath.Ext(path))
@@ -88,26 +96,11 @@ func identifyPlayStation(
) (*Result, error) {
result := NewResult(console)
- // Get root files
- files, err := iso.IterFiles(true)
+ rootFiles, serial, err := playStationRootInfo(iso, console, database)
if err != nil {
- return nil, fmt.Errorf("iterate files: %w", err)
- }
-
- // Build list of root filenames
- rootFiles := make([]string, 0, len(files))
- for _, f := range files {
- name := strings.TrimPrefix(f.Path, "/")
- // Remove version suffix (;1)
- if idx := strings.Index(name, ";"); idx != -1 {
- name = name[:idx]
- }
- rootFiles = append(rootFiles, name)
+ return nil, err
}
- // Try to find serial from root files using ID prefixes
- serial := findPlayStationSerial(rootFiles, console, database)
-
// Fallback to volume ID. Unlike upstream GameID this is accepted without
// a database match: the volume ID is read from the disc itself, so an
// image and the physical disc it was dumped from still agree on it.
@@ -141,6 +134,96 @@ func identifyPlayStation(
return result, nil
}
+func playStationRootInfo(iso playstationISO, console Console, database Database) (
+ rootFiles []string,
+ serial string,
+ err error,
+) {
+ if walker, ok := iso.(rootFileWalker); ok {
+ return playStationRootInfoWalk(iso, walker, console, database)
+ }
+
+ files, iterErr := iso.IterFiles(true)
+ if iterErr != nil {
+ return nil, "", fmt.Errorf("iterate files: %w", iterErr)
+ }
+ rootFiles = make([]string, 0, len(files))
+ for _, file := range files {
+ name := cleanISOFileName(file.Path)
+ rootFiles = append(rootFiles, name)
+ if serial == "" && strings.EqualFold(name, "SYSTEM.CNF") {
+ serial = serialFromSystemCNFFile(iso, file)
+ }
+ if serial == "" {
+ serial = serialFromRootFile(name)
+ }
+ }
+ if serial == "" {
+ serial = findPlayStationSerial(rootFiles, console, database)
+ }
+ return rootFiles, serial, nil
+}
+
+func playStationRootInfoWalk(
+ iso playstationISO,
+ walker rootFileWalker,
+ console Console,
+ database Database,
+) (rootFiles []string, serial string, err error) {
+ rootFiles = make([]string, 0)
+ err = walker.WalkFiles(true, func(file iso9660.FileInfo) bool {
+ name := cleanISOFileName(file.Path)
+ rootFiles = append(rootFiles, name)
+ if serial == "" && strings.EqualFold(name, "SYSTEM.CNF") {
+ serial = serialFromSystemCNFFile(iso, file)
+ }
+ if serial == "" {
+ serial = serialFromRootFile(name)
+ }
+ return true
+ })
+ if err != nil {
+ return nil, "", fmt.Errorf("iterate files: %w", err)
+ }
+ if serial == "" {
+ serial = findPlayStationSerial(rootFiles, console, database)
+ }
+ return rootFiles, serial, nil
+}
+
+func cleanISOFileName(path string) string {
+ name := strings.TrimPrefix(path, "/")
+ if idx := strings.Index(name, ";"); idx != -1 {
+ name = name[:idx]
+ }
+ return name
+}
+
+func serialFromSystemCNFFile(iso playstationISO, file iso9660.FileInfo) string {
+ reader, ok := iso.(isoFileReader)
+ if !ok {
+ return ""
+ }
+ data, err := reader.ReadFile(file)
+ if err != nil {
+ return ""
+ }
+ return serialFromSystemCNF(string(data))
+}
+
+func serialFromSystemCNF(content string) string {
+ fields := strings.FieldsFunc(strings.ToUpper(content), func(runeValue rune) bool {
+ return !unicode.IsLetter(runeValue) && !unicode.IsDigit(runeValue) &&
+ runeValue != '_' && runeValue != '-' && runeValue != '.'
+ })
+ for _, field := range fields {
+ if serial := serialFromRootFile(field); serial != "" {
+ return serial
+ }
+ }
+ return ""
+}
+
// findPlayStationSerial searches for serial in root files.
func findPlayStationSerial(rootFiles []string, console Console, database Database) string {
for _, fileName := range rootFiles {
diff --git a/identifier/psx_test.go b/identifier/psx_test.go
index 45acefa..f533023 100644
--- a/identifier/psx_test.go
+++ b/identifier/psx_test.go
@@ -20,6 +20,7 @@ package identifier
import (
"errors"
+ "slices"
"testing"
"github.com/ZaparooProject/go-gameid/iso9660"
@@ -40,6 +41,51 @@ func (m *mockPlayStationISO) IterFiles(_ bool) ([]iso9660.FileInfo, error) {
}
func (*mockPlayStationISO) Close() error { return nil }
+type mockWalkingPlayStationISO struct {
+ readData map[string][]byte
+ walkErr error
+ readErr error
+ mockPlayStationISO
+ walked int
+ read int
+}
+
+func (m *mockWalkingPlayStationISO) WalkFiles(_ bool, fn func(iso9660.FileInfo) bool) error {
+ if m.walkErr != nil {
+ return m.walkErr
+ }
+ for _, file := range m.files {
+ m.walked++
+ if !fn(file) {
+ return nil
+ }
+ }
+ return nil
+}
+
+func (m *mockWalkingPlayStationISO) ReadFile(info iso9660.FileInfo) ([]byte, error) {
+ m.read++
+ if m.readErr != nil {
+ return nil, m.readErr
+ }
+ return m.readData[info.Path], nil
+}
+
+type mockReadingPlayStationISO struct {
+ readData map[string][]byte
+ readErr error
+ mockPlayStationISO
+ read int
+}
+
+func (m *mockReadingPlayStationISO) ReadFile(info iso9660.FileInfo) ([]byte, error) {
+ m.read++
+ if m.readErr != nil {
+ return nil, m.readErr
+ }
+ return m.readData[info.Path], nil
+}
+
// mockDatabase implements Database for testing.
type mockDatabase struct {
stringEntries map[Console]map[string]map[string]string
@@ -87,6 +133,104 @@ func (m *mockDatabase) setPrefixes(console Console, prefixes []string) {
m.idPrefixes[console] = prefixes
}
+func TestSerialFromSystemCNF(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ content string
+ want string
+ }{
+ {"boot", "BOOT = cdrom:\\SLUS_005.94;1", "SLUS_00594"},
+ {"boot2", "BOOT2 = cdrom0:\\SLES-123.45;1", "SLES_12345"},
+ {"none", "BOOT = cdrom:\\MAIN.EXE;1", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ if got := serialFromSystemCNF(tt.content); got != tt.want {
+ t.Errorf("serialFromSystemCNF() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestPlayStationRootInfo_SystemCNFReaderPaths(t *testing.T) {
+ t.Parallel()
+
+ files := []iso9660.FileInfo{
+ {Path: "/SYSTEM.CNF;1"},
+ {Path: "/README.TXT"},
+ }
+ readData := map[string][]byte{
+ "/SYSTEM.CNF;1": []byte("BOOT2 = cdrom0:\\SLES-123.45;1"),
+ }
+
+ t.Run("walk files dispatch", func(t *testing.T) {
+ t.Parallel()
+ mockISO := &mockWalkingPlayStationISO{
+ mockPlayStationISO: mockPlayStationISO{files: files},
+ readData: readData,
+ }
+
+ rootFiles, serial, err := playStationRootInfo(mockISO, ConsolePSX, nil)
+ if err != nil {
+ t.Fatalf("playStationRootInfo() error = %v", err)
+ }
+
+ assertPlayStationRootInfo(t, rootFiles, serial, mockISO.walked, mockISO.read)
+ })
+
+ t.Run("walk files direct", func(t *testing.T) {
+ t.Parallel()
+ mockISO := &mockWalkingPlayStationISO{
+ mockPlayStationISO: mockPlayStationISO{files: files},
+ readData: readData,
+ }
+
+ rootFiles, serial, err := playStationRootInfoWalk(mockISO, mockISO, ConsolePSX, nil)
+ if err != nil {
+ t.Fatalf("playStationRootInfoWalk() error = %v", err)
+ }
+
+ assertPlayStationRootInfo(t, rootFiles, serial, mockISO.walked, mockISO.read)
+ })
+
+ t.Run("iter files", func(t *testing.T) {
+ t.Parallel()
+ mockISO := &mockReadingPlayStationISO{
+ mockPlayStationISO: mockPlayStationISO{files: files},
+ readData: readData,
+ }
+
+ rootFiles, serial, err := playStationRootInfo(mockISO, ConsolePSX, nil)
+ if err != nil {
+ t.Fatalf("playStationRootInfo() error = %v", err)
+ }
+
+ assertPlayStationRootInfo(t, rootFiles, serial, len(files), mockISO.read)
+ })
+}
+
+func assertPlayStationRootInfo(t *testing.T, rootFiles []string, serial string, walked, read int) {
+ t.Helper()
+
+ if serial != "SLES_12345" {
+ t.Errorf("serial = %q, want %q", serial, "SLES_12345")
+ }
+ wantRootFiles := []string{"SYSTEM.CNF", "README.TXT"}
+ if !slices.Equal(rootFiles, wantRootFiles) {
+ t.Errorf("rootFiles = %v, want %v", rootFiles, wantRootFiles)
+ }
+ if walked != 2 {
+ t.Errorf("visited %d files, want 2", walked)
+ }
+ if read != 1 {
+ t.Errorf("ReadFile called %d times, want 1", read)
+ }
+}
+
// Tests for serialFromVolumeID
func TestSerialFromVolumeID(t *testing.T) {
t.Parallel()
diff --git a/internal/testiso/testiso.go b/internal/testiso/testiso.go
new file mode 100644
index 0000000..b46a43a
--- /dev/null
+++ b/internal/testiso/testiso.go
@@ -0,0 +1,166 @@
+// Copyright (c) 2026 Niema Moshiri and The Zaparoo Project.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of go-gameid.
+//
+// go-gameid is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// go-gameid is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with go-gameid. If not, see .
+
+// Package testiso builds small ISO9660 images for tests.
+package testiso
+
+import (
+ "encoding/binary"
+ "testing"
+)
+
+// BlockSize is the logical sector size used by generated test ISOs.
+const BlockSize = 2048
+
+// File describes a root-level file to add to a generated test ISO.
+type File struct {
+ Name string
+ Data []byte
+}
+
+// CreateMinimal returns a minimal ISO9660 image with optional root-level files.
+func CreateMinimal(tb testing.TB, volumeID, systemID, publisherID string, files []File) []byte {
+ tb.Helper()
+
+ totalBlocks := 20 + len(files)
+ data := make([]byte, totalBlocks*BlockSize)
+ pvdOffset := 16 * BlockSize
+
+ data[pvdOffset] = 0x01
+ copy(data[pvdOffset+1:], "CD001")
+ data[pvdOffset+6] = 0x01
+ copyBounded(data[pvdOffset+8:], systemID, 32)
+ copyBounded(data[pvdOffset+40:], volumeID, 32)
+ binary.LittleEndian.PutUint32(data[pvdOffset+80:], mustUint32(tb, totalBlocks))
+ binary.BigEndian.PutUint32(data[pvdOffset+84:], mustUint32(tb, totalBlocks))
+ binary.LittleEndian.PutUint16(data[pvdOffset+120:], 1)
+ binary.BigEndian.PutUint16(data[pvdOffset+122:], 1)
+ binary.LittleEndian.PutUint16(data[pvdOffset+124:], 1)
+ binary.BigEndian.PutUint16(data[pvdOffset+126:], 1)
+ binary.LittleEndian.PutUint16(data[pvdOffset+128:], BlockSize)
+ binary.BigEndian.PutUint16(data[pvdOffset+130:], BlockSize)
+ binary.LittleEndian.PutUint32(data[pvdOffset+132:], 10)
+ binary.BigEndian.PutUint32(data[pvdOffset+136:], 10)
+ binary.LittleEndian.PutUint32(data[pvdOffset+140:], 18)
+ copyBounded(data[pvdOffset+318:], publisherID, 128)
+ copy(data[pvdOffset+813:], "2024010112000000")
+
+ WriteDirectoryRecord(tb, data[pvdOffset+156:], 19, BlockSize, "\x00")
+ writePathTable(data)
+ writeRootDirectory(tb, data, files)
+
+ return data
+}
+
+func copyBounded(dst []byte, value string, maxLen int) {
+ if len(value) > maxLen {
+ value = value[:maxLen]
+ }
+ copy(dst, value)
+}
+
+func writePathTable(data []byte) {
+ pathTableOffset := 18 * BlockSize
+ data[pathTableOffset] = 1
+ binary.LittleEndian.PutUint32(data[pathTableOffset+2:], 19)
+ binary.LittleEndian.PutUint16(data[pathTableOffset+6:], 1)
+ data[pathTableOffset+8] = 0x00
+}
+
+func writeRootDirectory(tb testing.TB, data []byte, files []File) {
+ tb.Helper()
+
+ rootOffset := 19 * BlockSize
+ WriteDirectoryRecord(tb, data[rootOffset:], 19, BlockSize, "\x00")
+ WriteDirectoryRecord(tb, data[rootOffset+34:], 19, BlockSize, "\x01")
+
+ recordOffset := rootOffset + 68
+ for idx, file := range files {
+ recordLen := DirectoryRecordLength(file.Name)
+ if recordOffset+recordLen > rootOffset+BlockSize {
+ tb.Fatalf("test ISO root directory records exceed one block at file %s", file.Name)
+ }
+ if len(file.Data) > BlockSize {
+ tb.Fatalf("test ISO file %s is %d bytes, max one block (%d)", file.Name, len(file.Data), BlockSize)
+ }
+ fileLBA := 20 + idx
+ WriteFileRecord(tb, data[recordOffset:], fileLBA, len(file.Data), file.Name)
+ copy(data[fileLBA*BlockSize:fileLBA*BlockSize+len(file.Data)], file.Data)
+ recordOffset += recordLen
+ }
+}
+
+// WriteDirectoryRecord writes an ISO9660 directory record into record.
+func WriteDirectoryRecord(tb testing.TB, record []byte, lba, size int, name string) {
+ tb.Helper()
+
+ writeRecord(tb, record, lba, size, name)
+ record[25] = 0x02
+}
+
+// WriteFileRecord writes an ISO9660 file record into record.
+func WriteFileRecord(tb testing.TB, record []byte, lba, size int, name string) {
+ tb.Helper()
+
+ writeRecord(tb, record, lba, size, name)
+}
+
+func writeRecord(tb testing.TB, record []byte, lba, size int, name string) {
+ tb.Helper()
+
+ recLen := DirectoryRecordLength(name)
+ if len(record) < recLen {
+ tb.Fatalf("test ISO directory record buffer for %s is %d bytes, need %d", name, len(record), recLen)
+ }
+ record[0] = mustByte(tb, recLen)
+ binary.LittleEndian.PutUint32(record[2:], mustUint32(tb, lba))
+ binary.BigEndian.PutUint32(record[6:], mustUint32(tb, lba))
+ binary.LittleEndian.PutUint32(record[10:], mustUint32(tb, size))
+ binary.BigEndian.PutUint32(record[14:], mustUint32(tb, size))
+ binary.LittleEndian.PutUint16(record[28:], 1)
+ binary.BigEndian.PutUint16(record[30:], 1)
+ record[32] = mustByte(tb, len(name))
+ copy(record[33:], name)
+}
+
+// DirectoryRecordLength returns an even-padded ISO9660 directory record length.
+func DirectoryRecordLength(name string) int {
+ recLen := 33 + len(name)
+ if recLen%2 == 1 {
+ recLen++
+ }
+ return recLen
+}
+
+func mustUint32(tb testing.TB, value int) uint32 {
+ tb.Helper()
+
+ if value < 0 || value > 1<<32-1 {
+ tb.Fatalf("test ISO value %d exceeds uint32", value)
+ }
+ return uint32(value) //nolint:gosec // Bounds checked above.
+}
+
+func mustByte(tb testing.TB, value int) byte {
+ tb.Helper()
+
+ if value < 0 || value > 1<<8-1 {
+ tb.Fatalf("test ISO value %d exceeds byte", value)
+ }
+ return byte(value) //nolint:gosec // Bounds checked above.
+}
diff --git a/internal/testiso/testiso_test.go b/internal/testiso/testiso_test.go
new file mode 100644
index 0000000..edb1ca0
--- /dev/null
+++ b/internal/testiso/testiso_test.go
@@ -0,0 +1,135 @@
+// Copyright (c) 2026 Niema Moshiri and The Zaparoo Project.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of go-gameid.
+//
+// go-gameid is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// go-gameid is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with go-gameid. If not, see .
+
+package testiso
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/ZaparooProject/go-gameid/iso9660"
+)
+
+func TestCreateMinimalWithFiles(t *testing.T) {
+ t.Parallel()
+
+ isoData := CreateMinimal(t, "TESTVOL", "TESTSYS", "TESTPUB", []File{
+ {Name: "FIRST.TXT;1", Data: []byte("first file")},
+ {Name: "SECOND.TXT;1", Data: []byte("second file")},
+ })
+
+ iso, err := iso9660.OpenReader(bytes.NewReader(isoData), int64(len(isoData)))
+ if err != nil {
+ t.Fatalf("OpenReader() error = %v", err)
+ }
+ defer func() { _ = iso.Close() }()
+
+ if got := iso.GetVolumeID(); !strings.HasPrefix(got, "TESTVOL") {
+ t.Errorf("GetVolumeID() = %q, want prefix %q", got, "TESTVOL")
+ }
+ if got := iso.GetSystemID(); !strings.HasPrefix(got, "TESTSYS") {
+ t.Errorf("GetSystemID() = %q, want prefix %q", got, "TESTSYS")
+ }
+ if got := iso.GetPublisherID(); !strings.HasPrefix(got, "TESTPUB") {
+ t.Errorf("GetPublisherID() = %q, want prefix %q", got, "TESTPUB")
+ }
+
+ files, err := iso.IterFiles(true)
+ if err != nil {
+ t.Fatalf("IterFiles() error = %v", err)
+ }
+ if len(files) != 2 {
+ t.Fatalf("IterFiles() found %d files, want 2", len(files))
+ }
+ if files[0].Path != "/FIRST.TXT;1" || files[1].Path != "/SECOND.TXT;1" {
+ t.Errorf("IterFiles() paths = %q, %q", files[0].Path, files[1].Path)
+ }
+
+ data, err := iso.ReadFileByPath("SECOND.TXT")
+ if err != nil {
+ t.Fatalf("ReadFileByPath() error = %v", err)
+ }
+ if string(data) != "second file" {
+ t.Errorf("ReadFileByPath() = %q, want %q", data, "second file")
+ }
+}
+
+func TestCreateMinimalTruncatesIdentifiers(t *testing.T) {
+ t.Parallel()
+
+ isoData := CreateMinimal(t,
+ "VOLUME-ID-THAT-IS-LONGER-THAN-THIRTY-TWO-BYTES",
+ "SYSTEM-ID-THAT-IS-LONGER-THAN-THIRTY-TWO-BYTES",
+ "PUBLISHER-ID-THAT-FITS",
+ nil,
+ )
+
+ iso, err := iso9660.OpenReader(bytes.NewReader(isoData), int64(len(isoData)))
+ if err != nil {
+ t.Fatalf("OpenReader() error = %v", err)
+ }
+ defer func() { _ = iso.Close() }()
+
+ if len(iso.GetVolumeID()) < 32 || !strings.HasPrefix(iso.GetVolumeID(), "VOLUME-ID") {
+ t.Errorf("GetVolumeID() = %q, want truncated volume ID", iso.GetVolumeID())
+ }
+ if len(iso.GetSystemID()) < 32 || !strings.HasPrefix(iso.GetSystemID(), "SYSTEM-ID") {
+ t.Errorf("GetSystemID() = %q, want truncated system ID", iso.GetSystemID())
+ }
+}
+
+func TestWriteFileRecordExactBuffer(t *testing.T) {
+ t.Parallel()
+
+ const fileName = "FILE.BIN;1"
+ record := make([]byte, DirectoryRecordLength(fileName))
+
+ WriteFileRecord(t, record, 20, 4, fileName)
+
+ if int(record[0]) != len(record) {
+ t.Errorf("record length = %d, want %d", record[0], len(record))
+ }
+ if record[25] != 0 {
+ t.Errorf("file flags = %#x, want 0", record[25])
+ }
+ if got := string(record[33 : 33+len(fileName)]); got != fileName {
+ t.Errorf("file name = %q, want %q", got, fileName)
+ }
+}
+
+func TestDirectoryRecordLength(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ want int
+ }{
+ {"A", 34},
+ {"AB", 36},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ if got := DirectoryRecordLength(tt.name); got != tt.want {
+ t.Errorf("DirectoryRecordLength(%q) = %d, want %d", tt.name, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/iso9660/iso9660.go b/iso9660/iso9660.go
index 67740cd..97834b1 100644
--- a/iso9660/iso9660.go
+++ b/iso9660/iso9660.go
@@ -20,6 +20,7 @@
package iso9660
import (
+ "bytes"
"encoding/binary"
"errors"
"fmt"
@@ -55,13 +56,14 @@ type pathTableEntry struct {
// ISO9660 represents a parsed ISO9660 disc image.
type ISO9660 struct {
- reader io.ReaderAt
- closer io.Closer // Optional closer for the underlying reader
- pvd []byte
- pathTable []pathTableEntry
- blockSize int
- blockOffset int64
- size int64
+ reader io.ReaderAt
+ closer io.Closer // Optional closer for the underlying reader
+ pvd []byte
+ pathTable []pathTableEntry
+ blockSize int
+ blockOffset int64
+ size int64
+ tolerateHeaderReadErrors bool
}
// Open opens an ISO9660 disc image from a file.
@@ -96,6 +98,7 @@ func Open(path string) (*ISO9660, error) {
}
iso.size = end
iso.blockSize = 2048
+ iso.tolerateHeaderReadErrors = true
}
if err := iso.init(); err != nil {
@@ -154,33 +157,10 @@ func (iso *ISO9660) init() error {
iso.blockSize = detectBlockSize(iso.size)
}
- // Search for PVD in first ~1MB
- searchSize := int64(1000000)
- if searchSize > iso.size {
- searchSize = iso.size
- }
-
- header := make([]byte, searchSize)
- if _, err := iso.reader.ReadAt(header, 0); err != nil && err != io.EOF {
- return fmt.Errorf("failed to read header: %w", err)
- }
-
- // Find PVD magic word
- pvdOffset := int64(-1)
- for i := 0; i <= len(header)-len(pvdMagicWord); i++ {
- match := true
- for j, b := range pvdMagicWord {
- if header[i+j] != b {
- match = false
- break
- }
- }
- if match {
- pvdOffset = int64(i)
- break
- }
+ pvdOffset, err := iso.findPVDOffset()
+ if err != nil {
+ return err
}
-
if pvdOffset == -1 {
return ErrPVDNotFound
}
@@ -202,6 +182,86 @@ func (iso *ISO9660) init() error {
return nil
}
+func (iso *ISO9660) findPVDOffset() (int64, error) {
+ // Real optical block devices can stall or error on pregap sectors before
+ // the ISO9660 volume descriptors. Try the standard PVD sector first, then
+ // fall back to a bounded scan for image formats with offsets.
+ if offset, ok := iso.standardPVDOffset(); ok {
+ return offset, nil
+ }
+
+ // Search for PVD in first ~1MB. Read in logical-block chunks because
+ // optical block devices can reject large ReadAt calls even when the same
+ // sectors are readable individually.
+ searchSize := min(int64(1000000), iso.size)
+ chunkSize := iso.headerScanChunkSize(searchSize)
+ overlap := make([]byte, 0, len(pvdMagicWord)-1)
+
+ for offset := int64(0); offset < searchSize; offset += int64(chunkSize) {
+ chunk, err := iso.readHeaderChunk(offset, min(int64(chunkSize), searchSize-offset))
+ if err != nil {
+ return -1, err
+ }
+ if len(chunk) == 0 {
+ continue
+ }
+
+ searchBuf := make([]byte, 0, len(overlap)+len(chunk))
+ searchBuf = append(searchBuf, overlap...)
+ searchBuf = append(searchBuf, chunk...)
+ if idx := bytes.Index(searchBuf, pvdMagicWord); idx != -1 {
+ return offset - int64(len(overlap)) + int64(idx), nil
+ }
+ overlap = nextSearchOverlap(overlap, searchBuf)
+ }
+
+ return -1, nil
+}
+
+func (iso *ISO9660) standardPVDOffset() (int64, bool) {
+ offset := int64(16 * iso.blockSize)
+ if offset < 0 || offset+int64(len(pvdMagicWord)) > iso.size {
+ return -1, false
+ }
+
+ buf := make([]byte, len(pvdMagicWord))
+ if _, err := iso.reader.ReadAt(buf, offset); err != nil {
+ return -1, false
+ }
+ return offset, bytes.Equal(buf, pvdMagicWord)
+}
+
+func (iso *ISO9660) headerScanChunkSize(searchSize int64) int {
+ chunkSize := iso.blockSize
+ if chunkSize <= 0 {
+ chunkSize = 2048
+ }
+ if int64(chunkSize) > searchSize {
+ return int(searchSize)
+ }
+ return chunkSize
+}
+
+func (iso *ISO9660) readHeaderChunk(offset, readSize int64) ([]byte, error) {
+ buf := make([]byte, readSize)
+ bytesRead, err := iso.reader.ReadAt(buf, offset)
+ if err != nil && err != io.EOF {
+ if iso.tolerateHeaderReadErrors {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to read header at offset %d: %w", offset, err)
+ }
+ return buf[:bytesRead], nil
+}
+
+func nextSearchOverlap(dst, searchBuf []byte) []byte {
+ overlapLen := len(pvdMagicWord) - 1
+ if len(searchBuf) < overlapLen {
+ return append(dst[:0], searchBuf...)
+ }
+ return append(dst[:0], searchBuf[len(searchBuf)-overlapLen:]...)
+}
+
// parsePathTable parses the ISO9660 path table.
func (iso *ISO9660) parsePathTable() error {
// Path table size at offset 132 (little-endian)
@@ -211,6 +271,12 @@ func (iso *ISO9660) parsePathTable() error {
// Read path table
offset := iso.blockOffset + int64(pathTableLBA)*int64(iso.blockSize)
+ if offset < 0 || offset > iso.size {
+ return fmt.Errorf("path table offset %d outside image size %d", offset, iso.size)
+ }
+ if int64(pathTableSize) > iso.size-offset {
+ return fmt.Errorf("path table size %d exceeds remaining image size %d", pathTableSize, iso.size-offset)
+ }
pathTableRaw := make([]byte, pathTableSize)
if _, err := iso.reader.ReadAt(pathTableRaw, offset); err != nil {
return fmt.Errorf("failed to read path table: %w", err)
@@ -238,6 +304,8 @@ func (iso *ISO9660) parsePathTable() error {
if dirName == "\x00" {
dirName = ""
dirParentIdx = -1 // Root
+ } else if dirParentIdx < 0 || dirParentIdx >= len(iso.pathTable) {
+ return fmt.Errorf("invalid path table parent index %d at offset %d", dirParentIdx+1, i)
}
iso.pathTable = append(iso.pathTable, pathTableEntry{
@@ -323,12 +391,49 @@ func (iso *ISO9660) GetUUID() string {
// IterFiles returns a list of files in the filesystem.
// If onlyRootDir is true, only files in the root directory are returned.
+func (iso *ISO9660) IterFiles(onlyRootDir bool) ([]FileInfo, error) {
+ files := make([]FileInfo, 0)
+ err := iso.WalkFiles(onlyRootDir, func(file FileInfo) bool {
+ files = append(files, file)
+ return true
+ })
+ if err != nil {
+ return nil, err
+ }
+ return files, nil
+}
+
+func fileInfoFromDirRecord(recBuf []byte, dirPath string) (FileInfo, bool) {
+ flags := recBuf[24]
+ if (flags & 0x02) != 0 {
+ return FileInfo{}, false
+ }
+
+ fileNameLen := int(recBuf[31])
+ if fileNameLen == 0 || 32+fileNameLen > len(recBuf) {
+ return FileInfo{}, false
+ }
+
+ return FileInfo{
+ Path: dirPath + string(recBuf[32:32+fileNameLen]),
+ LBA: binary.LittleEndian.Uint32(recBuf[1:5]),
+ Size: binary.LittleEndian.Uint32(recBuf[9:13]),
+ }, true
+}
+
+// WalkFiles visits files in the ISO filesystem. Returning false from fn stops iteration early.
+// If onlyRootDir is true, only files in the root directory are visited.
//
//nolint:gocognit,revive // Directory traversal requires checking many conditions
-func (iso *ISO9660) IterFiles(onlyRootDir bool) ([]FileInfo, error) {
- var files []FileInfo
+func (iso *ISO9660) WalkFiles(onlyRootDir bool, fn func(FileInfo) bool) error {
+ var lenBuf [1]byte
+ var recStorage [255]byte
for idx, entry := range iso.pathTable {
+ if onlyRootDir && idx > 0 {
+ break
+ }
+
// Build full directory path
dirPath := entry.name
tmpIdx := entry.parentIdx
@@ -342,56 +447,33 @@ func (iso *ISO9660) IterFiles(onlyRootDir bool) ([]FileInfo, error) {
for {
// Read record length
- lenBuf := make([]byte, 1)
- if _, err := iso.reader.ReadAt(lenBuf, offset); err != nil {
- break
+ if _, err := iso.reader.ReadAt(lenBuf[:], offset); err != nil {
+ return fmt.Errorf("read directory record length at offset %d: %w", offset, err)
}
recLen := int(lenBuf[0])
if recLen == 0 {
break
}
+ if recLen < 34 {
+ return fmt.Errorf("invalid directory record length %d at offset %d", recLen, offset)
+ }
// Read record
- recBuf := make([]byte, recLen-1)
+ recBuf := recStorage[:recLen-1]
if _, err := iso.reader.ReadAt(recBuf, offset+1); err != nil {
- break
+ return fmt.Errorf("read directory record at offset %d: %w", offset, err)
}
- // Check flags (offset 24 in record, which is 25 from start)
- flags := recBuf[24]
-
- // Skip directories (bit 1 set)
- if (flags & 0x02) == 0 {
- // File entry
- fileLBA := binary.LittleEndian.Uint32(recBuf[1:5])
- fileSize := binary.LittleEndian.Uint32(recBuf[9:13])
- fileNameLen := int(recBuf[31])
-
- if fileNameLen > 0 && 32+fileNameLen <= len(recBuf) {
- fileName := string(recBuf[32 : 32+fileNameLen])
- filePath := dirPath + fileName
-
- // Only include if in root dir (when requested)
- if !onlyRootDir || strings.Count(filePath, "/") == 1 {
- files = append(files, FileInfo{
- Path: filePath,
- LBA: fileLBA,
- Size: fileSize,
- })
- }
- }
+ file, ok := fileInfoFromDirRecord(recBuf, dirPath)
+ if ok && (!onlyRootDir || strings.Count(file.Path, "/") == 1) && !fn(file) {
+ return nil
}
offset += int64(recLen)
}
-
- // If only root dir and this is not root, skip other directories
- if onlyRootDir && idx > 0 {
- break
- }
}
- return files, nil
+ return nil
}
// DefaultReadFileSizeLimit caps ReadFile allocations. Directory records store
@@ -420,25 +502,29 @@ func (iso *ISO9660) ReadFileWithLimit(info FileInfo, maxSize uint32) ([]byte, er
// ReadFileByPath reads a file by its path.
func (iso *ISO9660) ReadFileByPath(path string) ([]byte, error) {
- files, err := iso.IterFiles(false)
- if err != nil {
- return nil, err
- }
-
// Normalize path
path = strings.ToUpper(path)
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
- for _, file := range files {
+ var found *FileInfo
+ err := iso.WalkFiles(false, func(file FileInfo) bool {
// ISO9660 filenames often have version suffix (;1)
fpath := strings.ToUpper(file.Path)
fpath = strings.Split(fpath, ";")[0]
if fpath == path || file.Path == path {
- return iso.ReadFile(file)
+ found = &file
+ return false
}
+ return true
+ })
+ if err != nil {
+ return nil, err
+ }
+ if found != nil {
+ return iso.ReadFile(*found)
}
return nil, ErrFileNotFound
diff --git a/iso9660/iso9660_test.go b/iso9660/iso9660_test.go
index ecd1eae..5482eef 100644
--- a/iso9660/iso9660_test.go
+++ b/iso9660/iso9660_test.go
@@ -22,10 +22,13 @@ import (
"bytes"
"encoding/binary"
"errors"
+ "io"
"os"
"path/filepath"
"strings"
"testing"
+
+ "github.com/ZaparooProject/go-gameid/internal/testiso"
)
// createMinimalISO creates a minimal valid ISO9660 image for testing.
@@ -151,6 +154,65 @@ func createMinimalISO(volumeID, systemID, publisherID string) []byte {
return data
}
+func createMinimalISOWithFiles(t testing.TB, volumeID string, files []testiso.File) []byte {
+ t.Helper()
+
+ return testiso.CreateMinimal(t, volumeID, "SYS", "PUB", files)
+}
+
+type maxReadReaderAt struct {
+ data []byte
+ maxRead int
+}
+
+func (reader maxReadReaderAt) ReadAt(buffer []byte, off int64) (int, error) {
+ if len(buffer) > reader.maxRead {
+ return 0, errors.New("read too large")
+ }
+ if off >= int64(len(reader.data)) {
+ return 0, io.EOF
+ }
+ bytesRead := copy(buffer, reader.data[off:])
+ if bytesRead < len(buffer) {
+ return bytesRead, io.EOF
+ }
+ return bytesRead, nil
+}
+
+func TestISO9660_OpenReader_ChunkedHeaderScan(t *testing.T) {
+ t.Parallel()
+
+ data := createMinimalISO("TEST", "PLAYSTATION", "")
+ iso, err := OpenReader(maxReadReaderAt{data: data, maxRead: 2048}, int64(len(data)))
+ if err != nil {
+ t.Fatalf("OpenReader() error = %v", err)
+ }
+ defer func() { _ = iso.Close() }()
+
+ if got := iso.GetVolumeID(); !strings.HasPrefix(got, "TEST") {
+ t.Errorf("GetVolumeID() = %q, want prefix %q", got, "TEST")
+ }
+}
+
+func TestISO9660_OpenReader_HeaderScanAcrossChunkBoundary(t *testing.T) {
+ t.Parallel()
+
+ const prefixSize = 2045
+ data := append(bytes.Repeat([]byte{0xff}, prefixSize), createMinimalISO("OFFSET", "PLAYSTATION", "")...)
+ iso, err := OpenReader(maxReadReaderAt{data: data, maxRead: 2048}, int64(len(data)))
+ if err != nil {
+ t.Fatalf("OpenReader() error = %v", err)
+ }
+ defer func() { _ = iso.Close() }()
+
+ if iso.blockOffset != prefixSize {
+ t.Errorf("blockOffset = %d, want %d", iso.blockOffset, prefixSize)
+ }
+ if got := iso.GetVolumeID(); !strings.HasPrefix(got, "OFFSET") {
+ t.Errorf("GetVolumeID() = %q, want prefix %q", got, "OFFSET")
+ }
+}
+
//nolint:gosec // G306 permissions ok for tests
func TestISO9660_Open(t *testing.T) {
t.Parallel()
@@ -350,6 +412,67 @@ func TestISO9660_IterFiles(t *testing.T) {
_ = files // We just verify it doesn't error
}
+func TestISO9660_WalkFilesStopsEarlyAndReadFileByPath(t *testing.T) {
+ t.Parallel()
+
+ isoData := createMinimalISOWithFiles(t, "VOL", []testiso.File{
+ {Name: "FIRST.TXT;1", Data: []byte("first file")},
+ {Name: "SECOND.TXT;1", Data: []byte("second file")},
+ })
+ iso, err := OpenReader(bytes.NewReader(isoData), int64(len(isoData)))
+ if err != nil {
+ t.Fatalf("OpenReader() error = %v", err)
+ }
+ defer func() { _ = iso.Close() }()
+
+ visited := make([]string, 0)
+ err = iso.WalkFiles(true, func(file FileInfo) bool {
+ visited = append(visited, file.Path)
+ return false
+ })
+ if err != nil {
+ t.Fatalf("WalkFiles() error = %v", err)
+ }
+ if len(visited) != 1 || visited[0] != "/FIRST.TXT;1" {
+ t.Fatalf("visited = %v, want [/FIRST.TXT;1]", visited)
+ }
+
+ data, err := iso.ReadFileByPath("first.txt")
+ if err != nil {
+ t.Fatalf("ReadFileByPath() error = %v", err)
+ }
+ if string(data) != "first file" {
+ t.Errorf("ReadFileByPath() = %q, want %q", data, "first file")
+ }
+ if !iso.FileExists("/FIRST.TXT") {
+ t.Error("FileExists() should find file without ISO9660 version suffix")
+ }
+}
+
+func TestISO9660_WalkFilesReturnsDirectoryReadError(t *testing.T) {
+ t.Parallel()
+
+ isoData := createMinimalISOWithFiles(t, "VOL", []testiso.File{
+ {Name: "BROKEN.TXT;1", Data: []byte("data")},
+ })
+ iso, err := OpenReader(bytes.NewReader(isoData), int64(len(isoData)))
+ if err != nil {
+ t.Fatalf("OpenReader() error = %v", err)
+ }
+ iso.reader = maxReadReaderAt{data: isoData, maxRead: 1}
+
+ _, err = iso.ReadFileByPath("BROKEN.TXT")
+ if err == nil {
+ t.Fatal("ReadFileByPath() should return directory read error")
+ }
+ if errors.Is(err, ErrFileNotFound) {
+ t.Fatalf("ReadFileByPath() error = %v, want underlying directory read error", err)
+ }
+ if !strings.Contains(err.Error(), "read directory record") {
+ t.Errorf("ReadFileByPath() error = %v, want directory record read error", err)
+ }
+}
+
func TestISO9660_ReadFileByPath_NotFound(t *testing.T) {
t.Parallel()
@@ -632,3 +755,93 @@ func TestISO9660_TruncatedPathTable(t *testing.T) {
t.Errorf("Open() error = %v, want error containing 'truncated path table entry'", err)
}
}
+
+func TestISO9660_InvalidPathTableParent(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ isoData := createMinimalISO("VOL", "SYS", "PUB")
+
+ const blockSize = 2048
+ pvdOffset := 16 * blockSize
+ pathTableOffset := 18 * blockSize
+ pathTableSize := uint32(22)
+ binary.LittleEndian.PutUint32(isoData[pvdOffset+132:], pathTableSize)
+ binary.BigEndian.PutUint32(isoData[pvdOffset+136:], pathTableSize)
+
+ childOffset := pathTableOffset + 10
+ isoData[childOffset] = 4
+ isoData[childOffset+1] = 0
+ binary.LittleEndian.PutUint32(isoData[childOffset+2:], 19)
+ binary.LittleEndian.PutUint16(isoData[childOffset+6:], 2)
+ copy(isoData[childOffset+8:], "LOOP")
+
+ isoPath := filepath.Join(tmpDir, "invalid-parent.iso")
+ if err := os.WriteFile(isoPath, isoData, 0o600); err != nil {
+ t.Fatalf("Failed to write ISO: %v", err)
+ }
+
+ _, err := Open(isoPath)
+ if err == nil {
+ t.Fatal("Open() should error for invalid path table parent")
+ }
+ if !strings.Contains(err.Error(), "invalid path table parent index") {
+ t.Errorf("Open() error = %v, want error containing 'invalid path table parent index'", err)
+ }
+}
+
+func TestISO9660_PathTableSizeExceedsImage(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ isoData := createMinimalISO("VOL", "SYS", "PUB")
+
+ const blockSize = 2048
+ pvdOffset := 16 * blockSize
+ pathTableSize := uint32(999999)
+ binary.LittleEndian.PutUint32(isoData[pvdOffset+132:], pathTableSize)
+ binary.BigEndian.PutUint32(isoData[pvdOffset+136:], pathTableSize)
+
+ isoPath := filepath.Join(tmpDir, "oversize-path-table.iso")
+ if err := os.WriteFile(isoPath, isoData, 0o600); err != nil {
+ t.Fatalf("Failed to write ISO: %v", err)
+ }
+
+ _, err := Open(isoPath)
+ if err == nil {
+ t.Fatal("Open() should error for oversized path table")
+ }
+ if !strings.Contains(err.Error(), "path table size") {
+ t.Errorf("Open() error = %v, want error containing 'path table size'", err)
+ }
+}
+
+func TestISO9660_IterFilesShortDirectoryRecord(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ isoData := createMinimalISO("VOL", "SYS", "PUB")
+
+ const blockSize = 2048
+ rootOffset := 19 * blockSize
+ isoData[rootOffset] = 17
+
+ isoPath := filepath.Join(tmpDir, "short-record.iso")
+ if err := os.WriteFile(isoPath, isoData, 0o600); err != nil {
+ t.Fatalf("Failed to write ISO: %v", err)
+ }
+
+ iso, err := Open(isoPath)
+ if err != nil {
+ t.Fatalf("Open failed: %v", err)
+ }
+ defer func() { _ = iso.Close() }()
+
+ _, err = iso.IterFiles(true)
+ if err == nil {
+ t.Fatal("IterFiles() should error for short directory record")
+ }
+ if !strings.Contains(err.Error(), "invalid directory record length") {
+ t.Errorf("IterFiles() error = %v, want error containing 'invalid directory record length'", err)
+ }
+}