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..453b7235f8e 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,7 +33,6 @@ 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; @@ -207,21 +206,26 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion @Override public Optional getRemoteVersionByLocalFile(Path file) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); + var murmur = new MurmurHash2(1); try (InputStream stream = Files.newInputStream(file)) { - byte[] buf = new byte[1024]; + byte[] input = new byte[1024]; + byte[] hashBuffer = new byte[1024]; int len; - while ((len = stream.read(buf, 0, buf.length)) != -1) { + while ((len = stream.read(input, 0, input.length)) > 0) { + int hashBufferLen = 0; + for (int i = 0; i < len; i++) { - byte b = buf[i]; + byte b = input[i]; if (b != 0x9 && b != 0xa && b != 0xd && b != 0x20) { - baos.write(b); + hashBuffer[hashBufferLen++] = b; } } + + murmur.update(hashBuffer, 0, hashBufferLen); } } - long hash = Integer.toUnsignedLong(MurmurHash2.hash32(baos.toByteArray(), baos.size(), 1)); + long hash = murmur.getValue(); if (hash == 811513880) { // Workaround for https://github.com/HMCL-dev/HMCL/issues/4597 return Optional.empty(); } 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..e6e423cadbf 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MurmurHash2.java @@ -17,281 +17,206 @@ */ package org.jackhuang.hmcl.util; -import java.nio.charset.StandardCharsets; +import org.jetbrains.annotations.NotNullByDefault; + +import java.util.Arrays; +import java.util.Objects; +import java.util.zip.Checksum; + +/// Computes the 32-bit MurmurHash2 value of a sequence of bytes. +/// +/// Bytes supplied by successive update operations are treated as one contiguous +/// input. Four-byte words are decoded in little-endian order, matching the +/// reference implementation on little-endian platforms. [#getValue()] returns +/// the resulting unsigned 32-bit value as a `long`. +/// +/// Instances are not safe for concurrent use. +/// +/// @apiNote MurmurHash2 is a non-cryptographic hash and must not be used where +/// collision resistance or protection against malicious input is required. +/// @implNote The reference algorithm incorporates the final input length before +/// processing any words, so its state cannot be finalized incrementally. This +/// implementation retains one pre-mixed value for every complete input word. +/// Storage therefore grows linearly with the largest input processed by an +/// instance and is reused after [#reset()]. +/// @see MurmurHash2.cpp +@NotNullByDefault +public final class MurmurHash2 implements Checksum { + /// Multiplication constant used by the 32-bit MurmurHash2 mixer. + private static final int MIX_MULTIPLIER = 0x5bd1e995; + + /// Right-shift distance used by the 32-bit MurmurHash2 mixer. + private static final int MIX_SHIFT = 24; + + /// Initial capacity of the complete-word buffer. + private static final int INITIAL_BLOCK_CAPACITY = 16; + + /// Seed mixed into the initial hash value. + private final int seed; + + /// Pre-mixed values of all complete four-byte input words. + private int[] mixedBlocks = new int[INITIAL_BLOCK_CAPACITY]; + + /// Number of entries in [#mixedBlocks] that contain input words. + private int blockCount; + + /// Incomplete trailing word assembled in little-endian order. + private int tail; + + /// Number of input bytes currently stored in [#tail]. + private int tailLength; + + /// Total input length modulo 232. + private int length; + + /// Cached unsigned hash value returned by [#getValue()]. + private long value; + + /// Whether [#value] represents all input supplied so far. + private boolean valueValid; + + /// Creates an empty MurmurHash2 checksum with the specified seed. + /// + /// @param seed the 32-bit seed mixed into the hash + public MurmurHash2(int seed) { + this.seed = seed; + } -/** - * Implementation of the MurmurHash2 32-bit and 64-bit hash functions. - * - *

MurmurHash is a non-cryptographic hash function suitable for general - * hash-based lookup. The name comes from two basic operations, multiply (MU) - * and rotate (R), used in its inner loop. Unlike cryptographic hash functions, - * it is not specifically designed to be difficult to reverse by an adversary, - * making it unsuitable for cryptographic purposes.

- * - *

This contains a Java port of the 32-bit hash function {@code MurmurHash2} - * and the 64-bit hash function {@code MurmurHash64A} from Austin Applyby's - * original {@code c++} code in SMHasher.

- * - *

This is a re-implementation of the original C code plus some additional - * features.

- * - *

This is public domain code with no copyrights. From home page of - * SMHasher:

- * - *
- * "All MurmurHash versions are public domain software, and the author - * disclaims all copyright to their code." - *
- * - * @see MurmurHash - * @see - * Original MurmurHash2 c++ code - * @since 1.13 - */ -public final class MurmurHash2 { + /// Appends the low eight bits of `b` to the input sequence. + /// + /// @param b the value whose low eight bits are appended + @Override + public void update(int b) { + valueValid = false; + length++; + + tail |= (b & 0xff) << (tailLength * Byte.SIZE); + tailLength++; + + if (tailLength == Integer.BYTES) { + addBlock(tail); + tail = 0; + tailLength = 0; + } + } + + /// Appends `len` bytes starting at `off` in `b` to the input sequence. + /// + /// A zero-length update leaves the current value unchanged. + /// + /// @param b the array containing the bytes to append + /// @param off the offset of the first byte to append + /// @param len the number of bytes to append + /// @throws NullPointerException if `b` is `null` + /// @throws IndexOutOfBoundsException if `off` or `len` is negative, or if + /// `off + len` is greater than the array length + @Override + public void update(byte[] b, int off, int len) { + Objects.checkFromIndexSize(off, len, b.length); + if (len == 0) { + return; + } - // Constants for 32-bit variant - private static final int M32 = 0x5bd1e995; - private static final int R32 = 24; + valueValid = false; + length += len; - // Constants for 64-bit variant - private static final long M64 = 0xc6a4a7935bd1e995L; - private static final int R64 = 47; + int end = off + len; - /** - * No instance methods. - */ - private MurmurHash2() { - } + // Complete a word left over from a preceding update before processing + // directly from the caller's array. + while (tailLength != 0 && off < end) { + tail |= (b[off++] & 0xff) << (tailLength * Byte.SIZE); + tailLength++; - /** - * Generates a 32-bit hash from byte array with the given length and seed. - * - * @param data The input byte array - * @param length The length of the array - * @param seed The initial seed value - * @return The 32-bit hash - */ - public static int hash32(final byte[] data, final int length, final int seed) { - // Initialize the hash to a random value - int h = seed ^ length; - - // Mix 4 bytes at a time into the hash - final int nblocks = length >> 2; - - // body - for (int i = 0; i < nblocks; i++) { - final int index = (i << 2); - int k = ByteArray.getIntLE(data, index); - k *= M32; - k ^= k >>> R32; - k *= M32; - h *= M32; - h ^= k; + if (tailLength == Integer.BYTES) { + addBlock(tail); + tail = 0; + tailLength = 0; + } } - // Handle the last few bytes of the input array - final int index = (nblocks << 2); - switch (length - index) { - case 3: - h ^= (data[index + 2] & 0xff) << 16; - // fallthrough - case 2: - h ^= (data[index + 1] & 0xff) << 8; - // fallthrough - case 1: - h ^= (data[index] & 0xff); - h *= M32; + while (off <= end - Integer.BYTES) { + addBlock(ByteArray.getIntLE(b, off)); + off += Integer.BYTES; } - // Do a few final mixes of the hash to ensure the last few - // bytes are well-incorporated. - h ^= h >>> 13; - h *= M32; - h ^= h >>> 15; - - return h; - } - - /** - * Generates a 32-bit hash from byte array with the given length and a default seed value. - * This is a helper method that will produce the same result as: - * - *
-     * int seed = 0x9747b28c;
-     * int hash = MurmurHash2.hash32(data, length, seed);
-     * 
- * - * @param data The input byte array - * @param length The length of the array - * @return The 32-bit hash - * @see #hash32(byte[], int, int) - */ - public static int hash32(final byte[] data, final int length) { - return hash32(data, length, 0x9747b28c); + while (off < end) { + tail |= (b[off++] & 0xff) << (tailLength * Byte.SIZE); + tailLength++; + } } - /** - * Generates a 32-bit hash from a string with a default seed. - *

- * Before 1.14 the string was converted using default encoding. - * Since 1.14 the string is converted to bytes using UTF-8 encoding. - *

- * This is a helper method that will produce the same result as: - * - *
-     * int seed = 0x9747b28c;
-     * byte[] bytes = data.getBytes(StandardCharsets.UTF_8);
-     * int hash = MurmurHash2.hash32(bytes, bytes.length, seed);
-     * 
- * - * @param text The input string - * @return The 32-bit hash - * @see #hash32(byte[], int, int) - */ - public static int hash32(final String text) { - final byte[] bytes = text.getBytes(StandardCharsets.UTF_8); - return hash32(bytes, bytes.length); - } + /// Returns the MurmurHash2 value of all bytes supplied since construction + /// or the last call to [#reset()]. + /// + /// The value is in the range `0` through `0xffff_ffffL`. This method does + /// not reset the checksum; later updates append to the same input sequence. + /// + /// @return the current unsigned 32-bit hash value + @Override + public long getValue() { + if (valueValid) { + return value; + } - /** - * Generates a 32-bit hash from a substring with a default seed value. - * The string is converted to bytes using the default encoding. - * This is a helper method that will produce the same result as: - * - *
-     * int seed = 0x9747b28c;
-     * byte[] bytes = text.substring(from, from + length).getBytes(StandardCharsets.UTF_8);
-     * int hash = MurmurHash2.hash32(bytes, bytes.length, seed);
-     * 
- * - * @param text The input string - * @param from The starting index - * @param length The length of the substring - * @return The 32-bit hash - * @see #hash32(byte[], int, int) - */ - public static int hash32(final String text, final int from, final int length) { - return hash32(text.substring(from, from + length)); - } + int hash = seed ^ length; - /** - * Generates a 64-bit hash from byte array of the given length and seed. - * - * @param data The input byte array - * @param length The length of the array - * @param seed The initial seed value - * @return The 64-bit hash of the given array - */ - public static long hash64(final byte[] data, final int length, final int seed) { - long h = (seed & 0xffffffffL) ^ (length * M64); - - final int nblocks = length >> 3; - - // body - for (int i = 0; i < nblocks; i++) { - final int index = (i << 3); - long k = ByteArray.getLongLE(data, index); - - k *= M64; - k ^= k >>> R64; - k *= M64; - - h ^= k; - h *= M64; + for (int i = 0; i < blockCount; i++) { + hash *= MIX_MULTIPLIER; + hash ^= mixedBlocks[i]; } - final int index = (nblocks << 3); - switch (length - index) { - case 7: - h ^= ((long) data[index + 6] & 0xff) << 48; - // fallthrough - case 6: - h ^= ((long) data[index + 5] & 0xff) << 40; - // fallthrough - case 5: - h ^= ((long) data[index + 4] & 0xff) << 32; - // fallthrough - case 4: - h ^= ((long) data[index + 3] & 0xff) << 24; - // fallthrough - case 3: - h ^= ((long) data[index + 2] & 0xff) << 16; - // fallthrough - case 2: - h ^= ((long) data[index + 1] & 0xff) << 8; - // fallthrough - case 1: - h ^= ((long) data[index] & 0xff); - h *= M64; + if (tailLength != 0) { + hash ^= tail; + hash *= MIX_MULTIPLIER; } - h ^= h >>> R64; - h *= M64; - h ^= h >>> R64; + hash ^= hash >>> 13; + hash *= MIX_MULTIPLIER; + hash ^= hash >>> 15; - return h; + value = Integer.toUnsignedLong(hash); + valueValid = true; + return value; } - /** - * Generates a 64-bit hash from byte array with given length and a default seed value. - * This is a helper method that will produce the same result as: - * - *
-     * int seed = 0xe17a1465;
-     * int hash = MurmurHash2.hash64(data, length, seed);
-     * 
- * - * @param data The input byte array - * @param length The length of the array - * @return The 64-bit hash - * @see #hash64(byte[], int, int) - */ - public static long hash64(final byte[] data, final int length) { - return hash64(data, length, 0xe17a1465); + /// Discards all accumulated input while retaining the seed. + /// + /// After this method returns, [#getValue()] produces the hash of an empty + /// input with the seed supplied to [#MurmurHash2(int)]. + @Override + public void reset() { + blockCount = 0; + tail = 0; + tailLength = 0; + length = 0; + value = 0; + valueValid = false; } - /** - * Generates a 64-bit hash from a string with a default seed. - *

- * Before 1.14 the string was converted using default encoding. - * Since 1.14 the string is converted to bytes using UTF-8 encoding. - *

- * This is a helper method that will produce the same result as: - * - *
-     * int seed = 0xe17a1465;
-     * byte[] bytes = data.getBytes(StandardCharsets.UTF_8);
-     * int hash = MurmurHash2.hash64(bytes, bytes.length, seed);
-     * 
- * - * @param text The input string - * @return The 64-bit hash - * @see #hash64(byte[], int, int) - */ - public static long hash64(final String text) { - final byte[] bytes = text.getBytes(StandardCharsets.UTF_8); - return hash64(bytes, bytes.length); + /// Pre-mixes and stores one complete little-endian input word. + /// + /// @param block the input word to store + private void addBlock(int block) { + if (blockCount == mixedBlocks.length) { + int newCapacity = mixedBlocks.length << 1; + if (newCapacity <= mixedBlocks.length) { + newCapacity = Integer.MAX_VALUE; + } + mixedBlocks = Arrays.copyOf(mixedBlocks, newCapacity); + } + + mixedBlocks[blockCount++] = mixBlock(block); } - /** - * Generates a 64-bit hash from a substring with a default seed value. - * The string is converted to bytes using the default encoding. - * This is a helper method that will produce the same result as: - * - *
-     * int seed = 0xe17a1465;
-     * byte[] bytes = text.substring(from, from + length).getBytes(StandardCharsets.UTF_8);
-     * int hash = MurmurHash2.hash64(bytes, bytes.length, seed);
-     * 
- * - * @param text The The input string - * @param from The starting index - * @param length The length of the substring - * @return The 64-bit hash - * @see #hash64(byte[], int, int) - */ - public static long hash64(final String text, final int from, final int length) { - return hash64(text.substring(from, from + length)); + /// Applies the MurmurHash2 word mixer to one complete input word. + /// + /// @param block the input word + /// @return the mixed word + private static int mixBlock(int block) { + block *= MIX_MULTIPLIER; + block ^= block >>> MIX_SHIFT; + block *= MIX_MULTIPLIER; + return block; } } 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/util/MurmurHash2Test.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/MurmurHash2Test.java new file mode 100644 index 00000000000..dd54f1937d2 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/MurmurHash2Test.java @@ -0,0 +1,124 @@ +/* + * 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.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/// Tests the 32-bit MurmurHash2 implementation against values produced by the +/// reference C++ implementation. +@NotNullByDefault +public final class MurmurHash2Test { + + /// Verifies fixed reference values, including every possible tail length. + @Test + public void testReferenceValues() { + assertHash(0, new byte[0], 0L); + assertHash(1, new byte[0], 1_540_447_798L); + assertHash(1, new byte[]{0, 1}, 788_976_164L); + assertHash(0, "hello".getBytes(StandardCharsets.UTF_8), 3_848_350_155L); + assertHash(0x9747b28c, "hello".getBytes(StandardCharsets.UTF_8), 2_132_663_229L); + assertHash(1, "The quick brown fox jumps over the lazy dog".getBytes(StandardCharsets.UTF_8), + 504_383_975L); + + assertHash(1, sequentialBytes(256), 253_525_554L); + assertHash(-1, new byte[]{0, -1, -128, 127, 1, 2, 3, 4, 5}, 1_934_485_809L); + } + + /// Verifies that update boundaries do not affect the resulting hash. + @Test + public void testFragmentedUpdates() { + byte[] input = sequentialBytes(256); + + var byteAtATime = new MurmurHash2(1); + for (byte value : input) { + byteAtATime.update(value); + } + assertEquals(253_525_554L, byteAtATime.getValue()); + + var chunks = new MurmurHash2(1); + int offset = 0; + int chunkSize = 1; + while (offset < input.length) { + int length = Math.min(chunkSize, input.length - offset); + chunks.update(input, offset, length); + offset += length; + chunkSize = chunkSize % 7 + 1; + } + assertEquals(253_525_554L, chunks.getValue()); + } + + /// Verifies repeated finalization, continued updates, and reset behavior. + @Test + public void testLifecycle() { + var hash = new MurmurHash2(1); + hash.update(0x168); + assertEquals(3_451_942_824L, hash.getValue()); + assertEquals(3_451_942_824L, hash.getValue()); + + hash.update(new byte[]{'e', 'l', 'l'}, 0, 3); + assertEquals(1_799_137_576L, hash.getValue()); + + hash.update('o'); + assertEquals(2_788_266_382L, hash.getValue()); + + hash.reset(); + assertEquals(1_540_447_798L, hash.getValue()); + } + + /// Verifies that invalid slice ranges are rejected without changing state. + @Test + public void testInvalidRangeDoesNotChangeState() { + var hash = new MurmurHash2(1); + byte[] input = {0, 1, 2}; + hash.update(input, 0, 2); + long value = hash.getValue(); + + assertThrows(IndexOutOfBoundsException.class, () -> hash.update(input, -1, 1)); + assertThrows(IndexOutOfBoundsException.class, () -> hash.update(input, 0, 4)); + assertEquals(value, hash.getValue()); + } + + /// Creates a byte array containing consecutive values starting at zero. + /// + /// @param length the array length + /// @return the generated array + private static byte[] sequentialBytes(int length) { + byte[] result = new byte[length]; + for (int i = 0; i < result.length; i++) { + result[i] = (byte) i; + } + return result; + } + + /// Verifies a hash against a fixed reference value. + /// + /// @param seed the hash seed + /// @param input the complete input + /// @param expected the expected unsigned hash value + private static void assertHash(int seed, byte[] input, long expected) { + var hash = new MurmurHash2(seed); + hash.update(input, 0, input.length); + assertEquals(expected, hash.getValue()); + } +}