diff --git a/bundler/src/main/java/io/ib67/sfcraft/bundler/BundleReader.java b/bundler/src/main/java/io/ib67/sfcraft/bundler/BundleReader.java index cb47f1c..3980dda 100644 --- a/bundler/src/main/java/io/ib67/sfcraft/bundler/BundleReader.java +++ b/bundler/src/main/java/io/ib67/sfcraft/bundler/BundleReader.java @@ -85,7 +85,18 @@ public class BundleReader { var result = reassembler.writeReassembled(RegionFile.CompressType.ZLIB); try (var fc = FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { - result.readBytes(fc, 0, result.readableBytes()); + // readBytes(FileChannel, ...) does a single channel write and + // may write fewer bytes than requested; loop or the region file + // is silently truncated. + long pos = 0; + int remaining = result.readableBytes(); + while (remaining > 0) { + int written = result.readBytes(fc, pos, remaining); + if (written <= 0) + throw new IOException("Short write restoring " + entry.name() + " to " + path); + pos += written; + remaining -= written; + } } finally { result.release(); } diff --git a/bundler/src/main/java/io/ib67/sfcraft/bundler/EntryInputStream.java b/bundler/src/main/java/io/ib67/sfcraft/bundler/EntryInputStream.java index 68886a5..5525fdc 100644 --- a/bundler/src/main/java/io/ib67/sfcraft/bundler/EntryInputStream.java +++ b/bundler/src/main/java/io/ib67/sfcraft/bundler/EntryInputStream.java @@ -25,8 +25,12 @@ public class EntryInputStream extends FilterInputStream { public BundleEntry readEntry() throws IOException { var magic = (short) (in.read() << 8 | in.read()); // u16 if (magic != BundleEntry.MAGIC) throw new IOException("Invalid magic number: "+Long.toHexString(magic)); + // every byte must be widened to long before shifting: an int shift such as + // `in.read() << 24` goes negative when the byte is >= 0x80 and sign-extends into the + // high 32 bits, corrupting the timestamp (which then makes the entry look "too old" + // and get silently skipped on restore). var time = (long) in.read() << 56 | (long) in.read() << 48 | (long) in.read() << 40 | (long) in.read() << 32 | - (long) in.read() << 24 | in.read() << 16 | in.read() << 8 | in.read(); + (long) in.read() << 24 | (long) in.read() << 16 | (long) in.read() << 8 | (long) in.read(); var attribute = VarInts.readVarInt(in); var segmentLen = VarInts.readVarLong(in); var lenName = VarInts.readVarInt(in); diff --git a/bundler/src/test/java/io/ib67/bundler/TestBundleReader.java b/bundler/src/test/java/io/ib67/bundler/TestBundleReader.java index 71362ad..8e2da0d 100644 --- a/bundler/src/test/java/io/ib67/bundler/TestBundleReader.java +++ b/bundler/src/test/java/io/ib67/bundler/TestBundleReader.java @@ -73,6 +73,26 @@ public class TestBundleReader { "the symlink itself must be recreated at the link's path, not at its target"); } + @Test + public void testTimestampWithHighBitSurvivesRoundTrip(@TempDir Path tmp) throws IOException { + // 1798761600000 has 0xCE at bits 24-31; an int shift on that byte sign-extends and + // makes the decoded time negative, which used to make extract() skip the entry as + // "too old". ~half of all real-world dates land in such a band. + var time = 1798761600000L; + assertTrue(((time >> 24) & 0xFF) >= 0x80, "test vector must exercise the sign-extension path"); + var bundle = tmp.resolve("hibit.swb.zst"); + var payload = "kept".getBytes(); + try (var zstd = new ZstdOutputStreamNoFinalizer(Files.newOutputStream(bundle), 3); + var out = new EntryOutputStream(zstd)) { + out.writeEntryHeader("world.dat", (short) 0, payload.length, time); + out.write(payload); + } + var dest = tmp.resolve("dest"); + BundleReader.builder().build().extract(bundle, dest); + assertEquals("kept", Files.readString(dest.resolve("world.dat")), + "entry must not be skipped just because its timestamp byte has the high bit set"); + } + @Test public void testTruncatedEntryThrowsInsteadOfLooping(@TempDir Path tmp) throws IOException { var bundle = tmp.resolve("truncated.swb.zst"); diff --git a/bundler/test.mca b/bundler/test.mca index 3d19d2e..cd88f99 100644 Binary files a/bundler/test.mca and b/bundler/test.mca differ