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
-
+
[](https://github.com/Carthage/Carthage) /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
-[](https://github.com/Carthage/Carthage) /badge.svg)
+[](https://github.com/Carthage/Carthage) 
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) {