Fix js-dos ZIP extraction: write dirs shallowest-first, then files
Build & Deploy / build-and-deploy (push) Successful in 1m40s

js-dos v8's C extraction code (jsdos-libzip.c) iterates ZIP entries
linearly and calls mkdir() on each directory entry. If a child
directory appears before its parent, mkdir() fails with perror()
and exit(1) — fatal crash with 'No such file or directory'.

Previous approach wrote directory entries interleaved with files,
deepest-first (from the file path right-to-left). Now:

Phase 1: Walk entire file tree to discover ALL directories
Phase 2: Write ALL dir entries sorted (shallowest first via TreeSet
         natural ordering — '/' sorts before any letter)
Phase 3: Write .jsdos/ config files
Phase 4: Write game files (parents already exist from Phase 2)

This guarantees that mkdir("NAS50TH/TRACKS/") runs before
mkdir("NAS50TH/TRACKS/WILKES/"), which runs before open().
This commit is contained in:
Hermes Agent
2026-05-28 14:43:28 +02:00
parent f9967eacf8
commit 733bdaa44f
+35 -27
View File
@@ -296,47 +296,46 @@ public class UploadResource {
// ZIP it up directly from extractDir (no temp copy)
try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) {
var addedDirs = new java.util.HashSet<String>();
// Helper: write a directory entry and all its ancestors if not yet added
// (Emscripten FS requires every directory in the chain as explicit entries)
java.util.function.Consumer<String> ensureDir = entryName -> {
// ── Phase 1: Collect all directory paths from the file tree ──
// js-dos C code (jsdos-libzip.c) iterates ZIP entries linearly and
// calls mkdir() on each directory entry. All parents must come
// before their children or mkdir() fails with exit(1).
var allDirs = new java.util.TreeSet<String>();
try (var walk = Files.walk(extractDir)) {
walk.filter(Files::isRegularFile).forEach(f -> {
String entryName = extractDir.relativize(f).toString().replace('\\', '/');
int idx = entryName.lastIndexOf('/');
while (idx >= 0) {
String parent = entryName.substring(0, idx + 1);
if (addedDirs.add(parent)) {
try {
zos.putNextEntry(new java.util.zip.ZipEntry(parent));
zos.closeEntry();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
allDirs.add(entryName.substring(0, idx + 1));
idx = entryName.lastIndexOf('/', idx - 1);
}
};
});
}
// Helper: write a single file entry with its directory chain
java.util.function.Consumer<Path> writeFile = f -> {
// ── Phase 2: Write ALL directory entries first (sorted → shallowest first) ──
for (String dir : allDirs) {
zos.putNextEntry(new java.util.zip.ZipEntry(dir));
zos.closeEntry();
}
// ── Phase 3: .jsdos config files (must come before game files) ──
try (var walk = Files.walk(jsdos)) {
walk.filter(Files::isRegularFile)
.sorted()
.forEach(f -> {
try {
String entryName = extractDir.relativize(f).toString().replace('\\', '/');
ensureDir.accept(entryName);
zos.putNextEntry(new java.util.zip.ZipEntry(entryName));
Files.copy(f, zos);
zos.closeEntry();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
// ── First pass: .jsdos config files (must come first in ZIP) ──
try (var walk = Files.walk(jsdos)) {
walk.filter(Files::isRegularFile)
.sorted() // deterministic order
.forEach(writeFile);
});
}
// ── Second pass: all other game files ──
// ── Phase 4: All other game files ──
try (var walk = Files.walk(extractDir)) {
walk.filter(Files::isRegularFile)
.filter(f -> !f.startsWith(jsdos))
@@ -345,8 +344,17 @@ public class UploadResource {
int dot = n.lastIndexOf('.');
return dot < 0 || !SKIP_EXT.contains(n.substring(dot));
})
.sorted() // deterministic order
.forEach(writeFile);
.sorted()
.forEach(f -> {
try {
String entryName = extractDir.relativize(f).toString().replace('\\', '/');
zos.putNextEntry(new java.util.zip.ZipEntry(entryName));
Files.copy(f, zos);
zos.closeEntry();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
}