From 8eb662d3e88a3795e86f3e27bb6281f6138a7a62 Mon Sep 17 00:00:00 2001 From: Rick Hohler Date: Sat, 24 Oct 2026 19:38:52 -0500 Subject: [PATCH] Fix Merkle proofs for unpaired nodes, Bloom sizing, GZIP trailer fix-algorithm-edge-cases (DesignAlgorithmsKit-Specs). - generateProof adds the node's own hash as sibling for an unpaired node, so proofs verify for every leaf of odd trees (roots unchanged) - Bloom filters clamp capacity (>= 1) and rate (open interval 0..1) instead of trapping; Int.bloomHash uses the bit pattern for negative values - Gzip.decompress checks the CRC-32 and ISIZE trailer and reads XLEN little-endian; accepts Data slices Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ README.md | 1 - .../Algorithms/Compression/Gzip.swift | 30 ++++++-- .../DataStructures/BloomFilter.swift | 73 +++++++++++-------- .../DataStructures/MerkleTree.swift | 7 +- .../Algorithms/BloomFilterTests.swift | 45 ++++++++++++ .../Algorithms/MerkleTreeTests.swift | 25 +++++++ .../CompressionTests.swift | 38 ++++++++++ 8 files changed, 185 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 927f944..72a1b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed (`fix-algorithm-edge-cases`) +- `MerkleTree.generateProof(for:)` adds a path entry for an unpaired node (its own hash as the right sibling), so proofs verify for every leaf of trees with an odd node count. Roots are unchanged. +- `BloomFilter` and `CountingBloomFilter` no longer trap on degenerate sizing: a capacity below 1 counts as 1 and the false-positive rate is clamped into (0, 1). `capacity` and `falsePositiveRate` report the clamped values. +- `Int.bloomHash()` uses the two's-complement bit pattern and no longer traps for negative values. +- `Gzip.decompress` verifies the CRC-32 and ISIZE trailer and throws `Gzip.Error.invalidData` on mismatch; XLEN is read little-endian; `Data` slices are accepted. + ## [1.11.0] - 2026-10-24 ### Added diff --git a/README.md b/README.md index 0122144..43840dc 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,6 @@ swift test ## Known issues - `JobManager.cancel` does not stop a pending job; `TaskOrchestrator` never throws `taskFailure`; `ProcessingQueue` has a scheduling race; `ResourceCoordinator` releases locks late. -- Merkle proofs fail for unpaired leaves; Bloom filters trap on capacity 0, rate 0 or a negative `Int`; `Gzip.decompress` ignores the CRC and ISIZE trailer. - `ULID` text is not compatible with the ULID specification. - Platform claims are broader than tested, `WASMGuard.swift` is dead code, SHA code is duplicated, and the DocC catalog is not attached to the target. diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Compression/Gzip.swift b/Sources/DesignAlgorithmsKit/Algorithms/Compression/Gzip.swift index 33e000d..5e38644 100644 --- a/Sources/DesignAlgorithmsKit/Algorithms/Compression/Gzip.swift +++ b/Sources/DesignAlgorithmsKit/Algorithms/Compression/Gzip.swift @@ -41,8 +41,12 @@ public struct Gzip { /// Decompresses GZIP compressed data. /// - Parameter data: The compressed data (including GZIP header). /// - Returns: The uncompressed data. - /// - Throws: `Gzip.Error.decompressionFailed` or `invalidData`. - public static func decompress(data: Data) throws -> Data { + /// - Throws: `Gzip.Error.decompressionFailed`, or `invalidData` for a malformed header or a + /// trailer whose CRC-32 or ISIZE does not match the inflated bytes. + public static func decompress(data input: Data) throws -> Data { + // Offsets below are zero-based; rebase slices. + let data = input.startIndex == 0 ? input : Data(input) + // GZIP Header Validation guard data.count >= 18 else { throw Error.invalidData } guard data[0] == 0x1f, data[1] == 0x8b else { throw Error.invalidData } @@ -82,8 +86,17 @@ public struct Gzip { guard data.count > headerSize + footerSize else { throw Error.invalidData } let deflatePayload = data.subdata(in: headerSize..<(data.count - footerSize)) - - return try decompressRawDeflate(deflatePayload) + let inflated = try decompressRawDeflate(deflatePayload) + + // Trailer: CRC-32 and ISIZE (length modulo 2^32), both little-endian. + var trailer = DataParser(data: data, offset: data.count - footerSize) + let expectedCRC = try trailer.readUInt32() + let expectedSize = try trailer.readUInt32() + guard CRC32Checksum.checksum(inflated) == expectedCRC, + UInt32(truncatingIfNeeded: inflated.count) == expectedSize else { + throw Error.invalidData + } + return inflated } // MARK: - Internal Helpers @@ -124,8 +137,15 @@ struct DataParser { return chunk } + /// Little-endian, as every GZIP multi-byte field is. mutating func readUInt16() throws -> UInt16 { let bytes = try readBytes(count: 2) - return bytes.withUnsafeBytes { $0.load(as: UInt16.self) } + return UInt16(bytes[bytes.startIndex]) | UInt16(bytes[bytes.startIndex + 1]) << 8 + } + + /// Little-endian. + mutating func readUInt32() throws -> UInt32 { + let bytes = try readBytes(count: 4) + return (0..<4).reduce(UInt32(0)) { $0 | UInt32(bytes[bytes.startIndex + $1]) << (8 * $1) } } } diff --git a/Sources/DesignAlgorithmsKit/Algorithms/DataStructures/BloomFilter.swift b/Sources/DesignAlgorithmsKit/Algorithms/DataStructures/BloomFilter.swift index b790476..90b401a 100644 --- a/Sources/DesignAlgorithmsKit/Algorithms/DataStructures/BloomFilter.swift +++ b/Sources/DesignAlgorithmsKit/Algorithms/DataStructures/BloomFilter.swift @@ -40,7 +40,34 @@ extension Data: BloomFilterHashable { extension Int: BloomFilterHashable { public func bloomHash() -> UInt64 { - return UInt64(self) + return UInt64(bitPattern: Int64(self)) + } +} + +/// Sizing shared by `BloomFilter` and `CountingBloomFilter`. Capacity below 1 counts as 1 and the +/// false-positive rate is clamped into the open interval (0, 1), so sizing never divides by zero +/// or takes the logarithm of zero. +enum BloomFilterSizing { + static let minimumRate = 1e-12 + static let maximumRate = 1.0 - 1e-12 + + static func capacity(_ capacity: Int) -> Int { + max(1, capacity) + } + + static func rate(_ rate: Double) -> Double { + guard rate.isFinite else { return rate > 0 ? maximumRate : minimumRate } + return min(maximumRate, max(minimumRate, rate)) + } + + /// m = -n * ln(p) / (ln(2)^2) + static func slotCount(capacity: Int, rate: Double) -> Int { + Int(ceil(-Double(capacity) * log(rate) / (log(2.0) * log(2.0)))) + } + + /// k = (m/n) * ln(2) + static func hashCount(slots: Int, capacity: Int) -> Int { + Int(ceil((Double(slots) / Double(capacity)) * log(2.0))) } } @@ -109,8 +136,8 @@ public final class BloomFilter { /// Initialize Bloom Filter /// - Parameters: - /// - capacity: Expected number of elements to be inserted - /// - falsePositiveRate: Desired false positive rate (0.0 to 1.0) + /// - capacity: Expected number of elements to be inserted (values below 1 count as 1) + /// - falsePositiveRate: Desired false positive rate, clamped into the open interval (0, 1) /// - bitCount: Optional explicit bit count (calculated if nil) /// - hashFunctionCount: Optional explicit hash function count (calculated if nil) /// @@ -122,20 +149,11 @@ public final class BloomFilter { bitCount: Int? = nil, hashFunctionCount: Int? = nil ) { - self.capacity = capacity - self.falsePositiveRate = max(0.0, min(1.0, falsePositiveRate)) - - // Calculate optimal bit count: m = -n * ln(p) / (ln(2)^2) - // where n = capacity, p = false positive rate - let optimalBitCount = bitCount ?? Int( - ceil(-Double(capacity) * log(self.falsePositiveRate) / (log(2.0) * log(2.0))) - ) - - // Calculate optimal hash function count: k = (m/n) * ln(2) - // where m = bit count, n = capacity - let optimalHashCount = hashFunctionCount ?? Int( - ceil((Double(optimalBitCount) / Double(capacity)) * log(2.0)) - ) + self.capacity = BloomFilterSizing.capacity(capacity) + self.falsePositiveRate = BloomFilterSizing.rate(falsePositiveRate) + + let optimalBitCount = bitCount ?? BloomFilterSizing.slotCount(capacity: self.capacity, rate: self.falsePositiveRate) + let optimalHashCount = hashFunctionCount ?? BloomFilterSizing.hashCount(slots: optimalBitCount, capacity: self.capacity) self.bitCount = max(1, optimalBitCount) self.hashFunctionCount = max(1, min(optimalHashCount, 50)) // Cap at 50 hash functions @@ -338,8 +356,8 @@ public final class CountingBloomFilter { /// Initialize Counting Bloom Filter /// - Parameters: - /// - capacity: Expected number of elements to be inserted - /// - falsePositiveRate: Desired false positive rate (0.0 to 1.0) + /// - capacity: Expected number of elements to be inserted (values below 1 count as 1) + /// - falsePositiveRate: Desired false positive rate, clamped into the open interval (0, 1) /// - counterCount: Optional explicit counter count (calculated if nil) /// - hashFunctionCount: Optional explicit hash function count (calculated if nil) public init( @@ -348,18 +366,11 @@ public final class CountingBloomFilter { counterCount: Int? = nil, hashFunctionCount: Int? = nil ) { - self.capacity = capacity - self.falsePositiveRate = max(0.0, min(1.0, falsePositiveRate)) - - // Calculate optimal counter count (same as bit count for regular Bloom Filter) - let optimalCounterCount = counterCount ?? Int( - ceil(-Double(capacity) * log(self.falsePositiveRate) / (log(2.0) * log(2.0))) - ) - - // Calculate optimal hash function count - let optimalHashCount = hashFunctionCount ?? Int( - ceil((Double(optimalCounterCount) / Double(capacity)) * log(2.0)) - ) + self.capacity = BloomFilterSizing.capacity(capacity) + self.falsePositiveRate = BloomFilterSizing.rate(falsePositiveRate) + + let optimalCounterCount = counterCount ?? BloomFilterSizing.slotCount(capacity: self.capacity, rate: self.falsePositiveRate) + let optimalHashCount = hashFunctionCount ?? BloomFilterSizing.hashCount(slots: optimalCounterCount, capacity: self.capacity) self.counterCount = max(1, optimalCounterCount) self.hashFunctionCount = max(1, min(optimalHashCount, 50)) diff --git a/Sources/DesignAlgorithmsKit/Algorithms/DataStructures/MerkleTree.swift b/Sources/DesignAlgorithmsKit/Algorithms/DataStructures/MerkleTree.swift index e176f9a..5993fd8 100644 --- a/Sources/DesignAlgorithmsKit/Algorithms/DataStructures/MerkleTree.swift +++ b/Sources/DesignAlgorithmsKit/Algorithms/DataStructures/MerkleTree.swift @@ -192,9 +192,10 @@ public struct MerkleTree { // Check left subtree if traverse(node: node.left, targetHash: targetHash) { - // Add right sibling to path - if let right = node.right { - path.append(MerkleProofNode(hash: right.hash, isLeft: false)) + // Add right sibling to path. An unpaired node was hashed with itself, so its own + // hash stands in as the sibling. + if let sibling = node.right ?? node.left { + path.append(MerkleProofNode(hash: sibling.hash, isLeft: false)) } return true } diff --git a/Tests/DesignAlgorithmsKitTests/Algorithms/BloomFilterTests.swift b/Tests/DesignAlgorithmsKitTests/Algorithms/BloomFilterTests.swift index 57e3e72..feae639 100644 --- a/Tests/DesignAlgorithmsKitTests/Algorithms/BloomFilterTests.swift +++ b/Tests/DesignAlgorithmsKitTests/Algorithms/BloomFilterTests.swift @@ -131,3 +131,48 @@ final class BloomFilterTests: XCTestCase { XCTAssertEqual(filter.elementCount, 0) } } + +// MARK: - Degenerate parameters (fix-algorithm-edge-cases) + +extension BloomFilterTests { + func testZeroCapacityDoesNotTrap() { + let filter = BloomFilter(capacity: 0) + XCTAssertGreaterThanOrEqual(filter.bitCount, 1) + XCTAssertGreaterThanOrEqual(filter.hashFunctionCount, 1) + filter.insert("item") + XCTAssertTrue(filter.contains("item")) + } + + func testNegativeCapacityCountsAsOne() { + XCTAssertEqual(BloomFilter(capacity: -5).capacity, 1) + XCTAssertEqual(CountingBloomFilter(capacity: -5).capacity, 1) + } + + func testZeroRateHasFiniteBitCount() { + let filter = BloomFilter(capacity: 100, falsePositiveRate: 0) + XCTAssertGreaterThan(filter.falsePositiveRate, 0) + XCTAssertGreaterThan(filter.bitCount, 1) + } + + func testRateOfOneAndNaNAreClamped() { + XCTAssertLessThan(BloomFilter(capacity: 100, falsePositiveRate: 1).falsePositiveRate, 1) + XCTAssertLessThan(BloomFilter(capacity: 100, falsePositiveRate: 7).falsePositiveRate, 1) + let nan = BloomFilter(capacity: 100, falsePositiveRate: .nan) + XCTAssertTrue(nan.falsePositiveRate > 0 && nan.falsePositiveRate < 1) + } + + func testCountingFilterDegenerateParameters() { + let filter = CountingBloomFilter(capacity: 0, falsePositiveRate: 0) + filter.insert("item") + XCTAssertTrue(filter.contains("item")) + } + + func testNegativeIntegers() { + let filter = BloomFilter(capacity: 10) + filter.insert(-1) + filter.insert(Int.min) + XCTAssertTrue(filter.contains(-1)) + XCTAssertTrue(filter.contains(Int.min)) + XCTAssertEqual((-1).bloomHash(), UInt64.max) + } +} diff --git a/Tests/DesignAlgorithmsKitTests/Algorithms/MerkleTreeTests.swift b/Tests/DesignAlgorithmsKitTests/Algorithms/MerkleTreeTests.swift index 2a75c7c..e09d6e2 100644 --- a/Tests/DesignAlgorithmsKitTests/Algorithms/MerkleTreeTests.swift +++ b/Tests/DesignAlgorithmsKitTests/Algorithms/MerkleTreeTests.swift @@ -114,3 +114,28 @@ final class MerkleTreeTests: XCTestCase { XCTAssertFalse(tree.rootHash.isEmpty) } } + +// MARK: - Unpaired nodes (fix-algorithm-edge-cases) + +extension MerkleTreeTests { + func testProofsVerifyForEveryLeafOfOddAndEvenTrees() { + for blockCount in [1, 2, 3, 4, 5, 6, 7] { + let blocks = (0.. Data { + try Gzip.compress(data: Data(String(repeating: "trailer check ", count: 20).utf8)) + } + + func testCorruptedCRCThrows() throws { + var data = try sample() + data[data.count - 8] ^= 0xFF + XCTAssertThrowsError(try Gzip.decompress(data: data)) { error in + XCTAssertEqual(error as? Gzip.Error, .invalidData) + } + } + + func testCorruptedISizeThrows() throws { + var data = try sample() + data[data.count - 1] ^= 0x01 + XCTAssertThrowsError(try Gzip.decompress(data: data)) { error in + XCTAssertEqual(error as? Gzip.Error, .invalidData) + } + } + + func testExtraFieldLengthIsLittleEndian() throws { + let plain = try sample() + // Rebuild with FEXTRA set and a 3-byte extra field (XLEN = 03 00). + var data = Data(plain[0..<10]) + data[3] |= 0x04 + data.append(contentsOf: [0x03, 0x00, 0xAA, 0xBB, 0xCC]) + data.append(plain[10...]) + XCTAssertEqual(try Gzip.decompress(data: data), try Gzip.decompress(data: plain)) + } + + func testDecompressesSlice() throws { + let plain = try sample() + let padded = Data([0x00, 0x00]) + plain + XCTAssertEqual(try Gzip.decompress(data: padded[2...]), try Gzip.decompress(data: plain)) + } }