diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8666c..5d70792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:)`. diff --git a/README.md b/README.md index 9a81b83..71290b1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Identification/ULID.swift b/Sources/DesignAlgorithmsKit/Algorithms/Identification/ULID.swift index 1b2d0bb..7ba0e69 100644 --- a/Sources/DesignAlgorithmsKit/Algorithms/Identification/ULID.swift +++ b/Sources/DesignAlgorithmsKit/Algorithms/Identification/ULID.swift @@ -33,6 +33,8 @@ 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 @@ -40,6 +42,16 @@ public struct ULID: Sendable, Equatable, Hashable, Codable, CustomStringConverti 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) } @@ -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 } } diff --git a/Tests/DesignAlgorithmsKitTests/Algorithms/Identification/ULIDTests.swift b/Tests/DesignAlgorithmsKitTests/Algorithms/Identification/ULIDTests.swift index 2395b08..96ef35a 100644 --- a/Tests/DesignAlgorithmsKitTests/Algorithms/Identification/ULIDTests.swift +++ b/Tests/DesignAlgorithmsKitTests/Algorithms/Identification/ULIDTests.swift @@ -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")) + } +}