From d60c316688417b3e827a1d720f28462de0df7940 Mon Sep 17 00:00:00 2001 From: Sarunas <3808892+vsarunas@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:06:19 +0200 Subject: [PATCH 1/5] perf(minor): make bitmap storage immutable, test Swift 5.6 through latest --- .github/workflows/tests.yml | 22 +++---- Package.swift | 2 +- README.md | 4 +- Sources/SwiftRoaring/Roaring64Bitmap.swift | 40 ++++++------ Sources/SwiftRoaring/RoaringBitmap.swift | 63 +++++++++++-------- .../swiftRoaringTests/swiftRoaringTests.swift | 3 + 6 files changed, 74 insertions(+), 60 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a07e30c..57f59cd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,4 +1,4 @@ -name: Swift on Ubuntu (latest) +name: Tests on: push: @@ -9,18 +9,14 @@ on: - master jobs: - tests: + linux: + name: Swift ${{ matrix.image }} runs-on: ubuntu-latest - # The official image, rather than fwal/setup-swift, which does not - # support Ubuntu 26.04 (ubuntu-latest). - container: swift:6.2 + container: swift:${{ matrix.image }} + strategy: + fail-fast: false + matrix: + image: ["5.6-focal", "5.10", "6.2", "latest"] steps: - uses: actions/checkout@v4 - - name: Version - run: swift --version - - name: linuxmain - run: swift test - - name: Build - run: swift build - - name: Test - run: swift test \ No newline at end of file + - run: swift test diff --git a/Package.swift b/Package.swift index 6c1603a..e60dc54 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:4.0 +// swift-tools-version:5.6 import PackageDescription let package = Package( diff --git a/README.md b/README.md index 7a893c0..f19a154 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # SwiftRoaring -Swift 4 compatible +Swift 5.6 compatible [![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) ![Ubuntu CI](https://github.com/RoaringBitmap/SwiftRoaring/workflows/Swift%20on%20Ubuntu%20(latest)/badge.svg) @@ -29,7 +29,7 @@ Roaring bitmaps are found to work well in many important applications: ### Dependencies -Swift 4.0 or higher +Swift 5.6 or higher ### Usage using Swift Package Manager diff --git a/Sources/SwiftRoaring/Roaring64Bitmap.swift b/Sources/SwiftRoaring/Roaring64Bitmap.swift index f486343..37e7f7e 100644 --- a/Sources/SwiftRoaring/Roaring64Bitmap.swift +++ b/Sources/SwiftRoaring/Roaring64Bitmap.swift @@ -25,7 +25,7 @@ public final class Roaring64Bitmap: Sequence, Equatable, CustomStringConvertible Hashable, ExpressibleByArrayLiteral, SetAlgebra, Codable { @usableFromInline - var ptr: OpaquePointer + let ptr: OpaquePointer /// /// When this bitmap is a *frozen view* created by `frozenView(bytes:)`, this @@ -33,7 +33,7 @@ public final class Roaring64Bitmap: Sequence, Equatable, CustomStringConvertible /// bitmap itself, by `deinit`. It is `nil` for every ordinary bitmap. /// @usableFromInline - var frozenBacking: UnsafeMutableRawPointer? + let frozenBacking: UnsafeMutableRawPointer? public typealias Element = UInt64 @@ -44,8 +44,8 @@ public final class Roaring64Bitmap: Sequence, Equatable, CustomStringConvertible /// /// Creates a new bitmap (initially empty) /// - required public init() { - self.ptr = croaring.roaring64_bitmap_create()! + required public convenience init() { + self.init(ptr: croaring.roaring64_bitmap_create()!) } /// @@ -53,15 +53,21 @@ public final class Roaring64Bitmap: Sequence, Equatable, CustomStringConvertible /// required public init(ptr: OpaquePointer) { self.ptr = ptr + self.frozenBacking = nil + } + + private init(ptr: OpaquePointer, frozenBacking: UnsafeMutableRawPointer) { + self.ptr = ptr + self.frozenBacking = frozenBacking } /// /// Add all the values between min (included) and max (excluded) that are at a /// distance k*step from min. /// - public init(min: UInt64, max: UInt64, step: UInt64) { - self.ptr = croaring.roaring64_bitmap_from_range(min, max, step) - ?? croaring.roaring64_bitmap_create()! + public convenience init(min: UInt64, max: UInt64, step: UInt64) { + self.init(ptr: croaring.roaring64_bitmap_from_range(min, max, step) + ?? croaring.roaring64_bitmap_create()!) } public convenience init(range: Range, step: UInt64) { @@ -71,8 +77,8 @@ public final class Roaring64Bitmap: Sequence, Equatable, CustomStringConvertible /// /// Creates a new bitmap from an array of `uint64_t` integers /// - public init(values: [UInt64]) { - self.ptr = croaring.roaring64_bitmap_of_ptr(values.count, values)! + public convenience init(values: [UInt64]) { + self.init(ptr: croaring.roaring64_bitmap_of_ptr(values.count, values)!) } /// @@ -80,8 +86,8 @@ public final class Roaring64Bitmap: Sequence, Equatable, CustomStringConvertible /// its containers over. This is cheaper than copying them, but it leaves /// `bitmap` empty. /// - public init(moving bitmap: RoaringBitmap) { - self.ptr = croaring.roaring64_bitmap_move_from_roaring32(bitmap.ptr)! + public convenience init(moving bitmap: RoaringBitmap) { + self.init(ptr: croaring.roaring64_bitmap_move_from_roaring32(bitmap.ptr)!) } /// @@ -92,8 +98,8 @@ public final class Roaring64Bitmap: Sequence, Equatable, CustomStringConvertible self.init(moving: bitmap.copy()) } - public required init(arrayLiteral: Element...) { - self.ptr = croaring.roaring64_bitmap_of_ptr(arrayLiteral.count, arrayLiteral)! + public required convenience init(arrayLiteral: Element...) { + self.init(ptr: croaring.roaring64_bitmap_of_ptr(arrayLiteral.count, arrayLiteral)!) } deinit { @@ -1338,15 +1344,13 @@ extension Roaring64Bitmap { buffer.deallocate() return nil } - let bitmap = Self(ptr: ptr) - bitmap.frozenBacking = buffer - return bitmap + return Self(ptr: ptr, frozenBacking: buffer) } /// /// Encodable conformance, using the portable format. /// - public func encode(to encoder: Encoder) throws { + public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() try container.encode(Data(self.portableSerialize()).base64EncodedString()) } @@ -1354,7 +1358,7 @@ extension Roaring64Bitmap { /// /// Decodable conformance, using the portable format. /// - public convenience init(from decoder: Decoder) throws { + public convenience init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() let encoded = try container.decode(String.self) guard let data = Data(base64Encoded: encoded) else { diff --git a/Sources/SwiftRoaring/RoaringBitmap.swift b/Sources/SwiftRoaring/RoaringBitmap.swift index 3590104..a624dfe 100644 --- a/Sources/SwiftRoaring/RoaringBitmap.swift +++ b/Sources/SwiftRoaring/RoaringBitmap.swift @@ -23,7 +23,7 @@ public final class RoaringBitmap: Sequence, Equatable, CustomStringConvertible, Hashable, ExpressibleByArrayLiteral, SetAlgebra, Codable { @usableFromInline - var ptr: UnsafeMutablePointer + let ptr: UnsafeMutablePointer /// /// When this bitmap is a *frozen view* created by `frozenView(bytes:)`, this @@ -31,7 +31,7 @@ public final class RoaringBitmap: Sequence, Equatable, CustomStringConvertible, /// bitmap itself, by `deinit`. It is `nil` for every ordinary bitmap. /// @usableFromInline - var frozenBacking: UnsafeMutableRawPointer? + let frozenBacking: UnsafeMutableRawPointer? public typealias Element = UInt32 @@ -41,8 +41,8 @@ public final class RoaringBitmap: Sequence, Equatable, CustomStringConvertible, /// /// Creates a new bitmap (initially empty) /// - required public init() { - self.ptr = croaring.roaring_bitmap_create()! + required public convenience init() { + self.init(ptr: croaring.roaring_bitmap_create()!) } /// @@ -50,41 +50,47 @@ public final class RoaringBitmap: Sequence, Equatable, CustomStringConvertible, /// required public init(ptr: UnsafeMutablePointer) { self.ptr = ptr + self.frozenBacking = nil + } + + private init(ptr: UnsafeMutablePointer, frozenBacking: UnsafeMutableRawPointer) { + self.ptr = ptr + self.frozenBacking = frozenBacking } /// /// Add all the values between min (included) and max (excluded) that are at a /// distance k*step from min. /// - public init(min: UInt64, max: UInt64, step: UInt32) { - self.ptr = croaring.roaring_bitmap_from_range(min, max, step)! + public convenience init(min: UInt64, max: UInt64, step: UInt32) { + self.init(ptr: croaring.roaring_bitmap_from_range(min, max, step)!) } - public init(range: Range, step: UInt32) { - self.ptr = croaring.roaring_bitmap_from_range( + public convenience init(range: Range, step: UInt32) { + self.init(ptr: croaring.roaring_bitmap_from_range( range.lowerBound, range.upperBound, step - )! + )!) } /// /// Creates a new bitmap (initially empty) with a provided /// container-storage capacity (it is a performance hint). /// - public init(capacity: UInt32) { - self.ptr = croaring.roaring_bitmap_create_with_capacity(capacity)! + public convenience init(capacity: UInt32) { + self.init(ptr: croaring.roaring_bitmap_create_with_capacity(capacity)!) } /// /// Creates a new bitmap from a pointer of `uint32_t` integers /// - public init(values: [UInt32]) { - self.ptr = croaring.roaring_bitmap_of_ptr(values.count, values)! + public convenience init(values: [UInt32]) { + self.init(ptr: croaring.roaring_bitmap_of_ptr(values.count, values)!) } - public required init(arrayLiteral: Element...) { - self.ptr = croaring.roaring_bitmap_create()! + public required convenience init(arrayLiteral: Element...) { + self.init(ptr: croaring.roaring_bitmap_create()!) for i in arrayLiteral { add(i) } } @@ -782,14 +788,14 @@ public final class RoaringBitmap: Sequence, Equatable, CustomStringConvertible, /// /// Encodable conformance /// - public func encode(to encoder: Encoder) throws { + public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() let count = self.sizeInBytes() var data = Data(count: count) - data.withUnsafeMutableBytes { - let ptr = $0.baseAddress!.assumingMemoryBound(to: Int8.self) - croaring.roaring_bitmap_serialize(self.ptr, ptr) + data.withUnsafeMutableBytes { (buffer: UnsafeMutableRawBufferPointer) in + let ptr = buffer.baseAddress!.assumingMemoryBound(to: Int8.self) + _ = croaring.roaring_bitmap_serialize(self.ptr, ptr) } try container.encode(data.base64EncodedString()) } @@ -797,14 +803,21 @@ public final class RoaringBitmap: Sequence, Equatable, CustomStringConvertible, /// /// Decodable conformance /// - public init(from decoder: Decoder) throws { + public convenience init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() let buffer = try container.decode(String.self) - let data = Data(base64Encoded: buffer) - self.ptr = data!.withUnsafeBytes { - croaring.roaring_bitmap_deserialize($0)! + let ptr = Data(base64Encoded: buffer).flatMap { data in + data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in + bytes.baseAddress.flatMap { + croaring.roaring_bitmap_deserialize_safe($0, bytes.count) + } + } + } + guard let ptr = ptr else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "not a serialized bitmap") } + self.init(ptr: ptr) } /// @@ -1653,9 +1666,7 @@ extension RoaringBitmap { buffer.deallocate() return nil } - let bitmap = Self(ptr: ptr) - bitmap.frozenBacking = buffer - return bitmap + return Self(ptr: ptr, frozenBacking: buffer) } } diff --git a/Tests/swiftRoaringTests/swiftRoaringTests.swift b/Tests/swiftRoaringTests/swiftRoaringTests.swift index b38cb14..1339538 100644 --- a/Tests/swiftRoaringTests/swiftRoaringTests.swift +++ b/Tests/swiftRoaringTests/swiftRoaringTests.swift @@ -338,6 +338,9 @@ class swiftRoaringTests: XCTestCase { XCTAssert(s == expected) XCTAssert(r == b) + + XCTAssertThrowsError(try dec.decode(RoaringBitmap.self, from: Data("\"AAAA\"".utf8))) + XCTAssertThrowsError(try dec.decode(RoaringBitmap.self, from: Data("\"not base64\"".utf8))) } func makeSets() -> (RoaringBitmap, RoaringBitmap, Set, Set) { From 2a8c0a939238f03eb61c9ba34eb250bf87e68406 Mon Sep 17 00:00:00 2001 From: Sarunas <3808892+vsarunas@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:21:58 +0200 Subject: [PATCH 2/5] Seperate build and test steps --- .github/workflows/tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 57f59cd..7eabe97 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,4 +19,7 @@ jobs: image: ["5.6-focal", "5.10", "6.2", "latest"] steps: - uses: actions/checkout@v4 - - run: swift test + - name: Build + run: swift build + - name: Test + run: swift test From 986c58493148662f2fb43c617a2c869363970d7b Mon Sep 17 00:00:00 2001 From: Sarunas <3808892+vsarunas@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:24:17 +0200 Subject: [PATCH 3/5] Show swift version --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7eabe97..441f71e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,6 +19,8 @@ jobs: image: ["5.6-focal", "5.10", "6.2", "latest"] steps: - uses: actions/checkout@v4 + - name: Version + run: swift --version - name: Build run: swift build - name: Test From 7eca7f0d39aef22283cdf88a6974a4efef5786f6 Mon Sep 17 00:00:00 2001 From: Sarunas <3808892+vsarunas@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:27:08 +0200 Subject: [PATCH 4/5] Bump checkout actions version --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 441f71e..c06e30e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,7 +18,7 @@ jobs: matrix: image: ["5.6-focal", "5.10", "6.2", "latest"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Version run: swift --version - name: Build From 4dce1865cc60e1953ab1f3f1d9442d86a26ff6a8 Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Mon, 26 Oct 2026 16:04:26 -0400 Subject: [PATCH 5/5] Validate decoded bitmaps and fix the CI badge CRoaring's safe deserializers only bound their reads: a payload with a well-formed header over bad containers (unsorted array values, say) still yields a bitmap, and the header warns that using it may crash. Both Decodable inits now call internal_validate and throw DecodingError.dataCorrupted with CRoaring's reason. The tests swap the last two values of an encoded array container, which decoded without an error before. The 32-bit decoder now reports invalid base64 and an unreadable payload separately, like the 64-bit one, with the same guard-let structure. The README badge pointed at the old workflow name, "Swift on Ubuntu (latest)"; it now points at tests.yml. --- README.md | 2 +- Sources/SwiftRoaring/Roaring64Bitmap.swift | 7 +++++ Sources/SwiftRoaring/RoaringBitmap.swift | 29 +++++++++++++------ Tests/swiftRoaringTests/roaring64Tests.swift | 11 +++++++ .../swiftRoaringTests/swiftRoaringTests.swift | 8 +++++ 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f19a154..49963d0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SwiftRoaring Swift 5.6 compatible -[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) ![Ubuntu CI](https://github.com/RoaringBitmap/SwiftRoaring/workflows/Swift%20on%20Ubuntu%20(latest)/badge.svg) +[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) ![Tests](https://github.com/RoaringBitmap/SwiftRoaring/actions/workflows/tests.yml/badge.svg) diff --git a/Sources/SwiftRoaring/Roaring64Bitmap.swift b/Sources/SwiftRoaring/Roaring64Bitmap.swift index 37e7f7e..0b2433e 100644 --- a/Sources/SwiftRoaring/Roaring64Bitmap.swift +++ b/Sources/SwiftRoaring/Roaring64Bitmap.swift @@ -1380,6 +1380,13 @@ extension Roaring64Bitmap { ) } self.init(ptr: ptr) + // A bitmap read from untrusted bytes is only safe to use once validated. + if let reason = internalValidate() { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "not a valid serialized Roaring64Bitmap: \(reason)" + ) + } } } diff --git a/Sources/SwiftRoaring/RoaringBitmap.swift b/Sources/SwiftRoaring/RoaringBitmap.swift index a624dfe..0305d96 100644 --- a/Sources/SwiftRoaring/RoaringBitmap.swift +++ b/Sources/SwiftRoaring/RoaringBitmap.swift @@ -805,19 +805,30 @@ public final class RoaringBitmap: Sequence, Equatable, CustomStringConvertible, /// public convenience init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() - - let buffer = try container.decode(String.self) - let ptr = Data(base64Encoded: buffer).flatMap { data in - data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in - bytes.baseAddress.flatMap { - croaring.roaring_bitmap_deserialize_safe($0, bytes.count) - } - } + let encoded = try container.decode(String.self) + guard let data = Data(base64Encoded: encoded) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "not valid base64" + ) + } + let ptr = data.withUnsafeBytes { + croaring.roaring_bitmap_deserialize_safe($0.baseAddress, data.count) } guard let ptr = ptr else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "not a serialized bitmap") + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "not a valid serialized RoaringBitmap" + ) } self.init(ptr: ptr) + // A bitmap read from untrusted bytes is only safe to use once validated. + if let reason = internalValidate() { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "not a valid serialized RoaringBitmap: \(reason)" + ) + } } /// diff --git a/Tests/swiftRoaringTests/roaring64Tests.swift b/Tests/swiftRoaringTests/roaring64Tests.swift index 2169ad3..a39812b 100644 --- a/Tests/swiftRoaringTests/roaring64Tests.swift +++ b/Tests/swiftRoaringTests/roaring64Tests.swift @@ -453,6 +453,17 @@ class roaring64Tests: XCTestCase { let garbage = try JSONEncoder().encode("not a bitmap") XCTAssertThrowsError(try JSONDecoder().decode(Roaring64Bitmap.self, from: garbage)) + + // A well-formed header over an array container whose last two values + // are out of order: it deserializes, but it is not a valid bitmap. + let small = Roaring64Bitmap() + small.addMany(values: Array(stride(from: 0, to: 100, by: 2))) + let json = try JSONDecoder().decode(String.self, from: try JSONEncoder().encode(small)) + var bytes = [UInt8](try XCTUnwrap(Data(base64Encoded: json))) + bytes.swapAt(bytes.count - 4, bytes.count - 2) + bytes.swapAt(bytes.count - 3, bytes.count - 1) + let unsorted = try JSONEncoder().encode(Data(bytes).base64EncodedString()) + XCTAssertThrowsError(try JSONDecoder().decode(Roaring64Bitmap.self, from: unsorted)) } func testFromRoaringBitmap() { diff --git a/Tests/swiftRoaringTests/swiftRoaringTests.swift b/Tests/swiftRoaringTests/swiftRoaringTests.swift index 1339538..71513a7 100644 --- a/Tests/swiftRoaringTests/swiftRoaringTests.swift +++ b/Tests/swiftRoaringTests/swiftRoaringTests.swift @@ -341,6 +341,14 @@ class swiftRoaringTests: XCTestCase { XCTAssertThrowsError(try dec.decode(RoaringBitmap.self, from: Data("\"AAAA\"".utf8))) XCTAssertThrowsError(try dec.decode(RoaringBitmap.self, from: Data("\"not base64\"".utf8))) + + // A well-formed header over an array container whose last two values + // are out of order: it deserializes, but it is not a valid bitmap. + var bytes = [UInt8](Data(base64Encoded: try! dec.decode(String.self, from: a))!) + bytes.swapAt(bytes.count - 4, bytes.count - 2) + bytes.swapAt(bytes.count - 3, bytes.count - 1) + let unsorted = try! enc.encode(Data(bytes).base64EncodedString()) + XCTAssertThrowsError(try dec.decode(RoaringBitmap.self, from: unsorted)) } func makeSets() -> (RoaringBitmap, RoaringBitmap, Set, Set) {