-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 69904ba
Showing
6 changed files
with
361 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
.DS_Store | ||
/.build | ||
/Packages | ||
/*.xcodeproj | ||
xcuserdata/ | ||
DerivedData/ | ||
.swiftpm/config/registries.json | ||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata | ||
.netrc |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
MIT License | ||
|
||
Copyright (c) 2023 Andrew R. Madsen | ||
|
||
Permission is hereby granted, free of charge, to any person obtaining a copy | ||
of this software and associated documentation files (the "Software"), to deal | ||
in the Software without restriction, including without limitation the rights | ||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
copies of the Software, and to permit persons to whom the Software is | ||
furnished to do so, subject to the following conditions: | ||
|
||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
|
||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
SOFTWARE. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
// swift-tools-version: 5.8 | ||
// The swift-tools-version declares the minimum version of Swift required to build this package. | ||
|
||
import PackageDescription | ||
|
||
let package = Package( | ||
name: "GlassGem", | ||
products: [ | ||
.library( | ||
name: "GlassGem", | ||
targets: ["GlassGem"]), | ||
], | ||
targets: [ | ||
.target( | ||
name: "GlassGem", | ||
dependencies: []), | ||
.testTarget( | ||
name: "GlassGemTests", | ||
dependencies: ["GlassGem"]), | ||
] | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
# GlassGem | ||
|
||
GlassGem is a Swift package that implements the [Consistent Overhead Byte Stuffing (COBS)](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) algorithm for encoding arbitrary data with single byte packet delimiters. | ||
|
||
It consists of an extension on `Data` with exactly two methods: `encodedUsingCOBS()` and `decodedFromCOBS()`. | ||
|
||
## Usage | ||
|
||
Encoding: | ||
|
||
```swift | ||
let someData = ... | ||
let cobsEncodedData = someData.encodedUsingCOBS() | ||
// Do something with cobsEncodedData, e.g. sending across a communications link | ||
``` | ||
|
||
Decoding: | ||
```swift | ||
let someCOBSEncodeData = ... // e.g. from a communications link | ||
let someData = someData.decodedFromCOBS() | ||
// Use someData like normal | ||
``` | ||
|
||
The package includes a suite of unit tests. | ||
|
||
## Installation | ||
|
||
To use the `GlassGem` library in a SwiftPM project, | ||
add the following line to the dependencies in your `Package.swift` file: | ||
|
||
```swift | ||
.package(url: "https://github.com/armadsen/GlassGem", from: "1.0.0"), | ||
``` | ||
|
||
Include `"GlassGem"` as a dependency for your executable target: | ||
|
||
```swift | ||
.target(name: "<target>", dependencies: [ | ||
.product(name: "GlassGem", package: "GlassGem"), | ||
]), | ||
``` | ||
|
||
Finally, add `import GlassGem` to your source code. | ||
|
||
## To Do | ||
|
||
GlassGem is already completely usable for the most common scenarios. However, there are a few things I'd like to implement in the future. Pull requests for these are completely welcome. Please include tests for anything you add. | ||
|
||
- [ ] Support for using GlassGem from Objective-C | ||
- [ ] Support for arbitrary delimiter bytes, not just 0x00 | ||
- [ ] Performance improvements (while still emphasizing readability) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
// | ||
// Data+Cobs.swift | ||
// | ||
// Created by Andrew R Madsen on 9/2/23. | ||
// | ||
// MIT License | ||
// | ||
// Copyright (c) 2023 Andrew R. Madsen | ||
// | ||
// Permission is hereby granted, free of charge, to any person obtaining a copy | ||
// of this software and associated documentation files (the "Software"), to deal | ||
// in the Software without restriction, including without limitation the rights | ||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
// copies of the Software, and to permit persons to whom the Software is | ||
// furnished to do so, subject to the following conditions: | ||
// | ||
// The above copyright notice and this permission notice shall be included in all | ||
// copies or substantial portions of the Software. | ||
// | ||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
// SOFTWARE. | ||
|
||
import Foundation | ||
|
||
/// This extension provides methods for encoding and decoding data using the Consistent Overhead Byte Stuffing (COBS) | ||
/// algorithm. COBS is way to packetize data for transmission over possibly error-prone communication links. It is very efficient/lower overhead | ||
/// using the zero (0x00) byte as a packet delimiter, and replacing zeros in the actual data being sent with minimal replacement data. | ||
/// For more information, see https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing. This implementation | ||
/// prioritizes readibility over performance, as it is used (by the author) for very small amounts of data. | ||
public extension Data { | ||
|
||
|
||
/// Encode the receiver using the COBS algorithm. The result will always end in a zero (0x00) byte, which is the packet | ||
/// delimiter under standard COBS. | ||
/// - Returns: A new Data object containing the receiver's data encoded using COBS. | ||
func encodedUsingCOBS() -> Data { | ||
// Here we're using the 'Prefixed block description' encoding algorithm | ||
var scratch = self | ||
|
||
// 1. Append a zero byte | ||
scratch.append(0) | ||
|
||
var groups = [Data]() | ||
var currentGroup = Data() | ||
var currentCount: UInt8 = 0 | ||
|
||
// 2. Break into groups of either 254 non-zero bytes or 0-253 non-zero bytes followed by a zero byte | ||
for byte in scratch { | ||
currentGroup.append(byte) | ||
// If we see a zero byte, or we're up to 253 non-zero bytes, we've finished a group | ||
if byte == 0 || currentCount >= 253 { | ||
groups.append(currentGroup) | ||
currentGroup = Data() | ||
currentCount = 0 | ||
} else { // Otherwise, just keep building the current group | ||
currentCount += 1 | ||
} | ||
} | ||
|
||
// 3. Prepend the number of non-zero bytes plus one to each group | ||
groups = groups.map { group in | ||
var scratch = group | ||
if scratch[scratch.endIndex.advanced(by: -1)] == 0x00 { | ||
scratch.removeLast() | ||
} | ||
scratch.insert(UInt8(scratch.count+1), at: 0) | ||
return scratch | ||
} | ||
|
||
// 4. Concatenate all encoded groups | ||
var result = Data(groups.flatMap { $0 }) | ||
// 5. Append a final trailing zero, which is the packet delimiter | ||
result.append(0) | ||
|
||
return result | ||
} | ||
|
||
/// Decode the receiver using the COBS algorithm. Calling this on data that is not COBS encoded will | ||
/// produce surprising results. | ||
/// - Returns: An array of individual Data objects each representing a packet in the original COBS encoded receiver. Note that the packet delimiter zero bytes will be removed from each packet. | ||
func decodedFromCOBS() -> [Data] { | ||
// 1. Split the data into invidual packets, which are separated by zero bytes | ||
let rawPackets = self.split(separator: 0) | ||
var result = [Data]() | ||
result.reserveCapacity(rawPackets.count) | ||
|
||
// 2. Loop through each packet | ||
for var packet in rawPackets { | ||
var groupHeaderIndexesToRemove = [Data.Index]() | ||
|
||
// 3. The first byte is always the index of the first zero byte | ||
var nextZeroOffset = Int(packet[packet.startIndex]) | ||
var nextZeroAddress = packet.startIndex.advanced(by: nextZeroOffset) | ||
// 4. Continue walking to each zero byte location as indicated by the value of the | ||
// last zero byte location | ||
while true { | ||
// If we're at the end of the packet, finish up | ||
if nextZeroAddress >= packet.endIndex { | ||
break | ||
} | ||
// 5. Replace each zero byte offset with the original zero value | ||
// But only if it's a group header (ie. we're at the end of a full non-zero group) | ||
if nextZeroOffset < 255 { | ||
// The offest to the next zero byte is the value here | ||
nextZeroOffset = Int(packet[nextZeroAddress]) | ||
packet[nextZeroAddress] = 0 | ||
} else { | ||
// The offest to the next zero byte is the value here | ||
nextZeroOffset = Int(packet[nextZeroAddress]) | ||
// Otherwise just remove it, because it's a group header | ||
groupHeaderIndexesToRemove.append(nextZeroAddress) | ||
} | ||
// Get the address of the next zero byte using the offset and current location | ||
nextZeroAddress = nextZeroAddress.advanced(by: nextZeroOffset) | ||
} | ||
|
||
// 6. Remove all group header addresses | ||
for index in groupHeaderIndexesToRemove.reversed() { | ||
packet.remove(at: index) | ||
} | ||
|
||
// 7. Remove the first byte which is never part of the original (see step 3 in encoding algorithm) | ||
packet.removeFirst() | ||
result.append(packet) | ||
} | ||
|
||
return result | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
// | ||
// GlassGemTests.swift | ||
// | ||
// Created by Andrew R Madsen on 9/2/23. | ||
// | ||
// MIT License | ||
// | ||
// Copyright (c) 2023 Andrew R. Madsen | ||
// | ||
// Permission is hereby granted, free of charge, to any person obtaining a copy | ||
// of this software and associated documentation files (the "Software"), to deal | ||
// in the Software without restriction, including without limitation the rights | ||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
// copies of the Software, and to permit persons to whom the Software is | ||
// furnished to do so, subject to the following conditions: | ||
// | ||
// The above copyright notice and this permission notice shall be included in all | ||
// copies or substantial portions of the Software. | ||
// | ||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
// SOFTWARE. | ||
|
||
import XCTest | ||
@testable import GlassGem | ||
|
||
final class GlassGemTests: XCTestCase { | ||
|
||
let baseTestTable: [[UInt8] : [UInt8]] = [ | ||
[0x01] : [0x02, 0x01, 0x00], | ||
[0x00] : [0x01, 0x01, 0x00], | ||
[0x00, 0x00] : [0x01, 0x01, 0x01, 0x00], | ||
[0x00, 0x11, 0x00] : [0x01, 0x02, 0x11, 0x01, 0x00], | ||
[0x11, 0x22, 0x00, 0x33] : [0x03, 0x11, 0x22, 0x02, 0x33, 0x00], | ||
[0x11, 0x22, 0x33, 0x44] : [0x05, 0x11, 0x22, 0x33, 0x44, 0x00], | ||
[0x11, 0x00, 0x00, 0x00] : [0x02, 0x11, 0x01, 0x01, 0x01, 0x00], | ||
] | ||
|
||
func testEncoding() { | ||
for (original, encoded) in baseTestTable { | ||
let encodedOriginal = Data(original).encodedUsingCOBS() | ||
XCTAssertEqual(encodedOriginal, Data(encoded), "Encoding failed for \(original)). Expected \(encoded). Got \([UInt8](encodedOriginal)).") | ||
} | ||
} | ||
|
||
func testDecoding() { | ||
for (original, encoded) in baseTestTable { | ||
let decodedResults = Data(encoded).decodedFromCOBS() | ||
XCTAssertEqual(decodedResults.count, 1) | ||
let decodedResult = decodedResults[0] | ||
XCTAssertEqual(decodedResult, Data(original), "Decoding failed for \(encoded)). Expected \(original). Got \([UInt8](decodedResult)).") | ||
} | ||
} | ||
|
||
func testRoundTrip() { | ||
for original in baseTestTable.keys { | ||
let encoded = Data(original).encodedUsingCOBS() | ||
let decoded = encoded.decodedFromCOBS()[0] | ||
XCTAssertEqual(Data(original), decoded, "Round trip encode/decode failed for \(original)). Expected \(original). Got \([UInt8](decoded)).") | ||
} | ||
} | ||
|
||
func testDecodingMultiplePacketsInOneData() { | ||
let allOriginals = baseTestTable.keys.map { Data($0) } | ||
let allEncoded = allOriginals.map { $0.encodedUsingCOBS() } | ||
let multiplePackets = Data(allEncoded.joined()) | ||
let decoded = multiplePackets.decodedFromCOBS() | ||
XCTAssertEqual(allOriginals, decoded) | ||
} | ||
|
||
func testPacketSizesNearGroupBoundaries() { | ||
for i in 1...10 { | ||
let original = Data.random(byteCount: i * 254) | ||
let encoded = original.encodedUsingCOBS() | ||
let decoded = encoded.decodedFromCOBS()[0] | ||
XCTAssertEqual(original, decoded, "Round trip encode/decode failed for random \([UInt8](original)). Expected \([UInt8](original)). Got \([UInt8](decoded)).") | ||
} | ||
} | ||
|
||
func testLongZeroRuns() { | ||
for i in 1...10 { | ||
let basePacket = Data.random(byteCount: 50) | ||
let zeros = Data(repeating: 0, count: i*50) | ||
let original = basePacket + zeros + basePacket | ||
let encoded = original.encodedUsingCOBS() | ||
let decoded = encoded.decodedFromCOBS()[0] | ||
XCTAssertEqual(original, decoded, "Round trip encode/decode failed for random \([UInt8](original)). Expected \([UInt8](original)). Got \([UInt8](decoded)).") | ||
} | ||
} | ||
|
||
func testLongNonZeroRuns() { | ||
for i in 1...10 { | ||
let basePacket = Data.random(byteCount: 50) | ||
let nonZero = Data.random(byteCount: i*50, allowZero: false) | ||
let original = basePacket + nonZero + basePacket | ||
let encoded = original.encodedUsingCOBS() | ||
let decoded = encoded.decodedFromCOBS()[0] | ||
XCTAssertEqual(original, decoded, "Round trip encode/decode failed for random \([UInt8](original)). Expected \([UInt8](original)). Got \([UInt8](decoded)).") | ||
} | ||
} | ||
|
||
func testLargeRandomPackets() { | ||
for _ in 0..<1000 { | ||
let original = Data.random(byteCount: Int.random(in: 0...10000)) | ||
let encoded = original.encodedUsingCOBS() | ||
let decoded = encoded.decodedFromCOBS()[0] | ||
XCTAssertEqual(original, decoded, "Round trip encode/decode failed for random \([UInt8](original)). Expected \([UInt8](original)). Got \([UInt8](decoded)).") | ||
} | ||
} | ||
} | ||
|
||
private extension Data { | ||
static func random(byteCount: Int, allowZero: Bool = true) -> Data { | ||
var result = Data(capacity: byteCount) | ||
let range: ClosedRange<UInt8> = allowZero ? 0...255 : 1...255 | ||
for _ in 0..<byteCount { | ||
result.append(UInt8.random(in: range)) | ||
} | ||
return result | ||
} | ||
} |