diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java index e33849960bf..632c79d3a93 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java @@ -33,17 +33,19 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.time.Instant; import java.util.*; import java.util.concurrent.Semaphore; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.zip.Checksum; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Pair.pair; @@ -205,23 +207,65 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion } } - @Override - public Optional getRemoteVersionByLocalFile(Path file) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (InputStream stream = Files.newInputStream(file)) { - byte[] buf = new byte[1024]; - int len; - while ((len = stream.read(buf, 0, buf.length)) != -1) { + /// Calculates the CurseForge fingerprint without retaining the filtered file in memory. + static long calculateFingerprint(Path file) throws IOException { + try (SeekableByteChannel channel = Files.newByteChannel(file, StandardOpenOption.READ)) { + long startPosition = channel.position(); + + byte[] bufferArray = new byte[1024 * 1024]; + ByteBuffer buffer = ByteBuffer.wrap(bufferArray); + + long filteredLength = 0; + while (channel.read(buffer) > 0) { + int len = buffer.position(); for (int i = 0; i < len; i++) { - byte b = buf[i]; + byte b = bufferArray[i]; if (b != 0x9 && b != 0xa && b != 0xd && b != 0x20) { - baos.write(b); + filteredLength++; + } + } + buffer.clear(); + } + + channel.position(startPosition); + + Checksum hasher = MurmurHash2.hash32(filteredLength, 1); + while (channel.read(buffer) > 0) { + int len = buffer.position(); + + int pos = 0; + while (pos < len) { + byte b = bufferArray[pos]; + if (b == 0x9 || b == 0xa || b == 0xd || b == 0x20) { + break; + } + pos++; + } + + if (pos < len) { + int pos2 = pos + 1; + while (pos2 < len) { + byte b = bufferArray[pos2]; + if (b != 0x9 && b != 0xa && b != 0xd && b != 0x20) { + bufferArray[pos++] = b; + } + pos2++; } } + + hasher.update(bufferArray, 0, pos); + buffer.clear(); } + return hasher.getValue(); + } catch (IllegalArgumentException | IllegalStateException e) { + throw new IOException(e); } + } - long hash = Integer.toUnsignedLong(MurmurHash2.hash32(baos.toByteArray(), baos.size(), 1)); + /// Finds the remote CurseForge version matching a local file. + @Override + public Optional getRemoteVersionByLocalFile(Path file) throws IOException { + long hash = calculateFingerprint(file); if (hash == 811513880) { // Workaround for https://github.com/HMCL-dev/HMCL/issues/4597 return Optional.empty(); } @@ -309,7 +353,8 @@ public String getAddonChangelog(DownloadProvider downloadProvider, String addonI case SECTION_ADDONS -> "mc-addons"; case SECTION_CUSTOMIZATION -> "customization"; case SECTION_SHADER -> "shaders"; - default -> throw new IllegalArgumentException("Unsupported CurseForge class id [%d]".formatted(classId)); + default -> + throw new IllegalArgumentException("Unsupported CurseForge class id [%d]".formatted(classId)); }; return "%s/minecraft/%s/%s/files/%s".formatted(BASE, clazz, addon.slug(), version.versionId()); } finally { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java index 5538217ec12..79c37df3c05 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java @@ -17,7 +17,11 @@ */ package org.jackhuang.hmcl.util; +import org.jetbrains.annotations.NotNullByDefault; + import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.zip.Checksum; /** * Implementation of the MurmurHash2 32-bit and 64-bit hash functions. @@ -48,6 +52,7 @@ * Original MurmurHash2 c++ code * @since 1.13 */ +@NotNullByDefault public final class MurmurHash2 { // Constants for 32-bit variant @@ -64,6 +69,177 @@ public final class MurmurHash2 { private MurmurHash2() { } + /// Creates a streaming MurmurHash2 32-bit checksum for exactly `length` bytes. + /// + /// The length is incorporated into the initial hash state using its low 32 bits. Calls to + /// [Checksum#update(int)] and [Checksum#update(byte[], int, int)] may divide the input at + /// arbitrary byte boundaries. [Checksum#getValue()] returns the hash as an unsigned 32-bit + /// value represented by a `long`, and does not change the checksum state. + /// + /// The returned checksum verifies the number of supplied bytes when `getValue()` is called. + /// If too few bytes have been supplied, the caller may continue updating the checksum and call + /// `getValue()` again. Once too many bytes have been supplied, [Checksum#reset()] must be called + /// before a value can be obtained. Resetting retains the configured length and seed. The + /// returned checksum is mutable and is not safe for concurrent use. + /// + /// @param length the exact number of input bytes + /// @param seed the initial seed value + /// @return a new checksum initialized with the specified length and seed + /// @throws IllegalArgumentException if `length` is negative + public static Checksum hash32(final long length, final int seed) { + if (length < 0) { + throw new IllegalArgumentException("length must not be negative: " + length); + } + return new Hash32Checksum(length, seed); + } + + /// Computes a MurmurHash2 32-bit value from updates whose total length is known in advance. + private static final class Hash32Checksum implements Checksum { + /// The exact number of bytes required before the hash can be obtained. + private final long expectedLength; + + /// The hash state restored by [#reset()]. + private final int initialHash; + + /// The hash state after all complete four-byte blocks received so far. + private int hash; + + /// The number of bytes counted before [#inputLengthExceeded] becomes `true`. + private long inputLength; + + /// Whether more than [#expectedLength] bytes have been received. + private boolean inputLengthExceeded; + + /// Up to three unprocessed bytes packed in little-endian order. + private int tail; + + /// The number of bytes currently stored in [#tail]. + private int tailLength; + + /// Creates a checksum with a precomputed initial hash state. + /// + /// @param expectedLength the exact number of input bytes + /// @param seed the initial seed value + private Hash32Checksum(long expectedLength, int seed) { + this.expectedLength = expectedLength; + this.initialHash = seed ^ (int) expectedLength; + this.hash = initialHash; + } + + /// Incorporates the low eight bits of `value` into this checksum. + /// + /// @param value the value whose low eight bits are incorporated + @Override + public void update(int value) { + addInputLength(1); + appendByte(value); + } + + /// Incorporates `length` bytes beginning at `offset` into this checksum. + /// + /// @param data the array containing the input bytes + /// @param offset the offset of the first input byte + /// @param length the number of bytes to incorporate + @Override + public void update(byte[] data, int offset, int length) { + Objects.checkFromIndexSize(offset, length, data.length); + addInputLength(length); + + int index = offset; + final int end = offset + length; + + while (tailLength != 0 && index < end) { + appendByte(data[index++]); + } + + while (index <= end - Integer.BYTES) { + mixBlock(ByteArray.getIntLE(data, index)); + index += Integer.BYTES; + } + + while (index < end) { + appendByte(data[index++]); + } + } + + /// Returns the MurmurHash2 value after verifying the exact input length. + /// + /// @return the unsigned 32-bit hash value represented by a `long` + /// @throws IllegalStateException if the number of supplied bytes differs from the expected + /// length + @Override + public long getValue() { + if (inputLengthExceeded) { + throw new IllegalStateException( + "Expected " + expectedLength + " bytes, but received more than expected"); + } + if (inputLength != expectedLength) { + throw new IllegalStateException( + "Expected " + expectedLength + " bytes, but received " + inputLength); + } + + int result = hash; + if (tailLength != 0) { + result ^= tail; + result *= M32; + } + + result ^= result >>> 13; + result *= M32; + result ^= result >>> 15; + return Integer.toUnsignedLong(result); + } + + /// Restores this checksum to its initial state while retaining its expected length and seed. + @Override + public void reset() { + hash = initialHash; + inputLength = 0; + inputLengthExceeded = false; + tail = 0; + tailLength = 0; + } + + /// Records that `length` more input bytes have been supplied. + /// + /// @param length the non-negative number of additional bytes + private void addInputLength(int length) { + if (inputLengthExceeded) { + return; + } + if (length > expectedLength - inputLength) { + inputLengthExceeded = true; + } else { + inputLength += length; + } + } + + /// Buffers one byte and mixes the resulting block when four bytes are available. + /// + /// @param value the value whose low eight bits are appended + private void appendByte(int value) { + tail |= (value & 0xff) << (tailLength * Byte.SIZE); + tailLength++; + if (tailLength == Integer.BYTES) { + mixBlock(tail); + tail = 0; + tailLength = 0; + } + } + + /// Mixes one little-endian four-byte block into the current hash state. + /// + /// @param block the block to mix + private void mixBlock(int block) { + int mixedBlock = block; + mixedBlock *= M32; + mixedBlock ^= mixedBlock >>> R32; + mixedBlock *= M32; + hash *= M32; + hash ^= mixedBlock; + } + } + /** * Generates a 32-bit hash from byte array with the given length and seed. * diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/addon/curse/CurseForgeRemoteAddonRepositoryTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/addon/curse/CurseForgeRemoteAddonRepositoryTest.java deleted file mode 100644 index 6ded248177e..00000000000 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/addon/curse/CurseForgeRemoteAddonRepositoryTest.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2021 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.addon.curse; - -import org.jackhuang.hmcl.util.MurmurHash2; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Paths; - -import static org.junit.jupiter.api.Assertions.*; - -public class CurseForgeRemoteAddonRepositoryTest { - - @Test - @Disabled - public void testMurmurHash() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (InputStream is = Files.newInputStream(Paths.get("C:\\Users\\huang\\Downloads\\JustEnoughCalculation-1.16.5-3.8.5.jar"))) { - byte[] buf = new byte[1024]; - int len; - while ((len = is.read(buf, 0, buf.length)) > 0) { - for (int i = 0; i < len; i++) { - byte b = buf[i]; - if (b != 9 && b != 10 && b != 13 && b != 32) { - baos.write(b); - } - } - } - - } - long hash = Integer.toUnsignedLong(MurmurHash2.hash32(baos.toByteArray(), baos.size(), 1)); - - assertEquals(hash, 3333498611L); - } -} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepositoryTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepositoryTest.java new file mode 100644 index 00000000000..26ee62ceb6a --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepositoryTest.java @@ -0,0 +1,73 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.addon.repository; + +import org.jackhuang.hmcl.util.MurmurHash2; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Tests CurseForge fingerprint calculation for local files. +@NotNullByDefault +public final class CurseForgeRemoteAddonRepositoryTest { + + /// Verifies that streaming calculation remains identical to the previous in-memory algorithm. + @Test + public void calculatesFingerprintWithoutChangingTheResult(@TempDir Path tempDir) throws IOException { + byte[] boundarySample = new byte[8192 + 17]; + new Random(0).nextBytes(boundarySample); + for (int i = 0; i < boundarySample.length; i += 97) { + boundarySample[i] = 0x20; + } + + byte[][] samples = { + {}, + {0x9, 0xa, 0xd, 0x20}, + {1}, + {1, 2}, + {1, 2, 3}, + {1, 2, 3, 4}, + {1, 0x20, 2, 0xa, 3, 0xd, 4, 0x9, 5}, + boundarySample + }; + + for (int i = 0; i < samples.length; i++) { + byte[] sample = samples[i]; + Path file = tempDir.resolve("sample-" + i); + Files.write(file, sample); + + ByteArrayOutputStream filtered = new ByteArrayOutputStream(); + for (byte b : sample) { + if (b != 0x9 && b != 0xa && b != 0xd && b != 0x20) { + filtered.write(b); + } + } + long expected = Integer.toUnsignedLong(MurmurHash2.hash32(filtered.toByteArray(), filtered.size(), 1)); + + assertEquals(expected, CurseForgeRemoteAddonRepository.calculateFingerprint(file)); + } + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/MurmurHash2Test.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/MurmurHash2Test.java new file mode 100644 index 00000000000..ab4fdc7982b --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/MurmurHash2Test.java @@ -0,0 +1,272 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util; + +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Random; +import java.util.zip.Checksum; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Tests the streaming MurmurHash2 checksum implementation. +@NotNullByDefault +public final class MurmurHash2Test { + /// Compares whole-array, bulk, byte-wise, and fragmented updates across lengths and seeds. + @Test + public void testStreamingHash32AcrossLengthsAndSeeds() { + byte[] data = new byte[513]; + new Random(0x6d75726d75724cL).nextBytes(data); + + for (int length = 0; length <= data.length; length++) { + assertStreamingHash32(data, length, 0); + assertStreamingHash32(data, length, -1); + assertStreamingHash32(data, length, 0x9747b28c); + assertStreamingHash32(data, length, 0x9747b28c ^ length * 31); + } + } + + /// Compares every two-part split while reading from a non-zero source-array offset. + @Test + public void testStreamingHash32AtEverySplitPoint() { + byte[] data = new byte[73]; + new Random(0x73706c697473L).nextBytes(data); + + int sourceOffset = 5; + byte[] paddedData = new byte[sourceOffset + data.length + 7]; + System.arraycopy(data, 0, paddedData, sourceOffset, data.length); + + for (int length = 0; length <= data.length; length++) { + int seed = Integer.rotateLeft(0x4d757232, length & 31); + long expected = hash32(data, length, seed); + + for (int split = 0; split <= length; split++) { + Checksum checksum = MurmurHash2.hash32(length, seed); + checksum.update(paddedData, sourceOffset, split); + checksum.update(paddedData, sourceOffset + split, length - split); + assertEquals(expected, checksum.getValue(), + "length=" + length + ", split=" + split); + } + } + } + + /// Compares interleaved single-byte and bulk updates after every possible short prefix. + @Test + public void testStreamingHash32WithMixedUpdates() { + byte[] data = new byte[129]; + new Random(0x6d69786564L).nextBytes(data); + + for (int length = 0; length <= data.length; length++) { + int seed = 0x13579bdf ^ length; + long expected = hash32(data, length, seed); + + for (int prefixLength = 0; prefixLength <= Math.min(length, 7); prefixLength++) { + Checksum checksum = MurmurHash2.hash32(length, seed); + int offset = 0; + + while (offset < prefixLength) { + checksum.update(data[offset++]); + } + + // Exercise an empty bulk update both with and without a buffered tail. + checksum.update(data, offset, 0); + + while (offset < length) { + int chunkLength = Math.min(offset % 5 + 1, length - offset); + checksum.update(data, offset, chunkLength); + offset += chunkLength; + + if (offset < length) { + checksum.update(data[offset++]); + } + } + + assertEquals(expected, checksum.getValue(), + "length=" + length + ", prefixLength=" + prefixLength); + } + } + } + + /// Compares array-backed, read-only, direct, and empty-buffer updates. + @Test + public void testStreamingHash32WithByteBuffers() { + byte[] data = new byte[8207]; + new Random(0x627566666572L).nextBytes(data); + int seed = 0x2468ace0; + long expected = hash32(data, data.length, seed); + + int sourceOffset = 3; + byte[] paddedData = new byte[sourceOffset + data.length + 4]; + System.arraycopy(data, 0, paddedData, sourceOffset, data.length); + ByteBuffer arrayBackedBuffer = ByteBuffer.wrap(paddedData); + arrayBackedBuffer.position(sourceOffset); + arrayBackedBuffer.limit(sourceOffset + data.length); + assertTrue(arrayBackedBuffer.hasArray()); + + Checksum arrayBackedChecksum = MurmurHash2.hash32(data.length, seed); + arrayBackedChecksum.update(arrayBackedBuffer); + assertEquals(arrayBackedBuffer.limit(), arrayBackedBuffer.position()); + assertEquals(expected, arrayBackedChecksum.getValue()); + + ByteBuffer readOnlyBuffer = ByteBuffer.wrap(data).asReadOnlyBuffer(); + assertFalse(readOnlyBuffer.hasArray()); + + Checksum readOnlyChecksum = MurmurHash2.hash32(data.length, seed); + readOnlyChecksum.update(readOnlyBuffer); + assertEquals(readOnlyBuffer.limit(), readOnlyBuffer.position()); + assertEquals(expected, readOnlyChecksum.getValue()); + + ByteBuffer directBuffer = ByteBuffer.allocateDirect(sourceOffset + data.length); + directBuffer.position(sourceOffset); + directBuffer.put(data); + directBuffer.position(sourceOffset); + assertFalse(directBuffer.hasArray()); + + Checksum directChecksum = MurmurHash2.hash32(data.length, seed); + directChecksum.update(directBuffer); + assertEquals(directBuffer.limit(), directBuffer.position()); + assertEquals(expected, directChecksum.getValue()); + + ByteBuffer emptyBuffer = ByteBuffer.allocate(4); + emptyBuffer.position(2); + emptyBuffer.limit(2); + + Checksum emptyFirstChecksum = MurmurHash2.hash32(data.length, seed); + emptyFirstChecksum.update(emptyBuffer); + emptyFirstChecksum.update(data); + assertEquals(2, emptyBuffer.position()); + assertEquals(expected, emptyFirstChecksum.getValue()); + } + + /// Verifies that single-byte updates use only the low eight bits of each value. + @Test + public void testStreamingHash32SingleByteValues() { + byte[] data = {0x12, (byte) 0xff, (byte) 0x80, 0x00}; + int seed = 0x12345678; + + Checksum checksum = MurmurHash2.hash32(data.length, seed); + checksum.update(0x112); + checksum.update(-1); + checksum.update(0xabcdef80); + checksum.update(0x100); + + assertEquals(hash32(data, data.length, seed), checksum.getValue()); + } + + /// Verifies that rejected array ranges leave the checksum state unchanged. + @Test + public void testStreamingHash32ArrayRangeValidation() { + byte[] data = {0x12, 0x34, 0x56}; + int seed = 0x12345678; + Checksum checksum = MurmurHash2.hash32(data.length, seed); + + assertThrows(NullPointerException.class, () -> checksum.update(null, 0, 0)); + assertThrows(IndexOutOfBoundsException.class, () -> checksum.update(data, -1, 1)); + assertThrows(IndexOutOfBoundsException.class, () -> checksum.update(data, 0, -1)); + assertThrows(IndexOutOfBoundsException.class, () -> checksum.update(data, data.length, 1)); + + checksum.update(data); + assertEquals(hash32(data, data.length, seed), checksum.getValue()); + } + + /// Verifies that `getValue()` rejects incomplete and excessive input without finalizing state. + @Test + public void testExactLengthValidation() { + byte[] data = {0x12, 0x34, 0x56, 0x78, (byte) 0x9a}; + int seed = 0x12345678; + long expected = hash32(data, data.length, seed); + + Checksum checksum = MurmurHash2.hash32(data.length, seed); + checksum.update(data, 0, data.length - 1); + assertThrows(IllegalStateException.class, checksum::getValue); + + checksum.update(data[data.length - 1]); + assertEquals(expected, checksum.getValue()); + + checksum.update(0xff); + checksum.update(0xfe); + assertThrows(IllegalStateException.class, checksum::getValue); + + checksum.reset(); + checksum.update(data, 0, data.length); + assertEquals(expected, checksum.getValue()); + } + + /// Verifies the valid range of the declared input length. + @Test + public void testLengthRange() { + assertThrows(IllegalArgumentException.class, () -> MurmurHash2.hash32(-1, 0)); + + Checksum checksum = MurmurHash2.hash32(1L << 32, 0); + assertThrows(IllegalStateException.class, checksum::getValue); + } + + /// Compares the principal update styles with the byte-array implementation. + /// + /// @param data the input array + /// @param length the number of bytes to hash + /// @param seed the initial seed value + private static void assertStreamingHash32(byte[] data, int length, int seed) { + long expected = hash32(data, length, seed); + + byte[] exactData = Arrays.copyOf(data, length); + Checksum wholeArray = MurmurHash2.hash32(length, seed); + wholeArray.update(exactData); + assertEquals(expected, wholeArray.getValue()); + + Checksum bulk = MurmurHash2.hash32(length, seed); + bulk.update(data, 0, length); + assertEquals(expected, bulk.getValue()); + assertEquals(expected, bulk.getValue()); + + Checksum byteWise = MurmurHash2.hash32(length, seed); + for (int i = 0; i < length; i++) { + byteWise.update(data[i]); + } + assertEquals(expected, byteWise.getValue()); + + Checksum fragmented = MurmurHash2.hash32(length, seed); + int offset = 0; + while (offset < length) { + int chunkLength = Math.min(offset % 7 + 1, length - offset); + fragmented.update(data, offset, chunkLength); + offset += chunkLength; + } + assertEquals(expected, fragmented.getValue()); + } + + /// Returns the unsigned value produced by the byte-array implementation. + /// + /// @param data the input array + /// @param length the number of bytes to hash + /// @param seed the initial seed value + /// @return the unsigned 32-bit hash value represented by a `long` + private static long hash32(byte[] data, int length, int seed) { + return Integer.toUnsignedLong(MurmurHash2.hash32(data, length, seed)); + } + + /// Prevents instantiation. + private MurmurHash2Test() { + } +}