// 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 }