baseh 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d62aeddba45748e9bf4c1d40935e09d8f18bbe032f7d97320e4f78b7598d33ed
4
- data.tar.gz: 29a24ad4f909e9832ae668c1d0d1765d94884c4533b7fa1a98bfbc72d86e2347
3
+ metadata.gz: f624ebca106c2a3733ad2d655ff7f56a0b32b5ce55882e8932d107bc7561997d
4
+ data.tar.gz: 6bd8749f57881c40ef0b83b70d35b159fd69a2bb2fb493c0a009bb0b829be7b1
5
5
  SHA512:
6
- metadata.gz: e8b586ae0ac27e61037a413032bfcff338140ffb0a9fd0f99bebe3377657244d326e8a03e53c185c58713408d0968df71a8bfaf0ed908a80faea6c29b805de98
7
- data.tar.gz: a0a5578f2f70d553415396b47e28183edab7eab72e780fdd76834863a79ef8e0b3a99b7a7fb1fa55b8310ad38c7fcde2c82cc6a62cf87eb3b57d9f401a0ce97e
6
+ metadata.gz: b5b2361b1d7d6fc42927f9b9a9ec63d1905a514fb98d8bee73bd365b51a603b8f9a33b668c9219b39a5dc07236a2fd1a0040a3cecbc9061e2e71f001f57130c5
7
+ data.tar.gz: 760c74a6d57f511b4b1d24f0b87ae4e29e364dde6f15d44a7075fc00748943efc022950203051dc00c1113fa2cdcd34a0857974a5a349a28be6006af673089fe
data/README.md CHANGED
@@ -1,44 +1,105 @@
1
1
  # baseh
2
2
 
3
3
  Ruby port of the baseH (Human Reference Code) codec. Encodes integer IDs as
4
- fixed-length, checksummed, human-friendly reference codes with an opt-in
5
- reversible feistel-v1 permutation and profanity safety. The normative spec
6
- is `spec/IMPLEMENTATION_CODEC.md` in the monorepo root.
4
+ short, checksummed, human-friendly reference codes with a feistel-v1
5
+ permutation on every tier and profanity safety. The normative spec is
6
+ `spec/IMPLEMENTATION_CODEC.md` in the monorepo root.
7
7
 
8
8
  ## Install
9
9
 
10
+ ```sh
11
+ gem install baseh
12
+ ```
13
+
14
+ or in a Gemfile:
15
+
10
16
  ```ruby
11
17
  # Gemfile
12
18
  gem "baseh", path: "ruby"
13
19
  ```
14
20
 
15
- or
21
+ or from a local build:
16
22
 
17
23
  ```sh
18
24
  gem build baseh.gemspec
19
- gem install ./baseh-1.0.0.gem
25
+ gem install ./baseh-2.0.0.gem
20
26
  ```
21
27
 
22
28
  Zero runtime dependencies. Only `openssl` and `json` from the standard
23
29
  library are used.
24
30
 
25
- ## Frozen tiers
31
+ ## Expandable mode (recommended default)
32
+
33
+ Profiles carry a `mode:` field: `"expandable"` or `"fixed"`. Expandable is
34
+ the recommended default for new users. The frozen tier
35
+ `Baseh.baseh_expandable_v1` ships as the starting point, with a keyed
36
+ private-mapping variant `Baseh.baseh_expandable_p_v1` that relates to it
37
+ exactly as the other `-p` tiers relate to their plain tiers.
38
+
39
+ ```ruby
40
+ require "baseh"
41
+
42
+ codec = Baseh::Baseh.new(Baseh.baseh_expandable_v1)
43
+
44
+ code = codec.encode(id: 123_456) # 4 characters at this namespace size
45
+ codec.decode(code).id # => 123456
46
+ ```
26
47
 
27
- Four frozen tiers ship with the gem, built from the full alphanumeric set
28
- with cumulative visual and spoken strips. All four encode 6 body symbols,
29
- are case-insensitive and run the default profanity blocklist.
48
+ How expandable differs from fixed:
49
+
50
+ - **Codes start short and grow.** Minimum length is 4 characters
51
+ (`min_length`, default 4). When the id sequence climbs past a length's
52
+ capacity, codes simply become one character longer — transparently, no
53
+ migration, no re-issue. Old shorter codes keep decoding forever.
54
+ - **Medium safety plus the zero ban.** The default expandable body alphabet
55
+ is the 27 symbols left after the medium visual and spoken safety strips
56
+ and the `0`/`O` zero ban, so an issued code never emits a visual or spoken
57
+ confusable. A custom alphabet containing `0`/`O` has those symbols silently
58
+ removed during profile preparation (the derived alphabet is always
59
+ displayed in tooling). This applies on top of whatever visual/spoken
60
+ safety levels, profanity modes, or blocklists are configured; every
61
+ existing profile option composes with expandable unchanged.
62
+ - **Checksum alphabet gains `0`.** The checksum alphabet is the body
63
+ alphabet plus `0` (28 symbols for the default). The existing input alias
64
+ `O -> 0` remains, so a typed or misread `O` in a checksum position
65
+ resolves to `0`. There is no left-padding in expandable mode; a `0` or
66
+ `O` in a body position of presented input is simply an invalid character.
67
+ - **Permutation stays on.** The Feistel permutation is applied per
68
+ generation (per code length), with the length mixed into the key
69
+ derivation alongside the profile id. Codes within each length look random
70
+ even though issuance is a sequential counter. Presentation only, not
71
+ encryption.
72
+ - **Separators appear later.** Grouping only kicks in once codes reach
73
+ `separator_min_length` (the shipped tier uses 6, so no hyphen until codes
74
+ are 6+ characters). Below the threshold there is no separator.
75
+ - **Decode is length-driven.** The code's length selects the generation;
76
+ the checksum validates exactly as in fixed mode, domain-separated by
77
+ profile id.
78
+
79
+ The security posture is unchanged: a code is a reference alias, never an
80
+ authorization token. Expandable codes in the smallest generations are a
81
+ small namespace, so rate-limit public lookups and enforce authorization
82
+ after decode.
83
+
84
+ ## Fixed mode (frozen tiers)
85
+
86
+ Four frozen tiers ship with the gem, all `mode: "fixed"`, built from the
87
+ full alphanumeric set with cumulative visual and spoken strips. All four encode 6 body symbols,
88
+ are case-insensitive, hyphen-delimit at the midpoint, run the default
89
+ profanity blocklist and permute with the published frozen key.
30
90
 
31
91
  | Tier | Helper | Body symbols | Checksum | Format | Capacity |
32
92
  | ---- | ------ | ------------ | -------- | ------ | -------- |
33
93
  | Minimum | `Baseh.baseh_minimum_v1` | 36 | none | `XXX-XXX` | 2,176,782,336 |
34
- | Light | `Baseh.baseh_light_v1` | 31 | 1 | plain | 887,503,681 |
35
- | Medium | `Baseh.baseh_medium_v1` | 28 | 1 | plain | 481,890,304 |
36
- | Heavy | `Baseh.baseh_heavy_v1` | 26 | 1 | plain | 308,915,776 |
94
+ | Light | `Baseh.baseh_light_v1` | 31 | 2 | `XXXX-XXXX` | 887,503,681 |
95
+ | Medium | `Baseh.baseh_medium_v1` | 28 | 2 | `XXXX-XXXX` | 481,890,304 |
96
+ | Heavy | `Baseh.baseh_heavy_v1` | 26 | 2 | `XXXX-XXXX` | 308,915,776 |
37
97
 
38
- Medium is the default. Minimum keeps the full alphabet and uses a hyphen
39
- delimiter; the rest have no separator. Each tier keeps the typed O/I/L
40
- aliases where possible and adds spoken-confusion aliases for the stripped
41
- symbols.
98
+ Medium is the default of the fixed tiers. The frozen key is public by
99
+ design: it hides sequence, not records. It is not a secret and anyone can read it at
100
+ `Baseh::FROZEN_KEY_BYTES`; it must never change for a live namespace. Each
101
+ tier keeps the typed O/I/L aliases where possible and adds spoken-confusion
102
+ aliases for the stripped symbols.
42
103
 
43
104
  Every helper returns a freshly built mutable profile hash on each call, so
44
105
  callers can load a default and modify it before constructing a codec.
@@ -48,30 +109,43 @@ callers can load a default and modify it before constructing a codec.
48
109
  ```ruby
49
110
  require "baseh"
50
111
 
51
- codec = Baseh::Baseh.new(Baseh.baseh_medium_v1)
112
+ codec = Baseh::Baseh.new(Baseh.baseh_expandable_v1)
52
113
 
53
- code = codec.encode(id: 123_456) # => raw fixed-width code
114
+ code = codec.encode(id: 123_456) # => short code, grows as ids climb
54
115
 
55
116
  result = codec.decode(code)
56
117
  result.id # => 123456
57
118
  result.canonical_code # => canonical form
58
119
  result.corrected # => true when input needed correction
59
120
 
60
- codec.capacity # => 481890304
61
-
62
- check = codec.validate("0000000")
121
+ check = codec.validate("00000000")
63
122
  check.valid # => false
64
123
  check.reason # => "INVALID_CHECKSUM"
65
124
 
125
+ # Live as-you-type feedback for a code entry field (never raises)
126
+ look = codec.inspect("C8XP8")
127
+ look.state # => "typing" (or "empty", "bad-char",
128
+ # "too-long", "invalid", "valid")
129
+ look.typed # => "C8XP-8"
130
+ look.progress # => 0.625
131
+
66
132
  # Spoken-confusion correction
67
- result = codec.decode("TB14QDF", try_correction: true, confusion_profile: :light)
133
+ result = codec.decode("TB14QDFU", try_correction: true, confusion_profile: :light)
134
+ ```
135
+
136
+ The same API applies to the fixed tiers (`codec.capacity` reports the fixed
137
+ namespace size there; expandable grows instead):
138
+
139
+ ```ruby
140
+ codec = Baseh::Baseh.new(Baseh.baseh_medium_v1)
141
+ codec.capacity # => 481890304
68
142
  ```
69
143
 
70
- ## Permutation (opt-in)
144
+ ## Permutation
71
145
 
72
- The `-p` variants opt a tier into the reversible feistel-v1 permutation.
73
- `key_bytes:` is required; keep the key in a secret manager and never change
74
- it for a live profile:
146
+ The plain tiers permute with `Baseh::FROZEN_KEY_BYTES`, the published frozen
147
+ key. The `-p` variants take caller-supplied key material instead; keep that
148
+ key in a secret manager and never change it for a live profile:
75
149
 
76
150
  ```ruby
77
151
  profile = Baseh.baseh_medium_p_v1(
@@ -82,7 +156,7 @@ codec = Baseh::Baseh.new(profile)
82
156
  ```
83
157
 
84
158
  `rounds:` is also accepted (default 8). The `-p` profile is identical to
85
- its plain tier apart from the permutation; its profile id gains a `-p`
159
+ its plain tier apart from the key material; its profile id gains a `-p`
86
160
  segment, for example `baseh-medium-p-v1`.
87
161
 
88
162
  ## Profanity safety (spec 18)
@@ -121,3 +195,19 @@ The vector tests load `../vectors/vectors.json` and
121
195
  `../vectors/feistel-vectors.json` from the monorepo root and assert every
122
196
  entry. Running the suite from a different directory layout requires those
123
197
  files at that relative path.
198
+
199
+ The full 100,000-body single-substitution checksum sweep of the test-suite
200
+ spec (section 6) runs under `BASEH_SOAK=1` alongside the soak suite:
201
+
202
+ ```sh
203
+ BASEH_SOAK=1 rake test # full soak + 100k checksum sweep per tier
204
+ BASEH_SOAK=1 BASEH_SOAK_BODIES=1_000 rake test # smoke the sweep
205
+ ```
206
+
207
+ ## Linting
208
+
209
+ No RuboCop config ships in this gem: RuboCop is not part of the bundle, and
210
+ adding a lint dependency was judged not worth it for a zero-dependency,
211
+ ~500-line library whose conformance is enforced by the shared cross-language
212
+ vectors. The codebase follows the standard Ruby style (two-space indent,
213
+ frozen string literals) by convention instead.
data/lib/baseh/baseh.rb CHANGED
@@ -31,6 +31,13 @@ module Baseh
31
31
  # Result of validate, which never raises on user input.
32
32
  ValidateResult = Struct.new(:valid, :canonical_code, :reason, keyword_init: true)
33
33
 
34
+ # Result of inspect (spec 12.5). #state is one of "empty", "typing",
35
+ # "bad-char", "too-long", "invalid", "valid". Payload fields are present
36
+ # exactly when the state carries them: #typed and #progress for "typing",
37
+ # #reason for "invalid", #id and #canonical_code for "valid".
38
+ InspectResult = Struct.new(:state, :typed, :progress, :reason, :id, :canonical_code,
39
+ keyword_init: true)
40
+
34
41
  attr_reader :profile
35
42
 
36
43
  # @param profile [Hash] profile definition per spec 2.1 (symbol keys)
@@ -41,19 +48,85 @@ module Baseh
41
48
  end
42
49
 
43
50
  # Spec section 4. Capacity is an arbitrary-precision Integer.
51
+ # Spec 12.3: fixed mode only; expandable profiles have no single
52
+ # capacity (use the per-generation formulas of spec 19.1).
44
53
  def capacity
54
+ if @profile.mode != "fixed"
55
+ raise BasehError.new(
56
+ "INVALID_PROFILE",
57
+ "capacity is only defined for fixed-mode profiles",
58
+ safe_for_customer: false
59
+ )
60
+ end
45
61
  @profile.capacity
46
62
  end
47
63
 
48
- # Spec section 8, with the spec 18.2 blocklist scan over the raw code.
64
+ # Spec 19.1/22.3. First id of generation length: the sum of each
65
+ # generation's capacity A^(k - effectiveK(k)) for k from minLength
66
+ # through length-1. The effective checksum length is per-generation
67
+ # (spec 22), so the sum is not a single geometric series when the short
68
+ # checksum is on.
69
+ def generation_base(length)
70
+ base = 0
71
+ @profile.min_length.upto(length - 1) do |l|
72
+ base += generation_capacity(l)
73
+ end
74
+ base
75
+ end
76
+
77
+ # Spec 19.1/22.3. Ids held by generation length: A^(length - effectiveK).
78
+ def generation_capacity(length)
79
+ @profile.body_alphabet.length**(length - @profile.effective_checksum_length(length))
80
+ end
81
+
82
+ # Smallest generation whose range holds id, per spec 19.6. The loop is
83
+ # capped at the 32-symbol code limit (33 - minLength iterations) so an
84
+ # adversarial id fails fast with OUT_OF_RANGE instead of running big-integer
85
+ # multiplication on exponentially growing values.
86
+ def generation_for_id(id)
87
+ l = @profile.min_length
88
+ base = 0
89
+ cap = generation_capacity(l)
90
+ while id >= base + cap
91
+ if l >= 32
92
+ raise BasehError.new(
93
+ "OUT_OF_RANGE",
94
+ "ID requires a code longer than 32 symbols"
95
+ )
96
+ end
97
+ base += cap
98
+ l += 1
99
+ cap = generation_capacity(l)
100
+ end
101
+ l
102
+ end
103
+
104
+ # Spec 19.5. Balanced grouping: the split is a pure function of the total
105
+ # length — g = max(2, ceil(L / 5)) groups differing in size by at most
106
+ # one, larger groups to the left. There is no configurable pattern in
107
+ # expandable mode (grouping must be empty, section 2.2).
108
+ def self.expandable_grouping(length)
109
+ g = [2, (length + 4) / 5].max
110
+ base = length / g
111
+ return [length] if base < 1
112
+
113
+ rem = length % g
114
+ [base + 1] * rem + [base] * (g - rem)
115
+ end
116
+
117
+ # Spec section 8 (fixed mode) / 19.6 (expandable mode), with the spec
118
+ # 18.2 blocklist scan over the raw code.
49
119
  #
50
- # @param id [Integer] 0 <= id < capacity
51
- # @return [String] canonical code (grouped only when a separator is set)
120
+ # @param id [Integer] 0 <= id < capacity (fixed); any non-negative id
121
+ # whose code fits in 32 symbols (expandable)
122
+ # @return [String] canonical code (grouped only when a separator applies)
52
123
  # @raise [BasehError] OUT_OF_RANGE, PERMUTATION_FAILURE, BLOCKED_CODE
53
124
  def encode(id:)
54
125
  unless id.is_a?(Integer)
55
126
  raise TypeError, "id must be an Integer"
56
127
  end
128
+ return encode_expandable(id) if @profile.mode == "expandable"
129
+
57
130
  if id.negative? || id >= @profile.capacity
58
131
  raise BasehError.new("OUT_OF_RANGE", "ID #{id} is outside the profile capacity")
59
132
  end
@@ -93,8 +166,22 @@ module Baseh
93
166
  end
94
167
 
95
168
  raw = normalize(input, accept_spaces)
96
- body = raw.slice(0, @profile.body_length)
97
- supplied_checksum = raw.slice(@profile.body_length..) || ""
169
+ # Spec 22: the generation is selected by the presented total length,
170
+ # so the effective checksum length is a deterministic function of it.
171
+ effective_k =
172
+ if @profile.mode == "expandable"
173
+ @profile.effective_checksum_length(raw.length)
174
+ else
175
+ @profile.checksum_length
176
+ end
177
+ body_length =
178
+ if @profile.mode == "expandable"
179
+ raw.length - effective_k
180
+ else
181
+ @profile.body_length
182
+ end
183
+ body = raw.slice(0, body_length)
184
+ supplied_checksum = raw.slice(body_length..) || ""
98
185
 
99
186
  # normalize validates every symbol against the union of the body and
100
187
  # checksum alphabets (spec 3.1 step 6). A checksum-only symbol in a
@@ -111,7 +198,7 @@ module Baseh
111
198
  )
112
199
  end
113
200
 
114
- if Checksum.calculate_checksum(@profile, body, @body_index) != supplied_checksum
201
+ if Checksum.calculate_checksum(@profile, body, @body_index, effective_k) != supplied_checksum
115
202
  unless try_correction && max_corrections != 0
116
203
  raise BasehError.new(
117
204
  "INVALID_CHECKSUM",
@@ -132,7 +219,7 @@ module Baseh
132
219
  end
133
220
  valid = {}
134
221
  generate_candidates(body, filtered, max_corrections).each do |candidate|
135
- if Checksum.calculate_checksum(@profile, candidate, @body_index) == supplied_checksum
222
+ if Checksum.calculate_checksum(@profile, candidate, @body_index, effective_k) == supplied_checksum
136
223
  valid[candidate] = true
137
224
  end
138
225
  end
@@ -155,7 +242,22 @@ module Baseh
155
242
 
156
243
  value = BaseN.decode_base_n(body, @profile.body_alphabet, @body_index)
157
244
  perm = @profile.permutation
158
- if perm[:enabled]
245
+ if @profile.mode == "expandable"
246
+ # Spec 19.7: the offset is de-permuted within the generation's own
247
+ # domain (length mixed into the key derivation), then the generation
248
+ # base is added back.
249
+ l = raw.length
250
+ if perm[:enabled]
251
+ value = Feistel.inverse_permute(
252
+ value, generation_capacity(l),
253
+ profile_id: @profile.profile_id,
254
+ key_bytes: perm[:key_bytes],
255
+ rounds: perm[:rounds],
256
+ length: l
257
+ )
258
+ end
259
+ value = generation_base(l) + value
260
+ elsif perm[:enabled]
159
261
  value = Feistel.inverse_permute(
160
262
  value, @profile.capacity,
161
263
  profile_id: @profile.profile_id,
@@ -181,14 +283,80 @@ module Baseh
181
283
  ValidateResult.new(valid: false, reason: e.code)
182
284
  end
183
285
 
184
- # Spec 3.1 normalization, steps 1-9, with the spec 3.4 re-pad. Returns
185
- # the raw unformatted string.
286
+ # Spec 12.5. Live as-you-type inspection. Gates on the typed length before
287
+ # validating, so the spec 3.4 re-pad can never paint an incomplete
288
+ # fixed-mode code "valid" (or "invalid"): a short fixed input is "typing",
289
+ # never checked. Never raises on user input and never reports "valid" for
290
+ # an incomplete code.
291
+ def inspect(input)
292
+ p = @profile
293
+ # Step 1: remove every occurrence of the separator string, then drop
294
+ # ASCII whitespace anywhere (a paste can carry either inside the code).
295
+ s = p.separator.empty? ? input.dup : input.gsub(p.separator, "")
296
+ s = s.delete("\t\n\v\f\r ")
297
+ typed = s.length
298
+ # Step 2.
299
+ return InspectResult.new(state: "empty") if typed.zero?
300
+
301
+ # Step 3. Fixed: complete exactly at bodyLength + checksumLength.
302
+ # Expandable: complete at every length from minLength through 32 (the
303
+ # length selects the generation), 32 is the over-length bound.
304
+ fixed = p.mode == "fixed"
305
+ expected = fixed ? p.body_length + p.checksum_length : 32
306
+ return InspectResult.new(state: "too-long") if typed > expected
307
+
308
+ # Step 4: spec 3.1 steps 4-6 without the length checks — case
309
+ # normalization, aliases, then union membership. A symbol outside both
310
+ # alphabets is "bad-char"; a symbol valid only in the other region (say
311
+ # a checksum-only symbol typed into the body) passes here and fails
312
+ # later under validate as INVALID_CHARACTER.
313
+ s = s.upcase unless p.case_sensitive
314
+ raw = s.each_char.map do |ch|
315
+ inspect_union?(ch) ? ch : p.aliases.fetch(ch, ch)
316
+ end.join
317
+ return InspectResult.new(state: "bad-char") if raw.each_char.any? { |ch| !inspect_union?(ch) }
318
+
319
+ # Step 5.
320
+ complete = fixed ? typed == expected : typed >= p.min_length
321
+ unless complete
322
+ return InspectResult.new(
323
+ state: "typing",
324
+ typed: format_partial(raw),
325
+ progress: typed.to_f / (fixed ? expected : p.min_length)
326
+ )
327
+ end
328
+
329
+ # Step 6: judge the normalized string (no separator, no whitespace,
330
+ # case- and alias-normalized).
331
+ result = validate(raw)
332
+ return InspectResult.new(state: "invalid", reason: result.reason) unless result.valid
333
+
334
+ decoded = decode(raw)
335
+ InspectResult.new(state: "valid", id: decoded.id, canonical_code: decoded.canonical_code)
336
+ end
337
+
338
+ # Spec 3.1 normalization, steps 1-9, with the spec 3.4 re-pad in fixed
339
+ # mode only. Returns the raw unformatted string.
186
340
  def normalize(input, accept_spaces)
187
341
  s = input.gsub(ASCII_WS, "")
188
- s = s.delete(@profile.separator) unless @profile.separator.empty?
342
+ had_separator = !@profile.separator.empty? && s.include?(@profile.separator)
343
+ # Literal-substring removal (gsub with a string pattern), matching the
344
+ # JS split-join reference: String#delete would treat the separator as a
345
+ # character class and corrupt multi-character separators like "..".
346
+ s = s.gsub(@profile.separator, "") unless @profile.separator.empty?
189
347
  s = s.delete(" ") if accept_spaces
190
348
  s = s.upcase unless @profile.case_sensitive
191
- s = s.each_char.map { |ch| @profile.aliases.fetch(ch, ch) }.join
349
+ # Spec 3.2: an alias never maps two distinct canonical symbols into one
350
+ # value, so a symbol that is already canonical stays as-is and only
351
+ # non-canonical symbols are aliased. (In fixed tiers alias sources are
352
+ # never canonical, so this changes nothing there.)
353
+ s = s.each_char.map do |ch|
354
+ if @body_index.key?(ch) || @profile.checksum_alphabet.include?(ch)
355
+ ch
356
+ else
357
+ @profile.aliases.fetch(ch, ch)
358
+ end
359
+ end.join
192
360
 
193
361
  s.each_char do |ch|
194
362
  next if @body_index.key?(ch) || @profile.checksum_alphabet.include?(ch)
@@ -199,6 +367,32 @@ module Baseh
199
367
  )
200
368
  end
201
369
 
370
+ if @profile.mode == "expandable"
371
+ # Spec 19.2/19.7: no left-padding and no stripped-zero leniency. Input
372
+ # shorter than minLength or longer than 32 fails INVALID_LENGTH, and a
373
+ # separator below separatorMinLength is rejected (spec 19.5: the
374
+ # decoder expects no separators there).
375
+ if s.length < @profile.min_length
376
+ raise BasehError.new(
377
+ "INVALID_LENGTH",
378
+ "Expected at least #{@profile.min_length} symbols, got #{s.length}"
379
+ )
380
+ end
381
+ if s.length > 32
382
+ raise BasehError.new(
383
+ "INVALID_LENGTH",
384
+ "Expected at most 32 symbols, got #{s.length}"
385
+ )
386
+ end
387
+ if had_separator && s.length < @profile.separator_min_length
388
+ raise BasehError.new(
389
+ "INVALID_CHARACTER",
390
+ "Separators do not appear below #{@profile.separator_min_length} symbols"
391
+ )
392
+ end
393
+ return s
394
+ end
395
+
202
396
  expected = @profile.body_length + @profile.checksum_length
203
397
  # Spec 3.4: a code that lost leading zero body symbols is re-padded
204
398
  # with the body zero symbol. The checksum symbols always remain, so
@@ -242,20 +436,32 @@ module Baseh
242
436
  private
243
437
 
244
438
  # Spec 18.2: case-insensitive substring scan over the raw unformatted
245
- # code. BLOCKED_CODE is an issuance decision, not an end-user condition.
439
+ # code, plus the spec 21.2 run scan. BLOCKED_CODE is an issuance
440
+ # decision, not an end-user condition.
246
441
  def check_blocklist!(raw)
247
- return if @profile.blocklist.empty?
248
-
249
- upper = raw.upcase
250
- @profile.blocklist.each do |word|
251
- next unless upper.include?(word)
442
+ unless @profile.blocklist.empty?
443
+ upper = raw.upcase
444
+ @profile.blocklist.each do |word|
445
+ next unless upper.include?(word)
252
446
 
253
- raise BasehError.new(
254
- "BLOCKED_CODE",
255
- "The generated reference contains a blocked substring",
256
- safe_for_customer: false
257
- )
447
+ raise BasehError.new(
448
+ "BLOCKED_CODE",
449
+ "The generated reference contains a blocked substring",
450
+ safe_for_customer: false
451
+ )
452
+ end
258
453
  end
454
+ # Spec 21.2: a run of the same symbol at or above maxRepetition blocks
455
+ # the code. Runs are measured on the raw string, so a separator never
456
+ # breaks a run.
457
+ max = @profile.max_repetition
458
+ return unless max.positive? && /(.)\1{#{max - 1},}/.match?(raw)
459
+
460
+ raise BasehError.new(
461
+ "BLOCKED_CODE",
462
+ "The generated reference repeats a symbol beyond the profile limit",
463
+ safe_for_customer: false
464
+ )
259
465
  end
260
466
 
261
467
  def confusion_map(name)
@@ -269,12 +475,83 @@ module Baseh
269
475
  end
270
476
  end
271
477
 
478
+ # Spec 19.6. The id selects its generation by magnitude; the offset
479
+ # within the generation is permuted in that generation's own domain.
480
+ def encode_expandable(id)
481
+ if id.negative?
482
+ raise BasehError.new("OUT_OF_RANGE", "ID #{id} is negative")
483
+ end
484
+ l = generation_for_id(id)
485
+ value = id - generation_base(l)
486
+ domain = generation_capacity(l)
487
+ perm = @profile.permutation
488
+ effective_k = @profile.effective_checksum_length(l)
489
+ if perm[:enabled]
490
+ value = Feistel.permute(
491
+ value, domain,
492
+ profile_id: @profile.profile_id,
493
+ key_bytes: perm[:key_bytes],
494
+ rounds: perm[:rounds],
495
+ length: l
496
+ )
497
+ end
498
+ body = BaseN.encode_base_n(value, @profile.body_alphabet, l - effective_k)
499
+ checksum = Checksum.calculate_checksum(@profile, body, @body_index, effective_k)
500
+ raw = body + checksum
501
+ check_blocklist!(raw)
502
+ format_raw(raw)
503
+ end
504
+
505
+ # Spec 11/19.5. In expandable mode the separator applies only at or above
506
+ # separatorMinLength, with the balanced grouping derived from the total
507
+ # length.
272
508
  def format_raw(raw)
273
509
  return raw if @profile.separator.empty?
274
510
 
511
+ grouping =
512
+ if @profile.mode == "expandable"
513
+ return raw if raw.length < @profile.separator_min_length
514
+
515
+ self.class.expandable_grouping(raw.length)
516
+ else
517
+ @profile.grouping
518
+ end
275
519
  parts = []
276
520
  offset = 0
277
- @profile.grouping.each do |size|
521
+ grouping.each do |size|
522
+ parts << raw.slice(offset, size)
523
+ offset += size
524
+ end
525
+ parts.join(@profile.separator)
526
+ end
527
+
528
+ # Spec 12.5 step 4: membership in the union of the body and checksum
529
+ # alphabets.
530
+ def inspect_union?(ch)
531
+ @body_index.key?(ch) || @profile.checksum_alphabet.include?(ch)
532
+ end
533
+
534
+ # Spec 12.5: separators inserted into a partially typed code, as far as
535
+ # the groups go. Fixed mode walks the configured grouping, emitting no
536
+ # separator for a group the symbols do not reach; expandable mode uses
537
+ # the balanced grouping rule of spec 19.5 for the typed length, bare
538
+ # below separatorMinLength.
539
+ def format_partial(raw)
540
+ return raw if @profile.separator.empty?
541
+
542
+ grouping =
543
+ if @profile.mode == "expandable"
544
+ return raw if raw.length < @profile.separator_min_length
545
+
546
+ self.class.expandable_grouping(raw.length)
547
+ else
548
+ @profile.grouping
549
+ end
550
+ parts = []
551
+ offset = 0
552
+ grouping.each do |size|
553
+ break if offset >= raw.length
554
+
278
555
  parts << raw.slice(offset, size)
279
556
  offset += size
280
557
  end
@@ -284,7 +561,7 @@ module Baseh
284
561
  def canonical_raw(canonical_code)
285
562
  return canonical_code if @profile.separator.empty?
286
563
 
287
- canonical_code.delete(@profile.separator)
564
+ canonical_code.gsub(@profile.separator, "")
288
565
  end
289
566
  end
290
567
  end
data/lib/baseh/basen.rb CHANGED
@@ -8,6 +8,13 @@ module Baseh
8
8
  # Spec 5.1. All arithmetic stays in Integer (arbitrary precision).
9
9
  def encode_base_n(value, alphabet, length)
10
10
  base = alphabet.length
11
+ capacity = base**length
12
+ if value.negative? || value >= capacity
13
+ raise BasehError.new(
14
+ "OUT_OF_RANGE",
15
+ "Value #{value} does not fit in #{length} base-#{base} symbols"
16
+ )
17
+ end
11
18
  out = Array.new(length)
12
19
  v = value
13
20
  (length - 1).downto(0) do |pos|