Class: Baseh::Baseh

Inherits:
Object
  • Object
show all
Defined in:
lib/baseh/baseh.rb

Overview

Codec engine, spec sections 8 through 12 and 18. Instances wrap one validated profile and are stateless and safe to share across threads.

Defined Under Namespace

Classes: DecodeResult, ValidateResult

Constant Summary collapse

CONFUSION_MAPS =

Built-in spoken-confusion candidate maps, spec section 3.3. Pairs apply to body symbols only, never to checksum characters.

{
  light: {
    "B" => %w[D], "D" => %w[B], "P" => %w[T], "T" => %w[P]
  }.freeze,
  medium: {
    "B" => %w[D], "D" => %w[B], "P" => %w[T], "T" => %w[P],
    "M" => %w[N], "N" => %w[M], "V" => %w[W], "W" => %w[V]
  }.freeze,
  heavy: {
    "B" => %w[D], "D" => %w[B], "P" => %w[T], "T" => %w[P],
    "M" => %w[N], "N" => %w[M], "V" => %w[W], "W" => %w[V],
    "F" => %w[S], "S" => %w[F], "C" => %w[G], "G" => %w[C]
  }.freeze
}.freeze
MAX_CANDIDATES =
64
ASCII_WS =
/\A[\t\n\v\f\r ]+|[\t\n\v\f\r ]+\z/.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(profile) ⇒ Baseh

Returns a new instance of Baseh.

Parameters:

  • profile (Hash)

    profile definition per spec 2.1 (symbol keys)

Raises:

  • (BasehError)

    INVALID_PROFILE when the profile violates spec 2.2



38
39
40
41
# File 'lib/baseh/baseh.rb', line 38

def initialize(profile)
  @profile = Profile.prepare(profile)
  @body_index = BaseN.alphabet_index(@profile.body_alphabet)
end

Instance Attribute Details

#profileObject (readonly)

Returns the value of attribute profile.



34
35
36
# File 'lib/baseh/baseh.rb', line 34

def profile
  @profile
end

Instance Method Details

#capacityObject

Spec section 4. Capacity is an arbitrary-precision Integer.



44
45
46
# File 'lib/baseh/baseh.rb', line 44

def capacity
  @profile.capacity
end

#decode(input, accept_spaces: false, try_correction: false, confusion_profile: :none, max_corrections: 1) ⇒ DecodeResult

Spec section 9.

Parameters:

  • input (String)
  • accept_spaces (Boolean) (defaults to: false)

    strip ASCII spaces before validation

  • try_correction (Boolean) (defaults to: false)

    attempt single-symbol spoken correction

  • confusion_profile (:none, :light, :medium, :heavy) (defaults to: :none)
  • max_corrections (0, 1) (defaults to: 1)

Returns:

Raises:

  • (BasehError)

    INVALID_LENGTH, INVALID_CHARACTER, INVALID_CHECKSUM, AMBIGUOUS_INPUT, TOO_MANY_CANDIDATES, PERMUTATION_FAILURE, BLOCKED_CODE



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/baseh/baseh.rb', line 89

def decode(input, accept_spaces: false, try_correction: false,
           confusion_profile: :none, max_corrections: 1)
  unless input.is_a?(String)
    raise BasehError.new("INVALID_CHARACTER", "Input must be a string")
  end

  raw = normalize(input, accept_spaces)
  body = raw.slice(0, @profile.body_length)
  supplied_checksum = raw.slice(@profile.body_length..) || ""

  # normalize validates every symbol against the union of the body and
  # checksum alphabets (spec 3.1 step 6). A checksum-only symbol in a
  # body slot is INVALID_CHARACTER before any checksum work. A body-only
  # symbol in the checksum slot survives to the checksum comparison and
  # fails as INVALID_CHECKSUM; the frozen error vectors require that
  # exact outcome.
  body.each_char do |ch|
    next if @body_index.key?(ch)

    raise BasehError.new(
      "INVALID_CHARACTER",
      "Symbol #{ch.inspect} cannot appear in the body"
    )
  end

  if Checksum.calculate_checksum(@profile, body, @body_index) != supplied_checksum
    unless try_correction && max_corrections != 0
      raise BasehError.new(
        "INVALID_CHECKSUM",
        "The reference code did not pass validation"
      )
    end
    # Spec 10: replacements that are not body alphabet symbols are
    # dropped before candidate generation. A suggested symbol the alphabet
    # cannot contain (say a spoken drop on a stripped-alphabet profile)
    # could never validate; generating it anyway would throw
    # INVALID_CHARACTER from the checksum step instead of reporting an
    # honest INVALID_CHECKSUM.
    map = confusion_map(confusion_profile)
    filtered = {}
    map.each do |source, replacements|
      kept = replacements.select { |r| @body_index.key?(r) }
      filtered[source] = kept unless kept.empty?
    end
    valid = {}
    generate_candidates(body, filtered, max_corrections).each do |candidate|
      if Checksum.calculate_checksum(@profile, candidate, @body_index) == supplied_checksum
        valid[candidate] = true
      end
    end
    case valid.size
    when 0
      raise BasehError.new(
        "INVALID_CHECKSUM",
        "The reference code did not pass validation"
      )
    when 1
      body = valid.keys.first
    else
      raise BasehError.new(
        "AMBIGUOUS_INPUT",
        "The reference code matches more than one record",
        safe_for_customer: false
      )
    end
  end

  value = BaseN.decode_base_n(body, @profile.body_alphabet, @body_index)
  perm = @profile.permutation
  if perm[:enabled]
    value = Feistel.inverse_permute(
      value, @profile.capacity,
      profile_id: @profile.profile_id,
      key_bytes: perm[:key_bytes],
      rounds: perm[:rounds]
    )
  end

  # encode re-scans the blocklist, so decode raises BLOCKED_CODE when
  # reconstructing a canonical form that could never have been issued
  # (spec 18.2).
  canonical_code = encode(id: value)
  corrected = raw != canonical_raw(canonical_code)
  DecodeResult.new(id: value, canonical_code: canonical_code, corrected: corrected)
end

#encode(id:) ⇒ String

Spec section 8, with the spec 18.2 blocklist scan over the raw code.

Parameters:

  • id (Integer)

    0 <= id < capacity

Returns:

  • (String)

    canonical code (grouped only when a separator is set)

Raises:

  • (BasehError)

    OUT_OF_RANGE, PERMUTATION_FAILURE, BLOCKED_CODE



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/baseh/baseh.rb', line 53

def encode(id:)
  unless id.is_a?(Integer)
    raise TypeError, "id must be an Integer"
  end
  if id.negative? || id >= @profile.capacity
    raise BasehError.new("OUT_OF_RANGE", "ID #{id} is outside the profile capacity")
  end

  value = id
  perm = @profile.permutation
  if perm[:enabled]
    value = Feistel.permute(
      value, @profile.capacity,
      profile_id: @profile.profile_id,
      key_bytes: perm[:key_bytes],
      rounds: perm[:rounds]
    )
  end

  body = BaseN.encode_base_n(value, @profile.body_alphabet, @profile.body_length)
  checksum = Checksum.calculate_checksum(@profile, body, @body_index)
  raw = body + checksum
  check_blocklist!(raw)
  format_raw(raw)
end

#generate_candidates(body, confusion_map, max_edits = 1) ⇒ Object

Spec section 10. Substitution-only generation, capped and deduplicated.



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/baseh/baseh.rb', line 220

def generate_candidates(body, confusion_map, max_edits = 1)
  return [] if max_edits.zero?

  results = {}
  chars = body.chars
  chars.each_index do |pos|
    Array(confusion_map[chars[pos]]).each do |replacement|
      candidate = chars.dup
      candidate[pos] = replacement
      results[candidate.join] = true
      next unless results.size > MAX_CANDIDATES

      raise BasehError.new(
        "TOO_MANY_CANDIDATES",
        "Candidate generation exceeded 64 entries",
        safe_for_customer: false
      )
    end
  end
  results.keys
end

#normalize(input, accept_spaces) ⇒ Object

Spec 3.1 normalization, steps 1-9, with the spec 3.4 re-pad. Returns the raw unformatted string.



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/baseh/baseh.rb', line 186

def normalize(input, accept_spaces)
  s = input.gsub(ASCII_WS, "")
  s = s.delete(@profile.separator) unless @profile.separator.empty?
  s = s.delete(" ") if accept_spaces
  s = s.upcase unless @profile.case_sensitive
  s = s.each_char.map { |ch| @profile.aliases.fetch(ch, ch) }.join

  s.each_char do |ch|
    next if @body_index.key?(ch) || @profile.checksum_alphabet.include?(ch)

    raise BasehError.new(
      "INVALID_CHARACTER",
      "Symbol #{ch.inspect} is not accepted"
    )
  end

  expected = @profile.body_length + @profile.checksum_length
  # Spec 3.4: a code that lost leading zero body symbols is re-padded
  # with the body zero symbol. The checksum symbols always remain, so
  # the split point is unambiguous. A fully stripped no-checksum code
  # would be empty and stays a length error.
  if s.length < expected && s.length >= [@profile.checksum_length, 1].max
    s = @profile.body_alphabet[0] * (expected - s.length) + s
  end
  if s.length != expected
    raise BasehError.new(
      "INVALID_LENGTH",
      "Expected #{expected} symbols, got #{s.length}"
    )
  end
  s
end

#validate(input, **options) ⇒ Object

Spec section 12.4. Never raises on user input; returns a ValidateResult with the failing error code in #reason instead.



177
178
179
180
181
182
# File 'lib/baseh/baseh.rb', line 177

def validate(input, **options)
  result = decode(input, **options)
  ValidateResult.new(valid: true, canonical_code: result.canonical_code)
rescue BasehError => e
  ValidateResult.new(valid: false, reason: e.code)
end