Skip to content

Commit d6ea908

Browse files
committed
Fix a signed overflow in the sector checksum, via the JDK intrinsic
The hand-written Adler-32 loop used int accumulators with zlib's NMAX fold interval. zlib picks that interval so s2 cannot overflow an *unsigned* 32-bit accumulator; Java has no such type and a signed int overflows at half that, so a sector of a few thousand high-valued bytes was checksummed wrongly. Reachable in practice: the default recompression setting never shrinks a sector, so raw bytes reach the checksum as they are. Now computed with java.util.zip.Adler32 and corrected for the seed, which differs by a closed form -- 1 in the low half, one per byte in the high half. That is also the faster path, since Adler32.update is a HotSpot intrinsic and a Java loop is not, and sectors can be 16 MiB. The bug survived a round trip, a green suite and a review for the same reason the seed itself did: reader and writer shared the wrong arithmetic and agreed with each other. It took an independent implementation plus a fixture with high-valued stored sectors, which is now exported for CI. The old loop survives as the oracle the fast path is fuzzed against, with long accumulators so it is actually correct. Also aligns the hi-block table with StormLib: an archive declaring one it cannot hold is reported rather than read with the low words alone, which silently relocated every file. FORCE_V0 still opens such an archive, since a version 0 header has no such field.
1 parent c8325be commit d6ea908

6 files changed

Lines changed: 248 additions & 27 deletions

File tree

docs/mpq-format-notes.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,31 @@ with `0` starts them at `s1 = 0, s2 = 0`. The two results differ by 1 in the low
243243
half and by the byte count in the high half — for every input, without
244244
exception.
245245

246-
**Decision.** `MpqChecksums.adler32` implements the seeded-zero form.
247-
`java.util.zip.Adler32` cannot be used: it offers no way to seed, so it always
248-
computes the standard variant.
246+
**Decision.** `MpqChecksums.adler32` produces the seeded-zero form *via*
247+
`java.util.zip.Adler32`. The seeds differ by a closed form rather than anything
248+
structural — running the recurrence from `s1 = 1` adds 1 to the low half and one
249+
per byte to the high half — so the JDK's intrinsic does the work and the result
250+
is corrected:
251+
252+
```
253+
s1(seed 0) = s1(seed 1) - 1 (mod 65521)
254+
s2(seed 0) = s2(seed 1) - n (mod 65521)
255+
```
256+
257+
The first implementation was a hand-written loop instead, and it was **wrong**.
258+
zlib chooses its `NMAX = 5552` fold interval so that `s2` cannot overflow an
259+
*unsigned* 32-bit accumulator; Java has no such type, and a signed `int`
260+
overflows at half that. A single sector of a few thousand high-valued bytes was
261+
therefore checksummed incorrectly — reachable in practice, because the default
262+
recompression setting never shrinks a sector, so raw bytes reach the checksum as
263+
they are.
264+
265+
That bug survived a round trip, an all-green suite and a code review, for the
266+
same reason as the seed itself: the reader and the writer shared the wrong
267+
arithmetic and agreed with each other. `tools/mpqref.py` caught it once a
268+
fixture with high-valued stored sectors existed, which is now part of the
269+
exported set. The hand-written loop survives as `adler32Reference`, used only as
270+
the oracle the fast path is fuzzed against.
249271

250272
This one is worth dwelling on, because no self-consistent test can catch it. A
251273
reader and a writer that both use the standard seed agree with each other on

src/main/java/org/inwc3/jmpq/MpqArchive.java

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -633,11 +633,18 @@ private int[] readHiBlockTable(int blockCount) throws IOException {
633633
}
634634
final long tableBytes = (long) blockCount * MpqHeader.HI_BLOCK_ENTRY_SIZE;
635635
if (!source.contains(header.hiBlockTableFileOffset(), tableBytes)) {
636-
// The archive claims a hi-block table it does not hold. Reading the
637-
// low words alone at least matches what a version 0 reader sees.
638-
log.warn("{} declares a hi-block table at {} that does not fit; ignoring it.",
639-
source.origin(), header.hiBlockTableFileOffset());
640-
return new int[0];
636+
// Reported, not worked around. An archive declaring a hi-block table
637+
// is declaring that its file positions do not fit in 32 bits, so
638+
// carrying on with the low words alone puts every file at the wrong
639+
// offset -- reads that fail, or worse, succeed with wrong bytes.
640+
// StormLib treats an unreadable hi-block table as fatal too
641+
// (BuildFileTable_Classic sets dwErrCode and stops). A version 0
642+
// archive never reaches here, so MPQOpenOption.FORCE_V0 remains the
643+
// way to read one whose header claims a table it does not have.
644+
throw new JMpqException("Archive declares a hi-block table at "
645+
+ header.hiBlockTableFileOffset() + " spanning " + tableBytes
646+
+ " bytes, which is not inside " + source.origin()
647+
+ ". Its file positions cannot be resolved.");
641648
}
642649
final int[] highWords = new int[blockCount];
643650
for (int i = 0; i < blockCount; i++) {

src/main/java/org/inwc3/jmpq/MpqChecksums.java

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,47 @@
11
package org.inwc3.jmpq;
22

3+
import java.util.zip.Adler32;
4+
35
/**
46
* The Adler-32 variant MPQ sector checksums use.
57
*
6-
* <h2>Why this is not {@link java.util.zip.Adler32}</h2>
8+
* <h2>Why this is not plain {@link Adler32}</h2>
79
* StormLib computes sector checksums as {@code adler32(0, buffer, length)} —
810
* both when writing them ({@code SFileAddFile.cpp}) and when checking them
911
* ({@code ReadMpqSectors} in {@code SFileReadFile.cpp}). Passing zlib a seed of
1012
* {@code 0} starts the accumulators at {@code s1 = 0, s2 = 0}, whereas a
11-
* standard Adler-32 — and so {@code java.util.zip.Adler32}, which offers no way
12-
* to seed it — starts at {@code s1 = 1}. The results differ by 1 in the low half
13-
* and by the byte count in the high half, for every input.
13+
* standard Adler-32 — and so {@link Adler32}, which offers no way to seed it —
14+
* starts at {@code s1 = 1}. The results differ by 1 in the low half and by the
15+
* byte count in the high half, for every input.
1416
* <p>
1517
* That is a difference no self-consistent test can see: a reader and a writer
1618
* that both use the standard seed agree with each other perfectly and disagree
1719
* with every archive StormLib ever wrote. It was caught by
1820
* {@code tools/mpqref.py}, which computes the value independently, and is the
1921
* reason that cross-check exists.
22+
*
23+
* <h2>Getting it from the intrinsic anyway</h2>
24+
* The two seeds differ by a closed form rather than by anything structural, so
25+
* the JDK's implementation can still do the work. Running the recurrence from
26+
* {@code s1 = 1} instead of {@code s1 = 0} adds exactly 1 to the low half, and
27+
* adds 1 per byte to the high half:
28+
* <pre>
29+
* s1(seed 1) = s1(seed 0) + 1
30+
* s2(seed 1) = s2(seed 0) + n
31+
* </pre>
32+
* So the seeded-zero value is recovered by subtracting those, modulo 65521.
33+
* {@link Adler32#update(byte[], int, int)} is a HotSpot intrinsic, which a
34+
* hand-written loop in Java is not — worth having when a single sector can be
35+
* 16 MiB and every sector of every file passes through here.
2036
*/
2137
final class MpqChecksums {
2238

2339
/** Largest Adler-32 modulus below 65536. */
2440
private static final int BASE = 65521;
2541

2642
/**
27-
* Largest number of bytes that can be accumulated before {@code s2} could
28-
* overflow a signed 32-bit int. zlib calls this {@code NMAX}.
43+
* Largest number of bytes the reference implementation accumulates before
44+
* reducing. zlib calls this {@code NMAX}.
2945
*/
3046
private static final int NMAX = 5552;
3147

@@ -48,8 +64,38 @@ static int adler32(byte[] data) {
4864
* @return the sector checksum MPQ records.
4965
*/
5066
static int adler32(byte[] data, int offset, int length) {
51-
int s1 = 0;
52-
int s2 = 0;
67+
final Adler32 standard = new Adler32();
68+
standard.update(data, offset, length);
69+
final int seededOne = (int) standard.getValue();
70+
71+
// Undo the seed: 1 from the low half, one per byte from the high half.
72+
final int low = Math.floorMod((seededOne & 0xFFFF) - 1, BASE);
73+
final int high = Math.floorMod(((seededOne >>> 16) & 0xFFFF) - length % BASE, BASE);
74+
return (high << 16) | low;
75+
}
76+
77+
/**
78+
* The definition, computed directly.
79+
* <p>
80+
* Kept as the oracle {@code MpqChecksumTests} checks {@link #adler32}
81+
* against, so the seed correction above cannot drift from what it claims to
82+
* compute. Not used in production: it is the same arithmetic without the
83+
* intrinsic.
84+
*
85+
* @param data bytes to checksum.
86+
* @param offset first byte to include.
87+
* @param length how many bytes to include.
88+
* @return the sector checksum MPQ records.
89+
*/
90+
static int adler32Reference(byte[] data, int offset, int length) {
91+
// long accumulators, deliberately. zlib picks NMAX so that s2 cannot
92+
// overflow an *unsigned* 32-bit accumulator; Java has no such type, and
93+
// a signed int overflows at half that. An earlier version of this method
94+
// was the production path and used int, so it silently produced wrong
95+
// checksums for a sector of a few thousand high-valued bytes -- reachable
96+
// as soon as an archive uses a sector size above the 4 KiB default.
97+
long s1 = 0;
98+
long s2 = 0;
5399
int at = offset;
54100
int remaining = length;
55101

@@ -63,6 +109,6 @@ static int adler32(byte[] data, int offset, int length) {
63109
s2 %= BASE;
64110
remaining -= block;
65111
}
66-
return (s2 << 16) | s1;
112+
return (int) ((s2 << 16) | s1);
67113
}
68114
}

src/main/java/org/inwc3/jmpq/MpqHeader.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -692,9 +692,10 @@ private static MpqHeader parseAt(MpqSource source, Located located, boolean forc
692692

693693
if (hiBlockTablePosition < 0
694694
|| (hiBlockTablePosition != 0 && !source.contains(offset + hiBlockTablePosition, 0))) {
695-
// A position outside the file cannot be a table. Dropping it leaves
696-
// the low words, which is what a version 0 reader would use.
697-
hiBlockTablePosition = 0;
695+
// Kept, not dropped. Dropping it left the low words in place, which
696+
// silently relocates every file in an archive that needs the high
697+
// ones; MpqArchive reports it instead, as StormLib does. Recording
698+
// that the header is malformed is still worth doing.
698699
malformed = true;
699700
}
700701

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package systems.crigges.jmpq3test;
2+
3+
import org.testng.Assert;
4+
import org.testng.annotations.Test;
5+
6+
import java.lang.reflect.Method;
7+
import java.nio.charset.StandardCharsets;
8+
import java.util.Random;
9+
10+
/**
11+
* The seeded-zero Adler-32 that MPQ sector checksums use.
12+
* <p>
13+
* Two things need pinning. That the value matches what StormLib computes, which
14+
* is what the literal constants below are for; and that the fast path -- the
15+
* JDK intrinsic plus a seed correction -- agrees with the definition computed
16+
* directly, which is what the fuzz comparison is for. Deriving one from the
17+
* other is an algebraic shortcut, and a shortcut nobody checks is a bug waiting
18+
* to happen.
19+
*/
20+
public class MpqChecksumTests {
21+
22+
private static Method fast;
23+
private static Method reference;
24+
25+
private static void load() throws Exception {
26+
if (fast == null) {
27+
final Class<?> type = Class.forName("org.inwc3.jmpq.MpqChecksums");
28+
fast = type.getDeclaredMethod("adler32", byte[].class, int.class, int.class);
29+
reference = type.getDeclaredMethod("adler32Reference", byte[].class, int.class, int.class);
30+
fast.setAccessible(true);
31+
reference.setAccessible(true);
32+
}
33+
}
34+
35+
private static int fast(byte[] data, int offset, int length) throws Exception {
36+
load();
37+
return (int) fast.invoke(null, data, offset, length);
38+
}
39+
40+
private static int reference(byte[] data, int offset, int length) throws Exception {
41+
load();
42+
return (int) reference.invoke(null, data, offset, length);
43+
}
44+
45+
/** Values taken from {@code zlib.adler32(data, 0)}, not from this code. */
46+
@Test
47+
public void knownValuesMatchZlibSeededWithZero() throws Exception {
48+
final byte[] abc = "abc".getBytes(StandardCharsets.UTF_8);
49+
Assert.assertEquals(fast(abc, 0, abc.length), 0x024A0126,
50+
"seeding with 1 would give 0x024D0127");
51+
Assert.assertEquals(fast(new byte[0], 0, 0), 0);
52+
53+
final byte[] many = new byte[10_000];
54+
java.util.Arrays.fill(many, (byte) 'a');
55+
Assert.assertEquals(fast(many, 0, many.length), 0x78ABCDE2,
56+
"long enough to cross the block boundary the accumulator folds at");
57+
}
58+
59+
/**
60+
* The intrinsic-plus-correction path against the definition, over lengths
61+
* that straddle every boundary that matters: empty, one byte, and either
62+
* side of zlib's 5552-byte fold.
63+
*/
64+
@Test
65+
public void theFastPathAgreesWithTheDefinition() throws Exception {
66+
final Random random = new Random(11);
67+
final int[] lengths = {0, 1, 2, 15, 16, 255, 4096, 5551, 5552, 5553, 11_104, 40_000};
68+
69+
for (int length : lengths) {
70+
final byte[] data = new byte[length];
71+
random.nextBytes(data);
72+
Assert.assertEquals(fast(data, 0, length), reference(data, 0, length),
73+
"length " + length);
74+
75+
// All-zero and all-0xFF exercise the modulus at both extremes.
76+
Assert.assertEquals(fast(new byte[length], 0, length),
77+
reference(new byte[length], 0, length), "zeroes, length " + length);
78+
final byte[] high = new byte[length];
79+
java.util.Arrays.fill(high, (byte) 0xFF);
80+
Assert.assertEquals(fast(high, 0, length), reference(high, 0, length),
81+
"0xFF, length " + length);
82+
}
83+
}
84+
85+
/** Offsets and lengths inside a larger array must be honoured. */
86+
@Test
87+
public void slicesAreHonoured() throws Exception {
88+
final Random random = new Random(12);
89+
final byte[] data = new byte[8192];
90+
random.nextBytes(data);
91+
92+
for (int offset : new int[]{0, 1, 7, 4095}) {
93+
for (int length : new int[]{0, 1, 100, 4000}) {
94+
Assert.assertEquals(fast(data, offset, length), reference(data, offset, length),
95+
"offset " + offset + " length " + length);
96+
}
97+
}
98+
}
99+
}

src/test/java/systems/crigges/jmpq3test/Phase2FormatTests.java

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -538,12 +538,21 @@ public void aNonZeroHiBlockEntryMovesTheFilePosition() throws IOException {
538538
}
539539

540540
/**
541-
* An archive claiming a hi-block table it does not hold is read with the low
542-
* words alone, which is what a version 0 reader would do anyway. Refusing it
543-
* would lose an archive that is entirely readable.
541+
* An archive claiming a hi-block table it does not hold is reported, not
542+
* quietly read with the low words alone.
543+
* <p>
544+
* Ignoring the table looked like leniency and was not: declaring one means
545+
* the file positions do not fit in 32 bits, so dropping the high words puts
546+
* every file at the wrong offset — reads that fail, or worse, succeed with
547+
* the wrong bytes. StormLib treats an unreadable hi-block table as fatal for
548+
* the same reason.
549+
* <p>
550+
* The escape hatch is real rather than notional: a version 0 archive never
551+
* consults the field, so a Warcraft III map whose header was corrupted into
552+
* claiming one still opens under {@code FORCE_V0}.
544553
*/
545554
@Test
546-
public void aHiBlockTableOutsideTheFileIsIgnored() throws IOException {
555+
public void aHiBlockTableOutsideTheFileIsReported() throws IOException {
547556
final byte[] plain = MpqArchiveWriter.create(MpqWriteOptions.defaults()
548557
.withFormatVersion(1))
549558
.put("a.txt", "content".getBytes(StandardCharsets.UTF_8))
@@ -553,13 +562,36 @@ public void aHiBlockTableOutsideTheFileIsIgnored() throws IOException {
553562
final int headerAt = headerOffset(plain);
554563
edit.putLong(headerAt + 0x20, 0x7FFF_FFFFL);
555564

556-
try (MpqArchive archive = MpqArchive.open(plain, MpqOpenOptions.defaults())) {
557-
Assert.assertFalse(archive.header().hasHiBlockTable(), "dropped as implausible");
558-
Assert.assertTrue(archive.header().malformed());
565+
final JMpqException thrown = Assert.expectThrows(JMpqException.class,
566+
() -> MpqArchive.open(plain, MpqOpenOptions.defaults()).close());
567+
Assert.assertTrue(thrown.getMessage().contains("hi-block table"), thrown.getMessage());
568+
569+
// Read as version 0, the field is not part of the header at all.
570+
try (MpqArchive archive = MpqArchive.open(plain, MpqOpenOptions.warcraft3())) {
571+
Assert.assertFalse(archive.header().hasHiBlockTable());
559572
Assert.assertEquals(archive.read("a.txt"), "content".getBytes(StandardCharsets.UTF_8));
560573
}
561574
}
562575

576+
/** A table whose position is in the file but which runs off the end, likewise. */
577+
@Test
578+
public void aTruncatedHiBlockTableIsReported() throws IOException {
579+
final byte[] plain = MpqArchiveWriter.create(MpqWriteOptions.defaults()
580+
.withFormatVersion(1))
581+
.put("a.txt", "content".getBytes(StandardCharsets.UTF_8))
582+
.toByteArray();
583+
584+
// One byte before the end: inside the file, but far too small to hold
585+
// one entry per block.
586+
final ByteBuffer edit = ByteBuffer.wrap(plain).order(ByteOrder.LITTLE_ENDIAN);
587+
final int headerAt = headerOffset(plain);
588+
edit.putLong(headerAt + 0x20, plain.length - headerAt - 1);
589+
590+
final JMpqException thrown = Assert.expectThrows(JMpqException.class,
591+
() -> MpqArchive.open(plain, MpqOpenOptions.defaults()).close());
592+
Assert.assertTrue(thrown.getMessage().contains("hi-block table"), thrown.getMessage());
593+
}
594+
563595
/**
564596
* Appends a hi-block table to a version 1 archive and points the header at
565597
* it. The table is neither encrypted nor compressed, per StormLib.
@@ -723,11 +755,25 @@ public void exportForReferenceVerification() throws IOException {
723755

724756
final StringBuilder expected = new StringBuilder("# archive\tname\tsize\tmd5\n");
725757

758+
// 8 KiB sectors of incompressible data are stored raw, so a whole
759+
// sector of high-valued bytes reaches the checksum. That is what
760+
// overflows a signed 32-bit Adler accumulator -- and it is invisible to a
761+
// round trip, because both sides would share the same wrong arithmetic.
762+
// Only the reference can see it, which is why this shape is exported.
763+
files.put("wide.bin", incompressible(40_000, 21));
764+
// High-valued bytes, stored raw: the default recompression setting never
765+
// shrinks a sector, so these reach the checksum as they are. A run of
766+
// 0xFF is what pushes the Adler accumulator past a signed 32-bit range.
767+
final byte[] high = new byte[40_000];
768+
java.util.Arrays.fill(high, (byte) 0xFF);
769+
files.put("high.bin", high);
770+
726771
final MpqWriteOptions[] shapes = {
727772
MpqWriteOptions.defaults().withSectorChecksums(true),
728773
MpqWriteOptions.defaults().withAttributes(true).withAttributesTimestamp(0),
729774
MpqWriteOptions.defaults().withSectorChecksums(true).withAttributes(true)
730775
.withAttributesTimestamp(0).withFormatVersion(1),
776+
MpqWriteOptions.defaults().withSectorChecksums(true).withSectorSizeShift(4),
731777
};
732778

733779
for (int shape = 0; shape < shapes.length; shape++) {

0 commit comments

Comments
 (0)