isosilo/internal/iso/reader.go
2026-03-23 09:15:52 +01:00

190 lines
4.2 KiB
Go

// Package iso provides utilities for reading ISO 9660 image files.
package iso
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/kdomanski/iso9660"
)
// Entry represents a file or directory inside an ISO image.
type Entry struct {
Name string
Path string // Full path within the ISO (forward-slash separated)
IsDir bool
Size int64
ModTime time.Time
}
// Reader wraps an ISO image file.
type Reader struct {
path string
image *iso9660.Image
file *os.File
}
// Open opens an ISO file for reading. Call Close() when done.
func Open(isoPath string) (*Reader, error) {
f, err := os.Open(isoPath)
if err != nil {
return nil, fmt.Errorf("opening iso: %w", err)
}
img, err := iso9660.OpenImage(f)
if err != nil {
f.Close()
return nil, fmt.Errorf("parsing iso: %w", err)
}
return &Reader{path: isoPath, image: img, file: f}, nil
}
// Close releases the underlying file handle.
func (r *Reader) Close() error {
return r.file.Close()
}
// ListDir returns the entries in the given directory path within the ISO.
// Use an empty string or "/" for the root directory.
func (r *Reader) ListDir(dirPath string) ([]Entry, error) {
dirPath = cleanPath(dirPath)
root, err := r.image.RootDir()
if err != nil {
return nil, fmt.Errorf("reading root: %w", err)
}
// Navigate to the target directory.
dir, err := navigate(root, dirPath)
if err != nil {
return nil, err
}
children, err := dir.GetChildren()
if err != nil {
return nil, fmt.Errorf("listing directory: %w", err)
}
entries := make([]Entry, 0, len(children))
for _, child := range children {
name := child.Name()
// Skip the "." and ".." entries that some ISOs include.
if name == "." || name == ".." || name == "" {
continue
}
var entryPath string
if dirPath == "" {
entryPath = name
} else {
entryPath = dirPath + "/" + name
}
entries = append(entries, Entry{
Name: name,
Path: entryPath,
IsDir: child.IsDir(),
Size: child.Size(),
ModTime: child.ModTime(),
})
}
return entries, nil
}
// FileReader opens a file inside the ISO for streaming.
// The caller is responsible for closing the returned ReadCloser.
func (r *Reader) FileReader(filePath string) (io.ReadCloser, int64, error) {
filePath = cleanPath(filePath)
if filePath == "" {
return nil, 0, fmt.Errorf("no file path specified")
}
root, err := r.image.RootDir()
if err != nil {
return nil, 0, fmt.Errorf("reading root: %w", err)
}
// Split path into directory + filename.
dir := filepath.Dir(filePath)
name := filepath.Base(filePath)
var parent *iso9660.File
if dir == "." || dir == "/" {
parent = root
} else {
parent, err = navigate(root, cleanPath(dir))
if err != nil {
return nil, 0, err
}
}
children, err := parent.GetChildren()
if err != nil {
return nil, 0, fmt.Errorf("listing parent dir: %w", err)
}
for _, child := range children {
if strings.EqualFold(child.Name(), name) {
if child.IsDir() {
return nil, 0, fmt.Errorf("%q is a directory", filePath)
}
r := child.Reader()
if r == nil {
return nil, 0, fmt.Errorf("could not open reader for %q", filePath)
}
return io.NopCloser(r), child.Size(), nil
}
}
return nil, 0, fmt.Errorf("file not found: %q", filePath)
}
// navigate traverses the directory tree inside an ISO to find the
// directory at cleanedPath (e.g. "foo/bar/baz").
func navigate(root *iso9660.File, cleanedPath string) (*iso9660.File, error) {
if cleanedPath == "" {
return root, nil
}
parts := strings.Split(cleanedPath, "/")
current := root
for _, part := range parts {
if part == "" {
continue
}
children, err := current.GetChildren()
if err != nil {
return nil, fmt.Errorf("listing dir: %w", err)
}
found := false
for _, child := range children {
if strings.EqualFold(child.Name(), part) {
if !child.IsDir() {
return nil, fmt.Errorf("%q is not a directory", part)
}
current = child
found = true
break
}
}
if !found {
return nil, fmt.Errorf("directory not found: %q", part)
}
}
return current, nil
}
// cleanPath normalises a path: strips leading slash and cleans traversal.
func cleanPath(p string) string {
p = filepath.ToSlash(filepath.Clean("/" + p))
p = strings.TrimPrefix(p, "/")
return p
}