From 3f7eef408ae94ca3f52d3fc55bb5c5ef38107fdd Mon Sep 17 00:00:00 2001 From: Rick Hohler Date: Sat, 24 Oct 2026 19:56:43 -0500 Subject: [PATCH] Add DataReader, BinaryDataReader and StringExtractor relocate-binary-readers-into-dak (DesignAlgorithmsKit-Specs), for FileSystemCore relocate-retro-and-app-specific-code 1.6. Generic byte-level helpers move down from FileSystemCore, hardened on the way: byte-wise reads (no misaligned loads), negative offsets never trap, Data slices read relative to their start, and a throwing generic read/write so a missing field differs from 0. DataReader throws BinaryReadError instead of FileSystemError. StringExtractor carries only the generic printable-run scan; vendor heuristics stay in FileSystemCore. The byte-order enum is ByteOrder (RetroCore already has a public Endianness). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + README.md | 3 +- .../Algorithms/Binary/BinaryDataReader.swift | 140 ++++++++++++++++++ .../Algorithms/Binary/BinaryReadError.swift | 14 ++ .../Algorithms/Binary/DataReader.swift | 87 +++++++++++ .../Algorithms/Text/StringExtractor.swift | 44 ++++++ .../Algorithms/Binary/BinaryReaderTests.swift | 140 ++++++++++++++++++ 7 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 Sources/DesignAlgorithmsKit/Algorithms/Binary/BinaryDataReader.swift create mode 100644 Sources/DesignAlgorithmsKit/Algorithms/Binary/BinaryReadError.swift create mode 100644 Sources/DesignAlgorithmsKit/Algorithms/Binary/DataReader.swift create mode 100644 Sources/DesignAlgorithmsKit/Algorithms/Text/StringExtractor.swift create mode 100644 Tests/DesignAlgorithmsKitTests/Algorithms/Binary/BinaryReaderTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c52d08d..312c8e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added (`relocate-binary-readers-into-dak`) +- `DataReader`: sequential reader with offset tracking; throws `BinaryReadError.outOfBounds` and never advances on a short read. +- `BinaryDataReader`: big- and little-endian integer reads and writes at an offset. The named readers return 0, and the writers return `false`, when out of range; they never trap and do no misaligned loads. The generic `read(_:from:at:endian:)` and `write(_:to:at:endian:)` throw instead. +- `StringExtractor.extractStrings(from:minLength:maxLength:)`: printable runs, like `strings`. +- `BinaryReadError` and `ByteOrder`. +- All of them read `Data` slices relative to their start index. They replace FileSystemCore's copies of the same names. + ### Changed (`platform-and-test-hygiene`) - Building without CryptoKit is a compile-time error. The non-cryptographic placeholder that `SHA256.hash` returned on such platforms, and the `fatalError` paths in `MD5Strategy`/`SHA1Strategy`, are gone. `SHA256.hash(data:)` delegates to `SHA256Strategy`. - Platform claims match the build: macOS 12 and iOS 15 (both built in CI). WASM, tvOS and watchOS are no longer claimed. diff --git a/README.md b/README.md index f6ac009..0b6d5da 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ One library product and target: **`DesignAlgorithmsKit`** (`Sources/DesignAlgori | Probabilistic filters | `BloomFilter`, `CountingBloomFilter`, `BloomFilterHashable` | | Merkle tree | `MerkleTree`, `MerkleProof`, `MerkleHashable` | | Graphs | `Graph` (directed, BFS reachability, unreachable-node detection) | +| Binary data | `DataReader` (sequential, throwing), `BinaryDataReader` (big/little-endian at an offset; lenient named readers and checked `read`/`write`), `BinaryReadError`, `ByteOrder` | | 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`, `FallbackTextDecoder`/`TextDecodingStrategy`/`StringEncodingStrategy`/`TextDecoderRegistry`, `Data.decodedText(using:)` | +| Text / paths | `FilenameTokenizer`, `StringExtractor` (printable runs, like `strings`), `GitObjectLayout`, `FallbackTextDecoder`/`TextDecodingStrategy`/`StringEncodingStrategy`/`TextDecoderRegistry`, `Data.decodedText(using:)` | ## Platforms diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Binary/BinaryDataReader.swift b/Sources/DesignAlgorithmsKit/Algorithms/Binary/BinaryDataReader.swift new file mode 100644 index 0000000..c74b42f --- /dev/null +++ b/Sources/DesignAlgorithmsKit/Algorithms/Binary/BinaryDataReader.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Reads and writes integers at an offset in `Data`, in big- or little-endian byte order. +/// +/// Offsets are relative to the data's start index, and fields are read byte by byte, so +/// slices and unaligned offsets are fine. +/// +/// - The named readers (`readUInt32LE(from:at:)` and so on) return 0, and the named writers +/// return `false`, when the offset is negative or the field does not fit. They never trap. +/// - ``read(_:from:at:endian:)`` and ``write(_:to:at:endian:)`` throw +/// ``BinaryReadError/outOfBounds(offset:count:available:)`` instead, for callers that must +/// tell a missing field from a real 0. +public enum BinaryDataReader { + + // MARK: - Checked + + /// Reads a `T` at `offset`. + public static func read( + _ type: T.Type, + from data: Data, + at offset: Int, + endian: ByteOrder + ) throws -> T { + let size = MemoryLayout.size + try checkBounds(offset: offset, count: size, dataCount: data.count) + let start = data.startIndex + offset + var value: T = 0 + for index in 0..( + _ value: T, + to data: inout Data, + at offset: Int, + endian: ByteOrder + ) throws { + let size = MemoryLayout.size + try checkBounds(offset: offset, count: size, dataCount: data.count) + let start = data.startIndex + offset + for index in 0..> shift) + } + } + + // MARK: - Big-endian reads (0 when out of range) + + public static func readInt32BE(from data: Data, at offset: Int) -> Int32 { + lenient(Int32.self, data, offset, .big) + } + + public static func readUInt32BE(from data: Data, at offset: Int) -> UInt32 { + lenient(UInt32.self, data, offset, .big) + } + + public static func readInt16BE(from data: Data, at offset: Int) -> Int16 { + lenient(Int16.self, data, offset, .big) + } + + public static func readUInt16BE(from data: Data, at offset: Int) -> UInt16 { + lenient(UInt16.self, data, offset, .big) + } + + // MARK: - Little-endian reads (0 when out of range) + + public static func readInt32LE(from data: Data, at offset: Int) -> Int32 { + lenient(Int32.self, data, offset, .little) + } + + public static func readUInt32LE(from data: Data, at offset: Int) -> UInt32 { + lenient(UInt32.self, data, offset, .little) + } + + public static func readInt16LE(from data: Data, at offset: Int) -> Int16 { + lenient(Int16.self, data, offset, .little) + } + + public static func readUInt16LE(from data: Data, at offset: Int) -> UInt16 { + lenient(UInt16.self, data, offset, .little) + } + + public static func readUInt8(from data: Data, at offset: Int) -> UInt8 { + lenient(UInt8.self, data, offset, .little) + } + + // MARK: - Writes (false when out of range) + + @discardableResult + public static func writeInt32BE(_ value: Int32, to data: inout Data, at offset: Int) -> Bool { + lenientWrite(value, &data, offset, .big) + } + + @discardableResult + public static func writeInt32LE(_ value: Int32, to data: inout Data, at offset: Int) -> Bool { + lenientWrite(value, &data, offset, .little) + } + + @discardableResult + public static func writeUInt32BE(_ value: UInt32, to data: inout Data, at offset: Int) -> Bool { + lenientWrite(value, &data, offset, .big) + } + + @discardableResult + public static func writeUInt32LE(_ value: UInt32, to data: inout Data, at offset: Int) -> Bool { + lenientWrite(value, &data, offset, .little) + } + + @discardableResult + public static func writeUInt16BE(_ value: UInt16, to data: inout Data, at offset: Int) -> Bool { + lenientWrite(value, &data, offset, .big) + } + + @discardableResult + public static func writeUInt16LE(_ value: UInt16, to data: inout Data, at offset: Int) -> Bool { + lenientWrite(value, &data, offset, .little) + } + + // MARK: - Internal + + private static func checkBounds(offset: Int, count: Int, dataCount: Int) throws { + guard offset >= 0, offset <= dataCount, count <= dataCount - offset else { + let available = (offset >= 0 && offset <= dataCount) ? dataCount - offset : 0 + throw BinaryReadError.outOfBounds(offset: offset, count: count, available: available) + } + } + + private static func lenient(_ type: T.Type, _ data: Data, _ offset: Int, _ endian: ByteOrder) -> T { + (try? read(type, from: data, at: offset, endian: endian)) ?? 0 + } + + private static func lenientWrite(_ value: T, _ data: inout Data, _ offset: Int, _ endian: ByteOrder) -> Bool { + (try? write(value, to: &data, at: offset, endian: endian)) != nil + } +} diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Binary/BinaryReadError.swift b/Sources/DesignAlgorithmsKit/Algorithms/Binary/BinaryReadError.swift new file mode 100644 index 0000000..d2ce189 --- /dev/null +++ b/Sources/DesignAlgorithmsKit/Algorithms/Binary/BinaryReadError.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Errors from ``DataReader`` and the checked ``BinaryDataReader`` methods. +public enum BinaryReadError: Error, Equatable, Sendable { + /// `count` bytes were needed at `offset`, but only `available` bytes exist past it + /// (0 when the offset itself is outside the data). + case outOfBounds(offset: Int, count: Int, available: Int) +} + +/// Byte order of a multi-byte field. +public enum ByteOrder: Sendable { + case big + case little +} diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Binary/DataReader.swift b/Sources/DesignAlgorithmsKit/Algorithms/Binary/DataReader.swift new file mode 100644 index 0000000..63e0b26 --- /dev/null +++ b/Sources/DesignAlgorithmsKit/Algorithms/Binary/DataReader.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Reads binary data sequentially, tracking the offset. +/// +/// Offsets are relative to the data's start index, so slices read correctly. A read that +/// runs past the end throws ``BinaryReadError/outOfBounds(offset:count:available:)`` and +/// leaves the offset unchanged. +/// +/// ```swift +/// let reader = DataReader(data: header) +/// let magic = try reader.readUInt32BE() +/// let count = try reader.readUInt16LE() +/// ``` +public final class DataReader { + private let data: Data + + /// Current position, from 0 to the data's count. + public private(set) var offset: Int + + /// - Parameter data: Data to read from + public init(data: Data) { + self.data = data + self.offset = 0 + } + + /// Moves to `newOffset` (0 through the data's count). + public func seek(to newOffset: Int) throws { + guard newOffset >= 0 && newOffset <= data.count else { + throw BinaryReadError.outOfBounds(offset: newOffset, count: 0, available: 0) + } + offset = newOffset + } + + /// Reads `length` bytes. + public func readData(length: Int) throws -> Data { + let start = try reserve(length) + return data.subdata(in: start..<(start + length)) + } + + public func readUInt8() throws -> UInt8 { + try read(UInt8.self, .little) + } + + public func readUInt16LE() throws -> UInt16 { + try read(UInt16.self, .little) + } + + public func readUInt16BE() throws -> UInt16 { + try read(UInt16.self, .big) + } + + public func readUInt32LE() throws -> UInt32 { + try read(UInt32.self, .little) + } + + public func readUInt32BE() throws -> UInt32 { + try read(UInt32.self, .big) + } + + /// True when at least `count` bytes remain. + public func hasBytesAvailable(_ count: Int) -> Bool { + count >= 0 && count <= bytesRemaining + } + + /// Bytes left after the current offset. + public var bytesRemaining: Int { + max(0, data.count - offset) + } + + // MARK: - Internal + + private func read(_ type: T.Type, _ endian: ByteOrder) throws -> T { + let value = try BinaryDataReader.read(type, from: data, at: offset, endian: endian) + offset += MemoryLayout.size + return value + } + + /// Checks that `count` bytes remain, advances past them and returns their absolute start index. + private func reserve(_ count: Int) throws -> Data.Index { + guard count >= 0, count <= bytesRemaining else { + throw BinaryReadError.outOfBounds(offset: offset, count: count, available: bytesRemaining) + } + let start = data.startIndex + offset + offset += count + return start + } +} diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Text/StringExtractor.swift b/Sources/DesignAlgorithmsKit/Algorithms/Text/StringExtractor.swift new file mode 100644 index 0000000..98eae4a --- /dev/null +++ b/Sources/DesignAlgorithmsKit/Algorithms/Text/StringExtractor.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Finds printable text runs in binary data, like the Unix `strings` command. +public enum StringExtractor { + /// Default minimum run length kept by ``extractStrings(from:minLength:maxLength:)``. + public static let minimumStringLength = 4 + + /// Default maximum length of each returned run. + public static let maximumStringLength = 256 + + /// Returns each run of printable bytes, in order. + /// + /// A run is a sequence of bytes in 32...126 or 160...255 (read as Latin-1), tab, line feed + /// or carriage return. Each run is truncated to `maxLength` characters, trimmed of + /// surrounding whitespace and newlines, and kept when at least `minLength` characters remain. + public static func extractStrings( + from data: Data, + minLength: Int = minimumStringLength, + maxLength: Int = maximumStringLength + ) -> [String] { + var strings: [String] = [] + var current = "" + + func flush() { + let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count >= minLength { + strings.append(trimmed) + } + current = "" + } + + for byte in data { + if (32...126).contains(byte) || byte >= 160 || byte == 9 || byte == 10 || byte == 13 { + if current.count < maxLength { + current.append(Character(UnicodeScalar(byte))) + } + } else { + flush() + } + } + flush() + return strings + } +} diff --git a/Tests/DesignAlgorithmsKitTests/Algorithms/Binary/BinaryReaderTests.swift b/Tests/DesignAlgorithmsKitTests/Algorithms/Binary/BinaryReaderTests.swift new file mode 100644 index 0000000..3cde364 --- /dev/null +++ b/Tests/DesignAlgorithmsKitTests/Algorithms/Binary/BinaryReaderTests.swift @@ -0,0 +1,140 @@ +// +// BinaryReaderTests.swift +// DesignAlgorithmsKitTests +// + +import XCTest +@testable import DesignAlgorithmsKit + +final class DataReaderTests: XCTestCase { + func testMixedEndianness() throws { + let reader = DataReader(data: Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06])) + XCTAssertEqual(try reader.readUInt16LE(), 0x0201) + XCTAssertEqual(try reader.readUInt32BE(), 0x0304_0506) + XCTAssertEqual(reader.bytesRemaining, 0) + XCTAssertEqual(reader.offset, 6) + } + + func testAllReaders() throws { + let reader = DataReader(data: Data([0xAA, 0x01, 0x02, 0x01, 0x02, 0x01, 0x02, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0xEE, 0xFF])) + XCTAssertEqual(try reader.readUInt8(), 0xAA) + XCTAssertEqual(try reader.readUInt16LE(), 0x0201) + XCTAssertEqual(try reader.readUInt16BE(), 0x0102) + XCTAssertEqual(try reader.readUInt32LE(), 0x0403_0201) + XCTAssertEqual(try reader.readUInt32BE(), 0x0102_0304) + XCTAssertEqual(try reader.readData(length: 2), Data([0xEE, 0xFF])) + XCTAssertFalse(reader.hasBytesAvailable(1)) + XCTAssertTrue(reader.hasBytesAvailable(0)) + } + + func testShortReadThrowsAndDoesNotAdvance() { + let reader = DataReader(data: Data([0x01, 0x02])) + XCTAssertThrowsError(try reader.readUInt32LE()) { error in + XCTAssertEqual(error as? BinaryReadError, .outOfBounds(offset: 0, count: 4, available: 2)) + } + XCTAssertEqual(reader.offset, 0) + XCTAssertThrowsError(try reader.readData(length: 3)) + XCTAssertThrowsError(try reader.readData(length: -1)) + XCTAssertEqual(reader.offset, 0) + } + + func testSeek() throws { + let reader = DataReader(data: Data([0x00, 0x11, 0x22])) + try reader.seek(to: 2) + XCTAssertEqual(try reader.readUInt8(), 0x22) + try reader.seek(to: 3) + XCTAssertEqual(reader.bytesRemaining, 0) + XCTAssertThrowsError(try reader.seek(to: 4)) + XCTAssertThrowsError(try reader.seek(to: -1)) + } + + func testSlice() throws { + let data = Data([0x00, 0x11, 0x22, 0x33]) + let reader = DataReader(data: data[2...]) + XCTAssertEqual(try reader.readUInt8(), 0x22) + XCTAssertEqual(reader.bytesRemaining, 1) + XCTAssertEqual(try reader.readData(length: 1), Data([0x33])) + } +} + +final class BinaryDataReaderTests: XCTestCase { + private let bytes = Data([0x12, 0x34, 0x56, 0x78, 0x9A]) + + func testNamedReaders() { + XCTAssertEqual(BinaryDataReader.readUInt16BE(from: bytes, at: 0), 0x1234) + XCTAssertEqual(BinaryDataReader.readUInt16LE(from: bytes, at: 0), 0x3412) + XCTAssertEqual(BinaryDataReader.readUInt32BE(from: bytes, at: 1), 0x3456_789A) + XCTAssertEqual(BinaryDataReader.readUInt32LE(from: bytes, at: 1), 0x9A78_5634) + XCTAssertEqual(BinaryDataReader.readInt16BE(from: Data([0xFF, 0xFE]), at: 0), -2) + XCTAssertEqual(BinaryDataReader.readInt16LE(from: Data([0xFE, 0xFF]), at: 0), -2) + XCTAssertEqual(BinaryDataReader.readInt32BE(from: Data([0xFF, 0xFF, 0xFF, 0xFE]), at: 0), -2) + XCTAssertEqual(BinaryDataReader.readInt32LE(from: Data([0xFE, 0xFF, 0xFF, 0xFF]), at: 0), -2) + XCTAssertEqual(BinaryDataReader.readUInt8(from: bytes, at: 4), 0x9A) + } + + func testOutOfRangeAndNegativeOffsetsReturnZero() { + XCTAssertEqual(BinaryDataReader.readUInt32LE(from: bytes, at: -1), 0) + XCTAssertEqual(BinaryDataReader.readUInt32LE(from: bytes, at: 2), 0) + XCTAssertEqual(BinaryDataReader.readUInt8(from: bytes, at: 5), 0) + XCTAssertEqual(BinaryDataReader.readUInt16BE(from: bytes, at: Int.max), 0) + XCTAssertEqual(BinaryDataReader.readUInt16BE(from: bytes, at: Int.min), 0) + } + + func testCheckedReadThrows() { + XCTAssertThrowsError(try BinaryDataReader.read(UInt16.self, from: Data([0]), at: 0, endian: .little)) { error in + XCTAssertEqual(error as? BinaryReadError, .outOfBounds(offset: 0, count: 2, available: 1)) + } + XCTAssertThrowsError(try BinaryDataReader.read(UInt8.self, from: bytes, at: -3, endian: .big)) { error in + XCTAssertEqual(error as? BinaryReadError, .outOfBounds(offset: -3, count: 1, available: 0)) + } + XCTAssertEqual(try BinaryDataReader.read(UInt64.self, from: Data([1, 0, 0, 0, 0, 0, 0, 0]), at: 0, endian: .little), 1) + } + + func testSliceOffsetsAreRelative() { + let slice = bytes[1...] + XCTAssertEqual(BinaryDataReader.readUInt16BE(from: slice, at: 0), 0x3456) + XCTAssertEqual(BinaryDataReader.readUInt8(from: slice, at: 3), 0x9A) + XCTAssertEqual(BinaryDataReader.readUInt8(from: slice, at: 4), 0) + } + + func testWriters() throws { + var data = Data(count: 6) + XCTAssertTrue(BinaryDataReader.writeUInt16BE(0x1234, to: &data, at: 0)) + XCTAssertTrue(BinaryDataReader.writeUInt32LE(0xAABB_CCDD, to: &data, at: 2)) + XCTAssertEqual(data, Data([0x12, 0x34, 0xDD, 0xCC, 0xBB, 0xAA])) + XCTAssertTrue(BinaryDataReader.writeInt32BE(-2, to: &data, at: 2)) + XCTAssertEqual(BinaryDataReader.readInt32BE(from: data, at: 2), -2) + XCTAssertTrue(BinaryDataReader.writeInt32LE(-3, to: &data, at: 2)) + XCTAssertEqual(BinaryDataReader.readInt32LE(from: data, at: 2), -3) + XCTAssertTrue(BinaryDataReader.writeUInt16LE(0x0102, to: &data, at: 4)) + XCTAssertTrue(BinaryDataReader.writeUInt32BE(1, to: &data, at: 0)) + XCTAssertEqual(BinaryDataReader.readUInt32BE(from: data, at: 0), 1) + + let before = data + XCTAssertFalse(BinaryDataReader.writeUInt32LE(1, to: &data, at: 3)) + XCTAssertFalse(BinaryDataReader.writeUInt16BE(1, to: &data, at: -1)) + XCTAssertEqual(data, before) + XCTAssertThrowsError(try BinaryDataReader.write(UInt32(1), to: &data, at: 4, endian: .big)) + } +} + +final class StringExtractorTests: XCTestCase { + func testRunsBetweenControlBytes() { + var data = Data("ABC".utf8) + data.append(0) + data.append(contentsOf: Array("HELLO".utf8)) + data.append(0) + data.append(contentsOf: Array(" WORLD ".utf8)) + data.append(1) + XCTAssertEqual(StringExtractor.extractStrings(from: data), ["HELLO", "WORLD"]) + } + + func testLengthLimitsAndLatin1() { + let long = Data(repeating: 0x41, count: 300) + XCTAssertEqual(StringExtractor.extractStrings(from: long).first?.count, StringExtractor.maximumStringLength) + XCTAssertEqual(StringExtractor.extractStrings(from: long, maxLength: 10), [String(repeating: "A", count: 10)]) + XCTAssertEqual(StringExtractor.extractStrings(from: Data([0x41, 0x42, 0x00]), minLength: 2), ["AB"]) + XCTAssertEqual(StringExtractor.extractStrings(from: Data([0xA9, 0x41, 0x42, 0x43])), ["\u{A9}ABC"]) + XCTAssertEqual(StringExtractor.extractStrings(from: Data()), []) + } +}