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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed (`fix-ulid-spec-compliance`)
- **ULID text format:** `ULID.string`/`description` and `ULID(string:)` now follow the ULID specification (2 padding bits at the front, so the first character is `0`-`7`). `ULID(string:)` rejects strings starting above `7`. The 16 bytes are unchanged; only the text differs.
- **Migration:** strings stored by 1.10.x/1.11.x do not parse the same way. Read them with the new `ULID(legacyString:)` and re-save `string`. Known consumers: RetroboxBBS and RetroServe (TermFoundation).

### Added (`fix-async-work-correctness`)
- `ResourceCoordinator.withWriteLock(for:operation:)`.

Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ swift test

## Known issues

- `ULID` text is not compatible with the ULID specification.
- Platform claims are broader than tested, `WASMGuard.swift` is dead code, SHA code is duplicated, and the DocC catalog is not attached to the target.

## Documentation
Expand Down
76 changes: 53 additions & 23 deletions Sources/DesignAlgorithmsKit/Algorithms/Identification/ULID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,25 @@ public struct ULID: Sendable, Equatable, Hashable, Codable, CustomStringConverti
self.bytes = data
}

/// Parses the ULID-specification text form (case-insensitive). Returns nil for strings that
/// are not 26 characters, use characters outside the Crockford alphabet, or start above `7`.
public init?(string: String) {
guard let decoded = ULID.decode(string: string) else {
return nil
}
self.bytes = decoded
}

/// Parses text written by DesignAlgorithmsKit 1.10.x and earlier, which put the 2 padding bits
/// at the end instead of the front. Use it to migrate stored IDs: the bytes are unchanged and
/// `string` then gives the specification form.
public init?(legacyString: String) {
guard let decoded = ULID.decodeLegacy(string: legacyString) else {
return nil
}
self.bytes = decoded
}

public var description: String {
ULID.encode(bytes: bytes)
}
Expand All @@ -64,55 +76,73 @@ public struct ULID: Sendable, Equatable, Hashable, Codable, CustomStringConverti

// MARK: Helpers

/// 130 bits (2 zero padding bits, then the 128-bit value) as 26 five-bit characters.
private static func encode(bytes: [UInt8]) -> String {
precondition(bytes.count == 16, "ULID must be 16 bytes.")
var output: [Character] = []
output.reserveCapacity(26)
var buffer = 0
var bitsLeft = 0
var bitsLeft = 2 // leading padding bits

for byte in bytes {
buffer = (buffer << 8) | Int(byte)
buffer = ((buffer << 8) | Int(byte)) & 0x1FFF
bitsLeft += 8
while bitsLeft >= 5 {
let index = (buffer >> (bitsLeft - 5)) & 0x1F
output.append(encoding[index])
output.append(encoding[(buffer >> (bitsLeft - 5)) & 0x1F])
bitsLeft -= 5
}
}
return String(output)
}

if bitsLeft > 0 {
let index = (buffer << (5 - bitsLeft)) & 0x1F
output.append(encoding[index])
}
private static func decode(string: String) -> [UInt8]? {
let values = digits(of: string)
guard let values, let first = values.first, first <= 7 else { return nil }
// The first character holds the top 3 bits; the other 25 hold 5 bits each.
var output: [UInt8] = []
output.reserveCapacity(16)
var buffer = Int(first)
var bitsLeft = 3

while output.count < 26 {
output.append(encoding[0])
for value in values.dropFirst() {
buffer = ((buffer << 5) | Int(value)) & 0x1FFF
bitsLeft += 5
if bitsLeft >= 8 {
output.append(UInt8((buffer >> (bitsLeft - 8)) & 0xFF))
bitsLeft -= 8
}
}

return String(output.prefix(26))
return output.count == 16 ? output : nil
}

private static func decode(string: String) -> [UInt8]? {
guard string.count == 26 else { return nil }
/// The ≤ 1.10.x layout: 5-bit groups from the most significant bit, 2 trailing padding bits.
private static func decodeLegacy(string: String) -> [UInt8]? {
guard let values = digits(of: string) else { return nil }
var output: [UInt8] = []
output.reserveCapacity(16)
var buffer = 0
var bitsLeft = 0

for char in string.uppercased() {
guard let value = decoding[char] else { return nil }
buffer = (buffer << 5) | Int(value)
for value in values {
buffer = ((buffer << 5) | Int(value)) & 0x1FFF
bitsLeft += 5

if bitsLeft >= 8 {
let byte = UInt8((buffer >> (bitsLeft - 8)) & 0xFF)
output.append(byte)
output.append(UInt8((buffer >> (bitsLeft - 8)) & 0xFF))
bitsLeft -= 8
}
}
return output.count == 16 ? output : nil
}

if output.count != 16 {
return nil
/// Crockford base-32 digit values of a 26-character string, or nil.
private static func digits(of string: String) -> [UInt8]? {
guard string.count == 26 else { return nil }
var values: [UInt8] = []
values.reserveCapacity(26)
for char in string.uppercased() {
guard let value = decoding[char] else { return nil }
values.append(value)
}
return output
return values
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,51 @@ final class ULIDTests: XCTestCase {
XCTAssertTrue(earlyULID.string < lateULID.string, "ULIDs should be lexicographically sortable by time")
}
}

// MARK: - Specification layout (fix-ulid-spec-compliance)

extension ULIDTests {
private static let specExample = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
/// The same 16 bytes as written by DesignAlgorithmsKit 1.10.x.
private static let legacyExample = "05B3WENNTFB7CK31XYWS60NXBC"

func testSpecificationTimestampPrefix() {
let ulid = ULID(date: Date(timeIntervalSince1970: Double(1_469_922_850_259) / 1000.0), randomBytes: Array(repeating: 0, count: 10))
XCTAssertTrue(ulid.string.hasPrefix("01ARZ3NDEK"), ulid.string)
}

func testSpecificationStringRoundTrip() throws {
let ulid = try XCTUnwrap(ULID(string: Self.specExample))
XCTAssertEqual(ulid.string, Self.specExample)
XCTAssertEqual(ULID(string: Self.specExample.lowercased()), ulid)
}

func testGeneratedStringsStartWithZeroToSeven() {
for _ in 0..<100 {
let ulid = ULID(date: Date(timeIntervalSince1970: Double.random(in: 0...4_000_000_000)))
XCTAssertTrue("01234567".contains(ulid.string.first!), ulid.string)
XCTAssertEqual(ULID(string: ulid.string), ulid)
}
}

func testOverflowingFirstCharacterIsRejected() {
XCTAssertNil(ULID(string: "8ZZZZZZZZZZZZZZZZZZZZZZZZZ"))
XCTAssertNotNil(ULID(string: "7ZZZZZZZZZZZZZZZZZZZZZZZZZ"))
}

func testMaximumValueRoundTrip() throws {
let max = try XCTUnwrap(ULID(string: "7ZZZZZZZZZZZZZZZZZZZZZZZZZ"))
XCTAssertEqual(max.string, "7ZZZZZZZZZZZZZZZZZZZZZZZZZ")
}

func testLegacyStringMigratesToSameBytes() throws {
let legacy = try XCTUnwrap(ULID(legacyString: Self.legacyExample))
XCTAssertEqual(legacy, ULID(string: Self.specExample))
XCTAssertEqual(legacy.string, Self.specExample)
}

func testLegacyParserRejectsBadInput() {
XCTAssertNil(ULID(legacyString: "TOO-SHORT"))
XCTAssertNil(ULID(legacyString: "UUUUUUUUUUUUUUUUUUUUUUUUUU"))
}
}
Loading