From 7ae236830f33e9ad9839b940300c9aae9f31e576 Mon Sep 17 00:00:00 2001 From: Sarunas <3808892+vsarunas@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:14:30 +0200 Subject: [PATCH] feat(minor): read and write portable bytes through RawSpan --- .github/workflows/sanitizers.yml | 39 ++++ Benchmarks/README.md | 2 +- .../Serialization.swift | 14 ++ Fuzz/.gitignore | 6 + Fuzz/Package.swift | 11 ++ Fuzz/README.md | 16 ++ Fuzz/Sources/SwiftRoaringFuzz/main.swift | 53 ++++++ README.md | 14 ++ Sources/SwiftRoaring/Roaring64Bitmap.swift | 122 +++++++++++++ Sources/SwiftRoaring/RoaringBitmap.swift | 167 ++++++++++++++++++ .../roaring32ExtendedTests.swift | 72 ++++++++ Tests/swiftRoaringTests/roaring64Tests.swift | 45 +++++ 12 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sanitizers.yml create mode 100644 Fuzz/.gitignore create mode 100644 Fuzz/Package.swift create mode 100644 Fuzz/README.md create mode 100644 Fuzz/Sources/SwiftRoaringFuzz/main.swift diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml new file mode 100644 index 0000000..cd4bf84 --- /dev/null +++ b/.github/workflows/sanitizers.yml @@ -0,0 +1,39 @@ +name: Sanitizers + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + asan: + name: Address sanitizer + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Version + run: swift --version + - name: Test + run: swift test --sanitize=address + + fuzz: + name: Fuzzer + runs-on: ubuntu-latest + defaults: + run: + working-directory: Fuzz + steps: + - uses: actions/checkout@v7 + - name: Build + run: swift build -c release --sanitize=fuzzer --sanitize=address + - name: Fuzz + run: mkdir -p corpus && .build/release/SwiftRoaringFuzz -max_total_time=120 corpus + - name: Upload crash + if: failure() + uses: actions/upload-artifact@v7 + with: + name: fuzz-crash + path: Fuzz/crash-* diff --git a/Benchmarks/README.md b/Benchmarks/README.md index f8e9202..3ce2747 100644 --- a/Benchmarks/README.md +++ b/Benchmarks/README.md @@ -25,7 +25,7 @@ swift package benchmark baseline compare master Swift times one pass; C reports the fastest window per operation. Divide the Swift p0 by: -- `microbench/IterateAll`, `frozen/portable_deserialize`: bitmaps (200). +- `microbench/IterateAll`, `frozen/portable_deserialize`, `frozen/portable_deserialize_frozen`: bitmaps (200). - `microbench/RandomAccess`: 3 x bitmaps. - `microbench/SuccessiveIntersection`: bitmaps - 1. - `iteration/previous`: union cardinality. diff --git a/Benchmarks/SwiftRoaringBenchmarks/Serialization.swift b/Benchmarks/SwiftRoaringBenchmarks/Serialization.swift index 72f6070..27e7d40 100644 --- a/Benchmarks/SwiftRoaringBenchmarks/Serialization.swift +++ b/Benchmarks/SwiftRoaringBenchmarks/Serialization.swift @@ -14,5 +14,19 @@ func registerSerialization(_ datasets: [Dataset]) { data.benchmark("frozen/portable_deserialize", prepare: preparePortable) { _ in blackHole(portable.reduce(0) { $0 &+ RoaringBitmap.portableDeserialize(buffer: $1).count }) } + data.benchmark("frozen/portable_deserialize_frozen", prepare: preparePortable) { _ in + blackHole(portable.reduce(0) { sum, bytes in + sum &+ bytes.withUnsafeBytes { RoaringBitmap.withPortableView(of: RawSpan(_unsafeBytes: $0)) { $0.count }! } + }) + } + var unsigned: [[UInt8]] = [] + let prepareUnsigned = { + preparePortable() + unsigned = portable.map { $0.map(UInt8.init(bitPattern:)) } + } + // Not in benchmark.cpp: the copying frozen API, for comparison with the borrowed view. + data.benchmark("frozen/portable_deserialize_frozen_copy", prepare: prepareUnsigned) { _ in + blackHole(unsigned.reduce(0) { $0 &+ RoaringBitmap.portableDeserializeFrozen(bytes: $1)!.count }) + } } } diff --git a/Fuzz/.gitignore b/Fuzz/.gitignore new file mode 100644 index 0000000..f484905 --- /dev/null +++ b/Fuzz/.gitignore @@ -0,0 +1,6 @@ +.build/ +corpus/ +crash-* +leak-* +timeout-* +oom-* diff --git a/Fuzz/Package.swift b/Fuzz/Package.swift new file mode 100644 index 0000000..d766828 --- /dev/null +++ b/Fuzz/Package.swift @@ -0,0 +1,11 @@ +// swift-tools-version:6.2 +import PackageDescription + +let package = Package( + name: "SwiftRoaringFuzz", + platforms: [.macOS(.v14)], + dependencies: [.package(name: "SwiftRoaring", path: "..")], + targets: [ + .executableTarget(name: "SwiftRoaringFuzz", dependencies: [.product(name: "SwiftRoaring", package: "SwiftRoaring")]), + ] +) diff --git a/Fuzz/README.md b/Fuzz/README.md new file mode 100644 index 0000000..135ba88 --- /dev/null +++ b/Fuzz/README.md @@ -0,0 +1,16 @@ +# Fuzzing + +libFuzzer + AddressSanitizer target. + +**Scope: only APIs documented as safe on untrusted bytes.** APIs documented to trust their input are not fuzzed: bad bytes may crash them by design. + +## Run + +```sh +cd Fuzz +swift build -c release --sanitize=fuzzer --sanitize=address +mkdir -p corpus +.build/release/SwiftRoaringFuzz -max_total_time=120 corpus +``` + +Linux images from swift.org work as is. On macOS, Xcode's toolchain lacks libFuzzer: use the `swift` from a swift.org toolchain, e.g. `~/Library/Developer/Toolchains/swift-6.4.1-RELEASE.xctoolchain/usr/bin/swift`. diff --git a/Fuzz/Sources/SwiftRoaringFuzz/main.swift b/Fuzz/Sources/SwiftRoaringFuzz/main.swift new file mode 100644 index 0000000..e39ea76 --- /dev/null +++ b/Fuzz/Sources/SwiftRoaringFuzz/main.swift @@ -0,0 +1,53 @@ +// libFuzzer target for the APIs that read untrusted portable bytes. + +import SwiftRoaring + +/// Input layout: [selector] [8-byte probe value] [portable bitmap bytes...]; selector bit 0 picks 32 or 64-bit. +@_cdecl("LLVMFuzzerTestOneInput") +public func fuzzTest(_ start: UnsafeRawPointer, _ count: Int) -> CInt { + guard count >= 9 else { return 0 } + let probe = start.loadUnaligned(fromByteOffset: 1, as: UInt64.self) + let span = RawSpan(_unsafeBytes: UnsafeRawBufferPointer(start: start + 9, count: count - 9)) + if start.load(as: UInt8.self) & 1 == 0 { + fuzz32(span, UInt32(truncatingIfNeeded: probe)) + } else { + fuzz64(span, probe) + } + return 0 +} + +private func fuzz32(_ span: RawSpan, _ x: UInt32) { + _ = RoaringBitmap.withPortableView(of: span) { view in + // The view checks layout, not values: invalid values must not fault (ASan), but answers may differ. + let copy = view.copy() + guard copy.internalValidate() == nil else { return } + precondition(view.count == copy.count && view.isEmpty == copy.isEmpty) + precondition(view.contains(x) == copy.contains(x) && view.contains(x & 0xFFFF) == copy.contains(x & 0xFFFF)) + precondition(view.min() == copy.min() && view.max() == copy.max()) + precondition(view.rank(value: x) == copy.rank(value: x)) + precondition(view.select(rank: x & 0xFFF) == copy.select(rank: x & 0xFFF)) + } + // CRoaring requires internal_validate before using a bitmap parsed from untrusted bytes. + if let bitmap = RoaringBitmap(portableBytes: span), bitmap.internalValidate() == nil { + bitmap.portableSerialize().withUnsafeBytes { raw in + precondition(RoaringBitmap(portableBytes: RawSpan(_unsafeBytes: raw)) == bitmap, "round trip changed the bitmap") + } + } +} + +private func fuzz64(_ span: RawSpan, _ x: UInt64) { + _ = Roaring64Bitmap.withPortableView(of: span) { view in + let copy = view.copy() + guard copy.internalValidate() == nil else { return } + precondition(view.count == copy.count && view.isEmpty == copy.isEmpty) + precondition(view.contains(x) == copy.contains(x) && view.contains(x & 0xFFFF) == copy.contains(x & 0xFFFF)) + precondition(view.min() == copy.min() && view.max() == copy.max()) + precondition(view.rank(value: x) == copy.rank(value: x)) + precondition(view.select(rank: x & 0xFFF) == copy.select(rank: x & 0xFFF)) + } + if let bitmap = Roaring64Bitmap(portableBytes: span), bitmap.internalValidate() == nil { + bitmap.portableSerialize().withUnsafeBytes { raw in + precondition(Roaring64Bitmap(portableBytes: RawSpan(_unsafeBytes: raw)) == bitmap, "round trip changed the bitmap") + } + } +} diff --git a/README.md b/README.md index 3146ef9..7a893c0 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,20 @@ let common = bitmap & other let widened = Roaring64Bitmap(RoaringBitmap([1, 2, 3])) ``` +### RawSpan + +With Swift 6.2 or later, both bitmap types read the portable format from a [`RawSpan`](https://www.swift.org/blog/swift-6.2-released/#safe-systems-programming) and write it to an `OutputRawSpan`, so serialized bytes are read where they are instead of being copied into an array first: + +```swift +let bytes = bitmap.portableSerialize() + +// Deserialize; nil if the bytes do not parse +let copy = RoaringBitmap(portableBytes: bytes.span.bytes) + +// Query the bytes in place, without deserializing +let found = RoaringBitmap.withPortableView(of: bytes.span.bytes) { $0.contains(42) } +``` + ### Development You can build using Swift Package Manager as follows: diff --git a/Sources/SwiftRoaring/Roaring64Bitmap.swift b/Sources/SwiftRoaring/Roaring64Bitmap.swift index f7cce78..f486343 100644 --- a/Sources/SwiftRoaring/Roaring64Bitmap.swift +++ b/Sources/SwiftRoaring/Roaring64Bitmap.swift @@ -1378,3 +1378,125 @@ extension Roaring64Bitmap { self.init(ptr: ptr) } } + +#if compiler(>=6.2) + +///////////////////////////////////////////////////////////////////////////// +/// PORTABLE FORMAT IN PLACE /// +///////////////////////////////////////////////////////////////////////////// + +@available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) +extension Roaring64Bitmap { + /// + /// Reads a bitmap in the portable format from `bytes`, without copying them + /// first. Returns nil if `bytes` do not hold a valid bitmap. Reading no more + /// than `bytes`, the call itself is safe, but a bitmap recovered from + /// arbitrary bytes may still be malformed: check `internalValidate()` before + /// using one that came from an untrusted source. + /// + @inlinable + public convenience init?(portableBytes bytes: RawSpan) { + let ptr = bytes.withUnsafeBytes { bytes in + bytes.baseAddress.flatMap { + croaring.roaring64_bitmap_portable_deserialize_safe($0.assumingMemoryBound(to: CChar.self), bytes.count) + } + } + guard let ptr = ptr else { return nil } + self.init(ptr: ptr) + } + + /// + /// Appends the bitmap in the portable format to `buffer`, which must have + /// at least `portableSizeInBytes()` bytes of free capacity. + /// + @inlinable + public func portableSerialize(into buffer: inout OutputRawSpan) { + precondition(buffer.freeCapacity >= self.portableSizeInBytes(), "buffer has less free capacity than portableSizeInBytes()") + buffer.withUnsafeMutableBytes { bytes, initializedCount in + initializedCount += croaring.roaring64_bitmap_portable_serialize( + self.ptr, (bytes.baseAddress! + initializedCount).assumingMemoryBound(to: CChar.self)) + } + } + + /// + /// Calls `body` with a read-only view of the portable bitmap in `bytes`, read + /// in place, and returns its result, or nil if `bytes` do not hold exactly one + /// valid bitmap or on big-endian machines. Only the layout is checked, not the values: for bytes from + /// an untrusted source, use `init?(portableBytes:)` and `internalValidate()`. + /// + @inlinable + public static func withPortableView( + of bytes: RawSpan, + _ body: (borrowing View) throws(Failure) -> Result + ) throws(Failure) -> Result? { + return try bytes.withUnsafeBytes { (bytes) throws(Failure) -> Result? in + guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self), !bytes.isEmpty, + croaring.roaring64_bitmap_portable_deserialize_size(base, bytes.count) == bytes.count, + let ptr = croaring.roaring64_bitmap_portable_deserialize_frozen(base, bytes.count) else { + return nil + } + defer { croaring.roaring64_bitmap_free(ptr) } + return try body(View(ptr: ptr)) + } + } + + /// + /// A read-only bitmap reading serialized bytes in place, lent to the body of + /// `withPortableView(of:_:)`. Being noncopyable and borrowed, it cannot + /// outlive that call. + /// + public struct View: ~Copyable { + @usableFromInline + let ptr: OpaquePointer + + @inlinable + init(ptr: OpaquePointer) { + self.ptr = ptr + } + + @inlinable + public var count: UInt64 { + return croaring.roaring64_bitmap_get_cardinality(self.ptr) + } + + @inlinable + public var isEmpty: Bool { + return croaring.roaring64_bitmap_is_empty(self.ptr) + } + + @inlinable + public func contains(_ value: UInt64) -> Bool { + return croaring.roaring64_bitmap_contains(self.ptr, value) + } + + @inlinable + public func min() -> UInt64? { + return self.isEmpty ? nil : croaring.roaring64_bitmap_minimum(self.ptr) + } + + @inlinable + public func max() -> UInt64? { + return self.isEmpty ? nil : croaring.roaring64_bitmap_maximum(self.ptr) + } + + /// The number of values smaller than or equal to `value`. + @inlinable + public func rank(value: UInt64) -> UInt64 { + return croaring.roaring64_bitmap_rank(self.ptr, value) + } + + /// The value of rank `rank`, the smallest having rank 0. + @inlinable + public func select(rank: UInt64) -> UInt64? { + var value: UInt64 = 0 + return croaring.roaring64_bitmap_select(self.ptr, rank, &value) ? value : nil + } + + /// A mutable copy, for anything a view does not offer. + public func copy() -> Roaring64Bitmap { + return Roaring64Bitmap(ptr: croaring.roaring64_bitmap_copy(self.ptr)!) + } + } +} + +#endif diff --git a/Sources/SwiftRoaring/RoaringBitmap.swift b/Sources/SwiftRoaring/RoaringBitmap.swift index 8b96747..3590104 100644 --- a/Sources/SwiftRoaring/RoaringBitmap.swift +++ b/Sources/SwiftRoaring/RoaringBitmap.swift @@ -879,6 +879,17 @@ public final class RoaringBitmap: Sequence, Equatable, CustomStringConvertible, return croaring.roaring_bitmap_portable_serialize(self.ptr, &buffer) } + /// + /// Serializes the bitmap in the portable format and returns the bytes. + /// + @inlinable + public func portableSerialize() -> [UInt8] { + return [UInt8](unsafeUninitializedCapacity: self.portableSizeInBytes()) { buffer, count in + count = croaring.roaring_bitmap_portable_serialize( + self.ptr, UnsafeMutableRawPointer(buffer.baseAddress!).assumingMemoryBound(to: CChar.self)) + } + } + /// /// Returns the element having the designated rank, where the smallest element /// has rank 0. Returns nil if the bitmap holds `rank` elements or fewer. @@ -1667,3 +1678,159 @@ extension RoaringBitmap { return reason.map { String(cString: $0) } ?? "unknown reason" } } + +#if compiler(>=6.2) + +///////////////////////////////////////////////////////////////////////////// +/// PORTABLE FORMAT IN PLACE /// +///////////////////////////////////////////////////////////////////////////// + +@available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) +extension RoaringBitmap { + /// + /// Reads a bitmap in the portable format from `bytes`, without copying them + /// first. Returns nil if `bytes` do not hold a valid bitmap. Reading no more + /// than `bytes`, the call itself is safe, but a bitmap recovered from + /// arbitrary bytes may still be malformed: check `internalValidate()` before + /// using one that came from an untrusted source. + /// + @inlinable + public convenience init?(portableBytes bytes: RawSpan) { + let ptr = bytes.withUnsafeBytes { bytes in + bytes.baseAddress.flatMap { + croaring.roaring_bitmap_portable_deserialize_safe($0.assumingMemoryBound(to: CChar.self), bytes.count) + } + } + guard let ptr = ptr else { return nil } + self.init(ptr: ptr) + } + + /// + /// Appends the bitmap in the portable format to `buffer`, which must have + /// at least `portableSizeInBytes()` bytes of free capacity. + /// + @inlinable + public func portableSerialize(into buffer: inout OutputRawSpan) { + precondition(buffer.freeCapacity >= self.portableSizeInBytes(), "buffer has less free capacity than portableSizeInBytes()") + buffer.withUnsafeMutableBytes { bytes, initializedCount in + initializedCount += croaring.roaring_bitmap_portable_serialize( + self.ptr, (bytes.baseAddress! + initializedCount).assumingMemoryBound(to: CChar.self)) + } + } + + /// + /// Calls `body` with a read-only view of the portable bitmap in `bytes`, read + /// in place, and returns its result, or nil if `bytes` do not hold exactly one + /// valid bitmap or on big-endian machines. Only the layout is checked, not the values: for bytes from + /// an untrusted source, use `init?(portableBytes:)` and `internalValidate()`. + /// + @inlinable + public static func withPortableView( + of bytes: RawSpan, + _ body: (borrowing View) throws(Failure) -> Result + ) throws(Failure) -> Result? { + return try bytes.withUnsafeBytes { (bytes) throws(Failure) -> Result? in + guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self), !bytes.isEmpty, + croaring.roaring_bitmap_portable_deserialize_size(base, bytes.count) == bytes.count, + portableBytesAreFrozenSafe(bytes), + let ptr = croaring.roaring_bitmap_portable_deserialize_frozen(base) else { + return nil + } + defer { croaring.roaring_bitmap_free(ptr) } + return try body(View(ptr: ptr)) + } + } + + // roaring_bitmap_portable_deserialize_frozen follows the offset headers, which + // roaring_bitmap_portable_deserialize_size skips, and sizes run containers by + // their header cardinality; call only after deserialize_size accepted `bytes`. + @usableFromInline + static func portableBytesAreFrozenSafe(_ bytes: UnsafeRawBufferPointer) -> Bool { + func u16(_ offset: Int) -> Int { + return Int(UInt16(littleEndian: bytes.loadUnaligned(fromByteOffset: offset, as: UInt16.self))) + } + func u32(_ offset: Int) -> Int { + return Int(UInt32(littleEndian: bytes.loadUnaligned(fromByteOffset: offset, as: UInt32.self))) + } + let hasRun = u32(0) & 0xFFFF == Int(SERIAL_COOKIE) + let count = hasRun ? u32(0) >> 16 + 1 : u32(4) + let hasOffsets = !hasRun || count >= NO_OFFSET_THRESHOLD + let headers = hasRun ? 4 + (count + 7) / 8 : 8 + let offsets = headers + 4 * count + var position = hasOffsets ? offsets + 4 * count : offsets + for i in 0.. DEFAULT_MAX_SIZE { + position += 8192 + } else { + position += 2 * cardinality + } + } + return true + } + + /// + /// A read-only bitmap reading serialized bytes in place, lent to the body of + /// `withPortableView(of:_:)`. Being noncopyable and borrowed, it cannot + /// outlive that call. + /// + public struct View: ~Copyable { + @usableFromInline + let ptr: UnsafePointer + + @inlinable + init(ptr: UnsafePointer) { + self.ptr = ptr + } + + @inlinable + public var count: UInt64 { + return croaring.roaring_bitmap_get_cardinality(self.ptr) + } + + @inlinable + public var isEmpty: Bool { + return croaring.roaring_bitmap_is_empty(self.ptr) + } + + @inlinable + public func contains(_ value: UInt32) -> Bool { + return croaring.roaring_bitmap_contains(self.ptr, value) + } + + @inlinable + public func min() -> UInt32? { + return self.isEmpty ? nil : croaring.roaring_bitmap_minimum(self.ptr) + } + + @inlinable + public func max() -> UInt32? { + return self.isEmpty ? nil : croaring.roaring_bitmap_maximum(self.ptr) + } + + /// The number of values smaller than or equal to `value`. + @inlinable + public func rank(value: UInt32) -> UInt64 { + return croaring.roaring_bitmap_rank(self.ptr, value) + } + + /// The value of rank `rank`, the smallest having rank 0. + @inlinable + public func select(rank: UInt32) -> UInt32? { + var value: UInt32 = 0 + return croaring.roaring_bitmap_select(self.ptr, rank, &value) ? value : nil + } + + /// A mutable copy, for anything a view does not offer. + public func copy() -> RoaringBitmap { + return RoaringBitmap(ptr: croaring.roaring_bitmap_copy(self.ptr)!) + } + } +} + +#endif diff --git a/Tests/swiftRoaringTests/roaring32ExtendedTests.swift b/Tests/swiftRoaringTests/roaring32ExtendedTests.swift index 4351500..cb6c8d4 100644 --- a/Tests/swiftRoaringTests/roaring32ExtendedTests.swift +++ b/Tests/swiftRoaringTests/roaring32ExtendedTests.swift @@ -303,6 +303,78 @@ class roaring32ExtendedTests: XCTestCase { XCTAssertEqual(view.toArray(), a.toArray()) } + #if compiler(>=6.2) + @available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) + func testPortableBorrowedBytes() { + let a = RoaringBitmap(range: 0..<70_000, step: 3) + a.add(100_000..<101_000) + _ = a.runOptimize() + + let bytes = a.portableSerialize() + XCTAssertEqual(bytes.count, a.portableSizeInBytes()) + + let into = UnsafeMutableRawBufferPointer.allocate(byteCount: bytes.count + 1, alignment: 1) + defer { into.deallocate() } + var output = OutputRawSpan(buffer: into, initializedCount: 0) + output.append(7) + a.portableSerialize(into: &output) + let written = output.finalize(for: into) + XCTAssertEqual(written, bytes.count + 1) + XCTAssertEqual(Array(into[1...bytes.count]), bytes) + + // One byte off alignment, as values read out of a database can be. + let shifted = [0] + bytes + shifted.withUnsafeBytes { raw in + let borrowed = RawSpan(_unsafeBytes: UnsafeRawBufferPointer(rebasing: raw[1...])) + XCTAssertEqual(RoaringBitmap(portableBytes: borrowed), a) + XCTAssertEqual(RoaringBitmap.withPortableView(of: borrowed) { $0.copy() }, a) + XCTAssertEqual(RoaringBitmap.withPortableView(of: borrowed) { $0.contains(100_500) }, true) + XCTAssertEqual(RoaringBitmap.withPortableView(of: borrowed) { $0.count }, a.count) + XCTAssertEqual(RoaringBitmap.withPortableView(of: borrowed) { $0.max() } ?? nil, a.max()) + XCTAssertEqual(RoaringBitmap.withPortableView(of: borrowed) { $0.select(rank: 10) } ?? nil, a.select(rank: 10)) + XCTAssertEqual(RoaringBitmap.withPortableView(of: borrowed) { $0.min() } ?? nil, a.min()) + XCTAssertEqual(RoaringBitmap.withPortableView(of: borrowed) { $0.rank(value: 1_500) }, a.rank(value: 1_500)) + + let short = RawSpan(_unsafeBytes: UnsafeRawBufferPointer(rebasing: raw[1..<6])) + XCTAssertNil(RoaringBitmap(portableBytes: short)) + XCTAssertNil(RoaringBitmap.withPortableView(of: short) { _ in true }) + } + // A view covers exactly one bitmap: trailing bytes are rejected. + (bytes + [0]).withUnsafeBytes { raw in + XCTAssertNil(RoaringBitmap.withPortableView(of: RawSpan(_unsafeBytes: raw)) { _ in true }) + } + RoaringBitmap().portableSerialize().withUnsafeBytes { raw in + XCTAssertEqual(RoaringBitmap.withPortableView(of: RawSpan(_unsafeBytes: raw)) { + $0.isEmpty && $0.min() == nil && $0.max() == nil && $0.select(rank: 0) == nil + }, true) + } + // {42} with its offset header pointing past the end, at byte 32 instead of 16. + let badOffset: [UInt8] = [0x3a, 0x30, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0x20, 0, 0, 0, 0x2a, 0] + badOffset.withUnsafeBytes { raw in + XCTAssertEqual(RoaringBitmap(portableBytes: RawSpan(_unsafeBytes: raw)), RoaringBitmap([42])) + XCTAssertNil(RoaringBitmap.withPortableView(of: RawSpan(_unsafeBytes: raw)) { _ in true }) + XCTAssertNil(RoaringBitmap.withPortableView(of: RawSpan(_unsafeBytes: UnsafeRawBufferPointer(rebasing: raw[0..<0]))) { _ in true }) + } + // Array, bitset, run and array containers: four with a run, so offset headers are written. + let mixed = RoaringBitmap(range: 65_536..<131_072, step: 3) + mixed.add(131_072..<140_000) + mixed.add(7) + mixed.add(200_000) + _ = mixed.runOptimize() + mixed.portableSerialize().withUnsafeBytes { raw in + XCTAssertEqual(RoaringBitmap.withPortableView(of: RawSpan(_unsafeBytes: raw)) { $0.copy() }, mixed) + } + // One run container of header cardinality 1 holding two runs, {0, 2}. + let moreRunsThanValues: [UInt8] = [0x3b, 0x30, 0, 0, 1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 2, 0, 0, 0] + moreRunsThanValues.withUnsafeBytes { raw in + XCTAssertNil(RoaringBitmap.withPortableView(of: RawSpan(_unsafeBytes: raw)) { $0.copy() }) + } + RoaringBitmap([42]).portableSerialize().withUnsafeBytes { raw in + XCTAssertEqual(RoaringBitmap.withPortableView(of: RawSpan(_unsafeBytes: raw)) { $0.min() }, 42) + } + } + #endif + func testEmptyBitmapRoundTrips() { let empty = RoaringBitmap() _ = empty.shrink() diff --git a/Tests/swiftRoaringTests/roaring64Tests.swift b/Tests/swiftRoaringTests/roaring64Tests.swift index 962eec2..2169ad3 100644 --- a/Tests/swiftRoaringTests/roaring64Tests.swift +++ b/Tests/swiftRoaringTests/roaring64Tests.swift @@ -375,6 +375,51 @@ class roaring64Tests: XCTestCase { XCTAssertNil(Roaring64Bitmap.portableDeserializeFrozen(bytes: Array(bytes.prefix(3)))) } + #if compiler(>=6.2) + @available(macOS 10.14.4, iOS 12.2, watchOS 5.2, tvOS 12.2, visionOS 1.0, *) + func testPortableBorrowedBytes() { + rbm.addMany(values: spread) + rbm.add(1_000...2_000) + _ = rbm.runOptimize() + + let bytes = rbm.portableSerialize() + let into = UnsafeMutableRawBufferPointer.allocate(byteCount: bytes.count + 1, alignment: 1) + defer { into.deallocate() } + var output = OutputRawSpan(buffer: into, initializedCount: 0) + output.append(7) + rbm.portableSerialize(into: &output) + let written = output.finalize(for: into) + XCTAssertEqual(written, bytes.count + 1) + XCTAssertEqual(Array(into[1...bytes.count]), bytes) + + // One byte off alignment, as values read out of a database can be. + ([0] + bytes).withUnsafeBytes { raw in + let borrowed = RawSpan(_unsafeBytes: UnsafeRawBufferPointer(rebasing: raw[1...])) + XCTAssertEqual(Roaring64Bitmap(portableBytes: borrowed), rbm) + XCTAssertEqual(Roaring64Bitmap.withPortableView(of: borrowed) { $0.copy() }, rbm) + XCTAssertEqual(Roaring64Bitmap.withPortableView(of: borrowed) { $0.contains(1_500) }, true) + XCTAssertEqual(Roaring64Bitmap.withPortableView(of: borrowed) { $0.count }, rbm.count) + XCTAssertEqual(Roaring64Bitmap.withPortableView(of: borrowed) { $0.max() } ?? nil, rbm.max()) + XCTAssertEqual(Roaring64Bitmap.withPortableView(of: borrowed) { $0.select(rank: 10) } ?? nil, rbm.select(rank: 10)) + XCTAssertEqual(Roaring64Bitmap.withPortableView(of: borrowed) { $0.min() } ?? nil, rbm.min()) + XCTAssertEqual(Roaring64Bitmap.withPortableView(of: borrowed) { $0.rank(value: 1_500) }, rbm.rank(value: 1_500)) + + let short = RawSpan(_unsafeBytes: UnsafeRawBufferPointer(rebasing: raw[1..<4])) + XCTAssertNil(Roaring64Bitmap(portableBytes: short)) + XCTAssertNil(Roaring64Bitmap.withPortableView(of: short) { _ in true }) + } + // A view covers exactly one bitmap: trailing bytes are rejected. + (bytes + [0]).withUnsafeBytes { raw in + XCTAssertNil(Roaring64Bitmap.withPortableView(of: RawSpan(_unsafeBytes: raw)) { _ in true }) + } + Roaring64Bitmap().portableSerialize().withUnsafeBytes { raw in + XCTAssertEqual(Roaring64Bitmap.withPortableView(of: RawSpan(_unsafeBytes: raw)) { + $0.isEmpty && $0.min() == nil && $0.max() == nil && $0.select(rank: 0) == nil + }, true) + } + } + #endif + func testEmptyBitmapRoundTrips() { let empty = Roaring64Bitmap() _ = empty.shrink()