OCaml library for Crockford's Base32

initial import

+696
+2
.gitignore
··· 1 + _build 2 + .claude
+22
LICENSE.md
··· 1 + MIT License 2 + 3 + Copyright (c) 2024-2025 Front Matter 4 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org> 5 + 6 + Permission is hereby granted, free of charge, to any person obtaining a copy 7 + of this software and associated documentation files (the "Software"), to deal 8 + in the Software without restriction, including without limitation the rights 9 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 + copies of the Software, and to permit persons to whom the Software is 11 + furnished to do so, subject to the following conditions: 12 + 13 + The above copyright notice and this permission notice shall be included in all 14 + copies or substantial portions of the Software. 15 + 16 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 + SOFTWARE.
+95
README.md
··· 1 + # Crockford Base32 Encoding for OCaml 2 + 3 + An OCaml implementation of [Douglas Crockford's 4 + Base32](https://www.crockford.com/base32.html) encoding with ISO 7064 checksum 5 + support. Provides encoding and decoding of int64 values to URI-friendly base32 6 + strings, with optional checksum validation, padding, splitting, and random ID 7 + generation. Ported from <https://github.com/front-matter/commonmeta>. 8 + 9 + ## Installation 10 + 11 + ```bash 12 + opam install crockford 13 + ``` 14 + 15 + Or add to your `dune-project`: 16 + 17 + ```scheme 18 + (package 19 + (depends 20 + (crockford))) 21 + ``` 22 + 23 + ## Usage 24 + 25 + ### Basic Encoding and Decoding 26 + 27 + ```ocaml 28 + (* Encode a number *) 29 + let encoded = Crockford.encode 1234567890L in 30 + (* "16jkpa2" *) 31 + 32 + (* Decode back to number *) 33 + let decoded = Crockford.decode "16jkpa2" in 34 + (* 1234567890L *) 35 + ``` 36 + 37 + ### With Checksum 38 + 39 + ```ocaml 40 + (* Encode with checksum *) 41 + let encoded = Crockford.encode ~checksum:true 1234567890L in 42 + (* "16jkpa2d" *) 43 + 44 + (* Decode and validate checksum *) 45 + let decoded = Crockford.decode ~checksum:true "16jkpa2d" in 46 + (* 1234567890L - or raises Checksum_mismatch if invalid *) 47 + ``` 48 + 49 + ### Formatted Output 50 + 51 + ```ocaml 52 + (* Split with dashes for readability *) 53 + let encoded = Crockford.encode ~split_every:4 1234567890L in 54 + (* "16jk-pa2" *) 55 + 56 + (* With minimum length (zero-padded) *) 57 + let encoded = Crockford.encode ~min_length:10 1234L in 58 + (* "000000016j" *) 59 + ``` 60 + 61 + ### Random ID Generation 62 + 63 + ```ocaml 64 + Random.self_init (); 65 + 66 + (* Generate random IDs *) 67 + let id = Crockford.generate ~length:8 ~checksum:true () in 68 + (* e.g., "a3x7m9q5" *) 69 + 70 + (* Generate formatted IDs *) 71 + let id = Crockford.generate ~length:16 ~split_every:4 ~checksum:true () in 72 + (* e.g., "7n2q-8xkm-5pwt-3hr9" *) 73 + ``` 74 + 75 + ### Normalization 76 + 77 + ```ocaml 78 + (* Handles common character confusions *) 79 + let decoded = Crockford.decode "ILO" in (* Treated as "110" *) 80 + let decoded = Crockford.decode "16-JK-PA" in (* Dashes ignored *) 81 + ``` 82 + 83 + ## License 84 + 85 + MIT License 86 + 87 + ## Author 88 + 89 + Anil Madhavapeddy <anil@recoil.org> 90 + (based on code from https://github.com/front-matter/commonmeta) 91 + 92 + ## Links 93 + 94 + - [Homepage](https://tangled.org/@anil.recoil.org/ocaml-crockford) 95 + - [Crockford Base32 Specification](https://www.crockford.com/base32.html)
+4
bin/dune
··· 1 + (executable 2 + (name roguedoi) 3 + (public_name roguedoi) 4 + (libraries crockford cmdliner))
+32
bin/roguedoi.ml
··· 1 + (* roguedoi.ml - Generate random DOI identifiers with Crockford base32 encoding *) 2 + 3 + let generate_doi prefix length split = 4 + Random.self_init (); 5 + let suffix = Crockford.generate ~length ~split_every:split ~checksum:true () in 6 + Printf.printf "https://doi.org/%s/%s\n%!" prefix suffix 7 + 8 + let () = 9 + let open Cmdliner in 10 + 11 + let prefix = 12 + let doc = "DOI prefix to use (e.g., 10.59350)" in 13 + Arg.(value & opt string "10.59350" & info ["p"; "prefix"] ~docv:"PREFIX" ~doc) 14 + in 15 + 16 + let length = 17 + let doc = "Total length of the generated suffix (including checksum)" in 18 + Arg.(value & opt int 10 & info ["l"; "length"] ~docv:"LENGTH" ~doc) 19 + in 20 + 21 + let split = 22 + let doc = "Split the suffix every N characters with hyphens (0 = no splitting)" in 23 + Arg.(value & opt int 5 & info ["s"; "split"] ~docv:"SPLIT" ~doc) 24 + in 25 + 26 + let generate_cmd = 27 + let doc = "Generate a random DOI with Crockford base32 encoding" in 28 + let info = Cmd.info "roguedoi" ~version:"0.1.0" ~doc in 29 + Cmd.v info Term.(const generate_doi $ prefix $ length $ split) 30 + in 31 + 32 + exit (Cmd.eval generate_cmd)
+32
crockford.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Crockford Base32 encoding for OCaml" 4 + description: 5 + "An OCaml implementation of Douglas Crockford's Base32 encoding with ISO 7064 checksum support. Provides encoding and decoding of int64 values to URI-friendly base32 strings, with optional checksum validation, padding, splitting, and random ID generation." 6 + maintainer: ["Anil Madhavapeddy <anil@recoil.org>"] 7 + authors: ["Anil Madhavapeddy"] 8 + license: "ISC" 9 + homepage: "https://tangled.org/@anil.recoil.org/ocaml-crockford" 10 + bug-reports: "https://tangled.org/@anil.recoil.org/ocaml-crockford/issues" 11 + depends: [ 12 + "dune" {>= "3.20"} 13 + "ocaml" {>= "4.14.1"} 14 + "odoc" {with-doc} 15 + "alcotest" {with-test & >= "1.9.0"} 16 + "cmdliner" {>= "1.1.0"} 17 + ] 18 + build: [ 19 + ["dune" "subst"] {dev} 20 + [ 21 + "dune" 22 + "build" 23 + "-p" 24 + name 25 + "-j" 26 + jobs 27 + "@install" 28 + "@runtest" {with-test} 29 + "@doc" {with-doc} 30 + ] 31 + ] 32 + x-maintenance-intent: ["(latest)"]
+26
dune-project
··· 1 + (lang dune 3.20) 2 + 3 + (name crockford) 4 + 5 + (generate_opam_files true) 6 + 7 + (license ISC) 8 + (authors "Anil Madhavapeddy") 9 + (homepage "https://tangled.org/@anil.recoil.org/ocaml-crockford") 10 + (maintainers "Anil Madhavapeddy <anil@recoil.org>") 11 + (bug_reports "https://tangled.org/@anil.recoil.org/ocaml-crockford/issues") 12 + (maintenance_intent "(latest)") 13 + 14 + (package 15 + (name crockford) 16 + (synopsis "Crockford Base32 encoding for OCaml") 17 + (description 18 + "An OCaml implementation of Douglas Crockford's Base32 encoding with \ 19 + ISO 7064 checksum support. Provides encoding and decoding of int64 values \ 20 + to URI-friendly base32 strings, with optional checksum validation, padding, \ 21 + splitting, and random ID generation.") 22 + (depends 23 + (ocaml (>= 4.14.1)) 24 + (odoc :with-doc) 25 + (alcotest (and :with-test (>= 1.9.0))) 26 + (cmdliner (>= 1.1.0))))
+177
lib/crockford.ml
··· 1 + type invalid_length = { length: int; message: string } 2 + type invalid_character = { char: char; message: string } 3 + type invalid_checksum = { checksum: string; message: string } 4 + type checksum_mismatch = { expected: int64; got: int64; identifier: string } 5 + 6 + type decode_error = 7 + | Invalid_length of invalid_length 8 + | Invalid_character of invalid_character 9 + | Invalid_checksum of invalid_checksum 10 + | Checksum_mismatch of checksum_mismatch 11 + 12 + exception Decode_error of decode_error 13 + 14 + let pp_invalid_length fmt { length; message } = 15 + Format.fprintf fmt "Invalid_length: length=%d, %s" length message 16 + 17 + let pp_invalid_character fmt { char; message } = 18 + Format.fprintf fmt "Invalid_character: char='%c', %s" char message 19 + 20 + let pp_invalid_checksum fmt { checksum; message } = 21 + Format.fprintf fmt "Invalid_checksum: checksum=%s, %s" checksum message 22 + 23 + let pp_checksum_mismatch fmt { expected; got; identifier } = 24 + Format.fprintf fmt "Checksum_mismatch: expected=%Ld, got=%Ld, identifier=%s" 25 + expected got identifier 26 + 27 + let pp_decode_error fmt = function 28 + | Invalid_length e -> pp_invalid_length fmt e 29 + | Invalid_character e -> pp_invalid_character fmt e 30 + | Invalid_checksum e -> pp_invalid_checksum fmt e 31 + | Checksum_mismatch e -> pp_checksum_mismatch fmt e 32 + 33 + let encoding_chars = "0123456789abcdefghjkmnpqrstvwxyz" 34 + 35 + let generate_checksum number = 36 + Int64.(sub (add (sub 97L (rem (mul 100L number) 97L)) 1L) 0L) 37 + 38 + let validate number ~checksum = 39 + Int64.equal checksum (generate_checksum number) 40 + 41 + let normalize str = 42 + let len = String.length str in 43 + let buf = Bytes.create len in 44 + let rec process i j = 45 + if i >= len then Bytes.sub_string buf 0 j 46 + else 47 + let c = String.get str i in 48 + let c_lower = Char.lowercase_ascii c in 49 + match c_lower with 50 + | '-' -> process (i + 1) j 51 + | 'i' | 'l' -> Bytes.set buf j '1'; process (i + 1) (j + 1) 52 + | 'o' -> Bytes.set buf j '0'; process (i + 1) (j + 1) 53 + | _ -> Bytes.set buf j c_lower; process (i + 1) (j + 1) 54 + in 55 + process 0 0 56 + 57 + let encode ?(split_every=0) ?(min_length=0) ?(checksum=false) number = 58 + let original_number = number in 59 + 60 + (* Build base32 encoding *) 61 + let rec build_encoding acc n = 62 + if Int64.equal n 0L then acc 63 + else 64 + let remainder = Int64.to_int (Int64.rem n 32L) in 65 + let n' = Int64.div n 32L in 66 + build_encoding (encoding_chars.[remainder] :: acc) n' 67 + in 68 + 69 + let encoded_list = 70 + if Int64.equal number 0L then ['0'] 71 + else build_encoding [] number 72 + in 73 + 74 + let encoded_str = String.concat "" (List.map (String.make 1) encoded_list) in 75 + 76 + (* Adjust min_length if checksum is enabled *) 77 + let adjusted_length = 78 + if checksum && min_length > 2 then min_length - 2 79 + else min_length 80 + in 81 + 82 + (* Pad with zeros if needed *) 83 + let padded = 84 + if adjusted_length > 0 && String.length encoded_str < adjusted_length then 85 + String.make (adjusted_length - String.length encoded_str) '0' ^ encoded_str 86 + else 87 + encoded_str 88 + in 89 + 90 + (* Add checksum *) 91 + let with_checksum = 92 + if checksum then 93 + let cs = generate_checksum original_number in 94 + padded ^ Printf.sprintf "%02Ld" cs 95 + else 96 + padded 97 + in 98 + 99 + (* Split if requested *) 100 + if split_every > 0 then 101 + let len = String.length with_checksum in 102 + let num_splits = (len + split_every - 1) / split_every in 103 + let splits = Array.make num_splits "" in 104 + for i = 0 to num_splits - 1 do 105 + let start = i * split_every in 106 + let chunk_len = min split_every (len - start) in 107 + splits.(i) <- String.sub with_checksum start chunk_len 108 + done; 109 + String.concat "-" (Array.to_list splits) 110 + else 111 + with_checksum 112 + 113 + let decode ?(checksum=false) str = 114 + let encoded = normalize str in 115 + 116 + let (encoded_part, checksum_value) = 117 + if checksum then begin 118 + if String.length encoded < 3 then 119 + raise (Decode_error (Invalid_checksum { 120 + checksum = encoded; 121 + message = "encoded string too short for checksum" 122 + })); 123 + 124 + let cs_str = String.sub encoded (String.length encoded - 2) 2 in 125 + let cs = 126 + try Int64.of_string cs_str 127 + with Failure _ -> 128 + raise (Decode_error (Invalid_checksum { 129 + checksum = cs_str; 130 + message = "invalid checksum format" 131 + })) 132 + in 133 + (String.sub encoded 0 (String.length encoded - 2), Some cs) 134 + end else 135 + (encoded, None) 136 + in 137 + 138 + (* Decode base32 *) 139 + let number = ref 0L in 140 + String.iter (fun c -> 141 + number := Int64.mul !number 32L; 142 + match String.index_opt encoding_chars c with 143 + | Some pos -> number := Int64.add !number (Int64.of_int pos) 144 + | None -> 145 + raise (Decode_error (Invalid_character { 146 + char = c; 147 + message = Printf.sprintf "character '%c' not in base32 alphabet" c 148 + })) 149 + ) encoded_part; 150 + 151 + (* Validate checksum if present *) 152 + (match checksum_value with 153 + | Some cs -> 154 + if not (validate !number ~checksum:cs) then 155 + raise (Decode_error (Checksum_mismatch { 156 + expected = generate_checksum !number; 157 + got = cs; 158 + identifier = str 159 + })) 160 + | None -> ()); 161 + 162 + !number 163 + 164 + let generate ~length ?(split_every=0) ?(checksum=false) () = 165 + if checksum && length < 3 then 166 + raise (Decode_error (Invalid_length { 167 + length; 168 + message = "length must be >= 3 if checksum is enabled" 169 + })); 170 + 171 + let adjusted_length = if checksum then length - 2 else length in 172 + 173 + (* Generate random number between 0 and 32^length *) 174 + let max_val = 32.0 ** float_of_int adjusted_length in 175 + let random_num = Int64.of_float (Random.float max_val) in 176 + 177 + encode ~split_every ~min_length:adjusted_length ~checksum random_num
+89
lib/crockford.mli
··· 1 + (** Crockford Base32 encoding for OCaml *) 2 + 3 + (** {1 Error Types} *) 4 + 5 + type invalid_length = { length: int; message: string } 6 + (** Error for invalid length parameters *) 7 + 8 + type invalid_character = { char: char; message: string } 9 + (** Error for invalid characters during decoding *) 10 + 11 + type invalid_checksum = { checksum: string; message: string } 12 + (** Error for invalid checksum format *) 13 + 14 + type checksum_mismatch = { expected: int64; got: int64; identifier: string } 15 + (** Error for checksum validation failures *) 16 + 17 + type decode_error = 18 + | Invalid_length of invalid_length 19 + | Invalid_character of invalid_character 20 + | Invalid_checksum of invalid_checksum 21 + | Checksum_mismatch of checksum_mismatch 22 + (** Union of all possible decode errors *) 23 + 24 + exception Decode_error of decode_error 25 + (** Main exception raised for all decoding errors *) 26 + 27 + val pp_invalid_length : Format.formatter -> invalid_length -> unit 28 + (** Pretty-print an invalid_length error *) 29 + 30 + val pp_invalid_character : Format.formatter -> invalid_character -> unit 31 + (** Pretty-print an invalid_character error *) 32 + 33 + val pp_invalid_checksum : Format.formatter -> invalid_checksum -> unit 34 + (** Pretty-print an invalid_checksum error *) 35 + 36 + val pp_checksum_mismatch : Format.formatter -> checksum_mismatch -> unit 37 + (** Pretty-print a checksum_mismatch error *) 38 + 39 + val pp_decode_error : Format.formatter -> decode_error -> unit 40 + (** Pretty-print a decode_error *) 41 + 42 + (** {1 Constants} *) 43 + 44 + val encoding_chars : string 45 + (** The Crockford base32 encoding alphabet (excludes i, l, o, u) *) 46 + 47 + (** {1 Encoding and Decoding} *) 48 + 49 + val encode : 50 + ?split_every:int -> 51 + ?min_length:int -> 52 + ?checksum:bool -> 53 + int64 -> string 54 + (** [encode ?split_every ?min_length ?checksum n] encodes an int64 to a Crockford base32 string. 55 + @param split_every Split the output with '-' every n characters (default: no splitting) 56 + @param min_length Pad with zeros to this minimum length (default: no padding) 57 + @param checksum Append ISO 7064 checksum as 2 digits (default: false) *) 58 + 59 + val decode : ?checksum:bool -> string -> int64 60 + (** [decode ?checksum str] decodes a Crockford base32 string to int64. 61 + @param checksum Expect and validate ISO 7064 checksum (default: false) 62 + @raise Decode_error if decoding fails (invalid characters, invalid checksum format, or checksum mismatch) *) 63 + 64 + (** {1 ID Generation} *) 65 + 66 + val generate : 67 + length:int -> 68 + ?split_every:int -> 69 + ?checksum:bool -> 70 + unit -> string 71 + (** [generate ~length ?split_every ?checksum ()] generates a random Crockford base32 string. 72 + @param length The length of the generated string (excluding checksum) 73 + @param split_every Split the output with '-' every n characters (default: no splitting) 74 + @param checksum Append ISO 7064 checksum as 2 digits (default: false) 75 + @raise Decode_error if checksum is true and length < 3 76 + 77 + Note: Caller must initialize Random module with {!Random.self_init} before use *) 78 + 79 + (** {1 Utility Functions} *) 80 + 81 + val normalize : string -> string 82 + (** [normalize str] normalizes a string for decoding by converting to lowercase, 83 + removing dashes, and mapping confusable characters (i→1, l→1, o→0) *) 84 + 85 + val validate : int64 -> checksum:int64 -> bool 86 + (** [validate n ~checksum] validates that a checksum matches the number *) 87 + 88 + val generate_checksum : int64 -> int64 89 + (** [generate_checksum n] generates an ISO 7064 (mod 97-10) checksum for a number *)
+3
lib/dune
··· 1 + (library 2 + (name crockford) 3 + (public_name crockford))
+3
test/dune
··· 1 + (test 2 + (name test_crockford) 3 + (libraries crockford alcotest))
+211
test/test_crockford.ml
··· 1 + (* Test suite using Alcotest - ported from crockford_test.go *) 2 + 3 + (* Encode tests *) 4 + let test_encode () = 5 + let test_cases = [ 6 + (0L, 0, 0, false, "0"); 7 + (1234L, 0, 0, false, "16j"); 8 + (1234L, 2, 0, false, "16-j"); 9 + (1234L, 2, 4, false, "01-6j"); 10 + (538751765283013L, 5, 10, false, "f9zqn-sf065"); 11 + (736381604818L, 5, 10, true, "ndsw7-4yj20"); 12 + (258706475165200172L, 7, 14, true, "75rw5cg-n1bsc64"); 13 + (161006169L, 4, 8, true, "4shg-js75"); 14 + ] in 15 + List.iter (fun (input, split_every, length, checksum, expected) -> 16 + let result = Crockford.encode ~split_every ~min_length:length ~checksum input in 17 + let test_name = Printf.sprintf "encode %Ld (split=%d, len=%d, checksum=%b)" input split_every length checksum in 18 + Alcotest.(check string) test_name expected result 19 + ) test_cases 20 + 21 + (* Generate tests *) 22 + let test_generate () = 23 + Random.self_init (); 24 + 25 + let test_cases = [ 26 + (4, 0, false); 27 + (10, 5, false); 28 + (10, 5, true); 29 + ] in 30 + List.iter (fun (length, split_every, checksum) -> 31 + let result = Crockford.generate ~length ~split_every ~checksum () in 32 + let expected_length = 33 + if split_every > 0 then 34 + length + (length / split_every) - 1 35 + else 36 + length 37 + in 38 + let test_name = Printf.sprintf "generate length=%d split=%d checksum=%b" length split_every checksum in 39 + Alcotest.(check int) test_name expected_length (String.length result) 40 + ) test_cases 41 + 42 + (* Decode tests *) 43 + let test_decode () = 44 + let test_cases = [ 45 + ("0", false, Some 0L); 46 + ("16j", false, Some 1234L); 47 + ("16-j", false, Some 1234L); 48 + ("01-6j", false, Some 1234L); 49 + ("f9zqn-sf065", false, Some 538751765283013L); 50 + ("twwjw-1ww98", true, Some 924377286556L); 51 + ("9ed5m-ytn", false, Some 324712168277L); 52 + ("9ed5m-ytn30", true, Some 324712168277L); 53 + ("elife.01567", false, None); (* Should fail - contains invalid character '.' *) 54 + ] in 55 + List.iter (fun (input, checksum, expected) -> 56 + let test_name = Printf.sprintf "decode '%s' (checksum=%b)" input checksum in 57 + match expected with 58 + | Some want -> 59 + let got = Crockford.decode ~checksum input in 60 + Alcotest.(check int64) test_name want got 61 + | None -> 62 + (* Expected to fail *) 63 + Alcotest.check_raises 64 + test_name 65 + (Crockford.Decode_error (Crockford.Invalid_character { 66 + char = '.'; 67 + message = "character '.' not in base32 alphabet" 68 + })) 69 + (fun () -> ignore (Crockford.decode ~checksum input)) 70 + ) test_cases 71 + 72 + (* Normalize tests *) 73 + let test_normalize () = 74 + let test_cases = [ 75 + ("f9ZQNSF065", "f9zqnsf065"); 76 + ("f9zqn-sf065", "f9zqnsf065"); 77 + ("f9Llio", "f91110"); 78 + ] in 79 + List.iter (fun (input, expected) -> 80 + let result = Crockford.normalize input in 81 + let test_name = Printf.sprintf "normalize '%s'" input in 82 + Alcotest.(check string) test_name expected result 83 + ) test_cases 84 + 85 + (* GenerateChecksum tests *) 86 + let test_generate_checksum () = 87 + let test_cases = [ 88 + (450320459383L, 85L); 89 + (123456789012L, 44L); 90 + ] in 91 + List.iter (fun (input, expected) -> 92 + let result = Crockford.generate_checksum input in 93 + let test_name = Printf.sprintf "generate_checksum %Ld" input in 94 + Alcotest.(check int64) test_name expected result 95 + ) test_cases 96 + 97 + (* Validate tests *) 98 + let test_validate () = 99 + let test_cases = [ 100 + (375301249367L, 92L, true); 101 + (930412369850L, 36L, true); 102 + ] in 103 + List.iter (fun (input, checksum, expected) -> 104 + let result = Crockford.validate input ~checksum in 105 + let test_name = Printf.sprintf "validate %Ld checksum=%Ld" input checksum in 106 + Alcotest.(check bool) test_name expected result 107 + ) test_cases 108 + 109 + (* Additional roundtrip tests *) 110 + let test_roundtrip () = 111 + let test_numbers = [ 112 + 0L; 113 + 1L; 114 + 32L; 115 + 1024L; 116 + 1234567890L; 117 + Int64.max_int; 118 + ] in 119 + 120 + List.iter (fun num -> 121 + let encoded = Crockford.encode num in 122 + let decoded = Crockford.decode encoded in 123 + Alcotest.(check int64) (Printf.sprintf "roundtrip %Ld" num) num decoded 124 + ) test_numbers 125 + 126 + let test_roundtrip_with_checksum () = 127 + let test_numbers = [ 128 + 0L; 129 + 1L; 130 + 32L; 131 + 1024L; 132 + 1234567890L; 133 + ] in 134 + 135 + List.iter (fun num -> 136 + let encoded = Crockford.encode ~checksum:true num in 137 + let decoded = Crockford.decode ~checksum:true encoded in 138 + Alcotest.(check int64) (Printf.sprintf "roundtrip with checksum %Ld" num) num decoded 139 + ) test_numbers 140 + 141 + (* Error handling tests *) 142 + let test_error_invalid_character () = 143 + Alcotest.check_raises 144 + "invalid character" 145 + (Crockford.Decode_error (Crockford.Invalid_character { 146 + char = '#'; 147 + message = "character '#' not in base32 alphabet" 148 + })) 149 + (fun () -> ignore (Crockford.decode "abc#def")) 150 + 151 + let test_error_invalid_checksum () = 152 + Alcotest.check_raises 153 + "invalid checksum length" 154 + (Crockford.Decode_error (Crockford.Invalid_checksum { 155 + checksum = "ab"; 156 + message = "encoded string too short for checksum" 157 + })) 158 + (fun () -> ignore (Crockford.decode ~checksum:true "ab")) 159 + 160 + let test_error_checksum_mismatch () = 161 + let encoded = Crockford.encode ~checksum:true 1234L in 162 + let corrupted = String.sub encoded 0 (String.length encoded - 2) ^ "00" in 163 + 164 + Alcotest.check_raises 165 + "checksum mismatch" 166 + (Crockford.Decode_error (Crockford.Checksum_mismatch { 167 + expected = Crockford.generate_checksum 1234L; 168 + got = 0L; 169 + identifier = corrupted 170 + })) 171 + (fun () -> ignore (Crockford.decode ~checksum:true corrupted)) 172 + 173 + let test_error_invalid_length () = 174 + Alcotest.check_raises 175 + "invalid length for generate" 176 + (Crockford.Decode_error (Crockford.Invalid_length { 177 + length = 2; 178 + message = "length must be >= 3 if checksum is enabled" 179 + })) 180 + (fun () -> ignore (Crockford.generate ~length:2 ~checksum:true ())) 181 + 182 + let () = 183 + let open Alcotest in 184 + run "Crockford" [ 185 + "encoding", [ 186 + test_case "encode" `Quick test_encode; 187 + ]; 188 + "decoding", [ 189 + test_case "decode" `Quick test_decode; 190 + ]; 191 + "normalization", [ 192 + test_case "normalize" `Quick test_normalize; 193 + ]; 194 + "checksum", [ 195 + test_case "generate_checksum" `Quick test_generate_checksum; 196 + test_case "validate" `Quick test_validate; 197 + ]; 198 + "generation", [ 199 + test_case "generate" `Quick test_generate; 200 + ]; 201 + "roundtrip", [ 202 + test_case "roundtrip encoding/decoding" `Quick test_roundtrip; 203 + test_case "roundtrip with checksum" `Quick test_roundtrip_with_checksum; 204 + ]; 205 + "errors", [ 206 + test_case "invalid character" `Quick test_error_invalid_character; 207 + test_case "invalid checksum" `Quick test_error_invalid_checksum; 208 + test_case "checksum mismatch" `Quick test_error_checksum_mismatch; 209 + test_case "invalid length" `Quick test_error_invalid_length; 210 + ]; 211 + ]