From 03095785d5eaf67ca35de6060ce53e0d3a7d588e Mon Sep 17 00:00:00 2001 From: Rob Greene Date: Mon, 28 May 2018 13:35:49 -0500 Subject: [PATCH] Adding analyze command to 'asu'; adding support to API for date information. --- .../applesingle/AppleSingle.java | 101 +++++++++- .../applesingle/FileDatesInfo.java | 66 +++++++ .../applesingle/AppleSingleTest.java | 10 + gradle.properties | 2 +- .../applesingle/tools/asu/AnalyzeCommand.java | 172 ++++++++++++++++++ .../applesingle/tools/asu/CreateCommand.java | 32 +++- .../applesingle/tools/asu/HexDumper.java | 62 +++++++ .../applesingle/tools/asu/IntRange.java | 66 +++++++ .../applesingle/tools/asu/Main.java | 2 +- .../tools/asu/NullOutputStream.java | 16 ++ 10 files changed, 516 insertions(+), 13 deletions(-) create mode 100644 api/src/main/java/io/github/applecommander/applesingle/FileDatesInfo.java create mode 100644 tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/AnalyzeCommand.java create mode 100644 tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/HexDumper.java create mode 100644 tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/IntRange.java create mode 100644 tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/NullOutputStream.java diff --git a/api/src/main/java/io/github/applecommander/applesingle/AppleSingle.java b/api/src/main/java/io/github/applecommander/applesingle/AppleSingle.java index dcbebe0..2510a0a 100644 --- a/api/src/main/java/io/github/applecommander/applesingle/AppleSingle.java +++ b/api/src/main/java/io/github/applecommander/applesingle/AppleSingle.java @@ -10,6 +10,7 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -31,13 +32,34 @@ import java.util.function.Consumer; */ public class AppleSingle { public static final int MAGIC_NUMBER = 0x0051600; - public static final int VERSION_NUMBER = 0x00020000; - + public static final int VERSION_NUMBER1 = 0x00010000; + public static final int VERSION_NUMBER2 = 0x00020000; + public static final Map ENTRY_TYPE_NAMES = new HashMap() { + private static final long serialVersionUID = 7142066556402030814L; + { + put(1, "Data Fork"); + put(2, "Resource Fork"); + put(3, "Real Name"); + put(4, "Comment"); + put(5, "Icon, B&W"); + put(6, "Icon, Color"); + put(7, "File Info"); + put(8, "File Dates Info"); + put(9, "Finder Info"); + put(10, "Macintosh File Info"); + put(11, "ProDOS File Info"); + put(12, "MS-DOS File Info"); + put(13, "Short Name"); + put(14, "AFP File Info"); + put(15, "Directory ID"); + }}; + private Map> entryConsumers = new HashMap<>(); { entryConsumers.put(1, this::setDataFork); entryConsumers.put(2, this::setResourceFork); entryConsumers.put(3, this::setRealName); + entryConsumers.put(8, this::setFileDatesInfo); entryConsumers.put(11, this::setProdosFileInfo); } @@ -45,6 +67,7 @@ public class AppleSingle { private byte[] resourceFork; private String realName; private ProdosFileInfo prodosFileInfo = ProdosFileInfo.standardBIN(); + private FileDatesInfo fileDatesInfo = new FileDatesInfo(); private AppleSingle() { // Allow Builder construction @@ -54,7 +77,7 @@ public class AppleSingle { .order(ByteOrder.BIG_ENDIAN) .asReadOnlyBuffer(); required(buffer, MAGIC_NUMBER, "Not an AppleSingle file - magic number does not match."); - required(buffer, VERSION_NUMBER, "Only AppleSingle version 2 supported."); + required(buffer, VERSION_NUMBER2, "Only AppleSingle version 2 supported."); buffer.position(buffer.position() + 16); // Skip filler int entries = buffer.getShort(); for (int i = 0; i < entries; i++) { @@ -67,7 +90,8 @@ public class AppleSingle { buffer.get(entryData); // Defer to the proper set method or crash if we don't support that type of entry Optional.ofNullable(entryConsumers.get(entryId)) - .orElseThrow(() -> new IOException(String.format("Unknown entry type of %04X", entryId))) + .orElseThrow(() -> new IOException(String.format("Unsupported entry type of %04X (%s)", entryId, + ENTRY_TYPE_NAMES.getOrDefault(entryId, "Unknown")))) .accept(entryData); buffer.reset(); } @@ -99,6 +123,16 @@ public class AppleSingle { int auxType = infoData.getInt(); this.prodosFileInfo = new ProdosFileInfo(access, fileType, auxType); } + private void setFileDatesInfo(byte[] entryData) { + ByteBuffer infoData = ByteBuffer.wrap(entryData) + .order(ByteOrder.BIG_ENDIAN) + .asReadOnlyBuffer(); + int creation = infoData.getInt(); + int modification = infoData.getInt(); + int backup = infoData.getInt(); + int access = infoData.getInt(); + this.fileDatesInfo = new FileDatesInfo(creation, modification, backup, access); + } public byte[] getDataFork() { return dataFork; @@ -112,25 +146,31 @@ public class AppleSingle { public ProdosFileInfo getProdosFileInfo() { return prodosFileInfo; } + public FileDatesInfo getFileDatesInfo() { + return fileDatesInfo; + } public void save(OutputStream outputStream) throws IOException { - final boolean hasResourceFork = resourceFork == null ? false : true; - final boolean hasRealName = realName == null ? false : true; - final int entries = 2 + (hasRealName ? 1 : 0) + (hasResourceFork ? 1 : 0); + final boolean hasResourceFork = Objects.nonNull(resourceFork); + final boolean hasRealName = Objects.nonNull(realName); + final int entries = 3 + (hasRealName ? 1 : 0) + (hasResourceFork ? 1 : 0); int realNameOffset = 26 + (12 * entries); int prodosFileInfoOffset = realNameOffset + (hasRealName ? realName.length() : 0); - int resourceForkOffset = prodosFileInfoOffset + 8; + int fileDatesInfoOffset = prodosFileInfoOffset + 8; + int resourceForkOffset = fileDatesInfoOffset + 16; int dataForkOffset = resourceForkOffset + (hasResourceFork ? resourceFork.length : 0); writeFileHeader(outputStream, entries); if (hasRealName) writeHeader(outputStream, 3, realNameOffset, realName.length()); writeHeader(outputStream, 11, prodosFileInfoOffset, 8); + writeHeader(outputStream, 8, fileDatesInfoOffset, 16); if (hasResourceFork) writeHeader(outputStream, 2, resourceForkOffset, resourceFork.length); writeHeader(outputStream, 1, dataForkOffset, dataFork.length); if (hasRealName) writeRealName(outputStream); writeProdosFileInfo(outputStream); + writeFileDatesInfo(outputStream); if (hasResourceFork) writeResourceFork(outputStream); writeDataFork(outputStream); } @@ -149,7 +189,7 @@ public class AppleSingle { final byte[] filler = new byte[16]; ByteBuffer buf = ByteBuffer.allocate(26).order(ByteOrder.BIG_ENDIAN); buf.putInt(MAGIC_NUMBER); - buf.putInt(VERSION_NUMBER); + buf.putInt(VERSION_NUMBER2); buf.put(filler); buf.putShort((short)numberOfEntries); outputStream.write(buf.array()); @@ -171,6 +211,14 @@ public class AppleSingle { buf.putInt(prodosFileInfo.auxType); outputStream.write(buf.array()); } + private void writeFileDatesInfo(OutputStream outputStream) throws IOException { + ByteBuffer buf = ByteBuffer.allocate(16).order(ByteOrder.BIG_ENDIAN); + buf.putInt(fileDatesInfo.getCreation()); + buf.putInt(fileDatesInfo.getModification()); + buf.putInt(fileDatesInfo.getBackup()); + buf.putInt(fileDatesInfo.getAccess()); + outputStream.write(buf.array()); + } private void writeResourceFork(OutputStream outputStream) throws IOException { outputStream.write(resourceFork); } @@ -237,6 +285,41 @@ public class AppleSingle { as.prodosFileInfo.auxType = auxType; return this; } + public Builder creationDate(int creation) { + as.fileDatesInfo.creation = creation; + return this; + } + public Builder creationDate(Instant creation) { + as.fileDatesInfo.creation = FileDatesInfo.fromInstant(creation); + return this; + } + public Builder modificationDate(int modification) { + as.fileDatesInfo.modification = modification; + return this; + } + public Builder modificationDate(Instant modification) { + as.fileDatesInfo.modification = FileDatesInfo.fromInstant(modification); + return this; + } + public Builder backupDate(int backup) { + as.fileDatesInfo.backup = backup; + return this; + } + public Builder backupDate(Instant backup) { + as.fileDatesInfo.backup = FileDatesInfo.fromInstant(backup); + return this; + } + public Builder accessDate(int access) { + as.fileDatesInfo.access = access; + return this; + } + public Builder accessDate(Instant access) { + as.fileDatesInfo.access = FileDatesInfo.fromInstant(access); + return this; + } + public Builder allDates(Instant instant) { + return creationDate(instant).modificationDate(instant).backupDate(instant).accessDate(instant); + } public AppleSingle build() { return as; } diff --git a/api/src/main/java/io/github/applecommander/applesingle/FileDatesInfo.java b/api/src/main/java/io/github/applecommander/applesingle/FileDatesInfo.java new file mode 100644 index 0000000..0268527 --- /dev/null +++ b/api/src/main/java/io/github/applecommander/applesingle/FileDatesInfo.java @@ -0,0 +1,66 @@ +package io.github.applecommander.applesingle; + +import java.time.Instant; +import java.util.function.IntSupplier; + +public class FileDatesInfo { + /** The number of seconds at the begining of the AppleSingle date epoch since the Unix epoch began. */ + //public static final int EPOCH_2000 = 946684800; + public static final Instant EPOCH_INSTANT = Instant.parse("2000-01-01T00:00:00.00Z"); + /** Per the AppleSingle technical notes. */ + public static final int UNKNOWN_DATE = 0x80000000; + + int creation; + int modification; + int backup; + int access; + + public static int fromInstant(Instant instant) { + return (int)(instant.getEpochSecond() - EPOCH_INSTANT.getEpochSecond()); + } + + public FileDatesInfo() { + int current = FileDatesInfo.fromInstant(Instant.now()); + this.creation = current; + this.modification = current; + this.backup = current; + this.access = current; + } + public FileDatesInfo(int creation, int modification, int backup, int access) { + this.creation = creation; + this.modification = modification; + this.backup = backup; + this.access = access; + } + + public Instant getCreationInstant() { + return toInstant(this::getCreation); + } + public Instant getModificationInstant() { + return toInstant(this::getModification); + } + public Instant getBackupInstant() { + return toInstant(this::getBackup); + } + public Instant getAccessInstant() { + return toInstant(this::getAccess); + } + + /** Utility method to convert the int to a valid Unix epoch and Java Instant. */ + public Instant toInstant(IntSupplier timeSupplier) { + return Instant.ofEpochSecond(timeSupplier.getAsInt() + EPOCH_INSTANT.getEpochSecond()); + } + + public int getCreation() { + return creation; + } + public int getModification() { + return modification; + } + public int getBackup() { + return backup; + } + public int getAccess() { + return access; + } +} diff --git a/api/src/test/java/io/github/applecommander/applesingle/AppleSingleTest.java b/api/src/test/java/io/github/applecommander/applesingle/AppleSingleTest.java index 63fadcf..77aed77 100644 --- a/api/src/test/java/io/github/applecommander/applesingle/AppleSingleTest.java +++ b/api/src/test/java/io/github/applecommander/applesingle/AppleSingleTest.java @@ -7,6 +7,8 @@ import static org.junit.Assert.assertNull; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import org.junit.Test; @@ -32,11 +34,14 @@ public class AppleSingleTest { public void testCreateAndReadAppleSingle() throws IOException { final byte[] dataFork = "testing testing 1-2-3".getBytes(); final String realName = "test.as"; + // Need to truncate to seconds as the AppleSingle format is only good to seconds! + final Instant instant = Instant.now().truncatedTo(ChronoUnit.SECONDS); // Using default ProDOS info and skipping resource fork AppleSingle createdAS = AppleSingle.builder() .dataFork(dataFork) .realName(realName) + .allDates(instant) .build(); assertNotNull(createdAS); assertEquals(realName.toUpperCase(), createdAS.getRealName()); @@ -54,6 +59,11 @@ public class AppleSingleTest { assertArrayEquals(dataFork, readAS.getDataFork()); assertNull(readAS.getResourceFork()); assertNotNull(readAS.getProdosFileInfo()); + assertNotNull(readAS.getFileDatesInfo()); + assertEquals(instant, readAS.getFileDatesInfo().getCreationInstant()); + assertEquals(instant, readAS.getFileDatesInfo().getModificationInstant()); + assertEquals(instant, readAS.getFileDatesInfo().getAccessInstant()); + assertEquals(instant, readAS.getFileDatesInfo().getBackupInstant()); } @Test diff --git a/gradle.properties b/gradle.properties index dd027dd..440e1ac 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ # Universal applesingle version number. Used for: # - Naming JAR file. # - The build will insert this into a file that is read at run time as well. -version=1.0.1 +version=1.1.0 # Maven Central Repository G and A of GAV coordinate. :-) group=net.sf.applecommander diff --git a/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/AnalyzeCommand.java b/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/AnalyzeCommand.java new file mode 100644 index 0000000..bd06436 --- /dev/null +++ b/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/AnalyzeCommand.java @@ -0,0 +1,172 @@ +package io.github.applecommander.applesingle.tools.asu; + +import java.io.IOException; +import java.io.PrintStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; + +import io.github.applecommander.applesingle.AppleSingle; +import io.github.applecommander.applesingle.FileDatesInfo; +import io.github.applecommander.applesingle.ProdosFileInfo; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Perform a bit of analysis of an AppleSingle archive to help extend the library, fix bugs, + * or understand incompatibilities. + */ +@Command(name = "analyze", description = { "Perform an analysis on an AppleSingle file", + "Please include a file name or indicate stdin should be read, but not both." }, + parameterListHeading = "%nParameters:%n", + descriptionHeading = "%n", + optionListHeading = "%nOptions:%n") +public class AnalyzeCommand implements Callable { + @Option(names = { "-h", "--help" }, description = "Show help for subcommand", usageHelp = true) + private boolean helpFlag; + + @Option(names = "--stdin", description = "Read AppleSingle from stdin.") + private boolean stdinFlag; + + @Option(names = { "-v", "--verbose" }, description = "Be verbose.") + private boolean verboseFlag; + private PrintStream verbose = new PrintStream(NullOutputStream.INSTANCE); + + @Parameters(arity = "0..1", description = "File to process") + private Path path; + + @Override + public Void call() throws IOException { + byte[] fileData = stdinFlag ? AppleSingle.toByteArray(System.in) : Files.readAllBytes(path); + if (verboseFlag) this.verbose = System.out; + + State state = new State(fileData); + match(state, "Magic number", "Not an AppleSingle file - magic number does not match.", + AppleSingle.MAGIC_NUMBER); + int version = match(state, "Version", "Only recognize AppleSingle versions 1 and 2.", + AppleSingle.VERSION_NUMBER1, AppleSingle.VERSION_NUMBER2); + verbose.printf(" .. Version 0x%08x\n", version); + state.read(16, "Filler"); + int numberOfEntries = state.read(Short.BYTES, "Number of entries").getShort(); + verbose.printf(" .. Entries = %d\n", numberOfEntries); + List entries = new ArrayList<>(); + for (int i = 0; i < numberOfEntries; i++) { + ByteBuffer buffer = state.read(12, String.format("Entry #%d", i+1)); + Entry entry = new Entry(i+1, buffer); + entry.print(verbose); + entries.add(entry); + } + entries.sort((a,b) -> Integer.compare(a.offset, b.offset)); + for (Entry entry : entries) entryReport(state, entry); + + List ranges = IntRange.normalize(state.used); + if (ranges.size() == 1 && ranges.get(0).getLow() == 0 && ranges.get(0).getHigh() == fileData.length) { + verbose.printf("The entirety of the file was used.\n"); + } else { + verbose.printf("Parts of the file were skipped!\n - Expected: %s\n - Actual: %s\n", + Arrays.asList(IntRange.of(0,fileData.length)), ranges); + } + return null; + } + + public int match(State state, String description, String message, int... expecteds) throws IOException { + ByteBuffer buffer = state.read(Integer.BYTES, description); + int actual = buffer.getInt(); + for (int expected : expecteds) { + if (actual == expected) return actual; + } + throw new IOException(String.format("%s Aborting.", message)); + } + + public void entryReport(State state, Entry entry) throws IOException { + String entryName = AppleSingle.ENTRY_TYPE_NAMES.getOrDefault(entry.entryId, "Unknown"); + ByteBuffer buffer = state.readAt(entry.offset, entry.length, + String.format("Entry #%d data (%s)", entry.index, entryName)); + switch (entry.entryId) { + case 3: + case 4: + case 13: + displayEntryString(buffer, entryName); + break; + case 8: + displayFileDatesInfo(buffer, entryName); + break; + case 11: + displayProdosFileInfo(buffer, entryName); + break; + default: + verbose.printf(" .. No further details for this entry type (%s).\n", entryName); + break; + } + } + public void displayEntryString(ByteBuffer buffer, String entryName) { + StringBuilder sb = new StringBuilder(); + while (buffer.hasRemaining()) { + int ch = Byte.toUnsignedInt(buffer.get()) & 0x7f; + sb.append((char)ch); + } + verbose.printf(" .. %s: '%s'\n", entryName, sb.toString()); + } + public void displayFileDatesInfo(ByteBuffer buffer, String entryName) { + FileDatesInfo info = new FileDatesInfo(buffer.getInt(), buffer.getInt(), buffer.getInt(), buffer.getInt()); + verbose.printf(" .. %s -\n", entryName); + verbose.printf(" Creation: %s\n", info.getCreationInstant().toString()); + verbose.printf(" Modification: %s\n", info.getModificationInstant().toString()); + verbose.printf(" Backup: %s\n", info.getBackupInstant().toString()); + verbose.printf(" Access: %s\n", info.getAccessInstant().toString()); + } + public void displayProdosFileInfo(ByteBuffer buffer, String entryName) { + ProdosFileInfo info = new ProdosFileInfo(buffer.getShort(), buffer.getShort(), buffer.getInt()); + verbose.printf(" .. %s -\n", entryName); + verbose.printf(" Access: %02X\n", info.getAccess()); + verbose.printf(" File Type: %04X\n", info.getFileType()); + verbose.printf(" Aux. Type: %04X\n", info.getAuxType()); + } + + public static class State { + private final byte[] data; + private int pos = 0; + private List used = new ArrayList<>(); + private HexDumper dumper = HexDumper.standard(); + + public State(byte[] data) { + this.data = data; + } + public ByteBuffer read(int len, String description) throws IOException { + return readAt(pos, len, description); + } + public ByteBuffer readAt(int start, int len, String description) throws IOException { + byte[] chunk = new byte[len]; + System.arraycopy(data, start, chunk, 0, len); + ByteBuffer buffer = ByteBuffer.wrap(chunk) + .order(ByteOrder.BIG_ENDIAN) + .asReadOnlyBuffer(); + dumper.dump(start, chunk, description); + used.add(IntRange.of(start, start+len)); + pos= start+len; + return buffer; + } + } + public static class Entry { + private int index; + private int entryId; + private int offset; + private int length; + public Entry(int index, ByteBuffer buffer) { + this.index = index; + this.entryId = buffer.getInt(); + this.offset = buffer.getInt(); + this.length = buffer.getInt(); + } + public void print(PrintStream ps) { + ps.printf(" .. Entry #%d, entryId=%d (%s), offset=%d, length=%d\n", index, entryId, + AppleSingle.ENTRY_TYPE_NAMES.getOrDefault(entryId, "Unknown"), offset, length); + } + } +} diff --git a/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/CreateCommand.java b/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/CreateCommand.java index d9026a3..59032aa 100644 --- a/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/CreateCommand.java +++ b/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/CreateCommand.java @@ -3,6 +3,9 @@ package io.github.applecommander.applesingle.tools.asu; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; +import java.util.Optional; import java.util.concurrent.Callable; import io.github.applecommander.applesingle.AppleSingle; @@ -16,6 +19,10 @@ import picocli.CommandLine.Parameters; @Command(name = "create", description = { "Create an AppleSingle file" }, parameterListHeading = "%nParameters:%n", descriptionHeading = "%n", + footerHeading = "%nNotes:%n", + footer = { "* Dates should be supplied like '2007-12-03T10:15:30.00Z'.", + "* 'Known' ProDOS file types: TXT, BIN, INT, BAS, REL, SYS.", + "* Include the output file or specify stdout" }, optionListHeading = "%nOptions:%n") public class CreateCommand implements Callable { @Option(names = { "-h", "--help" }, description = "Show help for subcommand", usageHelp = true) @@ -42,11 +49,20 @@ public class CreateCommand implements Callable { @Option(names = "--access", description = "Set the ProDOS access flags", converter = IntegerTypeConverter.class) private Integer access; - @Option(names = "--filetype", description = "Set the ProDOS file type (also accepts BIN/BAS/SYS)", converter = ProdosFileTypeConverter.class) + @Option(names = "--filetype", description = "Set the ProDOS file type", converter = ProdosFileTypeConverter.class) private Integer filetype; @Option(names = "--auxtype", description = "Set the ProDOS auxtype", converter = IntegerTypeConverter.class) private Integer auxtype; + + @Option(names = "--creation-date", description = "Set the file creation date") + private Instant creationDate; + @Option(names = "--modification-date", description = "Set the file modification date") + private Instant modificationDate; + @Option(names = "--backup-date", description = "Set the file backup date") + private Instant backupDate; + @Option(names = "--access-date", description = "Set the file access date") + private Instant accessDate; @Parameters(arity = "0..1", description = "AppleSingle file to create") private Path file; @@ -107,7 +123,7 @@ public class CreateCommand implements Callable { return resourceFork; } - public AppleSingle buildAppleSingle(byte[] dataFork, byte[] resourceFork) { + public AppleSingle buildAppleSingle(byte[] dataFork, byte[] resourceFork) throws IOException { AppleSingle.Builder builder = AppleSingle.builder(); if (realName != null) { builder.realName(realName); @@ -121,6 +137,18 @@ public class CreateCommand implements Callable { if (dataFork != null) builder.dataFork(dataFork); if (resourceFork != null) builder.resourceFork(resourceFork); + if (dataForkFile != null || resourceForkFile != null) { + Path path = Optional.ofNullable(dataForkFile).orElse(resourceForkFile); + BasicFileAttributes attribs = Files.readAttributes(path, BasicFileAttributes.class); + builder.creationDate(attribs.creationTime().toInstant()); + builder.modificationDate(attribs.lastModifiedTime().toInstant()); + builder.accessDate(attribs.lastAccessTime().toInstant()); + } + if (creationDate != null) builder.creationDate(creationDate); + if (modificationDate != null) builder.modificationDate(modificationDate); + if (backupDate != null) builder.backupDate(backupDate); + if (accessDate != null) builder.accessDate(accessDate); + return builder.build(); } diff --git a/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/HexDumper.java b/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/HexDumper.java new file mode 100644 index 0000000..abe749a --- /dev/null +++ b/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/HexDumper.java @@ -0,0 +1,62 @@ +package io.github.applecommander.applesingle.tools.asu; + +import java.io.PrintStream; +import java.util.Arrays; + +/** A slightly-configurable reusable hex dumping mechanism. */ +public class HexDumper { + private PrintStream ps = System.out; + private int lineWidth = 16; + private LinePrinter printLine; + + public static HexDumper standard() { + HexDumper hd = new HexDumper(); + hd.printLine = hd::standardLine; + return hd; + } + public static HexDumper alternate(LinePrinter linePrinter) { + HexDumper hd = new HexDumper(); + hd.printLine = linePrinter; + return hd; + } + + private HexDumper() { + // Prevent construction + } + + public void dump(int address, byte[] data, String description) { + int offset = 0; + while (offset < data.length) { + byte[] line = Arrays.copyOfRange(data, offset, Math.min(offset+lineWidth,data.length)); + printLine.print(address+offset, line, description); + description = ""; // Only on first line! + offset += line.length; + } + } + + public void standardLine(int address, byte[] data, String description) { + ps.printf("%04x: ", address); + for (int i=0; i= ' ') ? (char)b : '.'; + } + ps.printf("%c", ch); + } + ps.printf(" | %s\n", description); + } + + @FunctionalInterface + public interface LinePrinter { + public void print(int address, byte[] data, String description); + } +} diff --git a/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/IntRange.java b/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/IntRange.java new file mode 100644 index 0000000..5ec3fb7 --- /dev/null +++ b/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/IntRange.java @@ -0,0 +1,66 @@ +package io.github.applecommander.applesingle.tools.asu; + +import java.util.List; +import java.util.Optional; +import java.util.Stack; + +/** + * A basic integer range used to track file usage. + * low is inclusive while high is exclusive, because it made + * the code "simpler". + * + * @author rob + */ +public class IntRange { + private int low; + private int high; + + /** Create an integer range. */ + public static IntRange of(int low, int high) { + if (low == high) throw new UnsupportedOperationException("low and high cannot be the same"); + return new IntRange(Math.min(low,high), Math.max(low,high)); + } + /** Normalize a list by combining all integer ranges that match. */ + public static List normalize(List ranges) { + Stack rangeStack = new Stack<>(); + ranges.stream() + .sorted((a,b) -> Integer.compare(a.low, b.low)) + .forEach(r -> { + if (rangeStack.isEmpty()) { + rangeStack.add(r); + } else { + rangeStack.peek() + .merge(r) + .ifPresent(ranges::add); + } + }); + return rangeStack; + } + + private IntRange(int low, int high) { + this.low = low; + this.high = high; + } + public int getLow() { + return low; + } + public int getHigh() { + return high; + } + /** Merge the other IntRange into this one, if it fits. */ + public Optional merge(IntRange other) { + if (this.high == other.low) { + this.high = other.high; + return Optional.empty(); + } else if (this.low == other.high) { + this.low = other.low; + return Optional.empty(); + } else { + return Optional.of(other); + } + } + @Override + public String toString() { + return String.format("[%d..%d)", low, high); + } +} \ No newline at end of file diff --git a/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/Main.java b/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/Main.java index 7091682..ff3d1b0 100644 --- a/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/Main.java +++ b/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/Main.java @@ -15,7 +15,7 @@ import picocli.CommandLine.Option; commandListHeading = "%nCommands:%n", optionListHeading = "%nOptions:%n", description = "AppleSingle utility", - subcommands = { HelpCommand.class, InfoCommand.class, CreateCommand.class, ExtractCommand.class }) + subcommands = { HelpCommand.class, InfoCommand.class, AnalyzeCommand.class, CreateCommand.class, ExtractCommand.class }) public class Main implements Runnable { @Option(names = "--debug", description = "Dump full stack trackes if an error occurs") private static boolean debugFlag; diff --git a/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/NullOutputStream.java b/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/NullOutputStream.java new file mode 100644 index 0000000..44f544e --- /dev/null +++ b/tools/asu/src/main/java/io/github/applecommander/applesingle/tools/asu/NullOutputStream.java @@ -0,0 +1,16 @@ +package io.github.applecommander.applesingle.tools.asu; + +import java.io.IOException; +import java.io.OutputStream; + +/** An OutputStream that doesn't do output. */ +public class NullOutputStream extends OutputStream { + public static final NullOutputStream INSTANCE = new NullOutputStream(); + + private NullOutputStream() { /* Prevent construction */ } + + @Override + public void write(int b) throws IOException { + // Do Nothing + } +}