Scaffold Java 21 multi-module project with UMD ISO + SFO parsing
- Gradle Kotlin DSL multi-module (core, ui, cli) with JPMS modules - UMD ISO 9660 reader (UmdIsoReader/UmdIsoFile + sector devices) - SFO (param.sfo) parser - 'dump' CLI command: metadata + recursive file listing - JUnit 5 + AssertJ tests (SFO and ISO reader) - Switch license from MIT to GPL-3.0-or-later
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package dev.quoije.umd.iso;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** The contents of an ISO 9660 directory, parsed from a run of directory records. */
|
||||
public class Iso9660Directory {
|
||||
|
||||
private final List<Iso9660File> files = new ArrayList<>();
|
||||
|
||||
public Iso9660Directory(UmdIsoReader reader, int directorySector, long directorySize) throws IOException {
|
||||
try (UmdIsoFile data = new UmdIsoFile(reader, directorySector, directorySize, null)) {
|
||||
long remaining = directorySize;
|
||||
while (remaining >= 1) {
|
||||
int entryLength = data.read();
|
||||
if (entryLength < 0) {
|
||||
break;
|
||||
}
|
||||
if (entryLength == 0) {
|
||||
// Padding byte.
|
||||
remaining--;
|
||||
continue;
|
||||
}
|
||||
remaining -= entryLength;
|
||||
|
||||
byte[] record = new byte[entryLength - 1];
|
||||
int read = data.read(record, 0, record.length);
|
||||
if (read <= 0) {
|
||||
break;
|
||||
}
|
||||
files.add(new Iso9660File(record, read, reader.hasJolietExtension()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Iso9660File getEntryByIndex(int index) {
|
||||
return files.get(index);
|
||||
}
|
||||
|
||||
public int getFileIndex(String fileName) throws IOException {
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
if (files.get(i).getName().equalsIgnoreCase(fileName)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
throw new IOException("File '" + fileName + "' not found in directory");
|
||||
}
|
||||
|
||||
public String[] getFileList() {
|
||||
String[] list = new String[files.size()];
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
list[i] = files.get(i).getName();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package dev.quoije.umd.iso;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.TimeZone;
|
||||
|
||||
/**
|
||||
* A single ISO 9660 directory entry (a file or a directory).
|
||||
*
|
||||
* <p>The {@code data} array starts at the "extended attribute record length" byte of
|
||||
* the directory record: the leading length byte has already been consumed by the
|
||||
* caller.</p>
|
||||
*/
|
||||
public final class Iso9660File {
|
||||
|
||||
private static final Charset JOLIET_CHARSET = Charset.forName("UTF-16BE");
|
||||
|
||||
private final int lba;
|
||||
private final long size;
|
||||
private final int properties;
|
||||
private final String name;
|
||||
private final Date timestamp;
|
||||
|
||||
public Iso9660File(byte[] data, int length, boolean jolietExtension) throws IOException {
|
||||
lba = ubyte(data[1]) | (ubyte(data[2]) << 8) | (ubyte(data[3]) << 16) | (data[4] << 24);
|
||||
size = ubyte(data[9])
|
||||
| ((long) ubyte(data[10]) << 8)
|
||||
| ((long) ubyte(data[11]) << 16)
|
||||
| ((long) ubyte(data[12]) << 24);
|
||||
|
||||
int year = ubyte(data[17]);
|
||||
int month = ubyte(data[18]);
|
||||
int day = ubyte(data[19]);
|
||||
int hour = ubyte(data[20]);
|
||||
int minute = ubyte(data[21]);
|
||||
int second = ubyte(data[22]);
|
||||
int gmtOffset = data[23]; // signed, in 15-minute intervals
|
||||
|
||||
int gmtOffsetHours = gmtOffset / 4;
|
||||
int gmtOffsetMinutes = (gmtOffset % 4) * 15;
|
||||
String timeZoneName = "GMT";
|
||||
if (gmtOffset >= 0) {
|
||||
timeZoneName += "+";
|
||||
}
|
||||
timeZoneName += gmtOffsetHours;
|
||||
if (gmtOffsetMinutes > 0) {
|
||||
if (gmtOffsetMinutes < 10) {
|
||||
timeZoneName += "0";
|
||||
}
|
||||
timeZoneName += gmtOffsetMinutes;
|
||||
}
|
||||
|
||||
TimeZone timeZone = TimeZone.getTimeZone(timeZoneName);
|
||||
Calendar calendar = Calendar.getInstance(timeZone);
|
||||
calendar.clear();
|
||||
calendar.set(1900 + year, month - 1, day, hour, minute, second);
|
||||
timestamp = calendar.getTime();
|
||||
|
||||
properties = ubyte(data[24]);
|
||||
|
||||
if (lba < 0 || size < 0) {
|
||||
throw new IOException("Invalid ISO entry: lba or size < 0");
|
||||
}
|
||||
|
||||
int nameLength = ubyte(data[31]);
|
||||
if (jolietExtension) {
|
||||
if (nameLength == 1) {
|
||||
char c = (char) data[32];
|
||||
if (c == 0) {
|
||||
c = '.';
|
||||
}
|
||||
name = Character.toString(c);
|
||||
} else {
|
||||
String raw = new String(data, 32, nameLength, JOLIET_CHARSET);
|
||||
int version = raw.indexOf(';');
|
||||
name = version >= 0 ? raw.substring(0, version) : raw;
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < nameLength; i++) {
|
||||
if (data[32 + i] == 0) {
|
||||
data[32 + i] = (byte) '.';
|
||||
}
|
||||
}
|
||||
name = new String(data, 32, nameLength, StandardCharsets.US_ASCII);
|
||||
}
|
||||
}
|
||||
|
||||
private static int ubyte(byte b) {
|
||||
return b & 0xFF;
|
||||
}
|
||||
|
||||
public int getLba() {
|
||||
return lba;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public int getProperties() {
|
||||
return properties;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Date getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public boolean isDirectory() {
|
||||
return (properties & 0x02) != 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package dev.quoije.umd.iso;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* The root directory of the ISO 9660 filesystem, resolved from the primary or
|
||||
* Joliet volume descriptor.
|
||||
*/
|
||||
public final class Iso9660Handler extends Iso9660Directory {
|
||||
|
||||
private final Iso9660Directory internalDir;
|
||||
|
||||
public Iso9660Handler(UmdIsoReader reader) throws IOException {
|
||||
super(reader, 0, 0);
|
||||
|
||||
int sector = reader.hasJolietExtension()
|
||||
? UmdIsoReader.START_SECTOR_JOLIET
|
||||
: UmdIsoReader.START_SECTOR;
|
||||
|
||||
byte[] volumeDescriptor = reader.readSector(sector);
|
||||
|
||||
// The root directory record begins at byte 156 of the volume descriptor.
|
||||
// Skip its leading length byte (157) and read the record body.
|
||||
byte[] record = new byte[38];
|
||||
System.arraycopy(volumeDescriptor, 157, record, 0, record.length);
|
||||
|
||||
Iso9660File rootEntry = new Iso9660File(record, record.length, reader.hasJolietExtension());
|
||||
internalDir = new Iso9660Directory(reader, rootEntry.getLba(), rootEntry.getSize());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iso9660File getEntryByIndex(int index) {
|
||||
return internalDir.getEntryByIndex(index);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getFileIndex(String fileName) throws IOException {
|
||||
return internalDir.getFileIndex(fileName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getFileList() {
|
||||
return internalDir.getFileList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package dev.quoije.umd.iso;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
|
||||
/** A {@link SectorDevice} backed directly by a raw ISO image file. */
|
||||
public final class RawIsoSectorDevice implements SectorDevice {
|
||||
|
||||
private final RandomAccessFile file;
|
||||
private final int numSectors;
|
||||
|
||||
public RawIsoSectorDevice(RandomAccessFile file) throws IOException {
|
||||
this.file = file;
|
||||
this.numSectors = (int) (file.length() / SECTOR_LENGTH);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getNumSectors() {
|
||||
return numSectors;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readSector(int sectorNumber, byte[] buffer, int offset) throws IOException {
|
||||
if (sectorNumber < 0 || sectorNumber >= numSectors) {
|
||||
throw new IOException("Sector " + sectorNumber + " out of range (numSectors=" + numSectors + ")");
|
||||
}
|
||||
file.seek((long) sectorNumber * SECTOR_LENGTH);
|
||||
file.readFully(buffer, offset, SECTOR_LENGTH);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int readSectors(int sectorNumber, int numberSectors, byte[] buffer, int offset) throws IOException {
|
||||
if (numberSectors == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (sectorNumber < 0 || sectorNumber + numberSectors > numSectors) {
|
||||
throw new IOException("Sector range " + sectorNumber + "+" + numberSectors
|
||||
+ " out of range (numSectors=" + numSectors + ")");
|
||||
}
|
||||
file.seek((long) sectorNumber * SECTOR_LENGTH);
|
||||
file.readFully(buffer, offset, numberSectors * SECTOR_LENGTH);
|
||||
return numberSectors;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package dev.quoije.umd.iso;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** A sector-oriented, read-only storage device. A sector is 2048 bytes. */
|
||||
public interface SectorDevice {
|
||||
|
||||
int SECTOR_LENGTH = 2048;
|
||||
|
||||
int getNumSectors() throws IOException;
|
||||
|
||||
void readSector(int sectorNumber, byte[] buffer, int offset) throws IOException;
|
||||
|
||||
int readSectors(int sectorNumber, int numberSectors, byte[] buffer, int offset) throws IOException;
|
||||
|
||||
void close() throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package dev.quoije.umd.iso;
|
||||
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
|
||||
/** A file within a UMD image, presented as a seekable, sector-backed stream. */
|
||||
public final class UmdIsoFile implements AutoCloseable {
|
||||
|
||||
private final UmdIsoReader reader;
|
||||
private final int startSector;
|
||||
private final long maxOffset;
|
||||
private final String name;
|
||||
|
||||
private long currentOffset;
|
||||
private int currentSectorNumber;
|
||||
private int sectorOffset;
|
||||
private byte[] currentSector;
|
||||
|
||||
UmdIsoFile(UmdIsoReader reader, int startSector, long lengthInBytes, String name) throws IOException {
|
||||
this.reader = reader;
|
||||
this.startSector = startSector;
|
||||
this.currentSectorNumber = startSector;
|
||||
this.currentOffset = 0;
|
||||
this.name = name;
|
||||
this.sectorOffset = 0;
|
||||
this.maxOffset = computeLength(lengthInBytes);
|
||||
this.currentSector = maxOffset == 0 ? null : reader.readSector(startSector);
|
||||
}
|
||||
|
||||
private long computeLength(long lengthInBytes) {
|
||||
if (lengthInBytes <= 0) {
|
||||
return 0;
|
||||
}
|
||||
int endSector = startSector
|
||||
+ (int) ((lengthInBytes + SectorDevice.SECTOR_LENGTH - 1) / SectorDevice.SECTOR_LENGTH)
|
||||
- 1;
|
||||
if (endSector >= reader.getNumSectors()) {
|
||||
endSector = reader.getNumSectors() - 1;
|
||||
return (long) (endSector - startSector + 1) * SectorDevice.SECTOR_LENGTH;
|
||||
}
|
||||
return lengthInBytes;
|
||||
}
|
||||
|
||||
public int read() throws IOException {
|
||||
if (currentOffset >= maxOffset) {
|
||||
throw new EOFException();
|
||||
}
|
||||
checkSectorAvailable();
|
||||
currentOffset++;
|
||||
return currentSector[sectorOffset++] & 0xFF;
|
||||
}
|
||||
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (b == null) {
|
||||
throw new NullPointerException();
|
||||
}
|
||||
if (off < 0 || len < 0 || len > b.length - off) {
|
||||
throw new IndexOutOfBoundsException();
|
||||
}
|
||||
if (len > (maxOffset - currentOffset)) {
|
||||
len = (int) (maxOffset - currentOffset);
|
||||
}
|
||||
|
||||
int total = 0;
|
||||
|
||||
int first = readInternal(b, off, Math.min(len, SectorDevice.SECTOR_LENGTH - sectorOffset));
|
||||
off += first;
|
||||
len -= first;
|
||||
total += first;
|
||||
|
||||
if (len >= SectorDevice.SECTOR_LENGTH) {
|
||||
int n = len / SectorDevice.SECTOR_LENGTH;
|
||||
reader.readSectors(currentSectorNumber + 1, n, b, off);
|
||||
currentSectorNumber += n;
|
||||
sectorOffset = SectorDevice.SECTOR_LENGTH;
|
||||
int bytes = n * SectorDevice.SECTOR_LENGTH;
|
||||
currentOffset += bytes;
|
||||
checkSectorAvailable();
|
||||
off += bytes;
|
||||
len -= bytes;
|
||||
total += bytes;
|
||||
}
|
||||
|
||||
if (len > 0) {
|
||||
checkSectorAvailable();
|
||||
total += readInternal(b, off, len);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
public void readFully(byte[] b) throws IOException {
|
||||
readFully(b, 0, b.length);
|
||||
}
|
||||
|
||||
public void readFully(byte[] b, int off, int len) throws IOException {
|
||||
while (len > 0) {
|
||||
if (currentOffset >= maxOffset) {
|
||||
throw new EOFException();
|
||||
}
|
||||
int n = read(b, off, len);
|
||||
if (n <= 0) {
|
||||
throw new EOFException();
|
||||
}
|
||||
off += n;
|
||||
len -= n;
|
||||
}
|
||||
}
|
||||
|
||||
public void seek(long offset) throws IOException {
|
||||
if (offset < 0) {
|
||||
throw new IOException("Negative seek offset: " + offset);
|
||||
}
|
||||
int newSectorNumber = startSector + (int) (offset / SectorDevice.SECTOR_LENGTH);
|
||||
if (newSectorNumber != currentSectorNumber) {
|
||||
currentSector = reader.readSector(newSectorNumber);
|
||||
}
|
||||
currentOffset = offset;
|
||||
currentSectorNumber = newSectorNumber;
|
||||
sectorOffset = (int) (offset % SectorDevice.SECTOR_LENGTH);
|
||||
}
|
||||
|
||||
public long skip(long n) throws IOException {
|
||||
if (n <= 0) {
|
||||
return 0;
|
||||
}
|
||||
long oldOffset = currentOffset;
|
||||
seek(Math.min(maxOffset, currentOffset + n));
|
||||
return currentOffset - oldOffset;
|
||||
}
|
||||
|
||||
public long length() {
|
||||
return maxOffset;
|
||||
}
|
||||
|
||||
public long getFilePointer() {
|
||||
return currentOffset;
|
||||
}
|
||||
|
||||
public int getStartSector() {
|
||||
return startSector;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
private int readInternal(byte[] b, int off, int len) {
|
||||
if (len > 0) {
|
||||
System.arraycopy(currentSector, sectorOffset, b, off, len);
|
||||
sectorOffset += len;
|
||||
currentOffset += len;
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
private void checkSectorAvailable() throws IOException {
|
||||
if (sectorOffset == SectorDevice.SECTOR_LENGTH && currentOffset < maxOffset) {
|
||||
currentSectorNumber++;
|
||||
currentSector = reader.readSector(currentSectorNumber);
|
||||
sectorOffset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// The underlying image is owned by the reader; nothing to release here.
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "UmdIsoFile(name=" + name + ", length=" + maxOffset + ", startSector=" + startSector + ")";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package dev.quoije.umd.iso;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Reader for UMD disc images (ISO 9660 filesystems). */
|
||||
public final class UmdIsoReader implements AutoCloseable {
|
||||
|
||||
public static final int START_SECTOR = 16;
|
||||
public static final int START_SECTOR_JOLIET = 17;
|
||||
|
||||
private static final System.Logger LOG = System.getLogger(UmdIsoReader.class.getName());
|
||||
|
||||
private final SectorDevice sectorDevice;
|
||||
private final int numSectors;
|
||||
private boolean hasJolietExtension;
|
||||
|
||||
private final Map<String, Iso9660File> fileCache = new HashMap<>();
|
||||
private final Map<String, Iso9660Directory> dirCache = new HashMap<>();
|
||||
|
||||
public UmdIsoReader(Path path) throws IOException {
|
||||
RandomAccessFile file = new RandomAccessFile(path.toFile(), "r");
|
||||
this.sectorDevice = new RawIsoSectorDevice(file);
|
||||
this.numSectors = sectorDevice.getNumSectors();
|
||||
if (!hasIsoHeader()) {
|
||||
sectorDevice.close();
|
||||
throw new IOException("Unsupported or corrupted disc image: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasJolietExtension() {
|
||||
return hasJolietExtension;
|
||||
}
|
||||
|
||||
public int getNumSectors() {
|
||||
return numSectors;
|
||||
}
|
||||
|
||||
public byte[] readSector(int sectorNumber) throws IOException {
|
||||
byte[] buffer = new byte[SectorDevice.SECTOR_LENGTH];
|
||||
readSector(sectorNumber, buffer, 0);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public void readSector(int sectorNumber, byte[] buffer, int offset) throws IOException {
|
||||
if (sectorNumber < 0 || sectorNumber >= numSectors) {
|
||||
Arrays.fill(buffer, offset, offset + SectorDevice.SECTOR_LENGTH, (byte) 0);
|
||||
LOG.log(System.Logger.Level.WARNING, "Sector {0} out of range ({1} sectors)",
|
||||
sectorNumber, numSectors);
|
||||
return;
|
||||
}
|
||||
sectorDevice.readSector(sectorNumber, buffer, offset);
|
||||
}
|
||||
|
||||
public int readSectors(int sectorNumber, int numberSectors, byte[] buffer, int offset) throws IOException {
|
||||
if (sectorNumber < 0 || sectorNumber + numberSectors > numSectors) {
|
||||
Arrays.fill(buffer, offset, offset + numberSectors * SectorDevice.SECTOR_LENGTH, (byte) 0);
|
||||
LOG.log(System.Logger.Level.WARNING, "Sectors {0}..{1} out of range ({2} sectors)",
|
||||
sectorNumber, sectorNumber + numberSectors, numSectors);
|
||||
return numberSectors;
|
||||
}
|
||||
return sectorDevice.readSectors(sectorNumber, numberSectors, buffer, offset);
|
||||
}
|
||||
|
||||
private boolean hasIsoHeader() throws IOException {
|
||||
if (numSectors <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] sector = readSector(START_SECTOR);
|
||||
if (sector[1] != 'C' || sector[2] != 'D' || sector[3] != '0' || sector[4] != '0' || sector[5] != '1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
hasJolietExtension = false;
|
||||
if (numSectors > START_SECTOR_JOLIET) {
|
||||
sector = readSector(START_SECTOR_JOLIET);
|
||||
if (sector[0] == 2
|
||||
&& sector[1] == 'C' && sector[2] == 'D'
|
||||
&& sector[3] == '0' && sector[4] == '0' && sector[5] == '1') {
|
||||
hasJolietExtension = true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public UmdIsoFile getFile(String filePath) throws IOException {
|
||||
if (numSectors == 0) {
|
||||
throw new IOException(filePath + ": empty image");
|
||||
}
|
||||
|
||||
int fileStart;
|
||||
long fileLength;
|
||||
String fileName = null;
|
||||
|
||||
if (filePath.isEmpty()) {
|
||||
fileStart = 0;
|
||||
fileLength = (long) numSectors * SectorDevice.SECTOR_LENGTH;
|
||||
} else {
|
||||
Iso9660File info = getFileEntry(filePath);
|
||||
if (info == null || info.isDirectory()) {
|
||||
throw new IOException("File '" + filePath + "' not found or is not a file");
|
||||
}
|
||||
fileStart = info.getLba();
|
||||
fileLength = info.getSize();
|
||||
fileName = info.getName();
|
||||
}
|
||||
|
||||
return new UmdIsoFile(this, fileStart, fileLength, fileName);
|
||||
}
|
||||
|
||||
public boolean hasFile(String filePath) {
|
||||
try (UmdIsoFile file = getFile(filePath)) {
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public String[] listDirectory(String filePath) throws IOException {
|
||||
Iso9660Directory dir;
|
||||
if (filePath.isEmpty()) {
|
||||
dir = new Iso9660Handler(this);
|
||||
} else {
|
||||
Iso9660File info = getFileEntry(filePath);
|
||||
if (info == null || !info.isDirectory()) {
|
||||
throw new IOException("'" + filePath + "' not found or is not a directory");
|
||||
}
|
||||
dir = new Iso9660Directory(this, info.getLba(), info.getSize());
|
||||
}
|
||||
return dir.getFileList();
|
||||
}
|
||||
|
||||
public boolean isDirectory(String filePath) throws IOException {
|
||||
if (filePath.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
Iso9660File info = getFileEntry(filePath);
|
||||
return info != null && info.isDirectory();
|
||||
}
|
||||
|
||||
private Iso9660File getFileEntry(String filePath) throws IOException {
|
||||
Iso9660File info = fileCache.get(filePath);
|
||||
if (info != null) {
|
||||
return info;
|
||||
}
|
||||
|
||||
int parentIndex = filePath.lastIndexOf('/');
|
||||
if (parentIndex >= 0) {
|
||||
String parentDirectory = filePath.substring(0, parentIndex);
|
||||
Iso9660Directory dir = dirCache.get(parentDirectory);
|
||||
if (dir != null) {
|
||||
int index = dir.getFileIndex(filePath.substring(parentIndex + 1));
|
||||
info = dir.getEntryByIndex(index);
|
||||
if (info != null) {
|
||||
fileCache.put(filePath, info);
|
||||
return info;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Iso9660Directory dir = new Iso9660Handler(this);
|
||||
|
||||
String[] path = filePath.split("[\\\\/]");
|
||||
int pathLength = path.length;
|
||||
for (int i = 0; i < pathLength; ) {
|
||||
if (path[i].equals(".")) {
|
||||
pathLength = removePath(path, i, pathLength);
|
||||
} else if (path[i].equals("..")) {
|
||||
pathLength = removePath(path, i, pathLength);
|
||||
pathLength = removePath(path, i - 1, pathLength);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < pathLength; i++) {
|
||||
int index = dir.getFileIndex(path[i]);
|
||||
info = dir.getEntryByIndex(index);
|
||||
if (info != null && info.isDirectory()) {
|
||||
dir = new Iso9660Directory(this, info.getLba(), info.getSize());
|
||||
StringBuilder dirPath = new StringBuilder(path[0]);
|
||||
for (int j = 1; j <= i; j++) {
|
||||
dirPath.append('/').append(path[j]);
|
||||
}
|
||||
dirCache.put(dirPath.toString(), dir);
|
||||
}
|
||||
}
|
||||
|
||||
if (info != null) {
|
||||
fileCache.put(filePath, info);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
private static int removePath(String[] path, int index, int length) {
|
||||
if (index < 0 || index >= length) {
|
||||
return length;
|
||||
}
|
||||
for (int i = index + 1; i < length; i++) {
|
||||
path[i - 1] = path[i];
|
||||
}
|
||||
return length - 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
sectorDevice.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package dev.quoije.umd.sfo;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Parser for the PSP "SFO" metadata format (for example {@code UMD_VIDEO/param.sfo}).
|
||||
*/
|
||||
public final class Sfo {
|
||||
|
||||
public static final int FORMAT_UTF8 = 0x0204;
|
||||
public static final int FORMAT_INT32 = 0x0404;
|
||||
|
||||
private final Map<String, Entry> entries = new LinkedHashMap<>();
|
||||
|
||||
/** A single SFO key/value entry. */
|
||||
public record Entry(int format, byte[] data) {
|
||||
public String asString() {
|
||||
int end = data.length;
|
||||
while (end > 0 && data[end - 1] == 0) {
|
||||
end--;
|
||||
}
|
||||
return new String(data, 0, end, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public int asInt() {
|
||||
return (data[0] & 0xFF)
|
||||
| ((data[1] & 0xFF) << 8)
|
||||
| ((data[2] & 0xFF) << 16)
|
||||
| ((data[3] & 0xFF) << 24);
|
||||
}
|
||||
}
|
||||
|
||||
public static Sfo parse(byte[] buf) throws IOException {
|
||||
if (buf.length < 20) {
|
||||
throw new IOException("SFO data too short");
|
||||
}
|
||||
if (buf[0] != 0 || buf[1] != 'P' || buf[2] != 'S' || buf[3] != 'F') {
|
||||
throw new IOException("Invalid SFO magic");
|
||||
}
|
||||
|
||||
int keyTableOffset = le32(buf, 8);
|
||||
int dataTableOffset = le32(buf, 12);
|
||||
int numEntries = le32(buf, 16);
|
||||
|
||||
Sfo sfo = new Sfo();
|
||||
for (int i = 0; i < numEntries; i++) {
|
||||
int off = 20 + i * 16;
|
||||
if (off + 16 > buf.length) {
|
||||
throw new IOException("SFO entry table truncated");
|
||||
}
|
||||
|
||||
int keyOffset = le16(buf, off);
|
||||
int format = le16(buf, off + 2);
|
||||
int dataLength = le32(buf, off + 4);
|
||||
int dataUsed = le32(buf, off + 8);
|
||||
int dataOffset = le32(buf, off + 12);
|
||||
|
||||
String key = readNullTerminated(buf, keyTableOffset + keyOffset);
|
||||
|
||||
int used = dataUsed;
|
||||
if (used <= 0 || used > dataLength) {
|
||||
used = dataLength;
|
||||
}
|
||||
if (dataTableOffset + dataOffset + used > buf.length) {
|
||||
throw new IOException("SFO data for '" + key + "' truncated");
|
||||
}
|
||||
|
||||
byte[] data = new byte[used];
|
||||
System.arraycopy(buf, dataTableOffset + dataOffset, data, 0, used);
|
||||
sfo.entries.put(key, new Entry(format, data));
|
||||
}
|
||||
return sfo;
|
||||
}
|
||||
|
||||
public Entry get(String key) {
|
||||
return entries.get(key);
|
||||
}
|
||||
|
||||
public String getString(String key) {
|
||||
Entry entry = entries.get(key);
|
||||
return entry == null ? null : entry.asString();
|
||||
}
|
||||
|
||||
public Integer getInt(String key) {
|
||||
Entry entry = entries.get(key);
|
||||
return entry == null ? null : entry.asInt();
|
||||
}
|
||||
|
||||
public Map<String, Entry> entries() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static String readNullTerminated(byte[] buf, int offset) throws IOException {
|
||||
int end = offset;
|
||||
while (end < buf.length && buf[end] != 0) {
|
||||
end++;
|
||||
}
|
||||
if (end == buf.length) {
|
||||
throw new IOException("Unterminated SFO key");
|
||||
}
|
||||
return new String(buf, offset, end - offset, StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static int le16(byte[] b, int o) {
|
||||
return (b[o] & 0xFF) | ((b[o + 1] & 0xFF) << 8);
|
||||
}
|
||||
|
||||
private static int le32(byte[] b, int o) {
|
||||
return (b[o] & 0xFF)
|
||||
| ((b[o + 1] & 0xFF) << 8)
|
||||
| ((b[o + 2] & 0xFF) << 16)
|
||||
| ((b[o + 3] & 0xFF) << 24);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
module dev.quoije.umd.core {
|
||||
exports dev.quoije.umd.iso;
|
||||
exports dev.quoije.umd.sfo;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package dev.quoije.umd.iso;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class UmdIsoReaderTest {
|
||||
|
||||
private static final int SECTOR = 2048;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void listsAndReadsFiles() throws Exception {
|
||||
Path isoPath = tempDir.resolve("test.iso");
|
||||
Files.write(isoPath, buildImage());
|
||||
|
||||
try (UmdIsoReader iso = new UmdIsoReader(isoPath)) {
|
||||
assertThat(iso.hasJolietExtension()).isFalse();
|
||||
assertThat(iso.listDirectory("")).containsExactlyInAnyOrder(".", "..", "HELLO.TXT");
|
||||
assertThat(iso.isDirectory("")).isTrue();
|
||||
|
||||
try (UmdIsoFile file = iso.getFile("HELLO.TXT")) {
|
||||
assertThat(file.length()).isEqualTo(6);
|
||||
byte[] data = new byte[6];
|
||||
file.readFully(data);
|
||||
assertThat(new String(data, StandardCharsets.US_ASCII)).isEqualTo("hello\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingFile() throws Exception {
|
||||
Path isoPath = tempDir.resolve("test.iso");
|
||||
Files.write(isoPath, buildImage());
|
||||
|
||||
try (UmdIsoReader iso = new UmdIsoReader(isoPath)) {
|
||||
assertThatThrownBy(() -> iso.getFile("NOPE.TXT")).isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] buildImage() {
|
||||
int numSectors = 32;
|
||||
byte[] image = new byte[numSectors * SECTOR];
|
||||
|
||||
int rootLba = 20;
|
||||
int fileLba = 21;
|
||||
|
||||
// Primary volume descriptor at sector 16.
|
||||
int pvd = 16 * SECTOR;
|
||||
image[pvd] = 1;
|
||||
image[pvd + 1] = 'C';
|
||||
image[pvd + 2] = 'D';
|
||||
image[pvd + 3] = '0';
|
||||
image[pvd + 4] = '0';
|
||||
image[pvd + 5] = '1';
|
||||
image[pvd + 6] = 1;
|
||||
System.arraycopy(dirRecord(rootLba, 2048, true, "."), 0, image, pvd + 156, 34);
|
||||
|
||||
// Root directory at sector 20: ".", "..", "HELLO.TXT".
|
||||
int dir = rootLba * SECTOR;
|
||||
int off = dir;
|
||||
off += writeDirRecord(image, off, rootLba, 2048, true, ".");
|
||||
off += writeDirRecord(image, off, rootLba, 2048, true, "..");
|
||||
off += writeDirRecord(image, off, fileLba, 6, false, "HELLO.TXT");
|
||||
|
||||
// File content at sector 21.
|
||||
byte[] content = "hello\n".getBytes(StandardCharsets.US_ASCII);
|
||||
System.arraycopy(content, 0, image, fileLba * SECTOR, content.length);
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
private static int writeDirRecord(byte[] image, int offset, int lba, int size, boolean directory, String name) {
|
||||
byte[] record = dirRecord(lba, size, directory, name);
|
||||
System.arraycopy(record, 0, image, offset, record.length);
|
||||
return record.length;
|
||||
}
|
||||
|
||||
private static byte[] dirRecord(int lba, int size, boolean directory, String name) {
|
||||
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||
int nameLength = nameBytes.length;
|
||||
int recordLength = 33 + nameLength + (nameLength % 2 == 0 ? 1 : 0);
|
||||
|
||||
byte[] r = new byte[recordLength];
|
||||
r[0] = (byte) recordLength;
|
||||
r[1] = 0; // extended attribute record length
|
||||
le32(r, 2, lba);
|
||||
be32(r, 6, lba);
|
||||
le32(r, 10, size);
|
||||
be32(r, 14, size);
|
||||
// Recording date/time: year (since 1900), month, day, hour, min, sec, gmt.
|
||||
r[18] = 126;
|
||||
r[19] = 9;
|
||||
r[20] = 15;
|
||||
r[21] = 0;
|
||||
r[22] = 0;
|
||||
r[23] = 0;
|
||||
r[24] = 0;
|
||||
r[25] = (byte) (directory ? 0x02 : 0x00); // file flags
|
||||
r[26] = 0; // file unit size
|
||||
r[27] = 0; // interleave gap size
|
||||
le16(r, 28, 1); // volume sequence number (little)
|
||||
be16(r, 30, 1); // volume sequence number (big)
|
||||
r[32] = (byte) nameLength;
|
||||
System.arraycopy(nameBytes, 0, r, 33, nameLength);
|
||||
if (nameLength % 2 == 0) {
|
||||
r[33 + nameLength] = 0; // padding
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
private static void le16(byte[] b, int o, int v) {
|
||||
b[o] = (byte) (v & 0xFF);
|
||||
b[o + 1] = (byte) ((v >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
private static void be16(byte[] b, int o, int v) {
|
||||
b[o] = (byte) ((v >> 8) & 0xFF);
|
||||
b[o + 1] = (byte) (v & 0xFF);
|
||||
}
|
||||
|
||||
private static void le32(byte[] b, int o, int v) {
|
||||
b[o] = (byte) (v & 0xFF);
|
||||
b[o + 1] = (byte) ((v >> 8) & 0xFF);
|
||||
b[o + 2] = (byte) ((v >> 16) & 0xFF);
|
||||
b[o + 3] = (byte) ((v >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
private static void be32(byte[] b, int o, int v) {
|
||||
b[o] = (byte) ((v >> 24) & 0xFF);
|
||||
b[o + 1] = (byte) ((v >> 16) & 0xFF);
|
||||
b[o + 2] = (byte) ((v >> 8) & 0xFF);
|
||||
b[o + 3] = (byte) (v & 0xFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package dev.quoije.umd.sfo;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class SfoTest {
|
||||
|
||||
private record TestEntry(String key, int format, byte[] data) {
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesStringAndIntegerEntries() throws Exception {
|
||||
byte[] sfo = build(
|
||||
new TestEntry("TITLE", Sfo.FORMAT_UTF8, "Test Disc".getBytes(StandardCharsets.UTF_8)),
|
||||
new TestEntry("MEMSIZE", Sfo.FORMAT_INT32, le32(1)),
|
||||
new TestEntry("ATTRIBUTE", Sfo.FORMAT_INT32, le32(0x8000))
|
||||
);
|
||||
|
||||
Sfo parsed = Sfo.parse(sfo);
|
||||
|
||||
assertThat(parsed.getString("TITLE")).isEqualTo("Test Disc");
|
||||
assertThat(parsed.getInt("MEMSIZE")).isEqualTo(1);
|
||||
assertThat(parsed.getInt("ATTRIBUTE")).isEqualTo(0x8000);
|
||||
assertThat(parsed.entries()).containsOnlyKeys("TITLE", "MEMSIZE", "ATTRIBUTE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidMagic() {
|
||||
byte[] bad = new byte[64];
|
||||
assertThatThrownBy(() -> Sfo.parse(bad)).isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
private static byte[] build(TestEntry... entries) {
|
||||
Map<String, byte[]> keyBytes = new LinkedHashMap<>();
|
||||
Map<String, byte[]> dataBytes = new LinkedHashMap<>();
|
||||
for (TestEntry entry : entries) {
|
||||
keyBytes.put(entry.key, (entry.key + "\0").getBytes(StandardCharsets.US_ASCII));
|
||||
dataBytes.put(entry.key, entry.data);
|
||||
}
|
||||
|
||||
int keyTableOffset = 20 + entries.length * 16;
|
||||
int dataTableOffset = keyTableOffset
|
||||
+ keyBytes.values().stream().mapToInt(b -> b.length).sum();
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.writeBytes(new byte[]{0, 'P', 'S', 'F'});
|
||||
writeLe32(out, 0x101); // version
|
||||
writeLe32(out, keyTableOffset);
|
||||
writeLe32(out, dataTableOffset);
|
||||
writeLe32(out, entries.length);
|
||||
|
||||
Map<String, Integer> keyOffsets = new LinkedHashMap<>();
|
||||
int keyOff = 0;
|
||||
for (String key : keyBytes.keySet()) {
|
||||
keyOffsets.put(key, keyOff);
|
||||
keyOff += keyBytes.get(key).length;
|
||||
}
|
||||
Map<String, Integer> dataOffsets = new LinkedHashMap<>();
|
||||
int dataOff = 0;
|
||||
for (String key : dataBytes.keySet()) {
|
||||
dataOffsets.put(key, dataOff);
|
||||
dataOff += dataBytes.get(key).length;
|
||||
}
|
||||
|
||||
for (TestEntry entry : entries) {
|
||||
writeLe16(out, keyOffsets.get(entry.key));
|
||||
writeLe16(out, entry.format);
|
||||
writeLe32(out, entry.data.length);
|
||||
writeLe32(out, entry.data.length);
|
||||
writeLe32(out, dataOffsets.get(entry.key));
|
||||
}
|
||||
|
||||
for (byte[] kb : keyBytes.values()) {
|
||||
out.writeBytes(kb);
|
||||
}
|
||||
for (byte[] db : dataBytes.values()) {
|
||||
out.writeBytes(db);
|
||||
}
|
||||
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeLe16(ByteArrayOutputStream out, int v) {
|
||||
out.write(v & 0xFF);
|
||||
out.write((v >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
private static void writeLe32(ByteArrayOutputStream out, int v) {
|
||||
out.write(v & 0xFF);
|
||||
out.write((v >> 8) & 0xFF);
|
||||
out.write((v >> 16) & 0xFF);
|
||||
out.write((v >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
private static byte[] le32(int v) {
|
||||
return new byte[]{
|
||||
(byte) (v & 0xFF),
|
||||
(byte) ((v >> 8) & 0xFF),
|
||||
(byte) ((v >> 16) & 0xFF),
|
||||
(byte) ((v >> 24) & 0xFF)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user