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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/sanitizers.yml
Original file line number Diff line number Diff line change
@@ -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-*
2 changes: 1 addition & 1 deletion Benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions Benchmarks/SwiftRoaringBenchmarks/Serialization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
}
}
6 changes: 6 additions & 0 deletions Fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.build/
corpus/
crash-*
leak-*
timeout-*
oom-*
11 changes: 11 additions & 0 deletions Fuzz/Package.swift
Original file line number Diff line number Diff line change
@@ -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")]),
]
)
16 changes: 16 additions & 0 deletions Fuzz/README.md
Original file line number Diff line number Diff line change
@@ -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`.
53 changes: 53 additions & 0 deletions Fuzz/Sources/SwiftRoaringFuzz/main.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
122 changes: 122 additions & 0 deletions Sources/SwiftRoaring/Roaring64Bitmap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result: ~Copyable, Failure: Error>(
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
Loading
Loading