usernames 0.1.3

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 (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +39 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +362 -0
  5. data/SECURITY.md +42 -0
  6. data/config/locales/usernames.en.yml +72 -0
  7. data/config/locales/usernames.es.yml +51 -0
  8. data/config/locales/usernames.pt.yml +51 -0
  9. data/data/LICENSES/allprofanity.txt +24 -0
  10. data/data/LICENSES/big-username-blocklist.txt +25 -0
  11. data/data/LICENSES/corpora.txt +128 -0
  12. data/data/LICENSES/cuss.txt +25 -0
  13. data/data/LICENSES/django-registration.txt +33 -0
  14. data/data/LICENSES/dsojevic-profanity-list.txt +24 -0
  15. data/data/LICENSES/forwardemail.txt +24 -0
  16. data/data/LICENSES/friendly-words.txt +24 -0
  17. data/data/LICENSES/gitea.txt +23 -0
  18. data/data/LICENSES/gitlab.txt +31 -0
  19. data/data/LICENSES/iana-well-known-uris.txt +130 -0
  20. data/data/LICENSES/imsky-wordlists.txt +24 -0
  21. data/data/LICENSES/mottie-github-reserved-names.txt +24 -0
  22. data/data/LICENSES/obscenity.txt +27 -0
  23. data/data/LICENSES/petname.txt +218 -0
  24. data/data/LICENSES/rfc2142.txt +9 -0
  25. data/data/LICENSES/shouldbee.txt +25 -0
  26. data/data/LICENSES/usernames.txt +30 -0
  27. data/data/NOTICE +36 -0
  28. data/data/offensive/README.md +90 -0
  29. data/data/offensive/allowlist/en.txt +727 -0
  30. data/data/offensive/allowlist/es.txt +39 -0
  31. data/data/offensive/en.json +5680 -0
  32. data/data/offensive/es.json +1014 -0
  33. data/data/offensive/hate.yml +51 -0
  34. data/data/offensive/leet.yml +27 -0
  35. data/data/reserved/ALLOWLIST_EXCEPTIONS.txt +6 -0
  36. data/data/reserved/impersonation.txt +69 -0
  37. data/data/reserved/mailboxes.txt +77 -0
  38. data/data/reserved/system.txt +124 -0
  39. data/data/reserved/web.txt +962 -0
  40. data/data/words/adjectives.txt +367 -0
  41. data/data/words/animals.txt +253 -0
  42. data/lib/generators/usernames/availability_generator.rb +67 -0
  43. data/lib/generators/usernames/existing_schema.rb +85 -0
  44. data/lib/generators/usernames/install_generator.rb +241 -0
  45. data/lib/generators/usernames/install_messages.rb +63 -0
  46. data/lib/generators/usernames/locale_generator.rb +56 -0
  47. data/lib/generators/usernames/templates/add_username.rb.erb +53 -0
  48. data/lib/generators/usernames/templates/availability/_field.html.erb.tt +25 -0
  49. data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +107 -0
  50. data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +115 -0
  51. data/lib/generators/usernames/templates/usernames.rb +34 -0
  52. data/lib/tasks/usernames.rake +38 -0
  53. data/lib/usernames/adapters/moderate.rb +37 -0
  54. data/lib/usernames/audit/output.rb +51 -0
  55. data/lib/usernames/audit/values.rb +34 -0
  56. data/lib/usernames/audit.rb +230 -0
  57. data/lib/usernames/candidates.rb +64 -0
  58. data/lib/usernames/comparison/index_contract.rb +241 -0
  59. data/lib/usernames/comparison/index_expression.rb +183 -0
  60. data/lib/usernames/comparison.rb +127 -0
  61. data/lib/usernames/configuration.rb +197 -0
  62. data/lib/usernames/data.rb +205 -0
  63. data/lib/usernames/engine.rb +32 -0
  64. data/lib/usernames/errors.rb +16 -0
  65. data/lib/usernames/generator.rb +206 -0
  66. data/lib/usernames/model.rb +397 -0
  67. data/lib/usernames/normalizer.rb +51 -0
  68. data/lib/usernames/offensive/hate.rb +124 -0
  69. data/lib/usernames/offensive/lexicon.rb +156 -0
  70. data/lib/usernames/offensive/name.rb +254 -0
  71. data/lib/usernames/offensive.rb +136 -0
  72. data/lib/usernames/phrase.rb +68 -0
  73. data/lib/usernames/reserved/listing.rb +79 -0
  74. data/lib/usernames/reserved.rb +219 -0
  75. data/lib/usernames/result.rb +52 -0
  76. data/lib/usernames/rules/options.rb +175 -0
  77. data/lib/usernames/rules.rb +257 -0
  78. data/lib/usernames/unique_violation.rb +176 -0
  79. data/lib/usernames/validator.rb +61 -0
  80. data/lib/usernames/version.rb +5 -0
  81. data/lib/usernames/words.rb +13 -0
  82. data/lib/usernames.rb +138 -0
  83. data/sig/usernames.rbs +75 -0
  84. metadata +217 -0
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ module Usernames
6
+ # Reserved names: the exact list (config.reserved, your routes,
7
+ # the bundled system, mailbox and web words) and the impersonation list
8
+ # (config.forbidden, the bundled impersonation words, your app's name),
9
+ # with config.allowed lifting individual words.
10
+ #
11
+ # The two lists are matched differently because they fail differently.
12
+ # Somebody posing as staff types `adm1n`, `ad.min`, `admin_bob` or
13
+ # `admins`, so impersonation words are looked for in every form the name
14
+ # can be read as. The thousand-odd exact words are paths and mailboxes
15
+ # that only collide when spelled exactly: folding trailing digits over
16
+ # them is what refused `game969` in production, and matching them as
17
+ # prefixes is what refused `badminton_fan`.
18
+ module Reserved
19
+ SEPARATORS = "_.-"
20
+
21
+ # A segment is what sits between separators and digit runs:
22
+ # `adm1n_bob` reads as adm, n, bob.
23
+ SEGMENT_BOUNDARY = /[_.-]+|\d+/
24
+
25
+ # Names that read as a well-known URI or as an IDNA/punycode label
26
+ # whatever follows: `/.well-known/…` is served by the web server, and
27
+ # `xn--…` is how a Unicode lookalike travels as ASCII.
28
+ PREFIXES = %w[.well-known xn--].freeze
29
+
30
+ # The compiled sets for one Usernames::Rules. Every word is an ASCII
31
+ # comparison key, so a lookup is one Set#include?. :nodoc:
32
+ Sets = ::Data.define(:exact, :custom, :impersonation, :allowed)
33
+
34
+ class << self
35
+ # The kind of reservation that matched — :impersonation, :exact, :route
36
+ # or :custom — or nil when the name is free to use. `key` is
37
+ # Usernames.comparison_key of a name that already passed every format
38
+ # rule; `rules` is the Usernames::Rules in force.
39
+ #
40
+ # config.allowed lifts any of them, but only for exactly that name:
41
+ # allowing `help` frees `help`, not `help_desk`.
42
+ #
43
+ # A word in config.forbidden is matched as an impersonation
44
+ # word and reported as :impersonation; :custom names a config.reserved
45
+ # word that is not bundled. A reserved prefix (`.well-known`, `xn--`)
46
+ # is reported as :exact.
47
+ def verdict(key, rules)
48
+ sets = sets_for(rules)
49
+ return if sets.allowed.include?(key)
50
+
51
+ if impersonation?(key, sets.impersonation) then :impersonation
52
+ elsif sets.custom.include?(key) then :custom
53
+ elsif sets.exact.include?(key) || prefix?(key) then :exact
54
+ elsif rules.reserve_routes && routes.include?(key) then :route
55
+ end
56
+ end
57
+
58
+ # Whether any spelling of the name is an impersonation entry: the name
59
+ # exactly as typed (so a custom entry that is itself digits or
60
+ # separators, `web3`, still forbids itself), and — as typed and
61
+ # leet-folded — whole and without trailing digits (separators removed),
62
+ # and every segment. The first spelling that is an entry answers, so
63
+ # most names are refused or passed in a few lookups.
64
+ def impersonation?(key, entries)
65
+ return true if entries.include?(key)
66
+
67
+ [key, leet(key)].uniq.any? do |form|
68
+ entries.include?(form.delete(SEPARATORS)) ||
69
+ entries.include?(form.sub(/\d+\z/, "").delete(SEPARATORS)) ||
70
+ form.split(SEGMENT_BOUNDARY).any? { |segment| entries.include?(segment) }
71
+ end
72
+ end
73
+
74
+ def prefix?(key)
75
+ key.start_with?(*PREFIXES)
76
+ end
77
+
78
+ # The digit folds of data/offensive/leet.yml: 0→o 1→i 3→e 4→a 5→s 7→t.
79
+ def leet(string)
80
+ from, to = digit_folds
81
+ string.tr(from, to)
82
+ end
83
+
84
+ # Where a bundled reserved word comes from, for `usernames:reserved`:
85
+ # a Usernames::Data::Provenance (category, source, licence, reason),
86
+ # or nil for a word the gem does not ship.
87
+ def source_of(word)
88
+ Data.reserved_provenance[word.to_s.downcase(:ascii)]
89
+ end
90
+
91
+ # The first path segment of every static route the app draws, collected
92
+ # once and kept until reset_routes! (the engine calls it when routes
93
+ # reload). Empty outside a Rails application.
94
+ def routes
95
+ @routes ||= rails_routes
96
+ end
97
+
98
+ def reset_routes!
99
+ @routes = nil
100
+ end
101
+
102
+ # The app's own name as it would be typed: `config.app_name`
103
+ # when it is a String, else the module of the Rails application —
104
+ # `RecipeBox::Application` reserves `recipe_box` and
105
+ # `recipebox`. None when opted out (`false`) or when there is no
106
+ # named application.
107
+ def app_names(rules)
108
+ name = rules.app_name
109
+ name = detected_app_name if name.nil?
110
+ return [] unless name.is_a?(String)
111
+
112
+ underscored = name.underscore.downcase(:ascii).gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
113
+ [underscored, underscored.delete("_")].reject(&:empty?).uniq
114
+ end
115
+
116
+ private
117
+
118
+ def sets_for(rules)
119
+ rules.compiled(:reserved) do
120
+ bundled = Data.reserved.to_set
121
+ reserved = keys(rules.reserved)
122
+ Sets.new(
123
+ exact: (reserved & bundled).freeze,
124
+ custom: (reserved - bundled).freeze,
125
+ impersonation: impersonation_entries(keys(rules.forbidden) | app_names(rules)).freeze,
126
+ allowed: keys(rules.allowed)
127
+ )
128
+ end
129
+ end
130
+
131
+ # Each word as the matcher stores it: itself, leet-folded, and the plural
132
+ # of both; and, for an entry written with separators (`my_app`), the
133
+ # same again without them — the name side is read without separators
134
+ # too, and an entry with one could otherwise only ever match literally.
135
+ def impersonation_entries(words)
136
+ words.each_with_object(Set.new) do |word, entries|
137
+ [word, word.delete(SEPARATORS)].uniq.each do |form|
138
+ [form, leet(form)].each { |spelling| entries << spelling << "#{spelling}s" }
139
+ end
140
+ end
141
+ end
142
+
143
+ def keys(words)
144
+ words.to_set { |word| word.strip.downcase(:ascii) }.freeze
145
+ end
146
+
147
+ def digit_folds
148
+ @digit_folds ||= begin
149
+ digits = Data.leet.fetch("digits")
150
+ [digits.keys.join, digits.values.join].map(&:freeze).freeze
151
+ end
152
+ end
153
+
154
+ def rails_routes
155
+ return Set.new.freeze unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
156
+
157
+ Routes.collect(::Rails.application.routes)
158
+ end
159
+
160
+ # Rails::Application subclasses are named `<App>::Application`; one
161
+ # defined at the top level (a script, a test) has no app name.
162
+ def detected_app_name
163
+ return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
164
+
165
+ parent = ::Rails.application.class.module_parent
166
+ parent.name unless parent == Object
167
+ end
168
+ end
169
+
170
+ # The first path segment of an app's static routes, so a user
171
+ # called `login` cannot take /login from under the app's feet.
172
+ module Routes
173
+ # What a route's first segment may be: anything a username could be.
174
+ # Others (`@`, `~`) can never collide, so they are left out.
175
+ SEGMENT = /\A[a-z0-9._-]+\z/
176
+
177
+ class << self
178
+ # The frozen Set of first segments from an ActionDispatch::Routing::RouteSet.
179
+ #
180
+ # Reads each route's Journey AST: a path of the form `/literal`, then
181
+ # nothing, another `/`, or an optional group that starts with `/` or
182
+ # `.` (`/login(.:format)`, `/bar(/:x)`). A literal followed directly by a
183
+ # dot or a parameter (`/foo.:kind`, `/@:username`) is only part of a
184
+ # dynamic segment, and `/:username`, `/*path` have no literal at all.
185
+ # A mounted engine contributes its mount path, like any other route.
186
+ def collect(route_set)
187
+ route_set.routes.each_with_object(Set.new) do |route, segments|
188
+ segment = first_segment(route.path.spec)
189
+ segments << segment if segment
190
+ end.freeze
191
+ end
192
+
193
+ private
194
+
195
+ def first_segment(spec)
196
+ slash, literal, following = flatten(spec)
197
+ return unless slash&.type == :SLASH && literal&.type == :LITERAL && complete?(following)
198
+
199
+ segment = literal.left.to_s.downcase(:ascii)
200
+ segment if SEGMENT.match?(segment)
201
+ end
202
+
203
+ def complete?(node)
204
+ case node&.type
205
+ when nil, :SLASH then true
206
+ when :GROUP then %i[SLASH DOT].include?(flatten(node.left).first&.type)
207
+ else false
208
+ end
209
+ end
210
+
211
+ # A Journey path is a right-leaning tree of Cat nodes; this reads it
212
+ # as the list of nodes it spells, groups left whole.
213
+ def flatten(node)
214
+ node.type == :CAT ? flatten(node.left) + flatten(node.right) : [node]
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ # The answer to `Usernames.check`: whether a name may be registered, and if
5
+ # not, which rule said so.
6
+ #
7
+ # reason the error type a model would get (:username_reserved, :too_short…)
8
+ # rule for a content verdict, which kind of match it was: :impersonation,
9
+ # :exact, :route, :custom, :severe, :strong, :hate_number, :adapter.
10
+ # nil when the name is fine or failed a format rule (the reason
11
+ # already names a format rule exactly).
12
+ # canonical the normalised name the rules were applied to
13
+ # message the error message in the current locale, or nil when ok.
14
+ # Never contains what was typed — and never which word matched.
15
+ #
16
+ # `::Data`, not `Data`: inside this namespace `Data` is Usernames::Data.
17
+ Result = ::Data.define(:ok?, :reason, :rule, :canonical, :message) do
18
+ # `record` and `attribute` word the message the way that model's own
19
+ # error would be worded (see Failure#message).
20
+ def self.for(canonical, failure, record: nil, attribute: nil)
21
+ return new(ok?: true, reason: nil, rule: nil, canonical: canonical, message: nil) unless failure
22
+
23
+ new(ok?: false, reason: failure.reason, rule: failure.rule, canonical: canonical,
24
+ message: failure.message(record, attribute))
25
+ end
26
+ end
27
+
28
+ # One failed rule, as the pipeline hands it around before it becomes a
29
+ # Result (for the pure functions) or an Active Model error (for the
30
+ # validator). `details` are the interpolations the message needs, kept
31
+ # separate so the validator can hand them to errors.add and let a per-model
32
+ # translation win, exactly as for a Rails error. :nodoc:
33
+ Failure = ::Data.define(:reason, :rule, :details) do
34
+ def initialize(reason:, rule: nil, details: {})
35
+ super
36
+ end
37
+
38
+ # A Symbol :message in the details names the translation to use while the
39
+ # type stays `reason` — Active Model's own convention for errors.add.
40
+ #
41
+ # With a `record` (any instance of the model) and its `attribute`, the
42
+ # message is looked up exactly as that model's error would be
43
+ # (activerecord.errors.models.user.attributes.handle.too_short first, then
44
+ # up to errors.messages), so the browser hints and the availability
45
+ # check say what the form says.
46
+ def message(record = nil, attribute = nil)
47
+ return record.errors.generate_message(attribute, reason, details.dup) if record
48
+
49
+ I18n.t(details.fetch(:message, reason), scope: %i[errors messages], **details.except(:message)).to_s
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ class Rules
5
+ # Every option the gem takes, checked once when a Rules is built — so an
6
+ # initializer typo (`config.case = :lower`, `length: 3`) fails at boot
7
+ # with the bad value named, instead of quietly meaning something else on
8
+ # the day somebody signs up.
9
+ module Options
10
+ NAMES = %i[
11
+ length separators edge_separators case first_character reserve_routes app_name
12
+ reserved forbidden allowed offensive on_adapter_failure generator unique_index
13
+ ].freeze
14
+
15
+ OFFENSIVE_NAMES = %i[severity languages allowed adapters].freeze
16
+ GENERATOR_NAMES = %i[adjectives nouns callable].freeze
17
+
18
+ SEPARATORS = %w[_ . -].freeze
19
+ CASES = %i[lowercase preserve].freeze
20
+ FIRST_CHARACTERS = %i[alphanumeric letter].freeze
21
+ SEVERITIES = %i[severe strong mild off].freeze
22
+ ADAPTER_FAILURE_MODES = %i[allow reject].freeze
23
+
24
+ OffensiveSettings = ::Data.define(*OFFENSIVE_NAMES)
25
+ GeneratorSettings = ::Data.define(*GENERATOR_NAMES)
26
+
27
+ attr_reader(*NAMES)
28
+
29
+ private
30
+
31
+ def assign(options)
32
+ unknown = options.keys - NAMES
33
+ raise ConfigurationError, "unknown option #{unknown.first.inspect} (known: #{NAMES.join(', ')})" if unknown.any?
34
+
35
+ assign_format(options)
36
+ assign_lists(options)
37
+ assign_offensive(options.fetch(:offensive))
38
+ assign_generator(options.fetch(:generator))
39
+ @reserve_routes = boolean(:reserve_routes, options.fetch(:reserve_routes))
40
+ @app_name = valid_app_name(options.fetch(:app_name))
41
+ @unique_index = valid_unique_index(options.fetch(:unique_index))
42
+ end
43
+
44
+ def assign_format(options)
45
+ @length = valid_length(options.fetch(:length))
46
+ @separators = valid_separators(options.fetch(:separators))
47
+ @edge_separators = boolean(:edge_separators, options.fetch(:edge_separators))
48
+ @case = one_of(:case, options.fetch(:case), CASES)
49
+ @first_character = one_of(:first_character, options.fetch(:first_character), FIRST_CHARACTERS)
50
+ end
51
+
52
+ def assign_lists(options)
53
+ @reserved = valid_words(:reserved, options.fetch(:reserved))
54
+ @forbidden = valid_words(:forbidden, options.fetch(:forbidden))
55
+ @allowed = valid_words(:allowed, options.fetch(:allowed))
56
+ @on_adapter_failure = one_of(:on_adapter_failure, options.fetch(:on_adapter_failure), ADAPTER_FAILURE_MODES)
57
+ end
58
+
59
+ def assign_offensive(offensive)
60
+ offensive = settings(:offensive, offensive, OFFENSIVE_NAMES)
61
+ @offensive = OffensiveSettings.new(
62
+ severity: one_of(:"offensive.severity", offensive[:severity], SEVERITIES),
63
+ languages: valid_languages(offensive[:languages]),
64
+ allowed: valid_words(:"offensive.allowed", offensive[:allowed]),
65
+ adapters: valid_adapters(offensive[:adapters])
66
+ )
67
+ end
68
+
69
+ def assign_generator(generator)
70
+ generator = settings(:generator, generator, GENERATOR_NAMES)
71
+ callable = generator[:callable]
72
+ unless callable.nil? || callable.respond_to?(:call)
73
+ invalid!(:generator, callable, "a callable such as ->(attempt) { … }")
74
+ end
75
+
76
+ @generator = GeneratorSettings.new(
77
+ adjectives: valid_words(:"generator.adjectives", generator[:adjectives]),
78
+ nouns: valid_words(:"generator.nouns", generator[:nouns]),
79
+ callable: callable
80
+ )
81
+ end
82
+
83
+ def valid_length(value)
84
+ valid = value.is_a?(Range) && value.begin.is_a?(Integer) && value.end.is_a?(Integer) &&
85
+ value.min.to_i >= 1
86
+ invalid!(:length, value, "a Range of whole numbers from 1 up, like 3..30") unless valid
87
+
88
+ value.min..value.max
89
+ end
90
+
91
+ def valid_separators(value)
92
+ unless value.is_a?(Array) && value.all? { |separator| SEPARATORS.include?(separator) }
93
+ invalid!(:separators, value, "a list drawn from #{SEPARATORS.map(&:inspect).join(', ')}, like %w[_]")
94
+ end
95
+
96
+ value.uniq.freeze
97
+ end
98
+
99
+ def one_of(name, value, choices)
100
+ invalid!(name, value, "one of #{choices.map(&:inspect).join(', ')}") unless choices.include?(value)
101
+
102
+ value
103
+ end
104
+
105
+ def boolean(name, value)
106
+ one_of(name, value, [true, false])
107
+ end
108
+
109
+ # A list of words, naming the one entry that is not a word rather than
110
+ # printing a list that may hold a thousand of them.
111
+ def valid_words(name, value)
112
+ invalid!(name, value, "a list of words, like %w[recipe]") unless value.is_a?(Array)
113
+ # An index, not the entry itself: the bad entry may be nil.
114
+ bad = value.index { |word| !word.is_a?(String) || word.strip.empty? }
115
+ raise ConfigurationError, "#{name} must be a list of words, like %w[recipe] (got #{value[bad].inspect} in it)" if bad
116
+
117
+ value.dup.freeze
118
+ end
119
+
120
+ # Only languages whose words ship: a language without data would refuse
121
+ # nothing, so a typo (`:sp`) or a language not tiered yet would switch
122
+ # the word check off without a word of warning.
123
+ def valid_languages(value)
124
+ unless value.is_a?(Array) && value.all?(Symbol)
125
+ invalid!(:"offensive.languages", value, "a list of locale symbols, like %i[en es]")
126
+ end
127
+ unknown = value - Data.available_locales
128
+ if unknown.any?
129
+ raise ConfigurationError, "offensive.languages has no word list for #{unknown.first.inspect} " \
130
+ "(available: #{Data.available_locales.map(&:inspect).join(', ')})"
131
+ end
132
+
133
+ value.uniq.freeze
134
+ end
135
+
136
+ # :moderate (a no-op unless the moderate gem is loaded) or any callable
137
+ # that takes the name.
138
+ def valid_adapters(value)
139
+ unless value.is_a?(Array) && value.all? { |adapter| adapter == :moderate || adapter.respond_to?(:call) }
140
+ invalid!(:"offensive.adapters", value, "a list of :moderate or callables, like [:moderate]")
141
+ end
142
+
143
+ value.dup.freeze
144
+ end
145
+
146
+ # nil detects the name from the Rails application; false opts out; a
147
+ # String names it explicitly (an app whose brand is not its module name).
148
+ def valid_app_name(value)
149
+ valid = value.nil? || value == false || (value.is_a?(String) && !value.strip.empty?)
150
+ invalid!(:app_name, value, "a String, false to opt out, or nil to detect it") unless valid
151
+
152
+ value
153
+ end
154
+
155
+ def valid_unique_index(value)
156
+ valid = value.nil? || ((value.is_a?(String) || value.is_a?(Symbol)) && !value.empty?)
157
+ invalid!(:unique_index, value, "the name of an index, or nil") unless valid
158
+
159
+ value
160
+ end
161
+
162
+ def settings(name, value, names)
163
+ invalid!(name, value, "a Hash of #{names.join(', ')}") unless value.is_a?(Hash)
164
+ unknown = value.keys - names
165
+ raise ConfigurationError, "unknown option #{name}.#{unknown.first} (known: #{names.join(', ')})" if unknown.any?
166
+
167
+ value
168
+ end
169
+
170
+ def invalid!(name, value, expectation)
171
+ raise ConfigurationError, "#{name} must be #{expectation} (got #{value.inspect})"
172
+ end
173
+ end
174
+ end
175
+ end