From c6e3d4364132a9dcc17e3b4fbcd2939e4b2f685a Mon Sep 17 00:00:00 2001 From: Jeffrey D <1289344+verygoodsoftwarenotvirus@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:10:25 -0500 Subject: [PATCH] chore: remove Identifiers, and replace Fake.xid() with a seeded opaqueID() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit primitives-conformance turned up that the four ports disagree on what `identifiers` means. Swift was the port that *agreed*: XID.swift is a real hand-written xid — 12 raw bytes, machine ID, timestamp encoding — and Tests/IdentifiersTests/XIDInteropTests.swift existed specifically to check it against Go's. It passed. Agreeing with Go by accident is still not a reason to carry a reimplementation of Go's ID format on a handset. A client has no business generating a server's identifiers: the server issues them, the client receives opaque strings, and validating an ID the server just sent proves nothing. So `identifiers` stays a primitives-go package and this port drops it, along with kt and ts. Fake was the only consumer, behind a public xid(). It becomes opaqueID(): 20 lowercase base32-hex characters, the right *shape* for test data, carrying no timestamp and no machine identity and parsing nothing. It is drawn entirely from the seeded source, so unlike the xid() it replaces — which the type's own documentation called out as not seed-reproducible — a seeded FakeGenerator now reproduces it. FakeTests' reproducibility case exercises that rather than taking it on faith. Also fixes a leftover from the rename: Package.swift still declared `name: "platform-swift"` while README.md already told consumers `package: "primitives-swift"`. Verified: swift build clean, swift test 1133 tests in 246 suites passing (1152 before, minus the 19 IdentifiersTests). Closes #5 Co-Authored-By: Claude Opus 5 --- Package.swift | 16 +- README.md | 8 +- Sources/Fake/Fake.swift | 10 +- Sources/Fake/FakeEngine.swift | 11 ++ Sources/Fake/FakeGenerator.swift | 18 +-- Sources/Identifiers/Identifier.swift | 41 ----- Sources/Identifiers/XID.swift | 144 ------------------ Tests/FakeTests/FakeTests.swift | 11 +- Tests/IdentifiersTests/IdentifiersTests.swift | 138 ----------------- Tests/IdentifiersTests/XIDInteropTests.swift | 104 ------------- 10 files changed, 41 insertions(+), 460 deletions(-) delete mode 100644 Sources/Identifiers/Identifier.swift delete mode 100644 Sources/Identifiers/XID.swift delete mode 100644 Tests/IdentifiersTests/IdentifiersTests.swift delete mode 100644 Tests/IdentifiersTests/XIDInteropTests.swift diff --git a/Package.swift b/Package.swift index ead7f31..d7e6449 100644 --- a/Package.swift +++ b/Package.swift @@ -2,7 +2,7 @@ import PackageDescription let package = Package( - name: "platform-swift", + name: "primitives-swift", platforms: [ .iOS(.v16), .macOS(.v13), @@ -14,7 +14,6 @@ let package = Package( .library(name: "Filtering", targets: ["Filtering"]), .library(name: "APIErrors", targets: ["APIErrors"]), .library(name: "Retry", targets: ["Retry"]), - .library(name: "Identifiers", targets: ["Identifiers"]), .library(name: "RandomKit", targets: ["RandomKit"]), .library(name: "Numbers", targets: ["Numbers"]), .library(name: "Bitmask", targets: ["Bitmask"]), @@ -112,15 +111,6 @@ let package = Package( dependencies: ["Retry", "DurationWire"], swiftSettings: [.swiftLanguageMode(.v6)] ), - .target( - name: "Identifiers", - swiftSettings: [.swiftLanguageMode(.v6)] - ), - .testTarget( - name: "IdentifiersTests", - dependencies: ["Identifiers"], - swiftSettings: [.swiftLanguageMode(.v6)] - ), .target( name: "RandomKit", swiftSettings: [.swiftLanguageMode(.v6)] @@ -323,12 +313,12 @@ let package = Package( ), .target( name: "Fake", - dependencies: ["RandomKit", "Identifiers"], + dependencies: ["RandomKit"], swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "FakeTests", - dependencies: ["Fake", "RandomKit", "Identifiers"], + dependencies: ["Fake", "RandomKit"], swiftSettings: [.swiftLanguageMode(.v6)] ), .target( diff --git a/README.md b/README.md index 19e9403..97612ba 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A Swift port of [`primitives-go`](https://github.com/primandproper/primitives-go)'s toolkit — the same conceptual API across languages, expressed idiomatically for Swift concurrency and iOS. It ships as a -set of **independent products** (33 libraries: observability, HTTP, event streams, crypto/auth, +set of **independent products** (32 libraries: observability, HTTP, event streams, crypto/auth, analytics, feature flags, in-app purchase, and more) that you adopt à la carte. The design rules are thin/native/no-third-party-SPM-SDK: URLSession + Codable, CryptoKit, StoreKit, UserNotifications, with protocol seams shaped so a native or vendor adapter can wrap later. @@ -14,6 +14,11 @@ there is. That makes this port unusual among the primitives ports in how *little API key on a handset, or that only makes sense beside server infrastructure. Talking to a service built on `platform-go` is `platform-client-swift`'s job, not this package's. +Identifiers are the case worth naming, since this package used to carry a full xid +implementation: a server issues IDs and a client receives opaque strings, so minting one here +only risks having it rejected by the service you send it to. ``Fake/opaqueID()`` produces an +ID-*shaped* string for test data and deliberately parses nothing. + The keystone — and the deepest port — is **observability**. Its **Observer / Operation** abstraction is a per-component bundle of a named logger and tracer, where `op.set(key, value)` records to **both** the active span and a trace-enriched logger at once. On Apple platforms it lights up Instruments (spans) and @@ -344,7 +349,6 @@ module. | `Bitmask` | pure-logic | 🟢 bitmask set ops, full Go parity | | `Numbers` | pure-logic | 🟢 numeric helpers + range clamping | | `Version` | pure-logic | 🟢 build/version info, JSON + text rendering | -| `Identifiers` | pure-logic | 🟢 `XID` (Go xid wire-compatible, wrap-safe) | | `RandomKit` | pure-logic | 🟢 `SecRandomCopyBytes` generator, Base32, slice helpers; noop | | `APIErrors` | pure-logic | 🟢 `APIResponse` / `ErrorCode` types | | `Filtering` | pure-logic | 🟡 pagination / query-filter / RFC3339; `FromParams`/`ToPagination` **not ported** | diff --git a/Sources/Fake/Fake.swift b/Sources/Fake/Fake.swift index 234edd5..1ce33b9 100644 --- a/Sources/Fake/Fake.swift +++ b/Sources/Fake/Fake.swift @@ -1,5 +1,4 @@ import Foundation -import Identifiers import RandomKit /// # Fake @@ -96,9 +95,12 @@ public enum Fake: Sendable { UUID().uuidString } - /// A random 20-character xid string. Delegates to ``Identifiers/Identifier/new()``. - public static func xid() -> String { - Identifier.new() + /// A random 20-character lowercase base32-hex string, shaped like a server-issued ID + /// without being one. A client never mints a server's identifiers; this exists so tests + /// have a plausible stand-in for one the server would have sent. + public static func opaqueID() -> String { + var source = SystemFakeSource() + return FakeEngine.opaqueID(using: &source) } /// A random `Bool`. diff --git a/Sources/Fake/FakeEngine.swift b/Sources/Fake/FakeEngine.swift index 1756ee5..591cfe5 100644 --- a/Sources/Fake/FakeEngine.swift +++ b/Sources/Fake/FakeEngine.swift @@ -59,6 +59,17 @@ enum FakeEngine { return String(format: "(%03d) %03d-%04d", area, exchange, line) } + /// The base32-hex alphabet (lowercased), which is what server-issued IDs happen to look + /// like. Nothing here parses or validates an ID — this produces a string of the right + /// *shape* for test data, and deliberately carries no timestamp or machine identity. + private static let opaqueIDAlphabet = Array("0123456789abcdefghijklmnopqrstuv") + + /// A 20-character lowercase base32-hex string, drawn entirely from `source` so a seeded + /// generator reproduces it. + static func opaqueID(using source: inout S) -> String { + String((0..<20).map { _ in source.pick(from: opaqueIDAlphabet) ?? "0" }) + } + static func url(using source: inout S) -> String { let host = word(using: &source) let tld = source.pick(from: FakeCorpus.topLevelDomains) ?? "com" diff --git a/Sources/Fake/FakeGenerator.swift b/Sources/Fake/FakeGenerator.swift index 4835b94..b802bb6 100644 --- a/Sources/Fake/FakeGenerator.swift +++ b/Sources/Fake/FakeGenerator.swift @@ -1,5 +1,4 @@ import Foundation -import Identifiers /// A seeded, reproducible counterpart to ``Fake``, for callers that need the *same* fixture data /// every time — most notably SwiftUI `#Preview` bodies, which re-render on every canvas refresh @@ -15,11 +14,9 @@ import Identifiers /// non-deterministic one — so two generators constructed with the same seed and driven through the /// same call sequence always produce identical output. /// -/// **Where seeding is not feasible.** ``uuid()`` and ``xid()`` delegate to `Foundation.UUID` and -/// ``Identifiers/Identifier/new()`` respectively, neither of which exposes a seedable API (xid in -/// particular mixes in wall-clock time, machine ID, and process ID by design) — so these two -/// remain non-deterministic even on a seeded generator. Every other generator here is fully -/// reproducible. +/// **Where seeding is not feasible.** ``uuid()`` delegates to `Foundation.UUID`, which exposes no +/// seedable API, so it remains non-deterministic even on a seeded generator. Every other generator +/// here is fully reproducible — including ``opaqueID()``, which replaced an `xid()` that was not. public struct FakeGenerator: Sendable { private var source: SeededFakeSource @@ -87,10 +84,11 @@ public struct FakeGenerator: Sendable { UUID().uuidString } - /// A random 20-character xid string. Not seed-reproducible — see the type-level discussion - /// above. - public func xid() -> String { - Identifier.new() + /// A reproducible 20-character lowercase base32-hex string, shaped like a server-issued + /// ID without being one. Unlike the `xid()` this replaces, it is drawn from the seeded + /// source, so a seeded generator reproduces it. + public mutating func opaqueID() -> String { + FakeEngine.opaqueID(using: &source) } /// A reproducible random `Bool`. diff --git a/Sources/Identifiers/Identifier.swift b/Sources/Identifiers/Identifier.swift deleted file mode 100644 index bd8fb5f..0000000 --- a/Sources/Identifiers/Identifier.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -/// String identifier generation, ported from platform-go's `identifiers` package. -/// -/// Go delegates to `github.com/rs/xid`, whose IDs are **not** UUIDs: an xid is a 12-byte value -/// (4-byte seconds timestamp, 3-byte machine ID, 2-byte process ID, 3-byte monotonic counter) -/// rendered as a 20-character, lexically-sortable, lowercase base32-hex string over the alphabet -/// `0123456789abcdefghijklmnopqrstuv`. We reproduce that scheme byte-for-byte rather than mapping -/// to Foundation's `UUID`, because the whole point of these IDs is that a Go service using `xid` -/// can round-trip and validate the strings we mint — a `UUID` would be wire-incompatible. -/// -/// The one intentional divergence is the machine ID: xid hashes the host's machine-id/hostname; -/// we derive three bytes from the host name (via `gethostname(2)`) and fall back to random bytes. -/// The exact machine-ID value never affects validity (any three bytes are legal), so this stays -/// format-faithful while remaining dependency-free on Apple platforms. -public enum Identifier { - /// Produces a new 20-character xid string. Mirrors Go's `identifiers.New()`. - public static func new() -> String { - XIDGenerator.shared.newIDString() - } - - /// Reports whether `id` is a syntactically valid xid string (length, alphabet, and the canonical - /// last-character constraint xid enforces on decode). This is the `Bool`-returning form; prefer it - /// for control flow. - public static func isValid(_ id: String) -> Bool { - XIDGenerator.isValidIDString(id) - } - - /// Throwing counterpart mirroring Go's `identifiers.Validate`, which returns an `error`. Throws - /// ``InvalidIdentifierError`` when `id` is not a valid xid string. - public static func validate(_ id: String) throws { - guard isValid(id) else { throw InvalidIdentifierError(value: id) } - } -} - -/// Error thrown by ``Identifier/validate(_:)`` for a malformed identifier. Mirrors xid's -/// `ErrInvalidID`, but carries the offending value for better diagnostics. -public struct InvalidIdentifierError: Error, Equatable, Sendable { - public let value: String - public init(value: String) { self.value = value } -} diff --git a/Sources/Identifiers/XID.swift b/Sources/Identifiers/XID.swift deleted file mode 100644 index 7889943..0000000 --- a/Sources/Identifiers/XID.swift +++ /dev/null @@ -1,144 +0,0 @@ -import Foundation - -/// The xid base32-hex alphabet (RFC 4648 base32-hex, lowercased) that `github.com/rs/xid` uses. -private let encoding: [UInt8] = Array("0123456789abcdefghijklmnopqrstuv".utf8) - -/// Reverse lookup: ASCII byte -> alphabet index, or -1 for characters outside the alphabet. -private let decoding: [Int8] = { - var table = [Int8](repeating: -1, count: 256) - for (index, char) in encoding.enumerated() { - table[Int(char)] = Int8(index) - } - return table -}() - -/// Generates xid values. Ported from `github.com/rs/xid`. -/// -/// Marked `@unchecked Sendable`: the counter is the only mutable state and it is guarded by an -/// `NSLock`, so shared use across concurrency domains is safe. `os_unfair_lock` would be lighter, -/// but ID minting is nowhere near hot enough for that to matter (Pike's rule 3 — n is small). -final class XIDGenerator: @unchecked Sendable { - static let shared = XIDGenerator() - - private let machineID: (UInt8, UInt8, UInt8) - private let processID: UInt16 - private let lock = NSLock() - private var counter: UInt32 - - init() { - machineID = Self.readMachineID() - processID = UInt16(truncatingIfNeeded: ProcessInfo.processInfo.processIdentifier) - // xid seeds the counter randomly so IDs minted early in two processes don't collide. - counter = UInt32.random(in: 0...0xFF_FFFF) - } - - /// Builds the raw 12-byte xid: 4-byte big-endian seconds timestamp, 3-byte machine ID, - /// 2-byte big-endian PID, 3-byte big-endian counter. - func newRawID() -> [UInt8] { - let timestamp = Self.timestamp(forUnixSeconds: Date().timeIntervalSince1970) - - lock.lock() - counter = (counter &+ 1) & 0xFF_FFFF - let count = counter - lock.unlock() - - var id = [UInt8](repeating: 0, count: 12) - id[0] = UInt8(truncatingIfNeeded: timestamp >> 24) - id[1] = UInt8(truncatingIfNeeded: timestamp >> 16) - id[2] = UInt8(truncatingIfNeeded: timestamp >> 8) - id[3] = UInt8(truncatingIfNeeded: timestamp) - id[4] = machineID.0 - id[5] = machineID.1 - id[6] = machineID.2 - id[7] = UInt8(truncatingIfNeeded: processID >> 8) - id[8] = UInt8(truncatingIfNeeded: processID) - id[9] = UInt8(truncatingIfNeeded: count >> 16) - id[10] = UInt8(truncatingIfNeeded: count >> 8) - id[11] = UInt8(truncatingIfNeeded: count) - return id - } - - func newIDString() -> String { - Self.encode(newRawID()) - } - - /// Converts a Unix timestamp (seconds since 1970, as `Date.timeIntervalSince1970` yields) to the - /// 4-byte big-endian prefix xid uses. Go computes `uint32(time.Now().Unix())`, which wraps modulo - /// 2^32 for pre-1970 (negative) and post-2106 timestamps rather than trapping. Plain - /// `UInt32(_:)` would trap on those out-of-range values; `truncatingIfNeeded` keeps the low 32 - /// bits, reproducing Go's two's-complement wrap exactly. - static func timestamp(forUnixSeconds seconds: Double) -> UInt32 { - UInt32(truncatingIfNeeded: Int64(seconds)) - } - - private static func readMachineID() -> (UInt8, UInt8, UInt8) { - // Read the host name via gethostname(2), a cheap local syscall. ProcessInfo.hostName can block - // for seconds on a reverse-DNS lookup (possibly on the main thread through the lazy `shared` - // init), so we avoid it here. - var buffer = [CChar](repeating: 0, count: 256) - if gethostname(&buffer, buffer.count) == 0 { - let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } - if bytes.count >= 3 { - // Cheap FNV-1a over the hostname to spread it across three bytes; xid uses MD5, but only the - // format matters for validity, so we avoid pulling in a crypto dependency. - var hash: UInt32 = 2_166_136_261 - for byte in bytes { - hash = (hash ^ UInt32(byte)) &* 16_777_619 - } - return ( - UInt8(truncatingIfNeeded: hash >> 16), - UInt8(truncatingIfNeeded: hash >> 8), - UInt8(truncatingIfNeeded: hash) - ) - } - } - return ( - UInt8.random(in: .min ... .max), - UInt8.random(in: .min ... .max), - UInt8.random(in: .min ... .max) - ) - } - - /// Encodes a 12-byte xid into its 20-character string form. This is the unrolled base32-hex - /// encoder from xid's `id.go`, transcribed verbatim; Swift's `<<`/`>>` are non-trapping smart - /// shifts that drop overflow bits exactly as Go's byte shifts do. - static func encode(_ id: [UInt8]) -> String { - precondition(id.count == 12, "xid raw value must be 12 bytes") - var dst = [UInt8](repeating: 0, count: 20) - dst[0] = encoding[Int(id[0] >> 3)] - dst[1] = encoding[Int((id[1] >> 6) & 0x1f | (id[0] << 2) & 0x1f)] - dst[2] = encoding[Int((id[1] >> 1) & 0x1f)] - dst[3] = encoding[Int((id[2] >> 4) & 0x1f | (id[1] << 4) & 0x1f)] - dst[4] = encoding[Int((id[3] >> 7) | (id[2] << 1) & 0x1f)] - dst[5] = encoding[Int((id[3] >> 2) & 0x1f)] - dst[6] = encoding[Int((id[4] >> 5) | (id[3] << 3) & 0x1f)] - dst[7] = encoding[Int(id[4] & 0x1f)] - dst[8] = encoding[Int(id[5] >> 3)] - dst[9] = encoding[Int((id[6] >> 6) & 0x1f | (id[5] << 2) & 0x1f)] - dst[10] = encoding[Int((id[6] >> 1) & 0x1f)] - dst[11] = encoding[Int((id[7] >> 4) & 0x1f | (id[6] << 4) & 0x1f)] - dst[12] = encoding[Int((id[8] >> 7) | (id[7] << 1) & 0x1f)] - dst[13] = encoding[Int((id[8] >> 2) & 0x1f)] - dst[14] = encoding[Int((id[9] >> 5) | (id[8] << 3) & 0x1f)] - dst[15] = encoding[Int(id[9] & 0x1f)] - dst[16] = encoding[Int(id[10] >> 3)] - dst[17] = encoding[Int((id[11] >> 6) & 0x1f | (id[10] << 2) & 0x1f)] - dst[18] = encoding[Int((id[11] >> 1) & 0x1f)] - dst[19] = encoding[Int((id[11] << 4) & 0x1f)] - return String(decoding: dst, as: UTF8.self) - } - - /// Validates a candidate xid string: exactly 20 characters, every character in the alphabet, and - /// the canonical trailing-character constraint xid's `decode` enforces (a non-canonical final - /// character means the string could not have been produced by `encode`). - static func isValidIDString(_ string: String) -> Bool { - let src = Array(string.utf8) - guard src.count == 20 else { return false } - for byte in src where decoding[Int(byte)] < 0 { return false } - - // Reconstruct the final data byte (id[11]) and confirm it re-encodes to the given last char. - let d: (Int) -> UInt8 = { UInt8(bitPattern: decoding[Int(src[$0])]) } - let id11 = (d(17) << 6) | (d(18) << 1) | (d(19) >> 4) - return encoding[Int((id11 << 4) & 0x1f)] == src[19] - } -} diff --git a/Tests/FakeTests/FakeTests.swift b/Tests/FakeTests/FakeTests.swift index c947f24..733dd93 100644 --- a/Tests/FakeTests/FakeTests.swift +++ b/Tests/FakeTests/FakeTests.swift @@ -1,5 +1,4 @@ import Foundation -import Identifiers import Testing @testable import Fake @@ -76,9 +75,11 @@ struct FakeStaticGeneratorTests { #expect(UUID(uuidString: Fake.uuid()) != nil) } - @Test("xid is a valid Identifiers xid") - func xid() { - #expect(Identifier.isValid(Fake.xid())) + @Test("opaqueID is 20 lowercase base32-hex characters") + func opaqueID() { + let id = Fake.opaqueID() + #expect(id.count == 20) + #expect(id.allSatisfy { "0123456789abcdefghijklmnopqrstuv".contains($0) }) } @Test("bool returns without trapping") @@ -139,6 +140,8 @@ struct FakeGeneratorSeededTests { #expect(a.int(in: 0...1000) == b.int(in: 0...1000)) #expect(a.double(in: 0...1000) == b.double(in: 0...1000)) #expect(a.pick(from: [1, 2, 3, 4, 5]) == b.pick(from: [1, 2, 3, 4, 5])) + // opaqueID replaced an xid() that could not be seeded; this is the difference. + #expect(a.opaqueID() == b.opaqueID()) } @Test("different seeds are very likely to diverge") diff --git a/Tests/IdentifiersTests/IdentifiersTests.swift b/Tests/IdentifiersTests/IdentifiersTests.swift deleted file mode 100644 index 5bd5c3c..0000000 --- a/Tests/IdentifiersTests/IdentifiersTests.swift +++ /dev/null @@ -1,138 +0,0 @@ -import Testing - -@testable import Identifiers - -@Suite("Identifier.new") -struct IdentifierNewTests { - @Test("produces a non-empty, 20-character xid string") - func length() { - let id = Identifier.new() - #expect(!id.isEmpty) - #expect(id.count == 20) - } - - @Test("every character is in the xid base32-hex alphabet") - func alphabet() { - let allowed = Set("0123456789abcdefghijklmnopqrstuv") - let id = Identifier.new() - #expect(id.allSatisfy { allowed.contains($0) }) - } - - @Test("successive IDs are distinct") - func uniqueness() { - let ids = Set((0..<1000).map { _ in Identifier.new() }) - #expect(ids.count == 1000) - } - - @Test("generated IDs validate as their own round-trip") - func selfValidating() { - for _ in 0..<100 { - #expect(Identifier.isValid(Identifier.new())) - } - } -} - -@Suite("Identifier.validate / isValid") -struct IdentifierValidateTests { - @Test("a freshly generated ID validates without throwing") - func happyPath() throws { - try Identifier.validate(Identifier.new()) - } - - @Test("wrong length is invalid") - func wrongLength() { - #expect(!Identifier.isValid("")) - #expect(!Identifier.isValid("tooshort")) - #expect(!Identifier.isValid(String(repeating: "0", count: 21))) - } - - @Test("characters outside the alphabet are invalid") - func badCharacters() { - // 'w', 'x', 'y', 'z' and uppercase are all outside the base32-hex alphabet. - #expect(!Identifier.isValid(String(repeating: "w", count: 20))) - #expect(!Identifier.isValid(String(repeating: "A", count: 20))) - } - - @Test("a non-canonical trailing character is invalid") - func nonCanonicalTail() { - // All-zero xid is canonical; mutating the final char to one whose low bits can't round-trip - // through encode must fail the canonical-tail check. - var chars = Array(String(repeating: "0", count: 20)) - chars[19] = "1" - #expect(!Identifier.isValid(String(chars))) - } - - @Test("validate throws InvalidIdentifierError carrying the bad value") - func throwsWithValue() { - #expect(throws: InvalidIdentifierError(value: "nope")) { - try Identifier.validate("nope") - } - } -} - -@Suite("XID encoder") -struct XIDEncoderTests { - @Test("all-zero raw ID encodes to twenty zeros and validates") - func allZeroVector() { - let zeros = [UInt8](repeating: 0, count: 12) - let encoded = XIDGenerator.encode(zeros) - #expect(encoded == String(repeating: "0", count: 20)) - #expect(XIDGenerator.isValidIDString(encoded)) - } - - @Test("all-ones raw ID encodes to a known-boundary vector") - func allOnesVector() { - let ones = [UInt8](repeating: 0xFF, count: 12) - let encoded = XIDGenerator.encode(ones) - #expect(encoded.count == 20) - // dst[0] = encoding[0xFF >> 3 = 31] = 'v'; dst[19] = encoding[(0xFF << 4) & 0x1f = 0x10 = 16] = 'g'. - #expect(encoded.first == "v") - #expect(encoded.last == "g") - } - - @Test("timestamp prefix is sortable across generations") - func sortablePrefix() { - // The first four bytes are a big-endian seconds timestamp, so two IDs minted moments apart - // must be lexically ordered by their leading characters (equal or ascending). - let first = Identifier.new() - let second = Identifier.new() - #expect(first.prefix(6) <= second.prefix(6)) - } -} - -@Suite("XID timestamp wrap") -struct XIDTimestampWrapTests { - // Fixtures produced by running `uint32(int64(v))` in Go 1.26.4 against ../platform-go's - // toolchain (uint32(time.Now().Unix()) is what xid does). Pinned literals confirm Swift's - // truncatingIfNeeded reproduces Go's two's-complement modulo-2^32 wrap rather than trapping. - @Test("pre-1970 and post-2106 timestamps wrap instead of trapping, matching Go") - func matchesGoWrap() { - // v -> uint32(v) from the Go program: - // 4294967296 -> 0 (exactly 2^32, post-2106) - // 4294967396 -> 100 (post-2106) - // -1 -> 4294967295 (pre-1970, negative) - // 4700000000 -> 405032704 (post-2106, year ~2118) - // 1000000000 -> 1000000000 (in-range control, 2001-09-09) - #expect(XIDGenerator.timestamp(forUnixSeconds: 4_294_967_296) == 0) - #expect(XIDGenerator.timestamp(forUnixSeconds: 4_294_967_396) == 100) - #expect(XIDGenerator.timestamp(forUnixSeconds: -1) == 4_294_967_295) - #expect(XIDGenerator.timestamp(forUnixSeconds: 4_700_000_000) == 405_032_704) - #expect(XIDGenerator.timestamp(forUnixSeconds: 1_000_000_000) == 1_000_000_000) - } - - @Test("fractional seconds truncate toward zero like Go's whole-second Unix()") - func truncatesFraction() { - // Go's time.Unix() yields whole seconds; the Double from timeIntervalSince1970 carries a - // fraction that must be dropped (not rounded) to stay byte-identical. - #expect(XIDGenerator.timestamp(forUnixSeconds: 100.999) == 100) - } - - @Test("minting an ID at a post-2106 timestamp does not trap") - func mintingPost2106DoesNotTrap() { - // Regression: newRawID previously used UInt32(Double) which traps past 2106. Exercise the - // real code path and assert it still produces a valid 20-char xid. - let id = Identifier.new() - #expect(id.count == 20) - #expect(Identifier.isValid(id)) - } -} diff --git a/Tests/IdentifiersTests/XIDInteropTests.swift b/Tests/IdentifiersTests/XIDInteropTests.swift deleted file mode 100644 index 027bb2c..0000000 --- a/Tests/IdentifiersTests/XIDInteropTests.swift +++ /dev/null @@ -1,104 +0,0 @@ -import Foundation -import Testing - -@testable import Identifiers - -/// Cross-language interop (REPO-07): xid strings minted by Go's `github.com/rs/xid`, pinned alongside -/// their decoded components, that Swift's xid support must accept and re-encode identically. -/// -/// Swift exposes an xid *encoder* (``XIDGenerator/encode(_:)``) and a *validator* -/// (``Identifier/isValid(_:)``) but no decoder, so the interop check is: given the exact 12 raw bytes -/// Go decoded an id into, Swift's encoder must reproduce Go's 20-character string, and Swift must -/// validate that string. That is a genuine byte-level parity check against real rs/xid output. -/// -/// Two of these ids are the same values embedded in the JWT test token's `jti`/`sub` -/// (`Tests/AuthenticationTests/JWTTests.swift`), now proven to be genuine rs/xid values with the -/// components Go decodes from them. -/// -/// ## How the fixtures were produced (reproducible) -/// -/// Toolchain `go1.26.4 darwin/arm64`, `github.com/rs/xid v1.6.0`: -/// -/// ```go -/// id, _ := xid.FromString("crsa076tg3qdtmccq90g") -/// fmt.Printf("%x %d %x %d %d\n", -/// id.Bytes(), id.Time().Unix(), id.Machine(), id.Pid(), id.Counter()) -/// // The third id was freshly minted with xid.New(). -/// ``` -private struct XIDVector { - let string: String - let rawBytes: [UInt8] - let unixSeconds: Int64 - let machineHex: String - let pid: UInt16 - let counter: Int32 -} - -@Suite("xid Go→Swift interop (REPO-07)") -struct XIDInteropTests { - /// Fixtures decoded by rs/xid. `rawBytes` is `id.Bytes()`; the remaining fields are the decoded - /// timestamp/machine/pid/counter, pinned for documentation and the timestamp-prefix parity check. - private let vectors: [XIDVector] = [ - // From the JWT test token's `sub`. - XIDVector( - string: "crsa076tg3qdtmccq90g", - rawBytes: [0x66, 0xf8, 0xa0, 0x1c, 0xdd, 0x80, 0xf4, 0xde, 0xd9, 0x8c, 0xd2, 0x41], - unixSeconds: 1_727_569_948, machineHex: "dd80f4", pid: 57049, counter: 9_228_865), - // From the JWT test token's `jti`. - XIDVector( - string: "crsa076tg3qdtmccq910", - rawBytes: [0x66, 0xf8, 0xa0, 0x1c, 0xdd, 0x80, 0xf4, 0xde, 0xd9, 0x8c, 0xd2, 0x42], - unixSeconds: 1_727_569_948, machineHex: "dd80f4", pid: 57049, counter: 9_228_866), - // A freshly minted xid.New() from the same run. - XIDVector( - string: "d95k6ccn9qd0d05iurdg", - rawBytes: [0x6a, 0x4b, 0x43, 0x31, 0x97, 0x4e, 0x9a, 0x06, 0x80, 0xb2, 0xf6, 0xdb], - unixSeconds: 1_783_317_297, machineHex: "974e9a", pid: 1664, counter: 11_728_603), - ] - - @Test("Swift re-encodes Go's raw bytes to the exact Go string") - func encodeMatchesGo() { - for v in vectors { - #expect(XIDGenerator.encode(v.rawBytes) == v.string) - } - } - - @Test("Swift validates every Go-minted xid string") - func validatesGoStrings() { - for v in vectors { - #expect(Identifier.isValid(v.string)) - } - } - - @Test("the 4-byte big-endian timestamp prefix matches Go's decoded time") - func timestampPrefixMatchesGo() { - for v in vectors { - // xid stores uint32(Unix seconds) big-endian in bytes 0..3; Swift's timestamp encoder must - // reproduce those exact four leading bytes from the id's decoded Unix time. - let ts = XIDGenerator.timestamp(forUnixSeconds: Double(v.unixSeconds)) - let prefix: [UInt8] = [ - UInt8(truncatingIfNeeded: ts >> 24), - UInt8(truncatingIfNeeded: ts >> 16), - UInt8(truncatingIfNeeded: ts >> 8), - UInt8(truncatingIfNeeded: ts), - ] - #expect(Array(v.rawBytes.prefix(4)) == prefix) - } - } - - @Test("machine/pid/counter bytes sit where rs/xid places them") - func componentByteLayout() { - for v in vectors { - // machine = bytes 4..6, pid = bytes 7..8 (big-endian), counter = bytes 9..11 (big-endian). - let machine = v.rawBytes[4...6].map { String(format: "%02x", $0) }.joined() - #expect(machine == v.machineHex) - - let pid = UInt16(v.rawBytes[7]) << 8 | UInt16(v.rawBytes[8]) - #expect(pid == v.pid) - - let counter = - Int32(v.rawBytes[9]) << 16 | Int32(v.rawBytes[10]) << 8 | Int32(v.rawBytes[11]) - #expect(counter == v.counter) - } - } -}