Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
30 changes: 25 additions & 5 deletions Sources/DesignAlgorithmsKit/Algorithms/Compression/Gzip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
}

Expand Down Expand Up @@ -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)
///
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
45 changes: 45 additions & 0 deletions Tests/DesignAlgorithmsKitTests/Algorithms/BloomFilterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
25 changes: 25 additions & 0 deletions Tests/DesignAlgorithmsKitTests/Algorithms/MerkleTreeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<blockCount).map { Data("block \($0)".utf8) }
let tree = MerkleTree.build(from: blocks)
for (index, block) in blocks.enumerated() {
guard let proof = tree.generateProof(for: block) else {
XCTFail("No proof for block \(index) of \(blockCount)")
continue
}
XCTAssertTrue(MerkleTree.verify(proof: proof, rootHash: tree.rootHash),
"Proof for block \(index) of \(blockCount) does not verify")
}
}
}

func testProofForLastBlockOfThreeHasOnePathEntryPerLevel() throws {
let blocks = (0..<3).map { Data("block \($0)".utf8) }
let proof = try XCTUnwrap(MerkleTree.build(from: blocks).generateProof(for: blocks[2]))
XCTAssertEqual(proof.path.count, 2)
}
}
38 changes: 38 additions & 0 deletions Tests/DesignAlgorithmsKitTests/CompressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,42 @@ final class CompressionTests: XCTestCase {
let decompressed = try Gzip.decompress(data: compressed)
XCTAssertTrue(decompressed.isEmpty)
}

// MARK: - Trailer verification (fix-algorithm-edge-cases)

private func sample() throws -> 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))
}
}
Loading