From 96a618c97e106ee4f4bc2c01ed148f00f5b75764 Mon Sep 17 00:00:00 2001 From: Rick Hohler Date: Sat, 24 Oct 2026 15:12:48 -0500 Subject: [PATCH] Add Zlib codec, CRC32Checksum and FallbackTextDecoder (v1.11.0) - Zlib/ZlibError: one public zlib binding (import zlib) for zlib, gzip and raw DEFLATE framing selected by window bits, plus Zlib.Framing. Streams are heap-allocated, input is fed in chunks, and truncated or corrupt input throws instead of returning partial output. - Gzip is rebuilt on Zlib; the @_silgen_name proxy, hand-written z_stream layout and private CRC table are removed. Gzip's API and output bytes are unchanged. - CRC32Checksum: UInt32 CRC-32 with incremental update; CRC32Strategy uses it. - FallbackTextDecoder: decode bytes of unknown encoding by trying US-ASCII, UTF-8, Mac OS Roman and Windows-1252, stripping control characters except newlines and tabs. Callers add their own TextDecodingStrategy before or after the defaults, directly or through TextDecoderRegistry. Data.decodedText(using:) is the convenience entry. - DesignAlgorithmsKit.version is 1.11.0; README and CHANGELOG updated. - 343 tests pass; builds for macOS, iOS, tvOS and watchOS. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 + README.md | 26 +- .../Algorithms/Compression.swift | 299 ------------------ .../Algorithms/Compression/Gzip.swift | 131 ++++++++ .../Algorithms/Compression/Zlib.swift | 226 +++++++++++++ .../Algorithms/Hashing/CRC32Checksum.swift | 44 +++ .../Hashing/Strategies/CRC32Strategy.swift | 42 +-- .../Algorithms/Text/FallbackTextDecoder.swift | 236 ++++++++++++++ .../DesignAlgorithmsKit.swift | 2 +- .../Algorithms/CRC32ChecksumTests.swift | 31 ++ .../Algorithms/Compression/ZlibTests.swift | 105 ++++++ .../Text/FallbackTextDecoderTests.swift | 97 ++++++ 12 files changed, 904 insertions(+), 348 deletions(-) delete mode 100644 Sources/DesignAlgorithmsKit/Algorithms/Compression.swift create mode 100644 Sources/DesignAlgorithmsKit/Algorithms/Compression/Gzip.swift create mode 100644 Sources/DesignAlgorithmsKit/Algorithms/Compression/Zlib.swift create mode 100644 Sources/DesignAlgorithmsKit/Algorithms/Hashing/CRC32Checksum.swift create mode 100644 Sources/DesignAlgorithmsKit/Algorithms/Text/FallbackTextDecoder.swift create mode 100644 Tests/DesignAlgorithmsKitTests/Algorithms/CRC32ChecksumTests.swift create mode 100644 Tests/DesignAlgorithmsKitTests/Algorithms/Compression/ZlibTests.swift create mode 100644 Tests/DesignAlgorithmsKitTests/Algorithms/Text/FallbackTextDecoderTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b086b..927f944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.11.0] - 2026-10-24 + +### Added +- `Zlib` and `ZlibError`: in-memory zlib, gzip and raw DEFLATE compression/decompression with framing selected by window bits (`Zlib.Framing`), using `import zlib` +- `CRC32Checksum`: CRC-32 as `UInt32` with incremental `update(_:with:)` +- `FallbackTextDecoder`, `TextDecodingStrategy`, `StringEncodingStrategy`, `DecodedText`, `TextDecoderRegistry` and `Data.decodedText(using:)`: decode bytes of unknown encoding (US-ASCII, UTF-8, Mac OS Roman, Windows-1252) with pluggable strategies and control-character stripping + ### Changed +- `Gzip` is built on `Zlib`; the `@_silgen_name` zlib proxy and hand-written `z_stream` layout are removed +- `CRC32Strategy` uses `CRC32Checksum` +- `DesignAlgorithmsKit.version` is `1.11.0` - README rewritten to match the code: purpose, products, platforms, dependencies, build/test, known issues +### Fixed +- `Gzip.compress` no longer overflows an integer literal on 32-bit targets + ### Removed - `docs/DESIGN_PATTERNS.md` and `docs/EXAMPLES.md` (examples used APIs that do not exist; superseded by the README) - Committed `.swiftpm/xcode/xcuserdata` (now git-ignored) diff --git a/README.md b/README.md index 275007d..0122144 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,14 @@ One library product and target: **`DesignAlgorithmsKit`** (`Sources/DesignAlgori | Behavioral patterns | `StrategyContext`, `BaseStrategy`, `BaseObservable`/`Observer`, `CommandInvoker`/`ClosureCommand`, `BaseHandler`/`BaseTypedHandler`, `ArrayIterator`/`TreeIterator`, `Visitor`/`Visitable`, `Mergeable`/`MergeStrategy`/`DefaultMerger` | | Pipelines | `DataPipeline`, `AsyncDataPipeline`, `AnyAsyncPipelineStage`, `DynamicAsyncPipeline`, `PipelineError` | | Async work | `ProcessingQueue`/`QueueItem`/`QueueProcessor`, `JobManager`, `TaskOrchestrator`/`DAGTask`, `ResourceCoordinator` | -| Hashing | `HashAlgorithm` (SHA-256, SHA-1, MD5, CRC-32), `HashComputation`, `HashStrategy`/`HashStrategyRegistry`, `SHA256`, `Data.sha256`/`sha256Hex` | +| Hashing | `HashAlgorithm` (SHA-256, SHA-1, MD5, CRC-32), `HashComputation`, `HashStrategy`/`HashStrategyRegistry`, `SHA256`, `Data.sha256`/`sha256Hex`, `CRC32Checksum` (`UInt32`, incremental) | | Identifiers | `ULID`, `UUIDv5Generator` | | Probabilistic filters | `BloomFilter`, `CountingBloomFilter`, `BloomFilterHashable` | | Merkle tree | `MerkleTree`, `MerkleProof`, `MerkleHashable` | | Graphs | `Graph` (directed, BFS reachability, unreachable-node detection) | -| Compression | `Gzip` (in-memory GZIP via system zlib) | +| Compression | `Zlib`/`ZlibError` (zlib, gzip and raw DEFLATE framing by window bits, via system zlib), `Gzip` (in-memory GZIP) | | Encryption | `HybridEncryption` (Curve25519 + HKDF-SHA256 + ChaCha20-Poly1305) | -| Text / paths | `FilenameTokenizer`, `GitObjectLayout` | +| Text / paths | `FilenameTokenizer`, `GitObjectLayout`, `FallbackTextDecoder`/`TextDecodingStrategy`/`StringEncodingStrategy`/`TextDecoderRegistry`, `Data.decodedText(using:)` | ## Platforms @@ -41,9 +41,9 @@ One library product and target: **`DesignAlgorithmsKit`** (`Sources/DesignAlgori ``` - **How consumers reference DAK.** The canonical remote is `https://github.com/nibbleware/DesignAlgorithmsKit.git`, and `rickhohler/DesignAlgorithmsKit` redirects to it. Use a semver range: ```swift - .package(url: "https://github.com/nibbleware/DesignAlgorithmsKit.git", from: "1.10.0") + .package(url: "https://github.com/nibbleware/DesignAlgorithmsKit.git", from: "1.11.0") ``` - The latest tag is `v1.10.0` (the current `main`). + The latest tag is `v1.11.0`. ## Usage @@ -71,6 +71,18 @@ let id = UUIDv5Generator.generate(namespace: ns, name: "python.org") // GZIP let gz = try Gzip.compress(data: data) let raw = try Gzip.decompress(data: gz) + +// zlib with explicit framing (15 zlib, 31 gzip, -15 raw DEFLATE) +let deflated = try Zlib.compress(data: data, windowBits: -15) +let inflated = try Zlib.decompress(data: deflated, framing: .rawDeflate) +let crc32 = CRC32Checksum.checksum(data) // UInt32 + +// Text of unknown encoding: US-ASCII, UTF-8, Mac OS Roman, Windows-1252, +// control characters (except newlines and tabs) removed +let text = bytes.decodedText() +// Add your own character sets before or after the defaults +TextDecoderRegistry.shared.register(MyCharsetStrategy(), placement: .beforeDefaults) +let custom = TextDecoderRegistry.shared.decode(bytes) ``` ## Build & test @@ -81,7 +93,7 @@ swift test ``` - No other packages are needed to build DAK. -- Current baseline: `swift build` succeeds with two warnings, and `swift test` runs 313 XCTest tests and 1 swift-testing placeholder with 0 failures. +- Current baseline: `swift build` succeeds with two warnings, and `swift test` runs 343 XCTest tests and 1 swift-testing placeholder with 0 failures. - `IntegrationCompressionTests` runs `/usr/bin/gzip` and `/usr/bin/gunzip`, so it needs macOS. ## Known issues @@ -89,7 +101,7 @@ swift test - `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, the `version` constant is stale (`1.3.0`), `WASMGuard.swift` is dead code, CRC/SHA code is duplicated, and the DocC catalog is not attached to the target. +- 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. ## Documentation diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Compression.swift b/Sources/DesignAlgorithmsKit/Algorithms/Compression.swift deleted file mode 100644 index 7772855..0000000 --- a/Sources/DesignAlgorithmsKit/Algorithms/Compression.swift +++ /dev/null @@ -1,299 +0,0 @@ -import Foundation -import Compression - -/// A utility for GZIP compression and decompression using the native `Compression` framework. -public struct Gzip { - - /// Errors that can occur during compression or decompression. - public enum Error: Swift.Error { - case compressionFailed - case decompressionFailed - case invalidData - } - - /// Compresses data using GZIP algorithm. - /// - Parameter data: The input data to compress. - /// - Returns: The compressed data. - /// - Throws: `Gzip.Error.compressionFailed` if the operation fails. - public static func compress(data: Data) throws -> Data { - // GZIP Header (10 bytes) - // Magic (2), Method (1), Flags (1), MTime (4), XFlags (1), OS (1) - // GZIP Header (10 bytes) - // Magic (2), Method (1), Flags (1), MTime (4), XFlags (1), OS (1) - var result = Data([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03]) - - // 1. Compress with ZLIB algorithm (raw deflate) using low-level API - let rawDeflated = try compressRawDeflate(data) - - // 2. Append to Header - result.append(rawDeflated) - - // 3. Append CRC32 (4 bytes) - let crc = Checksum.crc32(data: data) - result.append(contentsOf: withUnsafeBytes(of: crc.littleEndian) { Array($0) }) - - // 4. Append ISIZE (Input Size) modulo 2^32 (4 bytes) - let isize = UInt32(data.count % 4294967296) - result.append(contentsOf: withUnsafeBytes(of: isize.littleEndian) { Array($0) }) - - return result - } - - /// 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 { - // GZIP Header Validation - guard data.count >= 18 else { throw Error.invalidData } - guard data[0] == 0x1f, data[1] == 0x8b else { throw Error.invalidData } - guard data[2] == 0x08 else { throw Error.decompressionFailed } // Method must be DEFLATE - - // Parse Flags - var parser = DataParser(data: data) - _ = try parser.readByte() // Magic 1 - _ = try parser.readByte() // Magic 2 - _ = try parser.readByte() // Method - let flags = try parser.readByte() - _ = try parser.readBytes(count: 6) // MTime, XFlags, OS - - // FEXTRA (0x04) - if (flags & 0x04) != 0 { - let xlen = try parser.readUInt16() - _ = try parser.readBytes(count: Int(xlen)) - } - - // FNAME (0x08) - if (flags & 0x08) != 0 { - while (try parser.readByte()) != 0 {} - } - - // FCOMMENT (0x10) - if (flags & 0x10) != 0 { - while (try parser.readByte()) != 0 {} - } - - // FHCRC (0x02) - if (flags & 0x02) != 0 { - _ = try parser.readBytes(count: 2) - } - - let headerSize = parser.offset - let footerSize = 8 - guard data.count > headerSize + footerSize else { throw Error.invalidData } - - let deflatePayload = data.subdata(in: headerSize..<(data.count - footerSize)) - - return try decompressRawDeflate(deflatePayload) - } - - // MARK: - Internal Helpers using ZlibProxy - - private static func compressRawDeflate(_ input: Data) throws -> Data { - return try ZlibProxy.compressRawDeflate(data: input) - } - - private static func decompressRawDeflate(_ input: Data) throws -> Data { - return try ZlibProxy.decompressRawDeflate(data: input) - } -} - -// MARK: - Checksum Utility -struct Checksum { - static func crc32(data: Data) -> UInt32 { - var crc: UInt32 = 0xFFFFFFFF - var table = [UInt32](repeating: 0, count: 256) - - // Compute table - for i in 0..<256 { - var c = UInt32(i) - for _ in 0..<8 { - if (c & 1) != 0 { - c = 0xEDB88320 ^ (c >> 1) - } else { - c = c >> 1 - } - } - table[i] = c - } - - for byte in data { - let index = Int((crc ^ UInt32(byte)) & 0xFF) - crc = table[index] ^ (crc >> 8) - } - - return crc ^ 0xFFFFFFFF - } -} - -// MARK: - ZLib Proxy via @_silgen_name -// Bypasses 'import zlib' requirement by linking directly to system symbols -struct ZlibProxy { - - // Compression Bindings - @_silgen_name("deflateInit2_") - private static func deflateInit2_(_ strm: UnsafeMutableRawPointer, _ level: Int32, _ method: Int32, _ windowBits: Int32, _ memLevel: Int32, _ strategy: Int32, _ version: UnsafePointer, _ stream_size: Int32) -> Int32 - - @_silgen_name("deflate") - private static func deflate(_ strm: UnsafeMutableRawPointer, _ flush: Int32) -> Int32 - - @_silgen_name("deflateEnd") - private static func deflateEnd(_ strm: UnsafeMutableRawPointer) -> Int32 - - // Decompression Bindings - @_silgen_name("inflateInit2_") - private static func inflateInit2_(_ strm: UnsafeMutableRawPointer, _ windowBits: Int32, _ version: UnsafePointer, _ stream_size: Int32) -> Int32 - - @_silgen_name("inflate") - private static func inflate(_ strm: UnsafeMutableRawPointer, _ flush: Int32) -> Int32 - - @_silgen_name("inflateEnd") - private static func inflateEnd(_ strm: UnsafeMutableRawPointer) -> Int32 - - // Internal z_stream structure layout (OS agnostic usually, but pointer size matters) - private struct ZStream { - var next_in: UnsafeMutableRawPointer? = nil - var avail_in: UInt32 = 0 - var total_in: UInt = 0 - - var next_out: UnsafeMutableRawPointer? = nil - var avail_out: UInt32 = 0 - var total_out: UInt = 0 - - var msg: UnsafePointer? = nil - var state: OpaquePointer? = nil - - var zalloc: OpaquePointer? = nil - var zfree: OpaquePointer? = nil - var opaque: OpaquePointer? = nil - - var data_type: Int32 = 0 - var adler: UInt = 0 - var reserved: UInt = 0 - } - - static func compressRawDeflate(data: Data) throws -> Data { - // ZLIB constants - let Z_DEFAULT_COMPRESSION: Int32 = -1 - let Z_DEFLATED: Int32 = 8 - let Z_DEFAULT_STRATEGY: Int32 = 0 - let Z_FINISH: Int32 = 4 - // let Z_OK: Int32 = 0 - let Z_STREAM_END: Int32 = 1 - - let windowBits: Int32 = -15 // Raw Deflate - let memLevel: Int32 = 8 - let version = "1.2.11" - - var stream = ZStream() - - return try data.withUnsafeBytes { inputPtr in - var mutableStream = stream - mutableStream.next_in = UnsafeMutableRawPointer(mutating: inputPtr.baseAddress) // can be nil - mutableStream.avail_in = UInt32(inputPtr.count) - - let versionPtr = (version as NSString).utf8String! - let res = deflateInit2_(&mutableStream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, windowBits, memLevel, Z_DEFAULT_STRATEGY, versionPtr, Int32(MemoryLayout.size)) - guard res == 0 else { throw Gzip.Error.compressionFailed } - defer { - var cleanupStream = mutableStream - _ = deflateEnd(&cleanupStream) - } - - var output = Data() - let bufferSize = 65536 - var buffer = [UInt8](repeating: 0, count: bufferSize) - - while true { - let status = buffer.withUnsafeMutableBufferPointer { outputPtr -> Int32 in - mutableStream.next_out = UnsafeMutableRawPointer(outputPtr.baseAddress!) - mutableStream.avail_out = UInt32(bufferSize) - return deflate(&mutableStream, Z_FINISH) - } - - let bytesWritten = bufferSize - Int(mutableStream.avail_out) - if bytesWritten > 0 { - output.append(buffer, count: bytesWritten) - } - - if status == Z_STREAM_END { break } - if status != 0 && status != 1 { // Error (0 is OK, 1 is END) - throw Gzip.Error.compressionFailed - } - } - return output - } - } - - static func decompressRawDeflate(data: Data) throws -> Data { - var stream = ZStream() - - // windowBits = -15 for raw deflate - let windowBits: Int32 = -15 - let version = "1.2.11" // Just needs to be compatible - - return try data.withUnsafeBytes { inputPtr in - var mutableStream = stream - mutableStream.next_in = UnsafeMutableRawPointer(mutating: inputPtr.baseAddress!) - mutableStream.avail_in = UInt32(inputPtr.count) - - let versionPtr = (version as NSString).utf8String! - - let res = inflateInit2_(&mutableStream, windowBits, versionPtr, Int32(MemoryLayout.size)) - guard res == 0 else { throw Gzip.Error.decompressionFailed } // Z_OK = 0 - defer { - var cleanupStream = mutableStream - _ = inflateEnd(&cleanupStream) - } - - var output = Data() - let bufferSize = 65536 - var buffer = [UInt8](repeating: 0, count: bufferSize) - - while true { - let status = buffer.withUnsafeMutableBufferPointer { outputPtr -> Int32 in - mutableStream.next_out = UnsafeMutableRawPointer(outputPtr.baseAddress!) - mutableStream.avail_out = UInt32(bufferSize) - return inflate(&mutableStream, 0) // Z_NO_FLUSH - } - - let bytesWritten = bufferSize - Int(mutableStream.avail_out) - if bytesWritten > 0 { - output.append(buffer, count: bytesWritten) - } - - if status == 1 { break } // Z_STREAM_END - if status != 0 && status != 1 { // Error - // status -5 is Z_BUF_ERROR - throw Gzip.Error.decompressionFailed - } - } - return output - } - } -} - -// Helper for parsing binary data -struct DataParser { - let data: Data - var offset = 0 - - mutating func readByte() throws -> UInt8 { - guard offset < data.count else { throw Gzip.Error.invalidData } - let b = data[offset] - offset += 1 - return b - } - - mutating func readBytes(count: Int) throws -> Data { - guard offset + count <= data.count else { throw Gzip.Error.invalidData } - let chunk = data.subdata(in: offset..<(offset+count)) - offset += count - return chunk - } - - mutating func readUInt16() throws -> UInt16 { - let bytes = try readBytes(count: 2) - return bytes.withUnsafeBytes { $0.load(as: UInt16.self) } - } -} diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Compression/Gzip.swift b/Sources/DesignAlgorithmsKit/Algorithms/Compression/Gzip.swift new file mode 100644 index 0000000..33e000d --- /dev/null +++ b/Sources/DesignAlgorithmsKit/Algorithms/Compression/Gzip.swift @@ -0,0 +1,131 @@ +import Foundation + +/// In-memory GZIP (RFC 1952) compression and decompression, built on ``Zlib`` raw DEFLATE. +/// +/// For other framings (zlib, raw DEFLATE, custom window bits) use ``Zlib`` directly. +public struct Gzip { + + /// Errors that can occur during compression or decompression. + public enum Error: Swift.Error { + case compressionFailed + case decompressionFailed + case invalidData + } + + /// Compresses data using GZIP algorithm. + /// - Parameter data: The input data to compress. + /// - Returns: The compressed data. + /// - Throws: `Gzip.Error.compressionFailed` if the operation fails. + public static func compress(data: Data) throws -> Data { + // GZIP Header (10 bytes) + // Magic (2), Method (1), Flags (1), MTime (4), XFlags (1), OS (1) + var result = Data([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03]) + + // 1. Compress with ZLIB algorithm (raw deflate) using low-level API + let rawDeflated = try compressRawDeflate(data) + + // 2. Append to Header + result.append(rawDeflated) + + // 3. Append CRC32 (4 bytes) + let crc = CRC32Checksum.checksum(data) + result.append(contentsOf: withUnsafeBytes(of: crc.littleEndian) { Array($0) }) + + // 4. Append ISIZE (Input Size) modulo 2^32 (4 bytes) + let isize = UInt32(truncatingIfNeeded: data.count) + result.append(contentsOf: withUnsafeBytes(of: isize.littleEndian) { Array($0) }) + + return result + } + + /// 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 { + // GZIP Header Validation + guard data.count >= 18 else { throw Error.invalidData } + guard data[0] == 0x1f, data[1] == 0x8b else { throw Error.invalidData } + guard data[2] == 0x08 else { throw Error.decompressionFailed } // Method must be DEFLATE + + // Parse Flags + var parser = DataParser(data: data) + _ = try parser.readByte() // Magic 1 + _ = try parser.readByte() // Magic 2 + _ = try parser.readByte() // Method + let flags = try parser.readByte() + _ = try parser.readBytes(count: 6) // MTime, XFlags, OS + + // FEXTRA (0x04) + if (flags & 0x04) != 0 { + let xlen = try parser.readUInt16() + _ = try parser.readBytes(count: Int(xlen)) + } + + // FNAME (0x08) + if (flags & 0x08) != 0 { + while (try parser.readByte()) != 0 {} + } + + // FCOMMENT (0x10) + if (flags & 0x10) != 0 { + while (try parser.readByte()) != 0 {} + } + + // FHCRC (0x02) + if (flags & 0x02) != 0 { + _ = try parser.readBytes(count: 2) + } + + let headerSize = parser.offset + let footerSize = 8 + guard data.count > headerSize + footerSize else { throw Error.invalidData } + + let deflatePayload = data.subdata(in: headerSize..<(data.count - footerSize)) + + return try decompressRawDeflate(deflatePayload) + } + + // MARK: - Internal Helpers + + private static func compressRawDeflate(_ input: Data) throws -> Data { + do { + return try Zlib.compress(data: input, framing: .rawDeflate) + } catch { + throw Error.compressionFailed + } + } + + private static func decompressRawDeflate(_ input: Data) throws -> Data { + do { + return try Zlib.decompress(data: input, framing: .rawDeflate) + } catch { + throw Error.decompressionFailed + } + } +} + +// Helper for parsing binary data +struct DataParser { + let data: Data + var offset = 0 + + mutating func readByte() throws -> UInt8 { + guard offset < data.count else { throw Gzip.Error.invalidData } + let b = data[offset] + offset += 1 + return b + } + + mutating func readBytes(count: Int) throws -> Data { + guard offset + count <= data.count else { throw Gzip.Error.invalidData } + let chunk = data.subdata(in: offset..<(offset+count)) + offset += count + return chunk + } + + mutating func readUInt16() throws -> UInt16 { + let bytes = try readBytes(count: 2) + return bytes.withUnsafeBytes { $0.load(as: UInt16.self) } + } +} diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Compression/Zlib.swift b/Sources/DesignAlgorithmsKit/Algorithms/Compression/Zlib.swift new file mode 100644 index 0000000..60b2be7 --- /dev/null +++ b/Sources/DesignAlgorithmsKit/Algorithms/Compression/Zlib.swift @@ -0,0 +1,226 @@ +// +// Zlib.swift +// DesignAlgorithmsKit +// +// In-memory zlib, gzip and raw DEFLATE compression using the system zlib. +// + +import Foundation +import zlib + +/// Errors thrown by ``Zlib``. +public enum ZlibError: Error, Equatable, Sendable { + /// zlib rejected the stream parameters (for example invalid window bits or level). + case initializationFailed + /// zlib reported an error, or the input stream was corrupt or truncated. + case processingFailed(message: String) + /// zlib could not allocate memory. + case memoryAllocationFailed +} + +/// A generic binding to the system zlib for in-memory compression and decompression. +/// +/// The stream framing is selected by window bits, as in zlib's `deflateInit2`/`inflateInit2`: +/// +/// | Window bits | Framing | +/// | --- | --- | +/// | `8...15` | zlib (RFC 1950) | +/// | `16 + (8...15)` | gzip (RFC 1952) | +/// | `-8 ... -15` | raw DEFLATE (RFC 1951) | +/// +/// ```swift +/// let gz = try Zlib.compress(data: data, framing: .gzip) +/// let raw = try Zlib.decompress(data: deflated, windowBits: -15) +/// ``` +public enum Zlib { + + /// Common stream framings and their window bits. + public enum Framing: Sendable, Equatable, CaseIterable { + /// zlib header and Adler-32 trailer (window bits 15). + case zlib + /// gzip header and CRC-32 trailer (window bits 31). + case gzip + /// Raw DEFLATE without header or trailer (window bits -15). + case rawDeflate + + /// The zlib window bits value for this framing. + public var windowBits: Int { + switch self { + case .zlib: return 15 + case .gzip: return 15 + 16 + case .rawDeflate: return -15 + } + } + } + + /// zlib's default compression level (`Z_DEFAULT_COMPRESSION`). + public static let defaultCompressionLevel: Int32 = Z_DEFAULT_COMPRESSION + + /// Size of the output buffer used per zlib call. + private static let bufferSize = 64 * 1024 + + /// Largest input slice handed to zlib per call (`avail_in` is 32-bit). + private static let maxInputChunk = 1 << 30 + + // MARK: - Framing convenience + + /// Compresses `data` with the given framing. + public static func compress(data: Data, framing: Framing, level: Int32 = defaultCompressionLevel) throws -> Data { + try compress(data: data, windowBits: framing.windowBits, level: level) + } + + /// Decompresses `data` with the given framing. + public static func decompress(data: Data, framing: Framing) throws -> Data { + try decompress(data: data, windowBits: framing.windowBits) + } + + // MARK: - Window bits API + + /// Compresses `data`. + /// - Parameters: + /// - data: The bytes to compress. Empty input produces a valid, empty stream. + /// - windowBits: Framing selector (15 zlib, 31 gzip, -15 raw DEFLATE). Defaults to gzip. + /// - level: Compression level 0-9, or ``defaultCompressionLevel``. + /// - Returns: The compressed stream. + /// - Throws: ``ZlibError``. + public static func compress(data: Data, windowBits: Int = 15 + 16, level: Int32 = defaultCompressionLevel) throws -> Data { + guard let bits = Int32(exactly: windowBits) else { throw ZlibError.initializationFailed } + + return try withStream { stream in + let initStatus = deflateInit2_(stream, level, Z_DEFLATED, bits, 8, Z_DEFAULT_STRATEGY, + ZLIB_VERSION, Int32(MemoryLayout.size)) + try check(initStatus) + defer { deflateEnd(stream) } + + var output = Data() + output.reserveCapacity(max(data.count / 2, 64)) + var buffer = [UInt8](repeating: 0, count: bufferSize) + + try data.withUnsafeBytes { (input: UnsafeRawBufferPointer) in + let base = input.bindMemory(to: Bytef.self).baseAddress + let total = input.count + var offset = 0 + var status: Int32 = Z_OK + + repeat { + if stream.pointee.avail_in == 0, offset < total, let base { + let count = min(total - offset, maxInputChunk) + stream.pointee.next_in = UnsafeMutablePointer(mutating: base + offset) + stream.pointee.avail_in = UInt32(count) + offset += count + } + let flush = offset == total ? Z_FINISH : Z_NO_FLUSH + + let produced = buffer.withUnsafeMutableBufferPointer { out -> Int in + stream.pointee.next_out = out.baseAddress + stream.pointee.avail_out = UInt32(out.count) + status = deflate(stream, flush) + return out.count - Int(stream.pointee.avail_out) + } + if produced > 0 { output.append(contentsOf: buffer[0.. Data { + guard !data.isEmpty else { return Data() } + guard let bits = Int32(exactly: windowBits) else { throw ZlibError.initializationFailed } + + return try withStream { stream in + let initStatus = inflateInit2_(stream, bits, ZLIB_VERSION, Int32(MemoryLayout.size)) + try check(initStatus) + defer { inflateEnd(stream) } + + var output = Data() + output.reserveCapacity(data.count * 2) + var buffer = [UInt8](repeating: 0, count: bufferSize) + + try data.withUnsafeBytes { (input: UnsafeRawBufferPointer) in + guard let base = input.bindMemory(to: Bytef.self).baseAddress else { return } + let total = input.count + var offset = 0 + var status: Int32 = Z_OK + + repeat { + if stream.pointee.avail_in == 0 { + guard offset < total else { + throw ZlibError.processingFailed(message: "Truncated stream") + } + let count = min(total - offset, maxInputChunk) + stream.pointee.next_in = UnsafeMutablePointer(mutating: base + offset) + stream.pointee.avail_in = UInt32(count) + offset += count + } + + let produced = buffer.withUnsafeMutableBufferPointer { out -> Int in + stream.pointee.next_out = out.baseAddress + stream.pointee.avail_out = UInt32(out.count) + status = inflate(stream, Z_NO_FLUSH) + return out.count - Int(stream.pointee.avail_out) + } + if produced > 0 { output.append(contentsOf: buffer[0..(_ body: (UnsafeMutablePointer) throws -> R) throws -> R { + let stream = UnsafeMutablePointer.allocate(capacity: 1) + stream.initialize(to: z_stream()) + defer { + stream.deinitialize(count: 1) + stream.deallocate() + } + return try body(stream) + } + + /// Maps a zlib status to ``ZlibError``; `Z_OK` and `Z_STREAM_END` pass. + private static func check(_ status: Int32) throws { + switch status { + case Z_OK, Z_STREAM_END: + return + case Z_MEM_ERROR: + throw ZlibError.memoryAllocationFailed + case Z_STREAM_ERROR, Z_VERSION_ERROR: + throw ZlibError.initializationFailed + case Z_DATA_ERROR: + throw ZlibError.processingFailed(message: "Corrupt stream (Z_DATA_ERROR)") + default: + throw ZlibError.processingFailed(message: "zlib error \(status)") + } + } +} diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Hashing/CRC32Checksum.swift b/Sources/DesignAlgorithmsKit/Algorithms/Hashing/CRC32Checksum.swift new file mode 100644 index 0000000..16e7da8 --- /dev/null +++ b/Sources/DesignAlgorithmsKit/Algorithms/Hashing/CRC32Checksum.swift @@ -0,0 +1,44 @@ +// +// CRC32Checksum.swift +// DesignAlgorithmsKit +// +// CRC-32 (IEEE 802.3) as an integer, backed by the system zlib. +// + +import Foundation +import zlib + +/// CRC-32 (IEEE 802.3, the checksum used by gzip, ZIP and PNG) as a `UInt32`. +/// +/// ```swift +/// let crc = CRC32Checksum.checksum(data) +/// // Incremental: same result as checksumming everything at once +/// var running = CRC32Checksum.initialValue +/// running = CRC32Checksum.update(running, with: part1) +/// running = CRC32Checksum.update(running, with: part2) +/// ``` +public enum CRC32Checksum { + /// The CRC of empty input; the starting value for ``update(_:with:)``. + public static let initialValue: UInt32 = 0 + + /// Returns the CRC-32 of `data`. + public static func checksum(_ data: Data) -> UInt32 { + update(initialValue, with: data) + } + + /// Continues `crc` (a previous result, or ``initialValue``) over `data`. + public static func update(_ crc: UInt32, with data: Data) -> UInt32 { + data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) -> UInt32 in + guard let base = buffer.bindMemory(to: Bytef.self).baseAddress else { return crc } + var value = uLong(crc) + var offset = 0 + let chunk = 1 << 30 + while offset < buffer.count { + let count = min(buffer.count - offset, chunk) + value = crc32(value, base + offset, uInt(count)) + offset += count + } + return UInt32(truncatingIfNeeded: value) + } + } +} diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Hashing/Strategies/CRC32Strategy.swift b/Sources/DesignAlgorithmsKit/Algorithms/Hashing/Strategies/CRC32Strategy.swift index 2347f26..22d3f04 100644 --- a/Sources/DesignAlgorithmsKit/Algorithms/Hashing/Strategies/CRC32Strategy.swift +++ b/Sources/DesignAlgorithmsKit/Algorithms/Hashing/Strategies/CRC32Strategy.swift @@ -10,47 +10,7 @@ public struct CRC32Strategy: HashStrategy { public init() {} public func compute(data: Data) -> Data { - var crc: UInt32 = 0xFFFFFFFF - for byte in data { - crc = crc32Table[Int((crc ^ UInt32(byte)) & 0xFF)] ^ (crc >> 8) - } - crc = crc ^ 0xFFFFFFFF + let crc = CRC32Checksum.checksum(data) return withUnsafeBytes(of: crc.bigEndian) { Data($0) } } - - // Standard CRC-32 (IEEE 802.3) table - private let crc32Table: [UInt32] = [ - 0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA, 0x076DC419, 0x706AF48F, 0xE963A535, 0x9E6495A3, - 0x0EDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988, 0x09B64C2B, 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91, - 0x1DB71064, 0x6AB020F2, 0xF3B97148, 0x84BE41DE, 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7, - 0x136C9856, 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC, 0x14015C4F, 0x63066CD9, 0xFA0F3D63, 0x8D080DF5, - 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172, 0x3C03E4D1, 0x4B04D447, 0xD20D85FD, 0xA50AB56B, - 0x35B5A8FA, 0x42B2986C, 0xDBBBC9D6, 0xACBCF940, 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59, - 0x26D930AC, 0x51DE003A, 0xC8D75180, 0xBFD06116, 0x21B4F4B5, 0x56B3C423, 0xCFBA9599, 0xB8BDA50F, - 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924, 0x2F6F7C87, 0x58684C11, 0xC1611DAB, 0xB6662D3D, - 0x76DC4190, 0x01DB7106, 0x98D220BC, 0xEFD5102A, 0x71B18589, 0x06B6B51F, 0x9FBFE4A5, 0xE8B8D433, - 0x7807C9A2, 0x0F00F934, 0x9609A88E, 0xE10E9818, 0x7F6A0DBB, 0x086D3D2D, 0x91646C97, 0xE6635C01, - 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E, 0x6C0695ED, 0x1B01A57B, 0x8208F4C1, 0xF50FC457, - 0x65B0D9C6, 0x12B7E950, 0x8BBEB8EA, 0xFCB9887C, 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65, - 0x4DB26158, 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2, 0x4ADFA541, 0x3DD895D7, 0xA4D1C46D, 0xD3D6F4FB, - 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0, 0x44042D73, 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9, - 0x5005713C, 0x270241AA, 0xBE0B1010, 0xC90C2086, 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F, - 0x5EDEF90E, 0x29D9C998, 0xB0D09822, 0xC7D7A8B4, 0x59B33D17, 0x2EB40D81, 0xB7BD5C3B, 0xC0BA6CAD, - 0xEDB88320, 0x9ABFB3B6, 0x03B6E20C, 0x74B1D29A, 0xEAD54739, 0x9DD277AF, 0x04DB2615, 0x73DC1683, - 0xE3630B12, 0x94643B84, 0x0D6D6A3E, 0x7A6A5AA8, 0xE40ECF0B, 0x9309FF9D, 0x0A00AE27, 0x7D079EB1, - 0xF00F9344, 0x8708A3D2, 0x1E01F268, 0x6906C2FE, 0xF762575D, 0x806567CB, 0x196C3671, 0x6E6B06E7, - 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC, 0xF9B9DF6F, 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5, - 0xD6D6A3E8, 0xA1D1937E, 0x38D8C2C4, 0x4FDFF252, 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B, - 0xD80D2BDA, 0xAF0A1B4C, 0x36034AF6, 0x41047A60, 0xDF60EFC3, 0xA867DF55, 0x316E8EEF, 0x4669BE79, - 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236, 0xCC0C7795, 0xBB0B4703, 0x220216B9, 0x5505262F, - 0xC5BA3BBE, 0xB2BD0B28, 0x2BB45A92, 0x5CB36A04, 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D, - 0x9B64C2B0, 0xEC63F226, 0x756AA39C, 0x026D930A, 0x9C0906A9, 0xEB0E363F, 0x72076785, 0x05005713, - 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0x0CB61B38, 0x92D28E9B, 0xE5D5BE0D, 0x7CDCEFB7, 0x0BDBDF21, - 0x86D3D2D4, 0xF1D4E242, 0x68DDB3F8, 0x1FDA836E, 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777, - 0x88085AE6, 0xFF0F6A70, 0x66063BCA, 0x11010B5C, 0x8F659EFF, 0xF862AE69, 0x616BFFD3, 0x166CCF45, - 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2, 0xA7672661, 0xD06016F7, 0x4969474D, 0x3E6E77DB, - 0xAED16A4A, 0xD9D65ADC, 0x40DF0B66, 0x37D83BF0, 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9, - 0xBDBDF21C, 0xCABAC28A, 0x53B39330, 0x24B4A3A6, 0xBAD03605, 0xCDD70693, 0x54DE5729, 0x23D967BF, - 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94, 0xB40BBE37, 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D - ] } diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Text/FallbackTextDecoder.swift b/Sources/DesignAlgorithmsKit/Algorithms/Text/FallbackTextDecoder.swift new file mode 100644 index 0000000..134d6f2 --- /dev/null +++ b/Sources/DesignAlgorithmsKit/Algorithms/Text/FallbackTextDecoder.swift @@ -0,0 +1,236 @@ +// +// FallbackTextDecoder.swift +// DesignAlgorithmsKit +// +// Decode bytes of unknown encoding by trying a list of text decoding strategies in order. +// + +import Foundation + +// MARK: - Strategy + +/// One way of turning bytes into text, for use with ``FallbackTextDecoder``. +/// +/// Conform to add a character set that `String.Encoding` does not cover. Return `nil` +/// when the data is not valid in, or not recognized as, this character set, so that the +/// next strategy is tried. +public protocol TextDecodingStrategy: Sendable { + /// A unique, human-readable name (for example `"utf-8"`); used as the registry key. + var name: String { get } + + /// Decodes `data`, or returns `nil` when the data cannot be decoded by this strategy. + func decode(_ data: Data) -> String? +} + +/// A ``TextDecodingStrategy`` backed by a Foundation `String.Encoding`. +public struct StringEncodingStrategy: TextDecodingStrategy { + public let name: String + public let encoding: String.Encoding + + /// - Parameters: + /// - encoding: The Foundation encoding to decode with. + /// - name: The strategy name; defaults to the encoding's IANA charset name. + public init(_ encoding: String.Encoding, name: String? = nil) { + self.encoding = encoding + self.name = name ?? StringEncodingStrategy.ianaName(for: encoding) + } + + public func decode(_ data: Data) -> String? { + String(data: data, encoding: encoding) + } + + /// 7-bit US-ASCII (fails on any byte above 0x7F). + public static let ascii = StringEncodingStrategy(.ascii, name: "us-ascii") + /// UTF-8. + public static let utf8 = StringEncodingStrategy(.utf8, name: "utf-8") + /// Mac OS Roman. + public static let macOSRoman = StringEncodingStrategy(.macOSRoman, name: "macintosh") + /// Windows code page 1252. + public static let windowsCP1252 = StringEncodingStrategy(.windowsCP1252, name: "windows-1252") + + private static func ianaName(for encoding: String.Encoding) -> String { + let cfEncoding = CFStringConvertNSStringEncodingToEncoding(encoding.rawValue) + if let iana = CFStringConvertEncodingToIANACharSetName(cfEncoding) { + return iana as String + } + return "encoding-\(encoding.rawValue)" + } +} + +// MARK: - Result + +/// Text produced by ``FallbackTextDecoder`` together with the strategy that produced it. +public struct DecodedText: Sendable, Equatable { + /// The decoded text (control characters removed when the decoder strips them). + public let text: String + /// ``TextDecodingStrategy/name`` of the strategy that succeeded. + public let strategyName: String + + public init(text: String, strategyName: String) { + self.text = text + self.strategyName = strategyName + } +} + +// MARK: - Decoder + +/// Decodes bytes of unknown encoding by trying strategies in order. +/// +/// Each strategy is tried in turn. When control-character stripping is on (the default), +/// control characters other than newlines and tabs are removed from the strategy's output. +/// The first strategy whose (stripped) output is non-empty wins. If none succeeds, the +/// result is `nil`. +/// +/// The default order is US-ASCII, UTF-8, Mac OS Roman, Windows-1252. Because the last two +/// accept any byte sequence, strategies that must get a chance should go before them: +/// +/// ```swift +/// let decoder = FallbackTextDecoder(before: [MyCharsetStrategy()]) +/// let text = decoder.decode(bytes) +/// ``` +public struct FallbackTextDecoder: Sendable { + + /// US-ASCII, UTF-8, Mac OS Roman, Windows-1252. + public static let defaultStrategies: [any TextDecodingStrategy] = [ + StringEncodingStrategy.ascii, + StringEncodingStrategy.utf8, + StringEncodingStrategy.macOSRoman, + StringEncodingStrategy.windowsCP1252, + ] + + /// A decoder with the ``defaultStrategies`` and control-character stripping. + public static let standard = FallbackTextDecoder() + + /// The strategies, in the order they are tried. + public let strategies: [any TextDecodingStrategy] + + /// Whether control characters other than newlines and tabs are removed. + public let stripsControlCharacters: Bool + + /// Creates a decoder that tries exactly `strategies`, in order. + public init(strategies: [any TextDecodingStrategy] = FallbackTextDecoder.defaultStrategies, + stripsControlCharacters: Bool = true) { + self.strategies = strategies + self.stripsControlCharacters = stripsControlCharacters + } + + /// Creates a decoder that tries `before`, then the ``defaultStrategies``, then `after`. + public init(before: [any TextDecodingStrategy], + after: [any TextDecodingStrategy] = [], + stripsControlCharacters: Bool = true) { + self.init(strategies: before + FallbackTextDecoder.defaultStrategies + after, + stripsControlCharacters: stripsControlCharacters) + } + + /// Returns the decoded text, or `nil` when no strategy yields non-empty text. + public func decode(_ data: Data) -> String? { + decodeWithStrategy(data)?.text + } + + /// Returns the decoded text and the name of the strategy that produced it, + /// or `nil` when no strategy yields non-empty text. + public func decodeWithStrategy(_ data: Data) -> DecodedText? { + for strategy in strategies { + guard let decoded = strategy.decode(data) else { continue } + let text = stripsControlCharacters + ? FallbackTextDecoder.removingControlCharacters(from: decoded) + : decoded + if !text.isEmpty { + return DecodedText(text: text, strategyName: strategy.name) + } + } + return nil + } + + /// Removes characters containing a Unicode control or format scalar + /// (`CharacterSet.controlCharacters`), keeping newlines and tabs. + public static func removingControlCharacters(from string: String) -> String { + string.filter { character in + if character.isNewline || character == "\t" { return true } + return !character.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) } + } + } +} + +// MARK: - Registry + +/// A thread-safe set of named text decoding strategies that libraries can add to at +/// start-up, and from which callers build a ``FallbackTextDecoder``. +/// +/// Registered strategies go before or after the ``FallbackTextDecoder/defaultStrategies``. +/// Within a placement, a higher `priority` is tried first; equal priorities are ordered by +/// name. Registering a strategy whose name is already registered replaces it. +/// +/// ```swift +/// TextDecoderRegistry.shared.register(MyCharsetStrategy(), placement: .beforeDefaults) +/// let text = TextDecoderRegistry.shared.decode(bytes) +/// ``` +public final class TextDecoderRegistry: Registry, @unchecked Sendable { + + /// Where a registered strategy sits relative to the default strategies. + public enum Placement: Sendable, Equatable { + case beforeDefaults + case afterDefaults + } + + /// A registered strategy and its ordering. + public struct Entry: Sendable { + public let strategy: any TextDecodingStrategy + public let placement: Placement + public let priority: Int + + public init(strategy: any TextDecodingStrategy, placement: Placement, priority: Int = 0) { + self.strategy = strategy + self.placement = placement + self.priority = priority + } + } + + /// Process-wide registry for composition roots. Prefer passing a registry or a + /// ``FallbackTextDecoder`` explicitly where possible. + public static let shared = TextDecoderRegistry() + + public override init() { + super.init() + } + + /// Registers `strategy` under its ``TextDecodingStrategy/name``, replacing any entry with that name. + public func register(_ strategy: any TextDecodingStrategy, + placement: Placement = .beforeDefaults, + priority: Int = 0) { + register(Entry(strategy: strategy, placement: placement, priority: priority), for: strategy.name) + } + + /// The registered strategies for `placement`, in the order they are tried. + public func strategies(placement: Placement) -> [any TextDecodingStrategy] { + all() + .filter { $0.placement == placement } + .sorted { lhs, rhs in + lhs.priority != rhs.priority ? lhs.priority > rhs.priority : lhs.strategy.name < rhs.strategy.name + } + .map(\.strategy) + } + + /// Builds a decoder: registered `.beforeDefaults`, the defaults, registered `.afterDefaults`. + public func makeDecoder(stripsControlCharacters: Bool = true) -> FallbackTextDecoder { + FallbackTextDecoder(before: strategies(placement: .beforeDefaults), + after: strategies(placement: .afterDefaults), + stripsControlCharacters: stripsControlCharacters) + } + + /// Decodes `data` with ``makeDecoder(stripsControlCharacters:)``. + public func decode(_ data: Data) -> String? { + makeDecoder().decode(data) + } +} + +// MARK: - Data convenience + +extension Data { + /// Decodes these bytes as text with `decoder` (by default ``FallbackTextDecoder/standard``: + /// US-ASCII, UTF-8, Mac OS Roman, Windows-1252, control characters stripped). + /// - Returns: The text, or `nil` when no strategy yields non-empty text. + public func decodedText(using decoder: FallbackTextDecoder = .standard) -> String? { + decoder.decode(self) + } +} diff --git a/Sources/DesignAlgorithmsKit/DesignAlgorithmsKit.swift b/Sources/DesignAlgorithmsKit/DesignAlgorithmsKit.swift index 6401f86..dff2458 100644 --- a/Sources/DesignAlgorithmsKit/DesignAlgorithmsKit.swift +++ b/Sources/DesignAlgorithmsKit/DesignAlgorithmsKit.swift @@ -51,7 +51,7 @@ import Foundation /// - WASM (limited - excludes hash/crypto types that require NSLock) public struct DesignAlgorithmsKit { /// Library version - public static let version = "1.3.0" + public static let version = "1.11.0" } // Re-export all modules diff --git a/Tests/DesignAlgorithmsKitTests/Algorithms/CRC32ChecksumTests.swift b/Tests/DesignAlgorithmsKitTests/Algorithms/CRC32ChecksumTests.swift new file mode 100644 index 0000000..42c51e3 --- /dev/null +++ b/Tests/DesignAlgorithmsKitTests/Algorithms/CRC32ChecksumTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import DesignAlgorithmsKit + +final class CRC32ChecksumTests: XCTestCase { + + func testCheckValue() { + XCTAssertEqual(CRC32Checksum.checksum(Data("123456789".utf8)), 0xCBF43926) + } + + func testEmpty() { + XCTAssertEqual(CRC32Checksum.checksum(Data()), 0) + XCTAssertEqual(CRC32Checksum.initialValue, 0) + } + + func testIncremental() { + let first = CRC32Checksum.checksum(Data("1234".utf8)) + XCTAssertEqual(CRC32Checksum.update(first, with: Data("56789".utf8)), 0xCBF43926) + XCTAssertEqual(CRC32Checksum.update(first, with: Data()), first) + } + + func testStrategyUsesSameValueBigEndian() { + let data = Data("123456789".utf8) + XCTAssertEqual(CRC32Strategy().compute(data: data), Data([0xCB, 0xF4, 0x39, 0x26])) + XCTAssertEqual(HashComputation.computeCRC32(data: data), Data([0xCB, 0xF4, 0x39, 0x26])) + } + + func testSlicedData() { + let whole = Data("xx123456789".utf8) + XCTAssertEqual(CRC32Checksum.checksum(whole.dropFirst(2)), 0xCBF43926) + } +} diff --git a/Tests/DesignAlgorithmsKitTests/Algorithms/Compression/ZlibTests.swift b/Tests/DesignAlgorithmsKitTests/Algorithms/Compression/ZlibTests.swift new file mode 100644 index 0000000..0f32ed2 --- /dev/null +++ b/Tests/DesignAlgorithmsKitTests/Algorithms/Compression/ZlibTests.swift @@ -0,0 +1,105 @@ +import XCTest +@testable import DesignAlgorithmsKit + +final class ZlibTests: XCTestCase { + + private let sample = Data((String(repeating: "The quick brown fox jumps over the lazy dog. ", count: 200)).utf8) + + func testRoundTripAllFramings() throws { + for framing in Zlib.Framing.allCases { + let compressed = try Zlib.compress(data: sample, framing: framing) + XCTAssertLessThan(compressed.count, sample.count, "\(framing)") + XCTAssertEqual(try Zlib.decompress(data: compressed, framing: framing), sample, "\(framing)") + } + } + + func testFramingWindowBits() { + XCTAssertEqual(Zlib.Framing.zlib.windowBits, 15) + XCTAssertEqual(Zlib.Framing.gzip.windowBits, 31) + XCTAssertEqual(Zlib.Framing.rawDeflate.windowBits, -15) + } + + func testGzipFramingHeaderAndDefaultWindowBits() throws { + let compressed = try Zlib.compress(data: sample, windowBits: 31) + XCTAssertEqual(compressed[0], 0x1F) + XCTAssertEqual(compressed[1], 0x8B) + // Default window bits are gzip + XCTAssertEqual(try Zlib.decompress(data: compressed), sample) + XCTAssertEqual(try Zlib.decompress(data: Zlib.compress(data: sample)), sample) + } + + func testZlibFramingHeader() throws { + let compressed = try Zlib.compress(data: sample, framing: .zlib) + XCTAssertEqual(compressed[0], 0x78) + XCTAssertEqual((UInt16(compressed[0]) << 8 | UInt16(compressed[1])) % 31, 0) + } + + func testAutoDetectZlibOrGzip() throws { + XCTAssertEqual(try Zlib.decompress(data: Zlib.compress(data: sample, framing: .zlib), windowBits: 47), sample) + XCTAssertEqual(try Zlib.decompress(data: Zlib.compress(data: sample, framing: .gzip), windowBits: 47), sample) + } + + func testRawDeflateInteropWithGzip() throws { + let gz = try Gzip.compress(data: sample) + let payload = gz.subdata(in: 10..<(gz.count - 8)) + XCTAssertEqual(try Zlib.decompress(data: payload, windowBits: -15), sample) + } + + func testGzipReadsZlibGzipFraming() throws { + let gz = try Zlib.compress(data: sample, framing: .gzip) + XCTAssertEqual(try Gzip.decompress(data: gz), sample) + } + + func testCompressionLevels() throws { + let stored = try Zlib.compress(data: sample, windowBits: -15, level: 0) + let best = try Zlib.compress(data: sample, windowBits: -15, level: 9) + XCTAssertGreaterThan(stored.count, best.count) + XCTAssertEqual(try Zlib.decompress(data: stored, windowBits: -15), sample) + XCTAssertEqual(try Zlib.decompress(data: best, windowBits: -15), sample) + } + + func testEmptyInput() throws { + XCTAssertEqual(try Zlib.decompress(data: Data(), framing: .rawDeflate), Data()) + for framing in Zlib.Framing.allCases { + let compressed = try Zlib.compress(data: Data(), framing: framing) + XCTAssertFalse(compressed.isEmpty) + XCTAssertEqual(try Zlib.decompress(data: compressed, framing: framing), Data()) + } + } + + func testLargeIncompressibleInputSpansBuffers() throws { + var generator = SystemRandomNumberGenerator() + let random = Data((0..<300_000).map { _ in UInt8.random(in: 0...255, using: &generator) }) + let compressed = try Zlib.compress(data: random, framing: .rawDeflate) + XCTAssertEqual(try Zlib.decompress(data: compressed, framing: .rawDeflate), random) + } + + func testTruncatedStreamThrows() throws { + let compressed = try Zlib.compress(data: sample, framing: .gzip) + let truncated = compressed.prefix(compressed.count / 2) + XCTAssertThrowsError(try Zlib.decompress(data: Data(truncated), framing: .gzip)) { error in + XCTAssertTrue(error is ZlibError) + } + } + + func testCorruptStreamThrows() { + XCTAssertThrowsError(try Zlib.decompress(data: Data("Not compressed data at all".utf8), framing: .zlib)) { error in + XCTAssertTrue(error is ZlibError) + } + } + + func testInvalidWindowBitsThrows() { + XCTAssertThrowsError(try Zlib.compress(data: sample, windowBits: 99)) { error in + XCTAssertEqual(error as? ZlibError, .initializationFailed) + } + XCTAssertThrowsError(try Zlib.decompress(data: sample, windowBits: 99)) { error in + XCTAssertEqual(error as? ZlibError, .initializationFailed) + } + } + + func testTrailingBytesAfterStreamAreIgnored() throws { + var compressed = try Zlib.compress(data: sample, framing: .rawDeflate) + compressed.append(contentsOf: [0xDE, 0xAD, 0xBE, 0xEF]) + XCTAssertEqual(try Zlib.decompress(data: compressed, framing: .rawDeflate), sample) + } +} diff --git a/Tests/DesignAlgorithmsKitTests/Algorithms/Text/FallbackTextDecoderTests.swift b/Tests/DesignAlgorithmsKitTests/Algorithms/Text/FallbackTextDecoderTests.swift new file mode 100644 index 0000000..c7498cf --- /dev/null +++ b/Tests/DesignAlgorithmsKitTests/Algorithms/Text/FallbackTextDecoderTests.swift @@ -0,0 +1,97 @@ +import XCTest +@testable import DesignAlgorithmsKit + +/// Test strategy: maps every byte to "X" when the first byte is a marker, else declines. +private struct MarkerStrategy: TextDecodingStrategy { + let name: String + let marker: UInt8 + func decode(_ data: Data) -> String? { + guard data.first == marker else { return nil } + return String(repeating: name, count: 1) + } +} + +final class FallbackTextDecoderTests: XCTestCase { + + func testDefaultStrategyOrder() { + XCTAssertEqual(FallbackTextDecoder.defaultStrategies.map(\.name), + ["us-ascii", "utf-8", "macintosh", "windows-1252"]) + XCTAssertTrue(FallbackTextDecoder.standard.stripsControlCharacters) + } + + func testASCII() { + let result = FallbackTextDecoder.standard.decodeWithStrategy(Data("HELLO\nWORLD\t!".utf8)) + XCTAssertEqual(result, DecodedText(text: "HELLO\nWORLD\t!", strategyName: "us-ascii")) + } + + func testUTF8() { + let result = FallbackTextDecoder.standard.decodeWithStrategy(Data("caf\u{E9} \u{2713}".utf8)) + XCTAssertEqual(result?.text, "caf\u{E9} \u{2713}") + XCTAssertEqual(result?.strategyName, "utf-8") + } + + func testMacRomanFallbackForInvalidUTF8() { + // 0x8E is "é" in Mac OS Roman and invalid as a lone UTF-8 byte. + let result = FallbackTextDecoder.standard.decodeWithStrategy(Data([0x63, 0x61, 0x66, 0x8E])) + XCTAssertEqual(result?.text, "caf\u{E9}") + XCTAssertEqual(result?.strategyName, "macintosh") + } + + func testControlCharactersStrippedKeepingNewlinesAndTabs() { + let data = Data([0x41, 0x00, 0x07, 0x42, 0x0D, 0x0A, 0x09, 0x43, 0x1B]) + XCTAssertEqual(data.decodedText(), "AB\r\n\tC") + } + + func testStrippingCanBeDisabled() { + let decoder = FallbackTextDecoder(stripsControlCharacters: false) + XCTAssertEqual(decoder.decode(Data([0x41, 0x00, 0x42])), "A\u{0}B") + } + + func testOnlyControlCharactersOrEmptyIsNil() { + XCTAssertNil(Data().decodedText()) + XCTAssertNil(Data([0x00, 0x01, 0x02]).decodedText()) + } + + func testRemovingControlCharacters() { + XCTAssertEqual(FallbackTextDecoder.removingControlCharacters(from: "a\u{0}b\u{FEFF}c\nd"), "abc\nd") + } + + func testCustomStrategiesBeforeAndAfterDefaults() { + let before = MarkerStrategy(name: "first", marker: 0x01) + let after = MarkerStrategy(name: "last", marker: 0x02) + let decoder = FallbackTextDecoder(before: [before], after: [after]) + XCTAssertEqual(decoder.strategies.map(\.name), + ["first", "us-ascii", "utf-8", "macintosh", "windows-1252", "last"]) + XCTAssertEqual(decoder.decodeWithStrategy(Data([0x01, 0x41]))?.strategyName, "first") + // Declined by "first", decoded by a default + XCTAssertEqual(decoder.decodeWithStrategy(Data([0x41]))?.strategyName, "us-ascii") + } + + func testExplicitStrategyList() { + let decoder = FallbackTextDecoder(strategies: [StringEncodingStrategy.utf8]) + XCTAssertNil(decoder.decode(Data([0xFF, 0xFE, 0x8E]))) + XCTAssertEqual(StringEncodingStrategy(.isoLatin1).name, "iso-8859-1") + } + + func testRegistryOrderingAndReplacement() { + let registry = TextDecoderRegistry() + registry.register(MarkerStrategy(name: "b", marker: 0x01)) + registry.register(MarkerStrategy(name: "a", marker: 0x01)) + registry.register(MarkerStrategy(name: "high", marker: 0x01), priority: 10) + registry.register(MarkerStrategy(name: "tail", marker: 0x02), placement: .afterDefaults) + + XCTAssertEqual(registry.strategies(placement: .beforeDefaults).map(\.name), ["high", "a", "b"]) + XCTAssertEqual(registry.makeDecoder().strategies.map(\.name), + ["high", "a", "b", "us-ascii", "utf-8", "macintosh", "windows-1252", "tail"]) + XCTAssertEqual(registry.decode(Data([0x01])), "high") + + registry.register(MarkerStrategy(name: "high", marker: 0x03), priority: -1) + XCTAssertEqual(registry.strategies(placement: .beforeDefaults).map(\.name), ["a", "b", "high"]) + + registry.unregister("a") + registry.unregister("b") + registry.unregister("high") + XCTAssertEqual(registry.decode(Data([0x01])), nil) + XCTAssertEqual(registry.decode(Data("ok".utf8)), "ok") + } +}