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) + } +}