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:
2026-09-15 21:00:22 -04:00
parent 3ad227615a
commit 797b0507bd
26 changed files with 2332 additions and 15 deletions
+20
View File
@@ -0,0 +1,20 @@
plugins {
`java-library`
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
testImplementation(platform(libs.junit.bom))
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.assertj.core)
}
tasks.test {
useJUnitPlatform()
}
@@ -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);
}
}
+5
View File
@@ -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)
};
}
}