diff --git a/AGENTS.md b/AGENTS.md
index e31909e..db54b6a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,6 +35,14 @@ go-gameid/
├── gameid.go # Main API: Identify(), IdentifyWithConsole(), DetectConsole()
├── console.go # Console detection from file extensions/headers
├── database.go # GameDatabase for metadata lookup (gob.gz format)
+├── archive/ # Archive support (ZIP, 7z, RAR)
+│ ├── archive.go # Archive interface and factory
+│ ├── zip.go # ZIP implementation
+│ ├── sevenzip.go # 7z implementation
+│ ├── rar.go # RAR implementation
+│ ├── path.go # MiSTer-style path parsing
+│ ├── detect.go # Game file detection
+│ └── errors.go # Error types
├── identifier/ # Console-specific identification logic
│ ├── identifier.go # Identifier interface, Result type, Console constants
│ ├── gb.go # Game Boy / Game Boy Color
@@ -256,6 +264,41 @@ if gameid.IsDiscBased(console) {
}
```
+### Identify game from archive
+
+The library supports MiSTer-style archive paths for cartridge-based games:
+
+```go
+// Explicit path inside archive
+result, err := gameid.Identify("/games/roms.zip/gba/game.gba", nil)
+
+// Auto-detect game file in archive
+result, err := gameid.Identify("/games/roms.7z", nil)
+
+// Also works with RAR
+result, err := gameid.Identify("/games/collection.rar/game.nes", nil)
+```
+
+### Work with archives directly
+
+```go
+import "github.com/ZaparooProject/go-gameid/archive"
+
+// Parse MiSTer-style path
+path, err := archive.ParsePath("/games/roms.zip/game.gba")
+// path.ArchivePath = "/games/roms.zip"
+// path.InternalPath = "game.gba"
+
+// Open and list archive contents
+arc, err := archive.Open("/games/roms.zip")
+defer arc.Close()
+files, err := arc.List()
+
+// Read file from archive
+reader, size, err := arc.Open("game.gba")
+defer reader.Close()
+```
+
## Platform-Specific Code
Block device detection has platform-specific implementations:
@@ -264,7 +307,9 @@ Block device detection has platform-specific implementations:
## Dependencies
-The project has zero external dependencies (stdlib only).
+Production dependencies:
+- `github.com/bodgit/sevenzip` - 7z archive support (BSD-3-Clause)
+- `github.com/nwaples/rardecode/v2` - RAR archive support (BSD-2-Clause)
## Debugging Tips
@@ -282,3 +327,5 @@ The project has zero external dependencies (stdlib only).
- GBC uses the same identifier as GB (header format is identical)
- Some disc formats (.bin, .iso, .cue) are ambiguous - detection relies on header magic and filesystem analysis
- Block device support allows reading directly from physical disc drives
+- Archive support (ZIP, 7z, RAR) only works for cartridge-based games - disc images in archives return an error
+- Archive paths use MiSTer-style format: `/path/to/archive.zip/internal/path/game.gba`
diff --git a/archive/archive.go b/archive/archive.go
new file mode 100644
index 0000000..4493fe3
--- /dev/null
+++ b/archive/archive.go
@@ -0,0 +1,124 @@
+// Copyright (c) 2025 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 archive provides support for reading game files from archives.
+// It supports ZIP, 7z, and RAR formats.
+package archive
+
+import (
+ "fmt"
+ "io"
+ "path/filepath"
+ "strings"
+)
+
+// FileInfo contains information about a file in an archive.
+type FileInfo struct {
+ Name string // Full path within archive
+ Size int64 // Uncompressed size
+}
+
+// Archive provides read access to files within an archive.
+type Archive interface {
+ // List returns all files in the archive.
+ List() ([]FileInfo, error)
+
+ // Open opens a file within the archive for reading.
+ // Returns the reader, uncompressed size, and any error.
+ Open(internalPath string) (io.ReadCloser, int64, error)
+
+ // OpenReaderAt opens a file and returns an io.ReaderAt interface.
+ // The file contents are buffered in memory to support random access.
+ // The returned Closer must be called to release resources.
+ OpenReaderAt(internalPath string) (io.ReaderAt, int64, io.Closer, error)
+
+ // Close closes the archive.
+ Close() error
+}
+
+// Open opens an archive file based on its extension.
+// Supported formats: .zip, .7z, .rar
+func Open(path string) (Archive, error) {
+ ext := strings.ToLower(filepath.Ext(path))
+
+ switch ext {
+ case ".zip":
+ return OpenZIP(path)
+ case ".7z":
+ return OpenSevenZip(path)
+ case ".rar":
+ return OpenRAR(path)
+ default:
+ return nil, FormatError{Format: ext}
+ }
+}
+
+// IsArchiveExtension checks if an extension is a supported archive format.
+func IsArchiveExtension(ext string) bool {
+ ext = strings.ToLower(ext)
+ switch ext {
+ case ".zip", ".7z", ".rar":
+ return true
+ default:
+ return false
+ }
+}
+
+// nopCloser wraps a value that doesn't need closing.
+type nopCloser struct{}
+
+func (nopCloser) Close() error { return nil }
+
+// bufferFile reads the entire file into memory and returns a ReaderAt.
+//
+//nolint:revive // 4 return values is necessary for this interface pattern
+func bufferFile(arc Archive, internalPath string) (io.ReaderAt, int64, io.Closer, error) {
+ reader, size, err := arc.Open(internalPath)
+ if err != nil {
+ return nil, 0, nil, fmt.Errorf("open file in archive: %w", err)
+ }
+ defer func() { _ = reader.Close() }()
+
+ data := make([]byte, size)
+ bytesRead, err := io.ReadFull(reader, data)
+ if err != nil {
+ return nil, 0, nil, fmt.Errorf("read file from archive: %w", err)
+ }
+
+ return &byteReaderAt{data: data}, int64(bytesRead), nopCloser{}, nil
+}
+
+// byteReaderAt implements io.ReaderAt for a byte slice.
+type byteReaderAt struct {
+ data []byte
+}
+
+func (br *byteReaderAt) ReadAt(buf []byte, off int64) (int, error) {
+ if off < 0 {
+ return 0, fmt.Errorf("negative offset: %d", off)
+ }
+ if off >= int64(len(br.data)) {
+ return 0, io.EOF
+ }
+
+ bytesRead := copy(buf, br.data[off:])
+ if bytesRead < len(buf) {
+ return bytesRead, io.EOF
+ }
+ return bytesRead, nil
+}
diff --git a/archive/archive_test.go b/archive/archive_test.go
new file mode 100644
index 0000000..2488ecb
--- /dev/null
+++ b/archive/archive_test.go
@@ -0,0 +1,563 @@
+// Copyright (c) 2025 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 archive_test
+
+import (
+ "archive/zip"
+ "bytes"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/ZaparooProject/go-gameid/archive"
+)
+
+// createTestZIP creates a ZIP archive in tmpDir with the given files.
+//
+//nolint:gosec // Test helper creates files in test temp directory
+func createTestZIP(t *testing.T, tmpDir, name string, files map[string][]byte) string {
+ t.Helper()
+
+ zipPath := filepath.Join(tmpDir, name)
+ file, err := os.Create(zipPath)
+ if err != nil {
+ t.Fatalf("create zip file: %v", err)
+ }
+ defer func() { _ = file.Close() }()
+
+ writer := zip.NewWriter(file)
+
+ for filename, content := range files {
+ fileWriter, err := writer.Create(filename)
+ if err != nil {
+ t.Fatalf("create file in zip: %v", err)
+ }
+ if _, err := fileWriter.Write(content); err != nil {
+ t.Fatalf("write file content: %v", err)
+ }
+ }
+
+ if err := writer.Close(); err != nil {
+ t.Fatalf("close zip writer: %v", err)
+ }
+
+ return zipPath
+}
+
+func TestOpen(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+
+ // Create a test ZIP
+ testContent := []byte("test content")
+ zipPath := createTestZIP(t, tmpDir, "test.zip", map[string][]byte{
+ "test.txt": testContent,
+ })
+
+ tests := []struct {
+ name string
+ path string
+ wantErr bool
+ }{
+ {
+ name: "ZIP archive",
+ path: zipPath,
+ wantErr: false,
+ },
+ {
+ name: "non-existent file",
+ path: filepath.Join(tmpDir, "nonexistent.zip"),
+ wantErr: true,
+ },
+ {
+ name: "unsupported format",
+ path: filepath.Join(tmpDir, "test.tar"),
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ arc, err := archive.Open(tt.path)
+ if tt.wantErr {
+ if err == nil {
+ t.Error("expected error, got nil")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ _ = arc.Close()
+ })
+ }
+}
+
+func TestIsArchiveExtension(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ ext string
+ want bool
+ }{
+ {".zip", true},
+ {".ZIP", true},
+ {".7z", true},
+ {".rar", true},
+ {".tar", false},
+ {".gz", false},
+ {".txt", false},
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.ext, func(t *testing.T) {
+ t.Parallel()
+
+ got := archive.IsArchiveExtension(tt.ext)
+ if got != tt.want {
+ t.Errorf("IsArchiveExtension(%q) = %v, want %v", tt.ext, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestZIPArchive_List(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+
+ files := map[string][]byte{
+ "game.gba": make([]byte, 100),
+ "readme.txt": []byte("readme"),
+ "folder/file.x": []byte("nested"),
+ }
+ zipPath := createTestZIP(t, tmpDir, "list.zip", files)
+
+ arc, err := archive.Open(zipPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ fileList, err := arc.List()
+ if err != nil {
+ t.Fatalf("list files: %v", err)
+ }
+
+ if len(fileList) != len(files) {
+ t.Errorf("got %d files, want %d", len(fileList), len(files))
+ }
+
+ fileMap := make(map[string]int64)
+ for _, file := range fileList {
+ fileMap[file.Name] = file.Size
+ }
+
+ for name, content := range files {
+ size, ok := fileMap[name]
+ if !ok {
+ t.Errorf("missing file: %s", name)
+ continue
+ }
+ if size != int64(len(content)) {
+ t.Errorf("file %s: got size %d, want %d", name, size, len(content))
+ }
+ }
+}
+
+func TestZIPArchive_Open_ExistingFile(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ testContent := []byte("test game content")
+ files := map[string][]byte{"game.gba": testContent}
+ zipPath := createTestZIP(t, tmpDir, "open.zip", files)
+
+ arc, err := archive.Open(zipPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ reader, size, err := arc.Open("game.gba")
+ if err != nil {
+ t.Fatalf("open file: %v", err)
+ }
+ defer func() { _ = reader.Close() }()
+
+ if size != int64(len(testContent)) {
+ t.Errorf("got size %d, want %d", size, len(testContent))
+ }
+
+ data, err := io.ReadAll(reader)
+ if err != nil {
+ t.Fatalf("read file: %v", err)
+ }
+
+ if !bytes.Equal(data, testContent) {
+ t.Error("content mismatch")
+ }
+}
+
+func TestZIPArchive_Open_NonExistent(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ testContent := []byte("test game content")
+ files := map[string][]byte{"game.gba": testContent}
+ zipPath := createTestZIP(t, tmpDir, "open2.zip", files)
+
+ arc, err := archive.Open(zipPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ _, _, err = arc.Open("nonexistent.gba")
+ if err == nil {
+ t.Error("expected error for non-existent file")
+ }
+
+ var notFoundErr archive.FileNotFoundError
+ if !errors.As(err, ¬FoundErr) {
+ t.Errorf("expected FileNotFoundError, got %T", err)
+ }
+}
+
+func TestZIPArchive_Open_CaseInsensitive(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ testContent := []byte("test game content")
+ files := map[string][]byte{"game.gba": testContent}
+ zipPath := createTestZIP(t, tmpDir, "open3.zip", files)
+
+ arc, err := archive.Open(zipPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ reader, _, err := arc.Open("GAME.GBA")
+ if err != nil {
+ t.Fatalf("open file case-insensitive: %v", err)
+ }
+ _ = reader.Close()
+}
+
+func TestZIPArchive_OpenReaderAt(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+
+ testContent := []byte("test game content for random access")
+ files := map[string][]byte{
+ "game.gba": testContent,
+ }
+ zipPath := createTestZIP(t, tmpDir, "readerAt.zip", files)
+
+ arc, err := archive.Open(zipPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ readerAt, size, closer, err := arc.OpenReaderAt("game.gba")
+ if err != nil {
+ t.Fatalf("open reader at: %v", err)
+ }
+ defer func() { _ = closer.Close() }()
+
+ if size != int64(len(testContent)) {
+ t.Errorf("got size %d, want %d", size, len(testContent))
+ }
+
+ // Test random access
+ buf := make([]byte, 4)
+ bytesRead, err := readerAt.ReadAt(buf, 5)
+ if err != nil {
+ t.Fatalf("read at: %v", err)
+ }
+ if bytesRead != 4 {
+ t.Errorf("got %d bytes, want 4", bytesRead)
+ }
+ if !bytes.Equal(buf, testContent[5:9]) {
+ t.Error("content mismatch at offset 5")
+ }
+}
+
+// Tests for 7z and RAR archives using real testdata via table-driven tests
+
+//nolint:gocognit,gocyclo,revive,cyclop,funlen // Table-driven test with nested subtests has inherent complexity
+func TestSevenZipAndRAR_Operations(t *testing.T) {
+ t.Parallel()
+
+ archiveFormats := []struct {
+ name string
+ path string
+ }{
+ {"7z", "../testdata/archive/snes.7z"},
+ {"RAR", "../testdata/archive/snes.rar"},
+ }
+
+ for _, format := range archiveFormats {
+ t.Run(format.name+"_List", func(t *testing.T) {
+ t.Parallel()
+
+ arc, err := archive.Open(format.path)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ files, err := arc.List()
+ if err != nil {
+ t.Fatalf("list files: %v", err)
+ }
+
+ if len(files) != 1 {
+ t.Errorf("got %d files, want 1", len(files))
+ }
+
+ if files[0].Name != "240pSuite.sfc" {
+ t.Errorf("got filename %q, want %q", files[0].Name, "240pSuite.sfc")
+ }
+ })
+
+ t.Run(format.name+"_Open", func(t *testing.T) {
+ t.Parallel()
+
+ arc, err := archive.Open(format.path)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ reader, size, err := arc.Open("240pSuite.sfc")
+ if err != nil {
+ t.Fatalf("open file: %v", err)
+ }
+ defer func() { _ = reader.Close() }()
+
+ if size != 524288 {
+ t.Errorf("got size %d, want 524288", size)
+ }
+
+ data := make([]byte, 32)
+ _, err = reader.Read(data)
+ if err != nil {
+ t.Fatalf("read file: %v", err)
+ }
+ })
+
+ t.Run(format.name+"_Open_NonExistent", func(t *testing.T) {
+ t.Parallel()
+
+ arc, err := archive.Open(format.path)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ _, _, err = arc.Open("nonexistent.sfc")
+ if err == nil {
+ t.Error("expected error for non-existent file")
+ }
+
+ var notFoundErr archive.FileNotFoundError
+ if !errors.As(err, ¬FoundErr) {
+ t.Errorf("expected FileNotFoundError, got %T", err)
+ }
+ })
+
+ t.Run(format.name+"_Open_CaseInsensitive", func(t *testing.T) {
+ t.Parallel()
+
+ arc, err := archive.Open(format.path)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ reader, _, err := arc.Open("240PSUITE.SFC")
+ if err != nil {
+ t.Fatalf("open file case-insensitive: %v", err)
+ }
+ _ = reader.Close()
+ })
+
+ t.Run(format.name+"_OpenReaderAt", func(t *testing.T) {
+ t.Parallel()
+
+ arc, err := archive.Open(format.path)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ readerAt, size, closer, err := arc.OpenReaderAt("240pSuite.sfc")
+ if err != nil {
+ t.Fatalf("open reader at: %v", err)
+ }
+ defer func() { _ = closer.Close() }()
+
+ if size != 524288 {
+ t.Errorf("got size %d, want 524288", size)
+ }
+
+ buf := make([]byte, 21)
+ _, err = readerAt.ReadAt(buf, 0x7FC0)
+ if err != nil {
+ t.Fatalf("read at: %v", err)
+ }
+ })
+ }
+}
+
+func TestOpenArchive_NonExistent(t *testing.T) {
+ t.Parallel()
+
+ nonExistentPaths := []string{
+ "/nonexistent/path/file.7z",
+ "/nonexistent/path/file.rar",
+ }
+
+ for _, path := range nonExistentPaths {
+ t.Run(path, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := archive.Open(path)
+ if err == nil {
+ t.Errorf("expected error for non-existent archive: %s", path)
+ }
+ })
+ }
+}
+
+// Test byteReaderAt edge cases
+
+func TestByteReaderAt_NegativeOffset(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ testContent := []byte("test content")
+ zipPath := createTestZIP(t, tmpDir, "negative.zip", map[string][]byte{"test.txt": testContent})
+
+ arc, err := archive.Open(zipPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ readerAt, _, closer, err := arc.OpenReaderAt("test.txt")
+ if err != nil {
+ t.Fatalf("open reader at: %v", err)
+ }
+ defer func() { _ = closer.Close() }()
+
+ buf := make([]byte, 4)
+ _, err = readerAt.ReadAt(buf, -1)
+ if err == nil {
+ t.Error("expected error for negative offset")
+ }
+}
+
+func TestByteReaderAt_OffsetPastEnd(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ testContent := []byte("test content")
+ zipPath := createTestZIP(t, tmpDir, "pastend.zip", map[string][]byte{"test.txt": testContent})
+
+ arc, err := archive.Open(zipPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ readerAt, _, closer, err := arc.OpenReaderAt("test.txt")
+ if err != nil {
+ t.Fatalf("open reader at: %v", err)
+ }
+ defer func() { _ = closer.Close() }()
+
+ buf := make([]byte, 4)
+ _, err = readerAt.ReadAt(buf, 1000)
+ if !errors.Is(err, io.EOF) {
+ t.Errorf("expected io.EOF for offset past end, got %v", err)
+ }
+}
+
+func TestByteReaderAt_PartialRead(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ testContent := []byte("test")
+ zipPath := createTestZIP(t, tmpDir, "partial.zip", map[string][]byte{"test.txt": testContent})
+
+ arc, err := archive.Open(zipPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ readerAt, _, closer, err := arc.OpenReaderAt("test.txt")
+ if err != nil {
+ t.Fatalf("open reader at: %v", err)
+ }
+ defer func() { _ = closer.Close() }()
+
+ // Request more bytes than available from offset 2
+ buf := make([]byte, 10)
+ n, err := readerAt.ReadAt(buf, 2)
+ if !errors.Is(err, io.EOF) {
+ t.Errorf("expected io.EOF for partial read, got %v", err)
+ }
+ if n != 2 {
+ t.Errorf("expected 2 bytes read, got %d", n)
+ }
+}
+
+// Test OpenReaderAt error case
+
+func TestOpenReaderAt_NonExistent(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ testContent := []byte("test content")
+ zipPath := createTestZIP(t, tmpDir, "readeraterr.zip", map[string][]byte{"test.txt": testContent})
+
+ arc, err := archive.Open(zipPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ _, _, _, err = arc.OpenReaderAt("nonexistent.txt")
+ if err == nil {
+ t.Error("expected error for non-existent file in OpenReaderAt")
+ }
+}
diff --git a/archive/detect.go b/archive/detect.go
new file mode 100644
index 0000000..dc64f97
--- /dev/null
+++ b/archive/detect.go
@@ -0,0 +1,84 @@
+// Copyright (c) 2025 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 archive
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+)
+
+// gameExtensions are file extensions that indicate cartridge-based game files.
+// This only includes unambiguous extensions that can be identified without header analysis.
+var gameExtensions = map[string]bool{
+ // Game Boy / Game Boy Color
+ ".gb": true,
+ ".gbc": true,
+
+ // Game Boy Advance
+ ".gba": true,
+ ".srl": true,
+
+ // Nintendo 64
+ ".n64": true,
+ ".z64": true,
+ ".v64": true,
+ ".ndd": true,
+
+ // NES
+ ".nes": true,
+ ".fds": true,
+ ".unf": true,
+ ".nez": true,
+
+ // SNES
+ ".sfc": true,
+ ".smc": true,
+ ".swc": true,
+
+ // Genesis / Mega Drive
+ ".gen": true,
+ ".md": true,
+ ".smd": true,
+}
+
+// IsGameFile checks if a filename has a recognized game file extension.
+// This only returns true for cartridge-based game extensions.
+func IsGameFile(filename string) bool {
+ ext := strings.ToLower(filepath.Ext(filename))
+ return gameExtensions[ext]
+}
+
+// DetectGameFile finds the first game file in an archive.
+// It scans the archive's file list and returns the path to the first file
+// that has a recognized game extension.
+func DetectGameFile(arc Archive) (string, error) {
+ files, err := arc.List()
+ if err != nil {
+ return "", fmt.Errorf("list archive files: %w", err)
+ }
+
+ for _, file := range files {
+ if IsGameFile(file.Name) {
+ return file.Name, nil
+ }
+ }
+
+ return "", NoGameFilesError{Archive: "archive"}
+}
diff --git a/archive/detect_test.go b/archive/detect_test.go
new file mode 100644
index 0000000..e49c1a6
--- /dev/null
+++ b/archive/detect_test.go
@@ -0,0 +1,169 @@
+// Copyright (c) 2025 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 archive_test
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/ZaparooProject/go-gameid/archive"
+)
+
+func TestIsGameFile(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ filename string
+ want bool
+ }{
+ // Game Boy / Game Boy Color
+ {"game.gb", true},
+ {"GAME.GB", true},
+ {"game.gbc", true},
+
+ // Game Boy Advance
+ {"game.gba", true},
+ {"game.srl", true},
+
+ // Nintendo 64
+ {"game.n64", true},
+ {"game.z64", true},
+ {"game.v64", true},
+ {"game.ndd", true},
+
+ // NES
+ {"game.nes", true},
+ {"game.fds", true},
+ {"game.unf", true},
+ {"game.nez", true},
+
+ // SNES
+ {"game.sfc", true},
+ {"game.smc", true},
+ {"game.swc", true},
+
+ // Genesis
+ {"game.gen", true},
+ {"game.md", true},
+ {"game.smd", true},
+
+ // Non-game files
+ {"game.iso", false},
+ {"game.bin", false},
+ {"game.cue", false},
+ {"readme.txt", false},
+ {"game.zip", false},
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.filename, func(t *testing.T) {
+ t.Parallel()
+
+ got := archive.IsGameFile(tt.filename)
+ if got != tt.want {
+ t.Errorf("IsGameFile(%q) = %v, want %v", tt.filename, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDetectGameFile_FindsGame(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+
+ files := map[string][]byte{
+ "readme.txt": []byte("readme"),
+ "game.gba": make([]byte, 100),
+ "notes.doc": []byte("notes"),
+ }
+ zipPath := createTestZIP(t, tmpDir, "games.zip", files)
+
+ arc, err := archive.Open(zipPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ gamePath, err := archive.DetectGameFile(arc)
+ if err != nil {
+ t.Fatalf("detect game file: %v", err)
+ }
+
+ if gamePath != "game.gba" {
+ t.Errorf("got %q, want %q", gamePath, "game.gba")
+ }
+}
+
+func TestDetectGameFile_NoGames(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+
+ files := map[string][]byte{
+ "readme.txt": []byte("readme"),
+ "notes.doc": []byte("notes"),
+ }
+ zipPath := createTestZIP(t, tmpDir, "nogames.zip", files)
+
+ arc, err := archive.Open(zipPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ _, err = archive.DetectGameFile(arc)
+ if err == nil {
+ t.Error("expected error for archive with no games")
+ }
+
+ var noGamesErr archive.NoGameFilesError
+ if !errors.As(err, &noGamesErr) {
+ t.Errorf("expected NoGameFilesError, got %T", err)
+ }
+}
+
+func TestDetectGameFile_MultipleGames(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+
+ // ZIP iteration order may vary, but we want to ensure at least one is returned
+ files := map[string][]byte{
+ "game1.gba": make([]byte, 100),
+ "game2.sfc": make([]byte, 200),
+ }
+ zipPath := createTestZIP(t, tmpDir, "multigames.zip", files)
+
+ arc, err := archive.Open(zipPath)
+ if err != nil {
+ t.Fatalf("open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ gamePath, err := archive.DetectGameFile(arc)
+ if err != nil {
+ t.Fatalf("detect game file: %v", err)
+ }
+
+ if !archive.IsGameFile(gamePath) {
+ t.Errorf("returned path %q is not a game file", gamePath)
+ }
+}
diff --git a/archive/errors.go b/archive/errors.go
new file mode 100644
index 0000000..1467b35
--- /dev/null
+++ b/archive/errors.go
@@ -0,0 +1,62 @@
+// Copyright (c) 2025 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 archive
+
+import "fmt"
+
+// FormatError indicates an unsupported or invalid archive format.
+type FormatError struct {
+ Format string
+ Reason string
+}
+
+func (e FormatError) Error() string {
+ if e.Reason != "" {
+ return fmt.Sprintf("unsupported archive format %s: %s", e.Format, e.Reason)
+ }
+ return fmt.Sprintf("unsupported archive format: %s", e.Format)
+}
+
+// FileNotFoundError indicates a file was not found in the archive.
+type FileNotFoundError struct {
+ Archive string
+ InternalPath string
+}
+
+func (e FileNotFoundError) Error() string {
+ return fmt.Sprintf("file %q not found in archive %q", e.InternalPath, e.Archive)
+}
+
+// NoGameFilesError indicates no game files were found in the archive.
+type NoGameFilesError struct {
+ Archive string
+}
+
+func (e NoGameFilesError) Error() string {
+ return fmt.Sprintf("no game files found in archive %q", e.Archive)
+}
+
+// DiscNotSupportedError indicates disc-based games in archives are not supported.
+type DiscNotSupportedError struct {
+ Console string
+}
+
+func (e DiscNotSupportedError) Error() string {
+ return fmt.Sprintf("disc-based games (%s) in archives are not supported", e.Console)
+}
diff --git a/archive/errors_test.go b/archive/errors_test.go
new file mode 100644
index 0000000..808febf
--- /dev/null
+++ b/archive/errors_test.go
@@ -0,0 +1,96 @@
+// Copyright (c) 2025 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 archive_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/ZaparooProject/go-gameid/archive"
+)
+
+func TestFormatError(t *testing.T) {
+ t.Parallel()
+
+ err := archive.FormatError{Format: ".tar", Reason: "not supported"}
+
+ msg := err.Error()
+ if !strings.Contains(msg, ".tar") {
+ t.Errorf("error message should contain format: %s", msg)
+ }
+ if !strings.Contains(msg, "not supported") {
+ t.Errorf("error message should contain reason: %s", msg)
+ }
+}
+
+func TestFormatError_NoReason(t *testing.T) {
+ t.Parallel()
+
+ err := archive.FormatError{Format: ".tar"}
+
+ msg := err.Error()
+ if !strings.Contains(msg, ".tar") {
+ t.Errorf("error message should contain format: %s", msg)
+ }
+}
+
+func TestFileNotFoundError(t *testing.T) {
+ t.Parallel()
+
+ err := archive.FileNotFoundError{
+ Archive: "/path/to/archive.zip",
+ InternalPath: "folder/game.gba",
+ }
+
+ msg := err.Error()
+ if !strings.Contains(msg, "archive.zip") {
+ t.Errorf("error message should contain archive: %s", msg)
+ }
+ if !strings.Contains(msg, "folder/game.gba") {
+ t.Errorf("error message should contain internal path: %s", msg)
+ }
+}
+
+func TestNoGameFilesError(t *testing.T) {
+ t.Parallel()
+
+ err := archive.NoGameFilesError{Archive: "/path/to/archive.zip"}
+
+ msg := err.Error()
+ if !strings.Contains(msg, "archive.zip") {
+ t.Errorf("error message should contain archive: %s", msg)
+ }
+ if !strings.Contains(msg, "game") {
+ t.Errorf("error message should mention games: %s", msg)
+ }
+}
+
+func TestDiscNotSupportedError(t *testing.T) {
+ t.Parallel()
+
+ err := archive.DiscNotSupportedError{Console: "PSX"}
+
+ msg := err.Error()
+ if !strings.Contains(msg, "PSX") {
+ t.Errorf("error message should contain console: %s", msg)
+ }
+ if !strings.Contains(msg, "disc") {
+ t.Errorf("error message should mention disc: %s", msg)
+ }
+}
diff --git a/archive/path.go b/archive/path.go
new file mode 100644
index 0000000..5b5c74a
--- /dev/null
+++ b/archive/path.go
@@ -0,0 +1,111 @@
+// Copyright (c) 2025 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 archive
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// Path represents a parsed archive path with optional internal path.
+type Path struct {
+ ArchivePath string // Path to the archive file
+ InternalPath string // Path inside the archive (empty means auto-detect)
+}
+
+// archiveExtensions are the supported archive extensions.
+var archiveExtensions = []string{".zip", ".7z", ".rar"}
+
+// ParsePath parses a path that may reference a file inside an archive.
+// It supports MiSTer-style paths like "/path/to/archive.zip/folder/game.gba".
+//
+// Returns:
+// - (*Path, nil) if the path contains an archive reference
+// - (nil, nil) if the path is not an archive reference
+// - (nil, error) if there was an error checking the path
+//
+//nolint:gocognit,nilnil,revive // Complex path parsing logic requires branching; nil,nil is documented API behavior
+func ParsePath(path string) (*Path, error) {
+ // Normalize path separators
+ normalizedPath := filepath.ToSlash(path)
+
+ // Search for archive extensions followed by a path separator
+ for _, ext := range archiveExtensions {
+ // Look for pattern like ".zip/" in the path
+ pattern := ext + "/"
+ idx := strings.Index(strings.ToLower(normalizedPath), pattern)
+
+ if idx != -1 {
+ archivePath := path[:idx+len(ext)]
+ internalPath := path[idx+len(ext)+1:]
+
+ // Verify the archive file exists
+ if _, err := os.Stat(archivePath); err != nil {
+ if os.IsNotExist(err) {
+ // Archive doesn't exist, this might not be an archive path
+ continue
+ }
+ return nil, fmt.Errorf("stat archive %s: %w", archivePath, err)
+ }
+
+ return &Path{
+ ArchivePath: archivePath,
+ InternalPath: internalPath,
+ }, nil
+ }
+ }
+
+ // Check if the path itself is an archive (for auto-detection)
+ ext := strings.ToLower(filepath.Ext(path))
+ if IsArchiveExtension(ext) {
+ // Verify the archive file exists
+ if _, err := os.Stat(path); err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil // Not an archive path
+ }
+ return nil, fmt.Errorf("stat archive %s: %w", path, err)
+ }
+
+ return &Path{
+ ArchivePath: path,
+ InternalPath: "", // Auto-detect
+ }, nil
+ }
+
+ return nil, nil // Not an archive path
+}
+
+// IsArchivePath checks if a path references an archive.
+// This is a quick check that doesn't verify file existence.
+func IsArchivePath(path string) bool {
+ normalizedPath := filepath.ToSlash(path)
+
+ // Check for archive extension followed by separator
+ for _, ext := range archiveExtensions {
+ if strings.Contains(strings.ToLower(normalizedPath), ext+"/") {
+ return true
+ }
+ }
+
+ // Check if path ends with archive extension
+ ext := strings.ToLower(filepath.Ext(path))
+ return IsArchiveExtension(ext)
+}
diff --git a/archive/path_test.go b/archive/path_test.go
new file mode 100644
index 0000000..1ff7ae0
--- /dev/null
+++ b/archive/path_test.go
@@ -0,0 +1,161 @@
+// Copyright (c) 2025 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 archive_test
+
+import (
+ "archive/zip"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/ZaparooProject/go-gameid/archive"
+)
+
+//nolint:gosec // Test helper creates files in test temp directory
+func createSimpleTestZIP(t *testing.T, zipPath string) {
+ t.Helper()
+
+ zipFile, err := os.Create(zipPath)
+ if err != nil {
+ t.Fatalf("create zip: %v", err)
+ }
+
+ writer := zip.NewWriter(zipFile)
+ fileWriter, err := writer.Create("game.gba")
+ if err != nil {
+ t.Fatalf("create file in zip: %v", err)
+ }
+ if _, err := fileWriter.Write([]byte("test")); err != nil {
+ t.Fatalf("write to zip: %v", err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("close zip writer: %v", err)
+ }
+ if err := zipFile.Close(); err != nil {
+ t.Fatalf("close zip file: %v", err)
+ }
+}
+
+func TestParsePath_ArchiveWithInternalPath(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ zipPath := filepath.Join(tmpDir, "games.zip")
+ createSimpleTestZIP(t, zipPath)
+
+ result, err := archive.ParsePath(zipPath + "/folder/game.gba")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if result == nil {
+ t.Fatal("expected non-nil result")
+ }
+ if result.ArchivePath != zipPath {
+ t.Errorf("ArchivePath = %q, want %q", result.ArchivePath, zipPath)
+ }
+ if result.InternalPath != "folder/game.gba" {
+ t.Errorf("InternalPath = %q, want %q", result.InternalPath, "folder/game.gba")
+ }
+}
+
+func TestParsePath_ArchiveOnly(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ zipPath := filepath.Join(tmpDir, "games.zip")
+ createSimpleTestZIP(t, zipPath)
+
+ result, err := archive.ParsePath(zipPath)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if result == nil {
+ t.Fatal("expected non-nil result")
+ }
+ if result.ArchivePath != zipPath {
+ t.Errorf("ArchivePath = %q, want %q", result.ArchivePath, zipPath)
+ }
+ if result.InternalPath != "" {
+ t.Errorf("InternalPath = %q, want empty", result.InternalPath)
+ }
+}
+
+func TestParsePath_NonArchive(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+
+ result, err := archive.ParsePath(filepath.Join(tmpDir, "regular.txt"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if result != nil {
+ t.Errorf("expected nil, got %+v", result)
+ }
+}
+
+func TestParsePath_NonExistentArchive(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+ // Use string concatenation instead of filepath.Join to include path separator
+ fakePath := tmpDir + "/nonexistent.zip/game.gba"
+
+ result, err := archive.ParsePath(fakePath)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if result != nil {
+ t.Errorf("expected nil, got %+v", result)
+ }
+}
+
+func TestIsArchivePath(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ path string
+ want bool
+ }{
+ {"/games/roms.zip/game.gba", true},
+ {"/games/roms.7z/folder/game.nes", true},
+ {"/games/roms.rar/game.sfc", true},
+ {"/games/roms.zip", true},
+ {"/games/roms.7z", true},
+ {"/games/roms.rar", true},
+ {"/games/game.gba", false},
+ {"/games/roms.tar/game.gba", false},
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ t.Parallel()
+
+ got := archive.IsArchivePath(tt.path)
+ if got != tt.want {
+ t.Errorf("IsArchivePath(%q) = %v, want %v", tt.path, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/archive/rar.go b/archive/rar.go
new file mode 100644
index 0000000..137d47d
--- /dev/null
+++ b/archive/rar.go
@@ -0,0 +1,149 @@
+// Copyright (c) 2025 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 archive
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/nwaples/rardecode/v2"
+)
+
+// RARArchive provides access to files in a RAR archive.
+type RARArchive struct {
+ file *os.File
+ path string
+}
+
+// OpenRAR opens a RAR archive for reading.
+func OpenRAR(path string) (*RARArchive, error) {
+ file, err := os.Open(path) //nolint:gosec // User-provided path is expected
+ if err != nil {
+ return nil, fmt.Errorf("open RAR archive: %w", err)
+ }
+
+ return &RARArchive{
+ file: file,
+ path: path,
+ }, nil
+}
+
+// List returns all files in the RAR archive.
+func (ra *RARArchive) List() ([]FileInfo, error) {
+ // Seek to beginning
+ if _, err := ra.file.Seek(0, io.SeekStart); err != nil {
+ return nil, fmt.Errorf("seek RAR archive: %w", err)
+ }
+
+ reader, err := rardecode.NewReader(ra.file)
+ if err != nil {
+ return nil, fmt.Errorf("create RAR reader: %w", err)
+ }
+
+ var files []FileInfo //nolint:prealloc // RAR file count unknown until full scan
+ for {
+ header, err := reader.Next()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return nil, fmt.Errorf("read RAR header: %w", err)
+ }
+
+ // Skip directories
+ if header.IsDir {
+ continue
+ }
+
+ files = append(files, FileInfo{
+ Name: header.Name,
+ Size: header.UnPackedSize,
+ })
+ }
+
+ return files, nil
+}
+
+// Open opens a file within the RAR archive.
+// Note: RAR archives require sequential reading, so this seeks through the archive.
+func (ra *RARArchive) Open(internalPath string) (io.ReadCloser, int64, error) {
+ // Normalize path separators
+ internalPath = filepath.ToSlash(internalPath)
+
+ // Seek to beginning
+ if _, err := ra.file.Seek(0, io.SeekStart); err != nil {
+ return nil, 0, fmt.Errorf("seek RAR archive: %w", err)
+ }
+
+ reader, err := rardecode.NewReader(ra.file)
+ if err != nil {
+ return nil, 0, fmt.Errorf("create RAR reader: %w", err)
+ }
+
+ for {
+ header, err := reader.Next()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return nil, 0, fmt.Errorf("read RAR header: %w", err)
+ }
+
+ if strings.EqualFold(header.Name, internalPath) {
+ // Wrap the reader since rardecode doesn't provide a closer
+ return &rarFileReader{reader: reader}, header.UnPackedSize, nil
+ }
+ }
+
+ return nil, 0, FileNotFoundError{
+ Archive: ra.path,
+ InternalPath: internalPath,
+ }
+}
+
+// OpenReaderAt opens a file and returns an io.ReaderAt interface.
+// The file contents are buffered in memory.
+//
+//nolint:revive // 4 return values is necessary for this interface pattern
+func (ra *RARArchive) OpenReaderAt(internalPath string) (io.ReaderAt, int64, io.Closer, error) {
+ return bufferFile(ra, internalPath)
+}
+
+// Close closes the RAR archive.
+func (ra *RARArchive) Close() error {
+ return ra.file.Close() //nolint:wrapcheck // Close error passthrough is intentional
+}
+
+// rarFileReader wraps a rardecode reader to provide io.ReadCloser.
+type rarFileReader struct {
+ reader *rardecode.Reader
+}
+
+func (rfr *rarFileReader) Read(p []byte) (int, error) {
+ return rfr.reader.Read(p) //nolint:wrapcheck // Read error passthrough is intentional
+}
+
+func (*rarFileReader) Close() error {
+ // rardecode doesn't have a close method, nothing to do
+ return nil
+}
diff --git a/archive/sevenzip.go b/archive/sevenzip.go
new file mode 100644
index 0000000..9bfea29
--- /dev/null
+++ b/archive/sevenzip.go
@@ -0,0 +1,102 @@
+// Copyright (c) 2025 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 .
+
+//nolint:dupl // Archive implementations are intentionally similar but use different types
+package archive
+
+import (
+ "fmt"
+ "io"
+ "path/filepath"
+ "strings"
+
+ "github.com/bodgit/sevenzip"
+)
+
+// SevenZipArchive provides access to files in a 7z archive.
+type SevenZipArchive struct {
+ reader *sevenzip.ReadCloser
+ path string
+}
+
+// OpenSevenZip opens a 7z archive for reading.
+func OpenSevenZip(path string) (*SevenZipArchive, error) {
+ reader, err := sevenzip.OpenReader(path)
+ if err != nil {
+ return nil, fmt.Errorf("open 7z archive: %w", err)
+ }
+
+ return &SevenZipArchive{
+ reader: reader,
+ path: path,
+ }, nil
+}
+
+// List returns all files in the 7z archive.
+func (sza *SevenZipArchive) List() ([]FileInfo, error) {
+ files := make([]FileInfo, 0, len(sza.reader.File))
+
+ for _, file := range sza.reader.File {
+ // Skip directories
+ if file.FileInfo().IsDir() {
+ continue
+ }
+
+ files = append(files, FileInfo{
+ Name: file.Name,
+ Size: int64(file.UncompressedSize), //nolint:gosec // Safe: file sizes don't exceed int64
+ })
+ }
+
+ return files, nil
+}
+
+// Open opens a file within the 7z archive.
+func (sza *SevenZipArchive) Open(internalPath string) (io.ReadCloser, int64, error) {
+ // Normalize path separators
+ internalPath = filepath.ToSlash(internalPath)
+
+ for _, file := range sza.reader.File {
+ if strings.EqualFold(file.Name, internalPath) {
+ reader, err := file.Open()
+ if err != nil {
+ return nil, 0, fmt.Errorf("open file in 7z: %w", err)
+ }
+ //nolint:gosec // Safe: file sizes don't exceed int64
+ return reader, int64(file.UncompressedSize), nil
+ }
+ }
+
+ return nil, 0, FileNotFoundError{
+ Archive: sza.path,
+ InternalPath: internalPath,
+ }
+}
+
+// OpenReaderAt opens a file and returns an io.ReaderAt interface.
+// The file contents are buffered in memory.
+//
+//nolint:revive // 4 return values is necessary for this interface pattern
+func (sza *SevenZipArchive) OpenReaderAt(internalPath string) (io.ReaderAt, int64, io.Closer, error) {
+ return bufferFile(sza, internalPath)
+}
+
+// Close closes the 7z archive.
+func (sza *SevenZipArchive) Close() error {
+ return sza.reader.Close() //nolint:wrapcheck // Close error passthrough is intentional
+}
diff --git a/archive/zip.go b/archive/zip.go
new file mode 100644
index 0000000..a39e134
--- /dev/null
+++ b/archive/zip.go
@@ -0,0 +1,101 @@
+// Copyright (c) 2025 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 .
+
+//nolint:dupl // Archive implementations are intentionally similar but use different types
+package archive
+
+import (
+ "archive/zip"
+ "fmt"
+ "io"
+ "path/filepath"
+ "strings"
+)
+
+// ZIPArchive provides access to files in a ZIP archive.
+type ZIPArchive struct {
+ reader *zip.ReadCloser
+ path string
+}
+
+// OpenZIP opens a ZIP archive for reading.
+func OpenZIP(path string) (*ZIPArchive, error) {
+ reader, err := zip.OpenReader(path)
+ if err != nil {
+ return nil, fmt.Errorf("open ZIP archive: %w", err)
+ }
+
+ return &ZIPArchive{
+ reader: reader,
+ path: path,
+ }, nil
+}
+
+// List returns all files in the ZIP archive.
+func (za *ZIPArchive) List() ([]FileInfo, error) {
+ files := make([]FileInfo, 0, len(za.reader.File))
+
+ for _, file := range za.reader.File {
+ // Skip directories
+ if file.FileInfo().IsDir() {
+ continue
+ }
+
+ files = append(files, FileInfo{
+ Name: file.Name,
+ Size: int64(file.UncompressedSize64), //nolint:gosec // Safe: file sizes don't exceed int64
+ })
+ }
+
+ return files, nil
+}
+
+// Open opens a file within the ZIP archive.
+func (za *ZIPArchive) Open(internalPath string) (io.ReadCloser, int64, error) {
+ // Normalize path separators
+ internalPath = filepath.ToSlash(internalPath)
+
+ for _, file := range za.reader.File {
+ if strings.EqualFold(file.Name, internalPath) {
+ reader, err := file.Open()
+ if err != nil {
+ return nil, 0, fmt.Errorf("open file in ZIP: %w", err)
+ }
+ //nolint:gosec // Safe: file sizes don't exceed int64
+ return reader, int64(file.UncompressedSize64), nil
+ }
+ }
+
+ return nil, 0, FileNotFoundError{
+ Archive: za.path,
+ InternalPath: internalPath,
+ }
+}
+
+// OpenReaderAt opens a file and returns an io.ReaderAt interface.
+// The file contents are buffered in memory.
+//
+//nolint:revive // 4 return values is necessary for this interface pattern
+func (za *ZIPArchive) OpenReaderAt(internalPath string) (io.ReaderAt, int64, io.Closer, error) {
+ return bufferFile(za, internalPath)
+}
+
+// Close closes the ZIP archive.
+func (za *ZIPArchive) Close() error {
+ return za.reader.Close() //nolint:wrapcheck // Close error passthrough is intentional
+}
diff --git a/console.go b/console.go
index 37d6a96..1e0cb7b 100644
--- a/console.go
+++ b/console.go
@@ -117,6 +117,33 @@ func DetectConsole(path string) (identifier.Console, error) {
return "", identifier.ErrNotSupported{Format: ext}
}
+// DetectConsoleFromExtension detects the console type based purely on file extension.
+// Unlike DetectConsole, this does not read file headers or check file existence.
+// It returns an error for ambiguous extensions (like .bin, .iso) that require header analysis.
+func DetectConsoleFromExtension(path string) (identifier.Console, error) {
+ ext := strings.ToLower(filepath.Ext(path))
+
+ // Strip .gz suffix
+ if ext == ".gz" {
+ path = strings.TrimSuffix(path, ext)
+ ext = strings.ToLower(filepath.Ext(path))
+ }
+
+ // Check for unambiguous extension
+ if console, ok := extToConsole[ext]; ok {
+ return console, nil
+ }
+
+ // Ambiguous extensions cannot be detected without header analysis
+ if ambiguousExts[ext] {
+ return "", identifier.ErrNotSupported{
+ Format: fmt.Sprintf("ambiguous extension %s requires header analysis", ext),
+ }
+ }
+
+ return "", identifier.ErrNotSupported{Format: ext}
+}
+
// detectConsoleFromDirectory detects console from a mounted disc directory
func detectConsoleFromDirectory(path string) (identifier.Console, error) {
// Check for PSP (UMD_DATA.BIN)
diff --git a/console_test.go b/console_test.go
index 06c04f3..150d2cf 100644
--- a/console_test.go
+++ b/console_test.go
@@ -486,3 +486,92 @@ func TestDetectConsoleFromCHD_NonExistent(t *testing.T) {
t.Error("DetectConsole() should fail for non-existent CHD")
}
}
+
+// TestDetectConsoleFromCue_MagicBased verifies CUE detection for consoles with magic headers.
+func TestDetectConsoleFromCue_MagicBased(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ magic string
+ want identifier.Console
+ }{
+ {"Saturn", "SEGA SEGASATURN", identifier.ConsoleSaturn},
+ {"SegaCD", "SEGADISCSYSTEM", identifier.ConsoleSegaCD},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+
+ // Create BIN file with magic
+ binPath := filepath.Join(tmpDir, "game.bin")
+ binData := make([]byte, 0x100)
+ copy(binData, tt.magic)
+ if err := os.WriteFile(binPath, binData, 0o600); err != nil {
+ t.Fatalf("Failed to write BIN file: %v", err)
+ }
+
+ // Create CUE file
+ cuePath := filepath.Join(tmpDir, "game.cue")
+ cueContent := `FILE "game.bin" BINARY
+ TRACK 01 MODE1/2352
+ INDEX 01 00:00:00
+`
+ if err := os.WriteFile(cuePath, []byte(cueContent), 0o600); err != nil {
+ t.Fatalf("Failed to write CUE file: %v", err)
+ }
+
+ console, err := DetectConsole(cuePath)
+ if err != nil {
+ t.Fatalf("DetectConsole() error = %v", err)
+ }
+ if console != tt.want {
+ t.Errorf("DetectConsole() = %v, want %v", console, tt.want)
+ }
+ })
+ }
+}
+
+// TestDetectConsoleFromCue_EmptyCue verifies error for empty CUE.
+func TestDetectConsoleFromCue_EmptyCue(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+
+ // Create CUE file with no BIN files
+ cuePath := filepath.Join(tmpDir, "game.cue")
+ cueContent := "REM Empty CUE\n"
+ if err := os.WriteFile(cuePath, []byte(cueContent), 0o600); err != nil {
+ t.Fatalf("Failed to write CUE file: %v", err)
+ }
+
+ _, err := DetectConsole(cuePath)
+ if err == nil {
+ t.Error("DetectConsole() should fail for empty CUE")
+ }
+}
+
+// TestDetectConsoleFromCue_MissingBin verifies error for CUE with missing BIN.
+func TestDetectConsoleFromCue_MissingBin(t *testing.T) {
+ t.Parallel()
+
+ tmpDir := t.TempDir()
+
+ // Create CUE file referencing missing BIN
+ cuePath := filepath.Join(tmpDir, "game.cue")
+ cueContent := `FILE "nonexistent.bin" BINARY
+ TRACK 01 MODE1/2352
+ INDEX 01 00:00:00
+`
+ if err := os.WriteFile(cuePath, []byte(cueContent), 0o600); err != nil {
+ t.Fatalf("Failed to write CUE file: %v", err)
+ }
+
+ _, err := DetectConsole(cuePath)
+ if err == nil {
+ t.Error("DetectConsole() should fail for CUE with missing BIN")
+ }
+}
diff --git a/gameid.go b/gameid.go
index afbd547..81c5c7f 100644
--- a/gameid.go
+++ b/gameid.go
@@ -26,6 +26,7 @@ import (
"os"
"strings"
+ "github.com/ZaparooProject/go-gameid/archive"
"github.com/ZaparooProject/go-gameid/identifier"
)
@@ -82,7 +83,23 @@ type pathIdentifier interface {
// Identify detects the console type and identifies the game at the given path.
// It returns the identification result or an error if identification fails.
// If db is nil, no database lookup is performed.
+//
+// Archive paths are supported in two forms:
+// - Explicit: /path/to/archive.zip/internal/path/game.gba
+// - Auto-detect: /path/to/archive.zip (finds first game file by extension)
+//
+// Supported archive formats: ZIP, 7z, RAR.
+// Only cartridge-based games (GB, GBC, GBA, NES, SNES, N64, Genesis) are supported in archives.
func Identify(path string, db *GameDatabase) (*Result, error) {
+ // Check if path references an archive
+ archivePath, err := archive.ParsePath(path)
+ if err != nil {
+ return nil, fmt.Errorf("parse archive path: %w", err)
+ }
+ if archivePath != nil {
+ return identifyFromArchive(archivePath, db)
+ }
+
console, err := DetectConsole(path)
if err != nil {
return nil, fmt.Errorf("failed to detect console: %w", err)
@@ -291,3 +308,98 @@ func identifyFromBlockDevice(path string, _ Console, ident identifier.Identifier
}
return result, nil
}
+
+// identifyFromArchive identifies a game file inside an archive.
+func identifyFromArchive(archivePath *archive.Path, db *GameDatabase) (*Result, error) {
+ // Open the archive
+ arc, err := archive.Open(archivePath.ArchivePath)
+ if err != nil {
+ return nil, fmt.Errorf("open archive: %w", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ // Determine internal path (auto-detect if not specified)
+ internalPath := archivePath.InternalPath
+ if internalPath == "" {
+ detected, detectErr := archive.DetectGameFile(arc)
+ if detectErr != nil {
+ return nil, fmt.Errorf("detect game file in archive: %w", detectErr)
+ }
+ internalPath = detected
+ }
+
+ // Detect console from the internal file's extension
+ console, err := DetectConsoleFromExtension(internalPath)
+ if err != nil {
+ return nil, fmt.Errorf("detect console from archive file: %w", err)
+ }
+
+ // Only cartridge-based games are supported in archives
+ if !IsCartridgeBased(console) {
+ return nil, archive.DiscNotSupportedError{Console: string(console)}
+ }
+
+ // Get the identifier for this console
+ id, ok := identifiers[console]
+ if !ok {
+ return nil, identifier.ErrNotSupported{Format: string(console)}
+ }
+
+ // Convert database to interface (nil-safe)
+ var dbInterface identifier.Database
+ if db != nil {
+ dbInterface = db
+ }
+
+ // Open the file as ReaderAt (buffered in memory)
+ reader, size, closer, err := arc.OpenReaderAt(internalPath)
+ if err != nil {
+ return nil, fmt.Errorf("open file in archive: %w", err)
+ }
+ defer func() { _ = closer.Close() }()
+
+ // Identify the game
+ result, err := id.Identify(reader, size, dbInterface)
+ if err != nil {
+ return nil, fmt.Errorf("identify: %w", err)
+ }
+ return result, nil
+}
+
+// IdentifyFromArchive identifies a game from an already-opened archive.
+// This is useful when you need to control archive lifecycle or identify multiple files.
+//
+//nolint:revive // Exported function using internal type is intentional for advanced usage
+func IdentifyFromArchive(
+ arc archive.Archive,
+ internalPath string,
+ console Console,
+ db *GameDatabase,
+) (*Result, error) {
+ // Only cartridge-based games are supported
+ if !IsCartridgeBased(console) {
+ return nil, archive.DiscNotSupportedError{Console: string(console)}
+ }
+
+ id, ok := identifiers[console]
+ if !ok {
+ return nil, identifier.ErrNotSupported{Format: string(console)}
+ }
+
+ var dbInterface identifier.Database
+ if db != nil {
+ dbInterface = db
+ }
+
+ reader, size, closer, err := arc.OpenReaderAt(internalPath)
+ if err != nil {
+ return nil, fmt.Errorf("open file in archive: %w", err)
+ }
+ defer func() { _ = closer.Close() }()
+
+ result, err := id.Identify(reader, size, dbInterface)
+ if err != nil {
+ return nil, fmt.Errorf("identify: %w", err)
+ }
+ return result, nil
+}
diff --git a/gameid_test.go b/gameid_test.go
index 0438308..a38e635 100644
--- a/gameid_test.go
+++ b/gameid_test.go
@@ -23,6 +23,7 @@ import (
"path/filepath"
"testing"
+ "github.com/ZaparooProject/go-gameid/archive"
"github.com/ZaparooProject/go-gameid/identifier"
)
@@ -424,3 +425,231 @@ func TestIdentifyFromDirectory_UnsupportedConsole(t *testing.T) {
t.Error("identifyFromDirectory() should error for unsupported console")
}
}
+
+// TestIdentifyFromArchive_ZIP verifies identification from ZIP archives.
+func TestIdentifyFromArchive_ZIP(t *testing.T) {
+ t.Parallel()
+
+ result, err := Identify("testdata/archive/snes.zip", nil)
+ if err != nil {
+ t.Fatalf("Identify() error = %v", err)
+ }
+
+ if result.Console != identifier.ConsoleSNES {
+ t.Errorf("Console = %v, want %v", result.Console, identifier.ConsoleSNES)
+ }
+
+ if result.InternalTitle != "240P TEST SUITE SNES" {
+ t.Errorf("InternalTitle = %q, want %q", result.InternalTitle, "240P TEST SUITE SNES")
+ }
+}
+
+// TestIdentifyFromArchive_7z verifies identification from 7z archives.
+func TestIdentifyFromArchive_7z(t *testing.T) {
+ t.Parallel()
+
+ result, err := Identify("testdata/archive/snes.7z", nil)
+ if err != nil {
+ t.Fatalf("Identify() error = %v", err)
+ }
+
+ if result.Console != identifier.ConsoleSNES {
+ t.Errorf("Console = %v, want %v", result.Console, identifier.ConsoleSNES)
+ }
+
+ if result.InternalTitle != "240P TEST SUITE SNES" {
+ t.Errorf("InternalTitle = %q, want %q", result.InternalTitle, "240P TEST SUITE SNES")
+ }
+}
+
+// TestIdentifyFromArchive_RAR verifies identification from RAR archives.
+func TestIdentifyFromArchive_RAR(t *testing.T) {
+ t.Parallel()
+
+ result, err := Identify("testdata/archive/snes.rar", nil)
+ if err != nil {
+ t.Fatalf("Identify() error = %v", err)
+ }
+
+ if result.Console != identifier.ConsoleSNES {
+ t.Errorf("Console = %v, want %v", result.Console, identifier.ConsoleSNES)
+ }
+
+ if result.InternalTitle != "240P TEST SUITE SNES" {
+ t.Errorf("InternalTitle = %q, want %q", result.InternalTitle, "240P TEST SUITE SNES")
+ }
+}
+
+// TestIdentifyFromArchive_Genesis verifies identification of Genesis ROMs from archives.
+func TestIdentifyFromArchive_Genesis(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ path string
+ }{
+ {"ZIP", "testdata/archive/genesis.zip"},
+ {"7z", "testdata/archive/genesis.7z"},
+ {"RAR", "testdata/archive/genesis.rar"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ result, err := Identify(tt.path, nil)
+ if err != nil {
+ t.Fatalf("Identify() error = %v", err)
+ }
+
+ if result.Console != identifier.ConsoleGenesis {
+ t.Errorf("Console = %v, want %v", result.Console, identifier.ConsoleGenesis)
+ }
+
+ if result.InternalTitle != "240P TEST SUITE" {
+ t.Errorf("InternalTitle = %q, want %q", result.InternalTitle, "240P TEST SUITE")
+ }
+ })
+ }
+}
+
+// TestIdentifyFromArchive_WithInternalPath verifies MiSTer-style paths work.
+func TestIdentifyFromArchive_WithInternalPath(t *testing.T) {
+ t.Parallel()
+
+ // Test explicit internal path
+ result, err := Identify("testdata/archive/snes.zip/240pSuite.sfc", nil)
+ if err != nil {
+ t.Fatalf("Identify() error = %v", err)
+ }
+
+ if result.Console != identifier.ConsoleSNES {
+ t.Errorf("Console = %v, want %v", result.Console, identifier.ConsoleSNES)
+ }
+}
+
+// TestDetectConsoleFromExtension tests extension-based console detection.
+func TestDetectConsoleFromExtension(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ path string
+ want Console
+ wantErr bool
+ }{
+ // Unambiguous cartridge extensions
+ {"GBA", "game.gba", ConsoleGBA, false},
+ {"GBA uppercase", "game.GBA", ConsoleGBA, false},
+ {"GB", "game.gb", ConsoleGB, false},
+ {"GBC", "game.gbc", ConsoleGBC, false},
+ {"NES", "game.nes", ConsoleNES, false},
+ {"SNES sfc", "game.sfc", ConsoleSNES, false},
+ {"SNES smc", "game.smc", ConsoleSNES, false},
+ {"N64 z64", "game.z64", ConsoleN64, false},
+ {"N64 n64", "game.n64", ConsoleN64, false},
+ {"Genesis gen", "game.gen", ConsoleGenesis, false},
+ {"Genesis md", "game.md", ConsoleGenesis, false},
+
+ // GZ suffix stripping
+ {"GBA with gz", "game.gba.gz", ConsoleGBA, false},
+ {"NES with gz", "game.nes.gz", ConsoleNES, false},
+
+ // Ambiguous extensions should fail
+ {"Ambiguous bin", "game.bin", "", true},
+ {"Ambiguous iso", "game.iso", "", true},
+ {"Ambiguous cue", "game.cue", "", true},
+
+ // Unsupported extensions
+ {"Unsupported xyz", "game.xyz", "", true},
+ {"Unsupported tar", "game.tar", "", true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := DetectConsoleFromExtension(tt.path)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("DetectConsoleFromExtension(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr)
+ return
+ }
+ if !tt.wantErr && got != tt.want {
+ t.Errorf("DetectConsoleFromExtension(%q) = %v, want %v", tt.path, got, tt.want)
+ }
+ })
+ }
+}
+
+// TestIdentifyFromArchive_Direct tests the IdentifyFromArchive function directly.
+func TestIdentifyFromArchive_Direct(t *testing.T) {
+ t.Parallel()
+
+ arc, err := archive.Open("testdata/archive/snes.zip")
+ if err != nil {
+ t.Fatalf("Failed to open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ result, err := IdentifyFromArchive(arc, "240pSuite.sfc", ConsoleSNES, nil)
+ if err != nil {
+ t.Fatalf("IdentifyFromArchive() error = %v", err)
+ }
+
+ if result.Console != identifier.ConsoleSNES {
+ t.Errorf("Console = %v, want %v", result.Console, identifier.ConsoleSNES)
+ }
+
+ if result.InternalTitle != "240P TEST SUITE SNES" {
+ t.Errorf("InternalTitle = %q, want %q", result.InternalTitle, "240P TEST SUITE SNES")
+ }
+}
+
+// TestIdentifyFromArchive_DiscConsole verifies error for disc-based consoles.
+func TestIdentifyFromArchive_DiscConsole(t *testing.T) {
+ t.Parallel()
+
+ arc, err := archive.Open("testdata/archive/snes.zip")
+ if err != nil {
+ t.Fatalf("Failed to open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ // PSX is disc-based, should fail
+ _, err = IdentifyFromArchive(arc, "240pSuite.sfc", ConsolePSX, nil)
+ if err == nil {
+ t.Error("IdentifyFromArchive() should error for disc-based console")
+ }
+}
+
+// TestIdentifyFromArchive_UnsupportedConsole verifies error for unsupported console.
+func TestIdentifyFromArchive_UnsupportedConsole(t *testing.T) {
+ t.Parallel()
+
+ arc, err := archive.Open("testdata/archive/snes.zip")
+ if err != nil {
+ t.Fatalf("Failed to open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ _, err = IdentifyFromArchive(arc, "240pSuite.sfc", "Xbox", nil)
+ if err == nil {
+ t.Error("IdentifyFromArchive() should error for unsupported console")
+ }
+}
+
+// TestIdentifyFromArchive_NonExistentFile verifies error for non-existent file in archive.
+func TestIdentifyFromArchive_NonExistentFile(t *testing.T) {
+ t.Parallel()
+
+ arc, err := archive.Open("testdata/archive/snes.zip")
+ if err != nil {
+ t.Fatalf("Failed to open archive: %v", err)
+ }
+ defer func() { _ = arc.Close() }()
+
+ _, err = IdentifyFromArchive(arc, "nonexistent.sfc", ConsoleSNES, nil)
+ if err == nil {
+ t.Error("IdentifyFromArchive() should error for non-existent file")
+ }
+}
diff --git a/go.mod b/go.mod
index c660295..465a6ad 100644
--- a/go.mod
+++ b/go.mod
@@ -3,12 +3,22 @@ module github.com/ZaparooProject/go-gameid
go 1.24.11
require (
+ github.com/bodgit/sevenzip v1.6.1
github.com/klauspost/compress v1.18.0
github.com/mewkiz/flac v1.0.12
+ github.com/nwaples/rardecode/v2 v2.2.2
github.com/ulikunitz/xz v0.5.15
)
require (
+ github.com/andybalholm/brotli v1.1.1 // indirect
+ github.com/bodgit/plumbing v1.3.0 // indirect
+ github.com/bodgit/windows v1.0.1 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14 // indirect
+ github.com/pierrec/lz4/v4 v4.1.22 // indirect
+ github.com/spf13/afero v1.11.0 // indirect
+ go4.org v0.0.0-20200411211856-f5505b9728dd // indirect
+ golang.org/x/text v0.21.0 // indirect
)
diff --git a/go.sum b/go.sum
index ba23629..a93db95 100644
--- a/go.sum
+++ b/go.sum
@@ -1,30 +1,200 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
+cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
+cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
+cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
+cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
+cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
+cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
+cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
+cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
+cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
+cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
+cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
+github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
+github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
+github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
+github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
+github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
+github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
+github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
+github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
+github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jszwec/csvutil v1.5.1/go.mod h1:Rpu7Uu9giO9subDyMCIQfHVDuLrcaC36UA4YcJjGBkg=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mewkiz/flac v1.0.12 h1:5Y1BRlUebfiVXPmz7hDD7h3ceV2XNrGNMejNVjDpgPY=
github.com/mewkiz/flac v1.0.12/go.mod h1:1UeXlFRJp4ft2mfZnPLRpQTd7cSjb/s17o7JQzzyrCA=
github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14 h1:tnAPMExbRERsyEYkmR1YjhTgDM0iqyiBYf8ojRXxdbA=
github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14/go.mod h1:QYCFBiH5q6XTHEbWhR0uhR3M9qNPoD2CSQzr0g75kE4=
+github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
+github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
+github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
+github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
+github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
+github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
+go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
+go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go4.org v0.0.0-20200411211856-f5505b9728dd h1:BNJlw5kRTzdmyfh5U8F93HA2OwkP7ZGwA51eJ/0wKOU=
+go4.org v0.0.0-20200411211856-f5505b9728dd/go.mod h1:CIiUVy99QCPfoE13bO4EZaz5GZMZXMSBGhxRdsvzbkg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
+golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -33,11 +203,90 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
+google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
+google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
+rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/testdata/archive/genesis.7z b/testdata/archive/genesis.7z
new file mode 100644
index 0000000..5d48dc7
Binary files /dev/null and b/testdata/archive/genesis.7z differ
diff --git a/testdata/archive/genesis.rar b/testdata/archive/genesis.rar
new file mode 100644
index 0000000..f7f33a6
Binary files /dev/null and b/testdata/archive/genesis.rar differ
diff --git a/testdata/archive/genesis.zip b/testdata/archive/genesis.zip
new file mode 100644
index 0000000..a6e5e5b
Binary files /dev/null and b/testdata/archive/genesis.zip differ
diff --git a/testdata/archive/snes.7z b/testdata/archive/snes.7z
new file mode 100644
index 0000000..bdf5ec1
Binary files /dev/null and b/testdata/archive/snes.7z differ
diff --git a/testdata/archive/snes.rar b/testdata/archive/snes.rar
new file mode 100644
index 0000000..c1ba29a
Binary files /dev/null and b/testdata/archive/snes.rar differ
diff --git a/testdata/archive/snes.zip b/testdata/archive/snes.zip
new file mode 100644
index 0000000..7f614df
Binary files /dev/null and b/testdata/archive/snes.zip differ