Files
dostalgia/src/main/java/com/dostalgia/UploadResource.java
T
Hermes Agent 383c66b166
Build & Deploy / build-and-deploy (push) Successful in 37s
restore dedup: mount one CD image per directory, prefer .bin over .cue
Since GameService now preserves CD images when regenerating configs,
the dedup logic is safe to re-add. Deduplicates by parent directory
so a .bin and .cue from the same folder only produce one imgmount.
Alphabetical sort ensures .bin (b) is mounted before .cue (c).
2026-06-03 10:22:27 +02:00

695 lines
30 KiB
Java

package com.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.logging.Logger;
@jakarta.ws.rs.Path("/api/upload")
@Produces(MediaType.APPLICATION_JSON)
public class UploadResource {
@Inject
GameService svc;
@Inject
IgdbService igdb;
private static final Logger LOG = Logger.getLogger(UploadResource.class.getName());
// ─── Upload ──────────────────────────────────────────────────
@POST
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response upload(
@RestForm("file") FileUpload upload,
@RestForm("title") String title,
@RestForm("igdb_id") Integer igdbId
) throws Exception {
if (upload == null) {
return Response.status(400).entity(Map.of("error", "No file provided")).build();
}
String filename = upload.fileName();
if (title == null || title.isBlank()) {
title = filename.endsWith(".zip") ? filename.substring(0, filename.length() - 4) : filename;
}
String gameId = GameService.sanitizeId(title);
// Check for duplicate by title (case-insensitive)
for (var g : svc.list()) {
if (g.getTitle() != null && g.getTitle().equalsIgnoreCase(title)) {
return Response.status(409)
.entity(Map.of("error", "\"" + title + "\" is already in your library"))
.build();
}
}
// Ensure unique ID
String baseId = gameId;
int counter = 2;
while (Files.exists(svc.gameJsonPath(gameId))) {
gameId = baseId + "-" + counter++;
}
// Extract ZIP
Path tmpDir = Files.createTempDirectory("dostalgia-");
try {
Path zipPath = tmpDir.resolve(filename);
Files.copy(upload.uploadedFile(), zipPath, StandardCopyOption.REPLACE_EXISTING);
Path extractDir = tmpDir.resolve("extracted");
unzip(zipPath, extractDir);
// If the ZIP has a single root directory, flatten it
try {
flattenSingleDir(extractDir);
} catch (Exception e) {
LOG.warning("Flatten failed for '" + filename + "': " + e.getMessage()
+ " — continuing (cd in autoexec handles subdirs)");
}
// Find main executable
String mainExe = findMainExe(extractDir);
if (mainExe == null) {
return Response.status(400)
.entity(Map.of("error", "No executable (.exe/.com/.bat) found in the archive"))
.build();
}
// Find setup executable (SETUP.EXE, INSTALL.EXE, CONFIG.EXE, etc.)
String setupExe = findSetupExe(extractDir);
// Create game directory
Path gameDir = svc.gameDir(gameId);
Files.createDirectories(gameDir);
// Create .jsdos bundle inside the game directory
String bundleFile = gameId + "/" + gameId + ".jsdos";
Path bundlePath = gameDir.resolve(gameId + ".jsdos");
createBundle(extractDir, mainExe, bundlePath);
// Detect platform (DOS vs Windows) by reading the main EXE header
String platform = detectPlatform(Path.of(mainExe));
// Store setup executable path if found and DOS-compatible.
// Some games (e.g. Fallout) ship a Windows SETUP.EXE even though the main
// game is DOS — that setup would fail with "This program cannot run in DOS mode".
// The .setup.jsdos bundle is now generated on-the-fly — no disk duplication needed.
// Save metadata
Game game = new Game(gameId, title);
game.setBundleFile(bundleFile);
game.setPlatform(platform);
// Store the selected executable and the full list for the UI picker
String relMain = extractDir.relativize(Path.of(mainExe)).toString().replace('\\', '/');
game.setExecutable(relMain);
game.setExecutables(findAllExecutables(extractDir));
if (setupExe != null) {
String setupPlatform = detectPlatform(Path.of(setupExe));
if (!"windows".equals(setupPlatform)) {
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
}
}
game.setReady(true);
svc.save(game);
// Auto-populate from IGDB (use provided igdb_id or auto-search)
if (igdb.isConfigured()) {
if (igdbId != null) {
igdb.applyIgdbId(game, igdbId);
} else {
igdb.autoScrape(game);
}
if (game.isHasCover() || game.getYear() != null || game.getGenre() != null
|| (game.getVideos() != null && !game.getVideos().isEmpty())
|| (game.getScreenshots() != null && !game.getScreenshots().isEmpty())) {
svc.save(game); // re-save with enriched metadata
}
}
return Response.status(201).entity(game).build();
} catch (Exception e) {
LOG.severe("Upload failed for '" + filename + "': " + e.getMessage());
return Response.serverError()
.entity(Map.of("error", "Upload failed: " + e.getMessage()))
.build();
} finally {
deleteDir(tmpDir);
}
}
@POST
@jakarta.ws.rs.Path("/{id}/cover")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response uploadCover(@PathParam("id") String id, @RestForm("file") FileUpload upload) throws Exception {
if (upload == null) {
return Response.status(400).entity(Map.of("error", "No file provided")).build();
}
if (!Files.exists(svc.gameJsonPath(id))) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
}
String fname = upload.fileName().toLowerCase();
String ext = "";
if (fname.endsWith(".jpg") || fname.endsWith(".jpeg")) ext = ".jpg";
else if (fname.endsWith(".png")) ext = ".png";
else if (fname.endsWith(".webp")) ext = ".webp";
else {
return Response.status(400).entity(Map.of("error", "Only JPG, PNG, or WebP images accepted")).build();
}
Path gameDir = svc.gameDir(id);
// Remove old covers
for (String old : new String[]{".jpg", ".jpeg", ".png", ".webp"}) {
Files.deleteIfExists(gameDir.resolve("cover" + old));
}
Files.copy(upload.uploadedFile(), gameDir.resolve("cover" + ext), StandardCopyOption.REPLACE_EXISTING);
return Response.ok(Map.of("status", "ok")).build();
}
// ─── ZIP Handling ────────────────────────────────────────────
private void unzip(Path zipPath, Path dest) throws IOException {
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(zipPath))) {
java.util.zip.ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
Path target = dest.resolve(entry.getName()).normalize();
if (!target.startsWith(dest)) continue; // prevent traversal
if (entry.isDirectory()) {
Files.createDirectories(target);
} else {
Files.createDirectories(target.getParent());
Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
}
zis.closeEntry();
}
}
}
/**
* If the extraction directory contains a single subdirectory, move
* its contents up (flatten). This handles ZIPs where all game files
* are inside a folder (e.g. "mastori2/ORION2.EXE" → "ORION2.EXE").
* Works even when there are metadata files (file_id.diz, *.nfo, etc.)
* alongside the single game directory at the ZIP root.
*/
private void flattenSingleDir(Path dir) throws IOException {
Path singleDir = null;
try (var files = Files.list(dir)) {
for (Path entry : files.toList()) {
if (Files.isDirectory(entry)) {
if (singleDir != null) return; // multiple directories — abort
singleDir = entry;
}
}
}
if (singleDir == null) return;
LOG.info("Flattening root directory: " + singleDir.getFileName());
// Walk in reverse order so directories are empty when we delete them
try (var walk = Files.walk(singleDir)) {
var list = walk.sorted(Comparator.reverseOrder()).toList();
for (Path f : list) {
if (f.equals(singleDir)) continue;
Path target = dir.resolve(singleDir.relativize(f));
if (Files.isDirectory(f)) {
Files.createDirectories(target);
Files.delete(f);
} else {
Files.createDirectories(target.getParent());
Files.move(f, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
Files.delete(singleDir);
LOG.info("Flattened " + singleDir.getFileName());
}
// ─── Executable Detection ────────────────────────────────────
/** Collect all discoverable executables (relative paths) from the extracted directory. */
private List<String> findAllExecutables(Path dir) throws IOException {
List<String> result = new ArrayList<>();
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
String name = f.getFileName().toString().toLowerCase();
if (name.endsWith(".exe") || name.endsWith(".com") || name.endsWith(".bat")) {
result.add(dir.relativize(f).toString().replace('\\', '/'));
}
return FileVisitResult.CONTINUE;
}
});
result.sort(String::compareTo);
return result;
}
private String findMainExe(Path dir) throws IOException {
record Candidate(int depth, boolean isInstaller, long size, String path, String platform) {}
List<Candidate> candidates = new ArrayList<>();
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
String name = f.getFileName().toString().toLowerCase();
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
return FileVisitResult.CONTINUE;
// Skip known extender / tool executables (DOS4GW, DEBUG, etc.)
int dot = name.lastIndexOf('.');
String stem = dot >= 0 ? name.substring(0, dot) : name;
if (SKIP_EXE_NAMES.contains(stem))
return FileVisitResult.CONTINUE;
// Skip self-extracting archive executables (PKSFX, PKZIP stubs, etc.)
if (isLikelySelfExtractor(f))
return FileVisitResult.CONTINUE;
boolean installer = name.contains("install") || name.contains("setup") || name.contains("config");
int depth = f.getNameCount() - dir.getNameCount();
String platform = detectPlatform(f);
candidates.add(new Candidate(depth, installer, a.size(), f.toString(), platform));
return FileVisitResult.CONTINUE;
}
});
if (candidates.isEmpty()) return null;
// Prefer: non-installer → DOS platform → larger file → shallow depth
candidates.sort(Comparator
.comparingInt((Candidate c) -> c.isInstaller() ? 1 : 0)
.thenComparingInt(c -> {
// Prefer DOS over unknown over Windows
if ("dos".equals(c.platform())) return 0;
if (c.platform() == null) return 1;
return 2; // "windows"
})
.thenComparingLong(c -> -c.size())
.thenComparingInt(Candidate::depth));
return candidates.getFirst().path();
}
/**
* Check if an executable is likely a self-extracting archive (PKSFX, PKZIP stub, etc.)
* by looking for characteristic PKWARE signatures in the MS-DOS stub portion.
* Self-extractors contain embedded archive data and are not game executables.
*/
static boolean isLikelySelfExtractor(Path exePath) {
try (var fis = Files.newInputStream(exePath)) {
byte[] buf = new byte[32768]; // 32KB to cover most PKSFX stubs
int read = fis.readNBytes(buf, 0, buf.length);
if (read < 0x40) return false;
// Must start with MZ
if (buf[0] != 0x4D || buf[1] != 0x5A) return false;
// Scan for PKWARE text signatures in the MS-DOS stub area
String content = new String(buf, 0, read, java.nio.charset.StandardCharsets.ISO_8859_1);
if (content.contains("PKZIP") || content.contains("PKSFX"))
return true;
// Check for PKWARE zip signatures in appended zip data.
// Only check at reasonable offsets (past the MZ header + stub)
// to avoid false positives on code bytes that happen to be 0x50,0x4B.
for (int i = 0x40; i < read - 4; i++) {
int p = buf[i] & 0xFF;
// PK\x03\x04 = local file header, PK\x01\x02 = central dir
if (p == 0x50 && (buf[i+1] & 0xFF) == 0x4B) {
int sig = (buf[i+2] & 0xFF) | ((buf[i+3] & 0xFF) << 8);
if (sig == 0x0403 || sig == 0x0201 || sig == 0x0605)
return true;
}
}
return false;
} catch (IOException e) {
return false;
}
}
// ─── Setup Executable Detection ─────────────────────────────
/**
* Find a setup/install/config executable in the extracted game directory.
* Looks for files named setup.exe/com/bat, install.exe/com/bat,
* config.exe/com, customize.exe/com etc.
*/
private String findSetupExe(Path dir) throws IOException {
record Candidate(int depth, long size, String path) {}
List<Candidate> candidates = new ArrayList<>();
List<String> patterns = List.of("setup", "install", "config", "custom");
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
String name = f.getFileName().toString().toLowerCase();
String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name;
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
return FileVisitResult.CONTINUE;
for (String pat : patterns) {
if (stem.equals(pat) || stem.startsWith(pat + ".") || stem.startsWith(pat + "-")) {
int depth = f.getNameCount() - dir.getNameCount();
candidates.add(new Candidate(depth, a.size(), f.toString()));
break;
}
}
return FileVisitResult.CONTINUE;
}
});
if (candidates.isEmpty()) return null;
// Prefer shallow (root-level) files, then smaller files (setup utilities tend to be small)
candidates.sort(Comparator
.comparingInt(Candidate::depth)
.thenComparingLong(Candidate::size));
return candidates.getFirst().path();
}
// ─── Bundle Creation ─────────────────────────────────────────
/** File extensions that are never game data and should be excluded from .jsdos bundles.
* Only formats DOSBox can't mount natively are stripped.
* Mountable formats (.iso, .cue, .img, .ccd) are kept and auto-mounted via imgmount. */
private static final java.util.Set<String> SKIP_EXT = java.util.Set.of(
".nrg", ".mdf", ".mds", // disk images DOSBox can't mount natively
".sub", // subchannel data (accompanies .ccd — CCD itself is kept)
".dmg" // Mac disk images
);
/** File extensions that DOSBox can mount as CD-ROM via imgmount. */
private static final java.util.Set<String> CD_EXT = java.util.Set.of(
".iso", ".cue", ".img", ".ccd", ".bin"
);
/** Find mountable CD image files in the extracted game directory.
* Returns relative paths (with forward slashes) sorted alphabetically. */
private List<String> findCdImages(Path dir) throws IOException {
List<String> cds = new ArrayList<>();
try (var walk = Files.walk(dir)) {
walk.filter(Files::isRegularFile).forEach(f -> {
String name = f.getFileName().toString().toLowerCase();
int dot = name.lastIndexOf('.');
if (dot >= 0 && CD_EXT.contains(name.substring(dot))) {
cds.add(dir.relativize(f).toString().replace('\\', '/'));
}
});
}
cds.sort(String::compareTo);
return cds;
}
/**
* Executable filenames (lowercase, stem only, no extension) that should NEVER
* be selected as the main game executable. These are DOS extenders, DPMI hosts,
* debuggers, and other auxiliary tools that ship alongside many DOS games.
*/
private static final java.util.Set<String> SKIP_EXE_NAMES = java.util.Set.of(
"dos4gw", "dos32a", "dos4gw2", // DOS/4GW family (DOS extenders)
"pmode", "pmodew", // Watcom / PMODE extenders
"cwsdpmi", // DPMI host (used by many DJGPP games)
"emm386", // Expanded memory manager
"himem", "himemx", // Extended memory managers
"debug", // Debugger
"uninst", "uninstall", "uninstaller", // Uninstallers
// Compression / archive tools (self-extractors)
"pksfx", "pkzip", "pkunzip", "sfx", "makesfx", // PKWARE
"unzip", "zip", "zip2exe", // Info-ZIP
"arj", "arj32", // ARJ
"lha", "lha32", "lzh", // LHA/LHarc
"rar", "unrar", // RAR
"ace", "unace", // ACE
"zoo", "arc", "ha", "cab" // others
);
private void createBundle(Path extractDir, String exePath, Path bundlePath) throws IOException {
// Write .jsdos config directly into the extract directory
Path jsdos = extractDir.resolve(".jsdos");
Files.createDirectories(jsdos);
String relExe = extractDir.relativize(Path.of(exePath)).toString();
// Find CD images for DOSBox imgmount (mountable via D:, E:, …)
List<String> cdImages = findCdImages(extractDir);
Files.writeString(jsdos.resolve("dosbox.conf"), buildDosboxConf(relExe, cdImages));
Files.write(jsdos.resolve("jsdos.json"), buildJsdosJson(relExe));
// ZIP it up directly from extractDir (no temp copy)
try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) {
// ── 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) {
allDirs.add(entryName.substring(0, idx + 1));
idx = entryName.lastIndexOf('/', idx - 1);
}
});
}
// ── 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('\\', '/');
zos.putNextEntry(new java.util.zip.ZipEntry(entryName));
Files.copy(f, zos);
zos.closeEntry();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
// ── Phase 4: All other game files ──
try (var walk = Files.walk(extractDir)) {
walk.filter(Files::isRegularFile)
.filter(f -> !f.startsWith(jsdos))
.filter(f -> {
String n = f.getFileName().toString().toLowerCase();
int dot = n.lastIndexOf('.');
return dot < 0 || !SKIP_EXT.contains(n.substring(dot));
})
.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);
}
});
}
}
// Clean up .jsdos config files from the extract dir
Files.walk(jsdos).sorted(Comparator.reverseOrder()).forEach(p -> {
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
});
}
// ─── Configuration Helpers ──────────────────────────────────
/**
* Split a relative executable path into its directory and file parts.
* If relExe contains a '/' (or '\\'), returns {dir, file}.
* If relExe is just a filename, returns {null, relExe}.
*/
private static String[] splitDirExe(String relExe) {
int idx = relExe.replace('\\', '/').lastIndexOf('/');
if (idx >= 0) {
return new String[]{relExe.substring(0, idx), relExe.substring(idx + 1)};
}
return new String[]{null, relExe};
}
private byte[] buildJsdosJson(String relExe) {
// DOS uses \\ as path separator; / is a switch character.
// If the executable is in a subdirectory, cd into it first.
var parts = splitDirExe(relExe);
StringBuilder script = new StringBuilder();
if (parts[0] != null) {
// Set PATH to include root so DOS extenders (DOS4GW etc.) are findable
script.append("path=c:\\\n");
script.append("cd ").append(parts[0]).append("\n");
}
script.append(parts[1]);
String json = "{"
+ "\"autoexec\":{"
+ "\"options\":{\"script\":{\"value\":\"" + escapeJson(script.toString()) + "\"}}"
+ "},"
+ "\"output\":{"
+ "\"options\":{\"autolock\":{\"value\":true}}}"
+ "}";
return json.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
private String buildDosboxConf(String relExe, List<String> cdImages) {
// DOS uses \\ as path separator; / is a switch character.
// If the executable is in a subdirectory, cd into it first.
var parts = splitDirExe(relExe);
String dir = parts[0];
String exe = parts[1];
StringBuilder sb = new StringBuilder();
sb.append("[sdl]\n");
sb.append("autolock=true\n");
sb.append("usescancodes=true\n");
sb.append("output=surface\n");
sb.append("\n");
sb.append("[cpu]\n");
sb.append("core=auto\n");
sb.append("cycles=auto\n");
sb.append("\n");
sb.append("[mixer]\n");
sb.append("nosound=false\n");
sb.append("rate=44100\n");
sb.append("\n");
sb.append("[sblaster]\n");
sb.append("sbtype=sb16\n");
sb.append("irq=7\n");
sb.append("dma=1\n");
sb.append("hdma=5\n");
sb.append("\n");
sb.append("[dos]\n");
sb.append("xms=true\n");
sb.append("ems=true\n");
sb.append("umb=true\n");
sb.append("\n");
sb.append("[autoexec]\n");
sb.append("@echo off\n");
sb.append("mount c .\n");
// Mount CD images as D:, E:, … so games can find their CD.
// Deduplicate by parent directory — prefer .bin over .cue since
// .bin files sort first alphabetically and avoid .cue FILE path
// case-mismatch issues.
Set<String> seenDirs = new java.util.HashSet<>();
char driveLetter = 'D';
for (String img : cdImages) {
int slashIdx = img.lastIndexOf('/');
String parentDir = slashIdx >= 0 ? img.substring(0, slashIdx) : "";
if (!seenDirs.add(parentDir)) continue; // already mounted from this dir
String imgLower = img.toLowerCase();
String flags = imgLower.endsWith(".bin") ? " -t cdrom -fs iso" : " -t cdrom";
sb.append("imgmount ").append(driveLetter).append(" \"").append(img).append("\"").append(flags).append("\n");
driveLetter++;
}
sb.append("c:\n");
if (dir != null) {
sb.append("path=c:\\\n");
sb.append("cd ").append(dir).append("\n");
}
sb.append(exe).append("\n");
return sb.toString();
}
private String escapeJson(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
/**
* Detect whether an executable targets DOS or Windows by reading its PE/NE header.
* <ul>
* <li>Pure MZ, LE (DOS/4GW extender), LX (OS/2) → "dos"</li>
* <li>NE (Windows 3.x), PE (Win32) → "windows"</li>
* <li>Cannot read → null</li>
* </ul>
* Note: LE (Linear Executable) is used by DOS/4GW, DOS/32A, PMODE/W
* — all 32-bit DOS extenders for games like DOOM, Blood, Duke Nukem 3D.
* Only PE (Portable Executable) and NE (New Executable) are Windows.
*/
static String detectPlatform(Path exePath) {
String name = exePath.getFileName().toString().toLowerCase();
// .com and .bat files are always DOS
if (name.endsWith(".com") || name.endsWith(".bat")) return "dos";
try (var fis = Files.newInputStream(exePath)) {
byte[] buf = new byte[0x80];
int read = fis.readNBytes(buf, 0, buf.length);
if (read < 2) return null;
// Must start with MZ
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
// Read e_lfanew at offset 0x3C (only meaningful for PE/NE — garbage for pure DOS)
if (read < 0x40) return "dos"; // too short for PE/NE, assume DOS
int peOffset = (buf[0x3C] & 0xFF)
| ((buf[0x3D] & 0xFF) << 8)
| ((buf[0x3E] & 0xFF) << 16)
| ((buf[0x3F] & 0xFF) << 24);
// e_lfanew is garbage for pure DOS executables — may be negative or absurd
if (peOffset < 0) return "dos";
// If signature is beyond our buffer, read it from the file
byte sig1, sig2;
if (peOffset + 2 <= read) {
sig1 = buf[peOffset];
sig2 = buf[peOffset + 1];
} else {
try (var fis2 = Files.newInputStream(exePath)) {
fis2.skip(peOffset);
sig1 = (byte) fis2.read();
sig2 = (byte) fis2.read();
}
}
// PE = Portable Executable (Win32)
// NE = New Executable (Windows 3.x)
// LE = Linear Executable (DOS/4GW, DOS/32A — 32-bit DOS extenders)
// LX = Extended Linear Executable (OS/2 2.x)
if ((sig1 == 0x50 && sig2 == 0x45) // PE
|| (sig1 == 0x4E && sig2 == 0x45)) // NE
return "windows";
return "dos"; // Plain MZ without known Windows signature
} catch (IOException e) {
LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage());
return null;
}
}
// ─── File Utilities ──────────────────────────────────────────
private void deleteDir(Path dir) throws IOException {
if (!Files.exists(dir)) return;
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
Files.delete(f);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
Files.delete(d);
return FileVisitResult.CONTINUE;
}
});
}
}