nondisposable 0.4.0 → 0.6.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +185 -6
  4. data/app/models/nondisposable/disposable_domain.rb +53 -34
  5. data/app/models/nondisposable/source_snapshot.rb +66 -0
  6. data/data/known_typo_domains.txt +99 -0
  7. data/data/never_disposable.txt +480 -0
  8. data/data/privacy_relays.txt +42 -0
  9. data/data/public_suffix_list.dat +16571 -0
  10. data/gemfiles/rails_7.2.gemfile +1 -0
  11. data/gemfiles/rails_8.0.gemfile +1 -0
  12. data/gemfiles/rails_8.1.gemfile +1 -0
  13. data/lib/generators/nondisposable/install_generator.rb +1 -0
  14. data/lib/generators/nondisposable/templates/create_nondisposable_source_snapshots.rb.erb +42 -0
  15. data/lib/generators/nondisposable/templates/nondisposable.rb +29 -7
  16. data/lib/generators/nondisposable/upgrade_generator.rb +43 -0
  17. data/lib/nondisposable/confirmable_email.rb +75 -0
  18. data/lib/nondisposable/domain.rb +238 -0
  19. data/lib/nondisposable/domain_list_updater.rb +510 -88
  20. data/lib/nondisposable/email_validator.rb +51 -4
  21. data/lib/nondisposable/known_typos.rb +64 -0
  22. data/lib/nondisposable/precedence.rb +209 -0
  23. data/lib/nondisposable/public_suffix.rb +147 -0
  24. data/lib/nondisposable/public_suffix_list_updater.rb +88 -0
  25. data/lib/nondisposable/source.rb +233 -0
  26. data/lib/nondisposable/source_fetcher.rb +69 -0
  27. data/lib/nondisposable/source_merge.rb +127 -0
  28. data/lib/nondisposable/suggestion.rb +201 -31
  29. data/lib/nondisposable/tld.rb +8 -3
  30. data/lib/nondisposable/version.rb +1 -1
  31. data/lib/nondisposable.rb +68 -13
  32. data/lib/tasks/nondisposable.rake +29 -0
  33. metadata +18 -2
@@ -13,6 +13,7 @@ group :test do
13
13
  gem "webmock", "~> 3.19"
14
14
  gem "sqlite3", "~> 2.1"
15
15
  gem "simplecov", require: false
16
+ gem "csv"
16
17
  end
17
18
 
18
19
  gemspec path: "../"
@@ -13,6 +13,7 @@ group :test do
13
13
  gem "webmock", "~> 3.19"
14
14
  gem "sqlite3", "~> 2.1"
15
15
  gem "simplecov", require: false
16
+ gem "csv"
16
17
  end
17
18
 
18
19
  gemspec path: "../"
@@ -13,6 +13,7 @@ group :test do
13
13
  gem "webmock", "~> 3.19"
14
14
  gem "sqlite3", "~> 2.1"
15
15
  gem "simplecov", require: false
16
+ gem "csv"
16
17
  end
17
18
 
18
19
  gemspec path: "../"
@@ -16,6 +16,7 @@ module Nondisposable
16
16
 
17
17
  def create_migration_file
18
18
  migration_template 'create_nondisposable_disposable_domains.rb.erb', File.join(db_migrate_path, "create_nondisposable_disposable_domains.rb")
19
+ migration_template 'create_nondisposable_source_snapshots.rb.erb', File.join(db_migrate_path, "create_nondisposable_source_snapshots.rb")
19
20
  end
20
21
 
21
22
  def create_initializer
@@ -0,0 +1,42 @@
1
+ class CreateNondisposableSourceSnapshots < ActiveRecord::Migration<%= migration_version %>
2
+ # One row per fetch of one blocklist source, so the merged
3
+ # nondisposable_disposable_domains table can always be rebuilt from each
4
+ # source's last good list (see Nondisposable::SourceSnapshot).
5
+ #
6
+ # Additive and safe to roll back: until this table exists, and again after
7
+ # dropping it, Nondisposable::DomainListUpdater uses the single default list
8
+ # exactly as before.
9
+ def change
10
+ primary_key_type = primary_key_type_setting
11
+
12
+ create_table :nondisposable_source_snapshots, id: primary_key_type do |t|
13
+ t.string :source, null: false
14
+ t.string :url
15
+ t.string :status, null: false
16
+ t.datetime :fetched_at, null: false
17
+ t.string :sha256
18
+ t.integer :domain_count
19
+ # Largest domain_count ever accepted for this source (the shrink guard's
20
+ # baseline), carried forward on each good snapshot so pruning never
21
+ # forgets it.
22
+ t.integer :high_water_count
23
+ # The largest list we know of is ~1.4 MB. The limit only matters on
24
+ # MySQL, where plain TEXT stops at 64 KB (this becomes LONGTEXT);
25
+ # PostgreSQL and SQLite ignore it.
26
+ t.text :domains, limit: 16.megabytes
27
+ t.text :error
28
+
29
+ t.timestamps
30
+ end
31
+
32
+ add_index :nondisposable_source_snapshots, [:source, :status, :fetched_at],
33
+ name: "index_nondisposable_source_snapshots_lookup"
34
+ end
35
+
36
+ private
37
+
38
+ def primary_key_type_setting
39
+ config = Rails.configuration.generators
40
+ config.options[config.orm][:primary_key_type] || :primary_key
41
+ end
42
+ end
@@ -14,9 +14,29 @@ Nondisposable.configure do |config|
14
14
  # config.on_check_failure = :allow
15
15
  #
16
16
  # Also match parent domains: an email at x.tempmail.com is blocked when
17
- # tempmail.com is on the list (checks up to 3 parent labels). Default: true
17
+ # tempmail.com is on the list (checks every parent down to the registrable domain). Default: true
18
18
  # config.check_parent_domains = true
19
19
 
20
+ # ---- Where the blocklist comes from ----
21
+ #
22
+ # Default: the single disposable-email-domains list this gem has always used.
23
+ # RECOMMENDED adds fakefilter, a second list that watches temp-mail sites
24
+ # directly (+3,275 domains on 2026-10-17, no known real provider among them).
25
+ # config.sources = Nondisposable::Sources::RECOMMENDED
26
+ #
27
+ # :corroborating sources only block a domain once this many INDEPENDENT
28
+ # lists carry it (a list that copies another doesn't count twice).
29
+ # config.corroboration_threshold = 2
30
+ #
31
+ # Privacy relays (Hide My Email, DuckDuckGo, Firefox Relay, SimpleLogin,
32
+ # addy.io) forward to a real inbox. Allowed by default, even when an upstream
33
+ # list carries them; set to true to refuse them.
34
+ # config.block_privacy_relays = false
35
+ #
36
+ # Who wins when lists disagree (top wins): excluded_domains, the gem's
37
+ # never-disposable list of real providers, additional_domains, the
38
+ # privacy-relay setting, the upstream sources.
39
+
20
40
  # ---- Catching typos ----
21
41
  #
22
42
  # A disposable address is someone hiding from you. A typo is someone who
@@ -39,15 +59,17 @@ Nondisposable.configure do |config|
39
59
  # customer you have.
40
60
  # config.allowed_tlds = nil
41
61
 
42
- # Reject addresses one keystroke from a well-known provider (gmail.co,
62
+ # Flag addresses one keystroke from a well-known provider (gmail.co,
43
63
  # gmial.com). A TLD check CANNOT catch these — .co, .cm and .om are Colombia,
44
64
  # Cameroon and Oman, all real.
45
65
  #
46
- # Think before switching this on: a suggestion is a guess about intent, and a
47
- # wrong guess stops a real person signing up with their real address.
48
- # Nondisposable.suggestion_for(email) is always available and blocks nothing,
49
- # which is the gentler way to use this — show the hint, let the human decide.
50
- # config.reject_lookalike_domains = true
66
+ # Reviewed typos (data/known_typo_domains.txt) are refused outright. Every
67
+ # other lookalike is a guess — ge.com is one edit from me.com and is General
68
+ # Electric — so it is a CONFIRMABLE error: the user ticks "yes, this is my
69
+ # address" and passes. Wire that checkbox first (see
70
+ # Nondisposable::ConfirmableEmail in the README), or a real user at a
71
+ # lookalike domain has no way through. `true` means the same as :confirmable.
72
+ # config.reject_lookalike_domains = :confirmable
51
73
  #
52
74
  # How many edits still count as a typo. 1 is safe; 2 starts colliding with
53
75
  # genuinely different domains. 0 disables suggestions entirely.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Nondisposable
7
+ module Generators
8
+ # For apps installed before 0.6.0: adds the source snapshots table that
9
+ # multi-source blocklists need. Purely additive — it touches no existing
10
+ # table, and the app keeps working the old way until it is migrated.
11
+ #
12
+ # rails generate nondisposable:upgrade
13
+ # rails db:migrate
14
+ class UpgradeGenerator < Rails::Generators::Base
15
+ include ActiveRecord::Generators::Migration
16
+
17
+ source_root File.expand_path('templates', __dir__)
18
+
19
+ def self.next_migration_number(dir)
20
+ ActiveRecord::Generators::Base.next_migration_number(dir)
21
+ end
22
+
23
+ def create_source_snapshots_migration
24
+ migration_template 'create_nondisposable_source_snapshots.rb.erb',
25
+ File.join(db_migrate_path, 'create_nondisposable_source_snapshots.rb')
26
+ end
27
+
28
+ def display_post_upgrade_message
29
+ say "\tAdded the nondisposable_source_snapshots migration.", :green
30
+ say "\nNext:"
31
+ say " 1. Run 'rails db:migrate'. Until you do, the gem keeps using the single default list, so deploying first is safe."
32
+ say " 2. Optionally choose your sources in config/initializers/nondisposable.rb, e.g. config.sources = Nondisposable::Sources::RECOMMENDED"
33
+ say " 3. Run 'Nondisposable::DomainListUpdater.update' (or wait for your scheduled job)."
34
+ end
35
+
36
+ private
37
+
38
+ def migration_version
39
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'active_model/type'
5
+
6
+ module Nondisposable
7
+ # The way out of a lookalike error: "yes, this address is right".
8
+ #
9
+ # With `reject_lookalike_domains` on, `someone@ge.com` gets "looks like a
10
+ # typo. Did you mean someone@me.com?" — and General Electric's staff are not
11
+ # mistyping anything. Offline the gem cannot tell them from `gmial.com`, so
12
+ # instead of refusing, it asks. This concern gives the record somewhere to
13
+ # put the answer:
14
+ #
15
+ # class User < ApplicationRecord
16
+ # include Nondisposable::ConfirmableEmail
17
+ # confirmable_email :email # defines email_lookalike_confirmed
18
+ #
19
+ # validates :email, nondisposable: true
20
+ # end
21
+ #
22
+ # # In the form, only when that error is showing:
23
+ # <% if @user.errors.of_kind?(:email, :lookalike_domain) %>
24
+ # <%= f.check_box :email_lookalike_confirmed %>
25
+ # <%= f.label :email_lookalike_confirmed, "Sí, mi email es correcto" %>
26
+ # <% end %>
27
+ #
28
+ # # And permit it: params.require(:user).permit(:email, :email_lookalike_confirmed)
29
+ #
30
+ # A confirmed record passes the lookalike check and nothing else: a
31
+ # disposable provider, a TLD that does not exist, a blocked TLD and a
32
+ # reviewed typo (Nondisposable::KnownTypos) are still refused, because for
33
+ # those there is no address the user could be right about.
34
+ #
35
+ # The attribute is virtual — never persisted — and is reset after every
36
+ # save, so it answers for one save: a later lookalike is asked again.
37
+ #
38
+ # No concern needed if you would rather wire it yourself: the validator
39
+ # looks for a method named `<attribute>_lookalike_confirmed` returning true,
40
+ # however the record defines it.
41
+ module ConfirmableEmail
42
+ extend ActiveSupport::Concern
43
+
44
+ SUFFIX = '_lookalike_confirmed'
45
+
46
+ class_methods do
47
+ def confirmable_email(*attributes)
48
+ attributes.each do |attribute|
49
+ name = "#{attribute}#{SUFFIX}"
50
+ ivar = "@#{name}"
51
+
52
+ define_method(name) { instance_variable_defined?(ivar) ? instance_variable_get(ivar) : false }
53
+ # Form params arrive as "1", "0", "true", "" — cast the way Rails
54
+ # casts a boolean column, so a checkbox works without ceremony.
55
+ define_method("#{name}=") { |value| instance_variable_set(ivar, ActiveModel::Type::Boolean.new.cast(value) || false) }
56
+ alias_method "#{name}?", name
57
+ end
58
+
59
+ # Cleared after every save, so a record that was confirmed once and
60
+ # then given a different lookalike address is asked again.
61
+ return unless respond_to?(:after_save)
62
+
63
+ names = attributes.map { |attribute| "#{attribute}#{SUFFIX}" }
64
+ after_save { names.each { |name| public_send("#{name}=", false) } }
65
+ end
66
+ end
67
+
68
+ # Has the user confirmed this attribute's address as typed? Strictly
69
+ # `true`: a truthy string a host forgot to cast must not count as a "yes".
70
+ def self.confirmed?(record, attribute)
71
+ reader = "#{attribute}#{SUFFIX}"
72
+ record.respond_to?(reader) && record.public_send(reader) == true
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nondisposable
4
+ # One answer to "is this the same domain?", shared by every path that
5
+ # compares a domain against a list.
6
+ #
7
+ # Before this existed each path trimmed its input its own way: the suggestion
8
+ # layer stripped a trailing dot and the database check did not, so
9
+ # `user@gmial.com.` and `user@gmial.com` could get different verdicts from the
10
+ # same gem. Anything that matches a domain against a list — bundled, host
11
+ # configured or downloaded — should go through #normalize first, so that two
12
+ # spellings of one host can never be judged differently.
13
+ #
14
+ # WHAT IT DOES
15
+ #
16
+ # Nondisposable::Domain.normalize(" GMail.COM. ") # => "gmail.com"
17
+ # Nondisposable::Domain.normalize("bücher.de") # => "xn--bcher-kva.de"
18
+ # Nondisposable::Domain.normalize("XN--BCHER-KVA.DE") # => "xn--bcher-kva.de"
19
+ # Nondisposable::Domain.from_email("First+tag@GMail.com") # => "gmail.com"
20
+ #
21
+ # Only the DOMAIN is ever touched. The local part is case-sensitive under
22
+ # RFC 5321 and `+tags` are the user's business; nothing here reads or
23
+ # rewrites them.
24
+ #
25
+ # WHY PUNYCODE, AND WHY NO DEPENDENCY
26
+ #
27
+ # A unicode domain and its punycode form are the same host (`bücher.de` ==
28
+ # `xn--bcher-kva.de`), and lists store the ASCII form, so matching has to
29
+ # happen in ASCII. The `simpleidn` / `addressable` gems would do it, but the
30
+ # encoding itself (RFC 3492) is forty lines of arithmetic and this gem ships
31
+ # with no runtime dependencies beyond Rails. What we deliberately do NOT
32
+ # implement is the full IDNA2008/UTS #46 validity table (which code points
33
+ # are disallowed): we map — NFKC, lowercase, the ideographic full stops, the
34
+ # invisible characters UTS #46 drops (nontransitionally: ZWJ/ZWNJ stay) — and
35
+ # encode, and leave "is this label legal" to the DNS. For matching that is
36
+ # the right trade: a domain we encode that the registry would have refused
37
+ # cannot be on anybody's list either.
38
+ module Domain
39
+ # The label separators UTS #46 treats as a full stop: ideographic,
40
+ # fullwidth and halfwidth ideographic. A user on a CJK keyboard types these.
41
+ DOTS = /[。.。]/.freeze
42
+
43
+ # Every code point UTS #46 maps to nothing ("ignored" in
44
+ # IdnaMappingTable.txt, Unicode 2026-06-29): soft hyphen, combining
45
+ # grapheme joiner, Hangul and Khmer fillers, Mongolian variation selectors,
46
+ # zero-width space, word joiner and invisible operators, variation
47
+ # selectors 1–256, BOM, shorthand format controls, musical beam controls.
48
+ # Invisible in a form field, so `maili<SHY>nator.com` would otherwise be a
49
+ # different string from the listed `mailinator.com` — while a mail server
50
+ # applying IDNA would deliver it to mailinator.com.
51
+ #
52
+ # ZWNJ and ZWJ (U+200C/D) are deliberately NOT here. UTS #46 lists them as
53
+ # "deviation" characters, and nontransitional processing (IDNA2008, what
54
+ # registries and browsers use) keeps them: `نامه‌ای.com` with a ZWNJ is a
55
+ # different registered domain from the same letters without one.
56
+ IGNORED = /[­͏ᅟᅠ឴឵᠋-᠏​⁠-⁤-ㅤ︀-️ᅠ\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}\u{E0100}-\u{E01EF}]/.freeze
57
+
58
+ # DNS caps a name at 253 octets (RFC 1035 §2.3.4) and a label at 63, and
59
+ # the punycode encoder is quadratic in a label's distinct code points, so
60
+ # #normalize hands anything longer back unencoded: it cannot be a
61
+ # registered domain or match a stored row. The cap applies to the
62
+ # NORMALISED name — `e` + combining acute is two code points that NFKC
63
+ # composes into one `é`, so a valid 32-letter label can arrive as 64.
64
+ MAX_LENGTH = 253
65
+ MAX_LABEL_LENGTH = 63
66
+
67
+ # The bounds on what is fed to NFKC, which is superlinear on hostile input
68
+ # (a long run of combining marks to reorder). 1,024 code points is four
69
+ # per allowed character — room for any real decomposition (Hangul jamo
70
+ # take three) — and matches DisposableDomain::MAX_INPUT_LENGTH. A run of
71
+ # more than 30 combining marks is not stream-safe text (UAX #15 §13), so
72
+ # no registered label contains one and it is not worth normalising.
73
+ MAX_RAW_LENGTH = 1_024
74
+ UNSTREAMSAFE = /\p{M}{31,}/.freeze
75
+
76
+
77
+ # RFC 3492 §5: the bootstring parameters that make it punycode.
78
+ BASE = 36
79
+ TMIN = 1
80
+ TMAX = 26
81
+ SKEW = 38
82
+ DAMP = 700
83
+ INITIAL_BIAS = 72
84
+ INITIAL_N = 128
85
+ private_constant :BASE, :TMIN, :TMAX, :SKEW, :DAMP, :INITIAL_BIAS, :INITIAL_N
86
+
87
+ class << self
88
+ # The canonical, ASCII form of a domain: stripped, lowercased, without a
89
+ # trailing root dot, unicode labels punycode-encoded. "" for blank input,
90
+ # never nil, so callers can chain without guarding.
91
+ def normalize(domain)
92
+ value = unicode(domain)
93
+ return value if value.ascii_only? || value.encoding == Encoding::BINARY
94
+ # The punycode encoder is quadratic in the distinct code points of a
95
+ # label: a 4,000-character label would cost seconds. Over DNS's limits
96
+ # it cannot be a registered domain; hand it back unencoded.
97
+ return value if oversized?(value)
98
+
99
+ value.split('.', -1).map { |label| label.ascii_only? ? label : "xn--#{punycode(label)}" }.join('.')
100
+ end
101
+
102
+ # Longer than DNS allows once normalised — over 253 characters, a label
103
+ # over 63, or more than MAX_RAW_LENGTH code points before NFKC could
104
+ # even run? Such a name cannot be registered, so no address at it can
105
+ # receive mail, and no list can hold it: callers must treat it as
106
+ # invalid rather than as "not disposable". Judged on the punycode form,
107
+ # since a label that fits in unicode can still encode past 63.
108
+ def too_long?(domain)
109
+ oversized?(normalize(domain))
110
+ end
111
+
112
+ # The same canonicalisation WITHOUT the punycode step, for code that
113
+ # compares what a human typed (the suggestion layer: a keyboard slip is a
114
+ # distance between the characters someone pressed, and punycode letters
115
+ # have no keyboard meaning — `xn--gmal-zqa` is `gmaíl`). Never raises.
116
+ def unicode(domain)
117
+ value = utf8(domain.to_s)
118
+ # Bytes that are not UTF-8 cannot be a domain anybody typed: keep a
119
+ # stable, comparable binary form instead of raising inside a validator.
120
+ return value.strip.downcase.delete_suffix('.') if value.encoding == Encoding::BINARY
121
+
122
+ value = value.strip
123
+ return value.downcase.delete_suffix('.') if value.ascii_only?
124
+
125
+ # Linear passes first, so the raw bound below sees what is left once
126
+ # the invisible padding is gone (300 soft hyphens around
127
+ # mailinator.com must not buy an exemption). The DNS limits are
128
+ # enforced by #normalize on the result, after NFKC has composed it.
129
+ value = value.gsub(IGNORED, '').gsub(DOTS, '.')
130
+ return value.downcase.delete_suffix('.') if value.length > MAX_RAW_LENGTH || value.match?(UNSTREAMSAFE)
131
+
132
+ # NFKC, not NFC: it also folds compatibility forms, so fullwidth
133
+ # `gmail` is `gmail` — the mapping UTS #46 applies before lookup.
134
+ value.unicode_normalize(:nfkc).gsub(DOTS, '.').downcase.delete_suffix('.')
135
+ end
136
+
137
+ # The domain half of an address, normalised. Splits on the LAST "@", so a
138
+ # quoted local part containing one (`"a@b"@example.com`) still yields the
139
+ # right domain. "" when there is no "@" or nothing after it. An empty
140
+ # local part (`@tempmail.com`) still has a domain worth judging, so it
141
+ # is not this method's place to refuse it.
142
+ #
143
+ # `unicode: true` returns the #unicode form instead of the punycode one:
144
+ # what DisposableDomain needs to also match rows 0.4.0 stored unencoded.
145
+ def from_email(email, unicode: false)
146
+ email = email.to_s
147
+ email = email.b unless email.valid_encoding? # unreadable bytes must not raise
148
+ _local, at, domain = email.rpartition('@')
149
+ return '' if at.empty?
150
+
151
+ unicode ? unicode(domain) : normalize(domain)
152
+ end
153
+
154
+ private
155
+
156
+ # A String in UTF-8 when its bytes are UTF-8, whatever encoding it was
157
+ # labelled with: a form param or a log line handed over as ASCII-8BIT is
158
+ # still `bücher.de`, and 0.4.0 accepted it without raising.
159
+ def utf8(value)
160
+ return value if value.encoding == Encoding::UTF_8 && value.valid_encoding?
161
+
162
+ copy = value.dup.force_encoding(Encoding::UTF_8)
163
+ copy.valid_encoding? ? copy : value.b # BINARY: "not UTF-8"
164
+ end
165
+
166
+ def oversized?(value)
167
+ value.length > MAX_LENGTH || value.split('.', -1).any? { |label| label.length > MAX_LABEL_LENGTH }
168
+ end
169
+
170
+
171
+ # RFC 3492 §6.3, the encoder only: we never need to decode.
172
+ def punycode(label)
173
+ input = label.codepoints
174
+ output = input.select { |c| c < 0x80 }.pack('U*')
175
+ basic = output.length
176
+ handled = basic
177
+ output += '-' if basic.positive?
178
+
179
+ n = INITIAL_N
180
+ delta = 0
181
+ bias = INITIAL_BIAS
182
+
183
+ while handled < input.length
184
+ m = input.select { |c| c >= n }.min
185
+ delta += (m - n) * (handled + 1)
186
+ n = m
187
+
188
+ input.each do |c|
189
+ delta += 1 if c < n
190
+ next unless c == n
191
+
192
+ q = delta
193
+ k = BASE
194
+ loop do
195
+ t = threshold(k, bias)
196
+ break if q < t
197
+
198
+ output << digit(t + ((q - t) % (BASE - t)))
199
+ q = (q - t) / (BASE - t)
200
+ k += BASE
201
+ end
202
+ output << digit(q)
203
+ bias = adapt(delta, handled + 1, handled == basic)
204
+ delta = 0
205
+ handled += 1
206
+ end
207
+
208
+ delta += 1
209
+ n += 1
210
+ end
211
+
212
+ output
213
+ end
214
+
215
+ def threshold(k, bias)
216
+ return TMIN if k <= bias
217
+ return TMAX if k >= bias + TMAX
218
+
219
+ k - bias
220
+ end
221
+
222
+ def digit(d)
223
+ (d < 26 ? d + 97 : d + 22).chr
224
+ end
225
+
226
+ def adapt(delta, points, first)
227
+ delta = first ? delta / DAMP : delta / 2
228
+ delta += delta / points
229
+ k = 0
230
+ while delta > ((BASE - TMIN) * TMAX) / 2
231
+ delta /= BASE - TMIN
232
+ k += BASE
233
+ end
234
+ k + (((BASE - TMIN + 1) * delta) / (delta + SKEW))
235
+ end
236
+ end
237
+ end
238
+ end