Skip to content

Commit 6039923

Browse files
authored
feat(intid): add _check variants for Crockford Base32 and Base58Check (#76)
* feat(intid): add _check variants for Crockford Base32 and Base58Check * test(intid): scope Base58Check round-trips to Erlang target
1 parent 7e9a056 commit 6039923

3 files changed

Lines changed: 222 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
88
## [Unreleased]
99

1010
### Added
11+
- **`yabase/intid` gains checksum-bearing `_check` variants** for the
12+
Crockford Base32 and Base58Check codecs, removing the
13+
`Int → BitArray → encode_check / decode_check → BitArray → Int`
14+
dance every caller previously reimplemented. New helpers:
15+
`encode_int_base32_crockford_check`,
16+
`decode_int_base32_crockford_check[_bounded]`,
17+
`encode_int_base58check`,
18+
`decode_int_base58check[_bounded]`. The decoders surface
19+
`Error(InvalidChecksum)` on a mistyped input — the whole reason
20+
callers reach for the checksummed variant. Base58Check is fixed at
21+
version byte `0` (Bitcoin mainnet P2PKH); callers who need a
22+
different version still drop to `yabase/base58check` directly. (#73)
1123
- **`yabase/intid` and the top-level `yabase` module now re-export
1224
`CodecError`** as a public type alias, so callers who only
1325
`import yabase/intid` (or only `import yabase`) can type-annotate a

src/yabase/intid.gleam

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import yabase/base32/rfc4648 as base32_rfc4648
5454
import yabase/base36
5555
import yabase/base58/bitcoin as base58_bitcoin
5656
import yabase/base58/flickr as base58_flickr
57+
import yabase/base58check
5758
import yabase/base62
5859
import yabase/core/error.{
5960
type CodecError as CoreCodecError, InvalidLength, Overflow,
@@ -193,6 +194,84 @@ pub fn decode_int_base58_flickr_bounded(
193194
bound_check(value, max)
194195
}
195196

197+
/// Encode a non-negative `Int` as a Crockford Base32 string with a
198+
/// trailing checksum symbol (Douglas Crockford's optional check
199+
/// character).
200+
///
201+
/// Issue #73: same shape as `encode_int_base32_crockford` but with
202+
/// the typo-resistance guard the underlying codec already supports.
203+
/// Use the matching `decode_int_base32_crockford_check` to recover
204+
/// the integer; the decoder verifies the symbol and returns
205+
/// `Error(InvalidChecksum)` if the input was mistyped.
206+
pub fn encode_int_base32_crockford_check(value: Int) -> String {
207+
base32_crockford.encode_check(int_to_bytes_be(value))
208+
}
209+
210+
/// Decode a checksummed Crockford Base32 string back to an `Int`,
211+
/// verifying the trailing check symbol.
212+
pub fn decode_int_base32_crockford_check(
213+
input: String,
214+
) -> Result(Int, CodecError) {
215+
use input <- result.try(reject_empty(input))
216+
base32_crockford.decode_check(input)
217+
|> result.map(bytes_to_int)
218+
}
219+
220+
/// Decode a checksummed Crockford Base32 string back to an `Int`,
221+
/// rejecting values greater than `max` with `Error(Overflow)`.
222+
pub fn decode_int_base32_crockford_check_bounded(
223+
input input: String,
224+
max max: Int,
225+
) -> Result(Int, CodecError) {
226+
use value <- result.try(decode_int_base32_crockford_check(input))
227+
bound_check(value, max)
228+
}
229+
230+
/// Encode a non-negative `Int` as a Base58Check string (Bitcoin's
231+
/// double-SHA-256 checksum format).
232+
///
233+
/// Issue #73: this is the int-typed counterpart of
234+
/// `yabase/base58check.encode/2`. Version is fixed at `0`
235+
/// (Bitcoin mainnet P2PKH) — callers that need a different version
236+
/// should reach for `yabase/base58check.encode/2` directly with their
237+
/// own `BitArray` payload.
238+
///
239+
/// Returns the canonical Base58Check string. The underlying
240+
/// `yabase/base58check.encode` only errors on out-of-range version
241+
/// bytes (this helper hard-codes a valid one), so this signature
242+
/// does not surface a `Result`.
243+
pub fn encode_int_base58check(value: Int) -> String {
244+
base58check.encode(0, int_to_bytes_be(value))
245+
|> result.unwrap("")
246+
}
247+
248+
/// Decode a Base58Check string back to an `Int`, verifying the
249+
/// 4-byte SHA-256 checksum.
250+
///
251+
/// Issue #73: returns the *payload* as an `Int`, ignoring the version
252+
/// byte (which `encode_int_base58check` always sets to `0`).
253+
/// Callers that need to inspect the version byte should reach for
254+
/// `yabase/base58check.decode/1` directly.
255+
pub fn decode_int_base58check(input: String) -> Result(Int, CodecError) {
256+
use input <- result.try(reject_empty(input))
257+
case base58check.decode(input) {
258+
Error(e) -> Error(e)
259+
Ok(decoded) -> Ok(bytes_to_int(decoded.payload))
260+
}
261+
}
262+
263+
/// Decode a Base58Check string back to an `Int`, rejecting payload
264+
/// values greater than `max` with `Error(Overflow)`. The checksum is
265+
/// verified before the bounds check, so a corrupted input fails as
266+
/// `InvalidChecksum` rather than `Overflow`.
267+
pub fn decode_int_base58check_bounded(
268+
input input: String,
269+
max max: Int,
270+
) -> Result(Int, CodecError) {
271+
use value <- result.try(decode_int_base58check(input))
272+
bound_check(value, max)
273+
}
274+
196275
/// Encode a non-negative `Int` as a Base62 string.
197276
pub fn encode_int_base62(value: Int) -> String {
198277
base62.encode(int_to_bytes_be(value))

test/intid_test.gleam

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import yabase/core/error.{InvalidCharacter, InvalidLength, Overflow}
1+
import gleam/string
2+
import yabase/core/error.{
3+
InvalidCharacter, InvalidChecksum, InvalidLength, Overflow,
4+
}
25
import yabase/intid
36

47
// === Base32 (RFC 4648) ===
@@ -359,6 +362,133 @@ pub fn decode_int_base32_crockford_bounded_above_cap_test() -> Nil {
359362
== Error(Overflow)
360363
}
361364

365+
// === Issue #73: Crockford Base32 with check symbol ===
366+
367+
pub fn encode_int_base32_crockford_check_zero_test() -> Nil {
368+
// 0 encoded as Crockford "0" then check digit for 0 mod 37 == "0".
369+
assert intid.encode_int_base32_crockford_check(0) == "00"
370+
}
371+
372+
pub fn decode_int_base32_crockford_check_roundtrip_test() -> Nil {
373+
let encoded = intid.encode_int_base32_crockford_check(987_654)
374+
assert intid.decode_int_base32_crockford_check(encoded) == Ok(987_654)
375+
}
376+
377+
pub fn decode_int_base32_crockford_check_empty_test() -> Nil {
378+
assert intid.decode_int_base32_crockford_check("") == Error(InvalidLength(0))
379+
}
380+
381+
pub fn decode_int_base32_crockford_check_detects_typo_test() -> Nil {
382+
// Take a valid checksummed encoding and mutate one body character.
383+
// The decoder must reject the typo via InvalidChecksum, which is the
384+
// whole reason callers reach for the `_check` variant.
385+
let encoded = intid.encode_int_base32_crockford_check(123_456)
386+
let body = string_drop_last(encoded)
387+
let check = string_take_last(encoded)
388+
let mutated = mutate_first_body_char(body) <> check
389+
assert intid.decode_int_base32_crockford_check(mutated)
390+
== Error(InvalidChecksum)
391+
}
392+
393+
pub fn decode_int_base32_crockford_check_bounded_within_test() -> Nil {
394+
let encoded = intid.encode_int_base32_crockford_check(42)
395+
assert intid.decode_int_base32_crockford_check_bounded(
396+
input: encoded,
397+
max: intid.int64_max,
398+
)
399+
== Ok(42)
400+
}
401+
402+
@target(erlang)
403+
pub fn decode_int_base32_crockford_check_bounded_above_cap_test() -> Nil {
404+
let encoded = intid.encode_int_base32_crockford_check(intid.int64_max + 1)
405+
assert intid.decode_int_base32_crockford_check_bounded(
406+
input: encoded,
407+
max: intid.int64_max,
408+
)
409+
== Error(Overflow)
410+
}
411+
412+
// === Issue #73: Base58Check ===
413+
//
414+
// The Base58Check round-trip tests below are `@target(erlang)` because
415+
// `yabase/base58check`'s round-trip is itself only exercised on Erlang
416+
// in this repo (see `test/base58check_test.gleam`); the JS-side
417+
// SHA-256 divergence is pre-existing scope and tracked separately. The
418+
// `decode_int_base58check_empty_test` runs on both targets because
419+
// empty-input rejection short-circuits before any hashing.
420+
421+
@target(erlang)
422+
pub fn encode_int_base58check_zero_roundtrips_test() -> Nil {
423+
let encoded = intid.encode_int_base58check(0)
424+
assert intid.decode_int_base58check(encoded) == Ok(0)
425+
}
426+
427+
@target(erlang)
428+
pub fn decode_int_base58check_roundtrip_test() -> Nil {
429+
let encoded = intid.encode_int_base58check(9_999_999_999)
430+
assert intid.decode_int_base58check(encoded) == Ok(9_999_999_999)
431+
}
432+
433+
pub fn decode_int_base58check_empty_test() -> Nil {
434+
assert intid.decode_int_base58check("") == Error(InvalidLength(0))
435+
}
436+
437+
@target(erlang)
438+
pub fn decode_int_base58check_detects_typo_test() -> Nil {
439+
// Mutate the first checksum-bearing position. Base58Check's 4-byte
440+
// SHA-256 suffix means this *must* fail — that is the property the
441+
// helper exists to guarantee for callers.
442+
let encoded = intid.encode_int_base58check(424_242)
443+
let mutated = mutate_first_body_char(encoded)
444+
assert intid.decode_int_base58check(mutated) == Error(InvalidChecksum)
445+
}
446+
447+
@target(erlang)
448+
pub fn decode_int_base58check_bounded_within_test() -> Nil {
449+
let encoded = intid.encode_int_base58check(1234)
450+
assert intid.decode_int_base58check_bounded(
451+
input: encoded,
452+
max: intid.int64_max,
453+
)
454+
== Ok(1234)
455+
}
456+
457+
@target(erlang)
458+
pub fn decode_int_base58check_bounded_above_cap_test() -> Nil {
459+
let encoded = intid.encode_int_base58check(intid.int64_max + 1)
460+
assert intid.decode_int_base58check_bounded(
461+
input: encoded,
462+
max: intid.int64_max,
463+
)
464+
== Error(Overflow)
465+
}
466+
467+
// Helpers for the typo-detection tests above. Kept in the test module so
468+
// the production API stays focused on Int↔string.
469+
fn string_drop_last(s: String) -> String {
470+
let len = string.length(s)
471+
string.slice(s, 0, len - 1)
472+
}
473+
474+
fn string_take_last(s: String) -> String {
475+
let len = string.length(s)
476+
string.slice(s, len - 1, 1)
477+
}
478+
479+
fn mutate_first_body_char(s: String) -> String {
480+
// Flip the first character to a different valid Crockford / Base58
481+
// character. Both alphabets contain "1" and "2", so swapping between
482+
// them produces a syntactically valid but checksum-invalid string.
483+
let head = string.slice(s, 0, 1)
484+
let tail = string.slice(s, 1, string.length(s) - 1)
485+
let replacement = case head {
486+
"1" -> "2"
487+
_ -> "1"
488+
}
489+
replacement <> tail
490+
}
491+
362492
// Issue #74: a wrapper that only `import yabase/intid` must be able to
363493
// type-annotate the error returned by `decode_int_*` without reaching
364494
// into `yabase/core/error`. This test pins down that the re-exported

0 commit comments

Comments
 (0)