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
28 changes: 20 additions & 8 deletions Sources/FeistelCipher/Cipher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,7 @@ public struct FeistelCipher: Sendable {
/// ```
public func encode(_ value: UInt64, withChecksum: Bool = true) -> String {
let data = encrypt(value)
return withChecksum
? CrockfordEncoder.encodeWithChecksum(data) : CrockfordEncoder.encode(data)
return CrockfordEncoder.encode(data, withChecksum: withChecksum)
}

/// Encrypts a value and encodes it as a Crockford Base32 string padded to a fixed length.
Expand Down Expand Up @@ -226,22 +225,35 @@ public struct FeistelCipher: Sendable {
///
/// - Parameter value: A Crockford Base32 string previously produced by ``encode(_:withChecksum:)``
/// or ``encode(_:length:withChecksum:)``.
/// - Parameters:
/// - value: A Crockford Base32 string previously produced by ``encode(_:withChecksum:)``
/// or ``encode(_:length:withChecksum:)``.
/// - withChecksum: When `true` (the default), the trailing check character is validated
/// before decoding. Set to `false` when decoding tokens that were encoded with
/// `withChecksum: false`.
/// - Returns: The original plain integer.
/// - Throws:
/// - ``FeistelCipherError/emptyToken`` if the token is empty after normalisation.
/// - ``FeistelCipherError/invalidCharacter(_:)`` if the token contains a character outside
/// the Crockford Base32 alphabet that could not be corrected automatically.
/// - ``FeistelCipherError/checksumMismatch`` if the trailing check character does not match.
/// - ``FeistelCipherError/checksumMismatch`` if `withChecksum` is `true` and the trailing
/// check character does not match.
///
/// ## Example
/// ```swift
/// let cipher = FeistelCipher(key: 722628)
/// try cipher.decode("99G7GB6QCKZBH0") // 1
/// try cipher.decode("0099G7GB6QCKZBH0") // 1 (padded token, same result)
/// try cipher.decode("99G7GB6QCKZBH9") // throws FeistelCipherError.checksumMismatch
///
/// // Default — checksum validated
/// try cipher.decode("99G7GB6QCKZBH0") // 1
///
/// // Token encoded without checksum — must decode the same way
/// let token = cipher.encode(1, withChecksum: false)
/// try cipher.decode(token, withChecksum: false) // 1
/// ```
public func decode(_ value: String) throws(FeistelCipherError) -> UInt64 {
let decoded = try CrockfordEncoder.decode(value)
public func decode(_ value: String, withChecksum: Bool = true) throws(FeistelCipherError)
-> UInt64
{
let decoded = try CrockfordEncoder.decode(value, withChecksum: withChecksum)
return decrypt(decoded)
}

Expand Down
51 changes: 39 additions & 12 deletions Sources/FeistelCipher/CrockfordEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ struct CrockfordEncoder {
/// - ``FeistelCipherError/emptyToken`` if the token is empty after normalisation.
/// - ``FeistelCipherError/invalidCharacter(_:)`` if the token contains a character outside
/// the Crockford Base32 alphabet that could not be corrected automatically.
/// - ``FeistelCipherError/checksumMismatch`` if the trailing check character does not match.
static func decode(_ input: String) throws(FeistelCipherError) -> UInt64 {
/// - ``FeistelCipherError/checksumMismatch`` if `withChecksum` is `true` and the trailing
/// check character does not match.
static func decode(_ input: String, withChecksum: Bool = true) throws(FeistelCipherError)
-> UInt64
{
let clean = input.uppercased()
.replacing("-", with: "")
.replacing("O", with: "0")
Expand All @@ -54,9 +57,17 @@ struct CrockfordEncoder {

guard !clean.isEmpty else { throw .emptyToken }

// 1. Separate the value from the check symbol (last character)
let valuePart = String(clean.dropLast())
let providedCheckChar = clean.last!
// 1. Separate the value from the optional check symbol
let valuePart: String
let providedCheckChar: Character?

if withChecksum {
valuePart = String(clean.dropLast())
providedCheckChar = clean.last
} else {
valuePart = clean
providedCheckChar = nil
}

// 2. Decode the value part, throwing on any unrecognised character
var numericValue: UInt64 = 0
Expand All @@ -68,20 +79,36 @@ struct CrockfordEncoder {
numericValue * 32 + UInt64(alphabet.distance(from: alphabet.startIndex, to: index))
}

// 3. Validate the check symbol
let expectedCheckIndex = Int(numericValue % 37)
let expectedCheckChar = checkSymbols[
checkSymbols.index(checkSymbols.startIndex, offsetBy: expectedCheckIndex)]

guard providedCheckChar == expectedCheckChar else {
throw .checksumMismatch
// 3. Validate the check symbol (only when withChecksum is true)
if let checkChar = providedCheckChar {
let expectedCheckIndex = Int(numericValue % 37)
let expectedCheckChar = checkSymbols[
checkSymbols.index(checkSymbols.startIndex, offsetBy: expectedCheckIndex)]
guard checkChar == expectedCheckChar else {
throw .checksumMismatch
}
}

return numericValue
}

// MARK: - Encoding

/// Encodes a `UInt64` value as a Crockford Base32 string, optionally appending a
/// modulo-37 check character.
///
/// This is the unified entry point that mirrors ``decode(_:withChecksum:)``.
/// Pass `withChecksum: true` to append a check character, or `false` for a raw
/// Base32 string with no trailing check symbol.
///
/// - Parameters:
/// - value: The `UInt64` value to encode.
/// - withChecksum: When `true`, a modulo-37 check character is appended.
/// - Returns: A Crockford Base32 string.
static func encode(_ value: UInt64, withChecksum: Bool) -> String {
withChecksum ? encodeWithChecksum(value) : encode(value)
}

/// Encodes a `UInt64` value as a Crockford Base32 string and appends a modulo-37 check
/// character to the end.
///
Expand Down
21 changes: 21 additions & 0 deletions Tests/FeistelCipherTests/CrockfordEncoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@ struct CrockfordEncoderTests {
}
}

@Test func decodeWithoutChecksum() async throws {
// encode (no checksum) → decode (no checksum) must round-trip
let value: UInt64 = 1_234_567_890
let encoded = CrockfordEncoder.encode(value)
let decoded = try CrockfordEncoder.decode(encoded, withChecksum: false)
#expect(decoded == value)
}

@Test func decodeWithChecksumFalseIgnoresLastChar() async throws {
// When withChecksum is false every character is treated as a value digit —
// passing a token that HAS a checksum would decode to a different (larger) number
let value: UInt64 = 1_234_567_890
let withCheck = CrockfordEncoder.encodeWithChecksum(value) // e.g. "14SC0PJV"
let withoutCheck = CrockfordEncoder.encode(value) // e.g. "14SC0PJ"
let decodedFull = try CrockfordEncoder.decode(withoutCheck, withChecksum: false)
#expect(decodedFull == value)
// The checksum-bearing token decoded without checksum would be a different value
let decodedFull2 = try CrockfordEncoder.decode(withCheck, withChecksum: false)
#expect(decodedFull2 != value)
}

@Test func formatForCopying() async throws {
let encoded = "1234567890"
let formatted = CrockfordEncoder.formatForCopying(encoded)
Expand Down
29 changes: 29 additions & 0 deletions Tests/FeistelCipherTests/FeistelCipherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,35 @@ struct FeistelCipherTests {
#expect(encoded == "9G0X9D4P5QCWW")
}

@Test func testDecodeWithoutChecksumRoundTrip() async throws {
// encode(withChecksum: false) + decode(withChecksum: false) must return the original value
for id: UInt64 in [1, 42, 1_000, 1_234_567] {
let token = cipher.encode(id, withChecksum: false)
let decoded = try cipher.decode(token, withChecksum: false)
#expect(decoded == id)
}
}

@Test func testDecodeWithChecksumTrueFailsOnChecksumlessToken() async throws {
// A token encoded without checksum must NOT decode successfully with checksum: true,
// because its last value character is consumed as (almost certainly wrong) check char
let token = cipher.encode(1, withChecksum: false)
#expect(throws: FeistelCipherError.checksumMismatch) {
try cipher.decode(token, withChecksum: true)
}
}

@Test func testBitWidth50DecodeWithoutChecksumRoundTrip() async throws {
// The canonical 10-char use-case: bitWidth 50, no checksum, fixed length
let cipher50 = FeistelCipher(key: 722628, bitWidth: 50)
for id: UInt64 in [1, 42, 1_000, 1_000_000] {
let token = cipher50.encode(id, length: 10, withChecksum: false)
#expect(token.count == 10)
let decoded = try cipher50.decode(token, withChecksum: false)
#expect(decoded == id)
}
}

// MARK: - Fixed-length encode overload

@Test func testEncodeWithLengthPadsWithLeadingZeros() async throws {
Expand Down
Loading