gitlab-labkit 5.2.1 → 5.3.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: 6a73e80c74e6f2578a9a86a5ad9c76c3c3c9ff4afc8c1deea262408763e84588
4
- data.tar.gz: e7e1d625f3c634a04fd9a6aea0036270966c3b3a6e924edfebfd56713ede00ef
3
+ metadata.gz: e333c26dffb8c22bb8a131e820b01920887c2792dcffcee8f4ee012663b70831
4
+ data.tar.gz: 1cd8ac6e3b87b8bf1e214234e0d44dfc2b593798554fb1a1206d6e94cc8a2863
5
5
  SHA512:
6
- metadata.gz: 77d32a2075415b452d10b1b01a4ef3679d46bbaacd1bef798ce2b17b0b0109a9ec26565f8065600df41f1090752ae311b5a90fadfd434b4e39025d57dcf2f46f
7
- data.tar.gz: c8e099df23ea1312476d3d1301044f5de159b6ed25098d755b47df2b7653b8958a9f30c778d5742620c4d804255ac31250f7834e090981803e54d26f4ab6f67b
6
+ metadata.gz: 4f602e792b4e8573f1ce8e29a9654221f274a3789e262af412f3b52a937970f5605748bbf9f4580698f6457436c561c905c7e1021e964f545bc511508892f8bc
7
+ data.tar.gz: b131bf8715cecbd30484b89299607b9781e6cda5c690da7dc24f851b1558fdd5eb78a417cbd9baaaf2d93393d3694de8c98aa180b0103e27f5e61d598aa110a8
data/.gitlab-ci.yml CHANGED
@@ -79,6 +79,13 @@ conformance-spec:
79
79
  variables:
80
80
  LABKIT_RUBY_SHA: $CI_COMMIT_SHA
81
81
  rules:
82
+ # Renovate's project-token bot cannot create pipelines in labkit-spec
83
+ # (GitLab forbids project bots from joining other projects), so its MRs
84
+ # would hard-fail the bridge. Attempt the trigger but do not block; a
85
+ # human re-running the pipeline gates the MR properly (it runs as them).
86
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $GITLAB_USER_LOGIN =~ /^project_\d+_bot/'
87
+ changes: !reference [.conformance-trigger-paths, changes]
88
+ allow_failure: true
82
89
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
83
90
  changes: !reference [.conformance-trigger-paths, changes]
84
91
  trigger:
@@ -207,17 +207,63 @@ A `match` hash gates whether a rule applies. Each value is normalised through
207
207
  | plain value | `eq` | `match: { user: nil }`, `match: { method: "POST" }` |
208
208
  | `Regexp` | `re` | `match: { endpoint: %r{\A/api/} }` |
209
209
  | `{ eq: <value> }` | `eq` | `match: { method: { eq: "POST" } }` (YAML-friendly) |
210
+ | `{ ne: <value> }` | `ne` | `match: { method: { ne: "OPTIONS" } }` |
210
211
  | `{ re: <source> }`| `re` | `match: { endpoint: { re: '\A/api/' } }` (YAML-friendly) |
212
+ | `{ nre: <source> }`| `nre` | `match: { endpoint: { nre: '\A/api/' } }` |
211
213
  | `{ oneOf: <Array> }` | `oneOf` | `match: { user: { oneOf: ["7", "42"] } }` (YAML-friendly) |
214
+ | `{ noneOf: <Array> }` | `noneOf` | `match: { plan: { noneOf: ["premium"] } }` |
215
+ | `Array` of matchers | `all` | `match: { endpoint: [%r{\A/api/}, { nre: 'internal' }] }` |
212
216
 
213
- `re` coerces the identifier value via `#to_s` before matching, so it can be
214
- used against non-String values (e.g. matching a 503 status against `{ re: '^5' }`).
217
+ `re` and `nre` coerce the identifier value via `#to_s` before matching, so they
218
+ can be used against non-String values (e.g. matching a 503 status against
219
+ `{ re: '^5' }`).
215
220
 
216
221
  `oneOf` is set membership via `Set#include?` (`eql?`/`hash` equality, no
217
222
  coercion): it agrees with `eq` for Strings, Symbols, booleans, nil, and
218
223
  same-class numerics, but cross-type numerics differ (`1 == 1.0`, yet
219
- `{ oneOf: [1] }` does not match `1.0`). A bare Array is rejected; membership must
220
- be spelled `{ oneOf: [...] }` explicitly.
224
+ `{ oneOf: [1] }` does not match `1.0`). `noneOf` is its complement.
225
+
226
+ An Array means **AND**, so one identifier key can carry several predicates:
227
+
228
+ ```yaml
229
+ endpoint:
230
+ - nre: '/oauth/'
231
+ - nre: '\A/api/'
232
+ - nre: '\A/-/(?:health|liveness|readiness|metrics)'
233
+ ```
234
+
235
+ Its entries must be matcher Hashes or bare `Regexp`s, the two shapes that
236
+ already mean "a matcher" on their own. An Array of plain values is rejected.
237
+ A value meant as one matcher, arriving as a list, therefore fails loudly rather
238
+ than becoming a membership test. Membership is spelled `{ oneOf: [...] }`.
239
+
240
+ An Array holds matchers, not other Arrays, so a match value nests three deep at
241
+ most: an Array, a matcher Hash inside it, and that Hash's `oneOf` list. A value
242
+ nesting deeper than `Matcher::MAX_NESTING_DEPTH` is rejected when the rule is
243
+ built.
244
+
245
+ #### Negation and absent keys
246
+
247
+ `ne`, `nre` and `noneOf` are plain complements of their positive forms, with no
248
+ special case for a missing key. The `Evaluator` looks an identifier key up
249
+ rather than requiring it, so an absent key reaches the matcher as `nil`, and a
250
+ complement is therefore **true** for one: a request with no `endpoint` is not a
251
+ request to an excluded endpoint.
252
+
253
+ That widens a rule rather than narrowing it. A rule that should apply only when
254
+ the key is present says so, with a presence matcher alongside the exclusions:
255
+
256
+ ```ruby
257
+ match: { endpoint: [/./, { nre: '\A/api/' }] }
258
+ ```
259
+
260
+ Since this follows from inversion, a matcher that does accept `nil` inverts the
261
+ other way: `{ ne: nil }`, `{ noneOf: [nil] }` and `{ nre: '^$' }` are all false
262
+ for an absent key, the last because `nil.to_s` is `""`.
263
+
264
+ There is no cap on regex source length. Rules are written by operators, and the
265
+ only bound on a pattern is `Matcher::MATCH_TIMEOUT_SECONDS`, a 50ms wall-clock
266
+ budget applied per compiled `Regexp`.
221
267
 
222
268
  Glob and prefix matchers are intentionally out of scope.
223
269
 
@@ -12,27 +12,49 @@ module Labkit
12
12
  # - any plain value (String, Symbol, Integer, ...) -> :eq matcher
13
13
  # - a Regexp instance -> :re matcher (Ruby convenience)
14
14
  # - { eq: <value> } -> :eq matcher (canonical, YAML-compatible)
15
+ # - { ne: <value> } -> :ne matcher (complement of :eq)
15
16
  # - { re: <String|Regexp> } -> :re matcher (canonical, YAML-compatible)
17
+ # - { nre: <String|Regexp> } -> :nre matcher (complement of :re)
16
18
  # - { oneOf: <Array> } -> :oneOf matcher (set membership, YAML-compatible)
19
+ # - { noneOf: <Array> } -> :noneOf matcher (complement of :oneOf)
20
+ # - [ <matcher>, <matcher>, ... ] -> :all matcher (every entry must match)
17
21
  #
18
- # A bare Array is rejected rather than treated as :oneOf, so a value that
19
- # was meant as a single matcher hash but arrived as an Array fails loudly
20
- # instead of silently becoming a membership test.
22
+ # An Array means AND, so several predicates can apply to one identifier key.
23
+ # Its entries must be matcher Hashes or bare Regexps, the two shapes that
24
+ # already mean "a matcher" on their own. An Array of plain values is
25
+ # rejected, so a value that was meant as a single matcher but arrived as a
26
+ # list fails loudly instead of silently becoming a membership test. An Array
27
+ # holds matchers rather than other Arrays, so a match value nests at most
28
+ # MAX_NESTING_DEPTH deep; see too_deeply_nested?.
21
29
  #
22
- # :oneOf membership is Set#include? (eql?/hash equality), not :eq's ==, so
23
- # cross-type numerics differ (1 == 1.0, but {oneOf: [1]} does not match 1.0).
30
+ # :oneOf and :noneOf membership is Set#include? (eql?/hash equality), not
31
+ # :eq's ==, so cross-type numerics differ (1 == 1.0, but {oneOf: [1]} does
32
+ # not match 1.0).
24
33
  #
25
34
  # Hash-key naming follows the metrics-catalog selector pattern. Glob,
26
35
  # prefix, and other matcher kinds are intentionally out of scope here; see
27
36
  # gitlab-com/gl-infra/production-engineering#28853 for that follow-up.
28
37
  #
29
- # An :re matcher coerces the identifier value via #to_s before applying
30
- # the regex, so callers can match non-String identifier values such as
31
- # Integer status codes (e.g. {status: { re: "^5" }} against status: 503).
38
+ # The :re and :nre matchers coerce the identifier value via #to_s before
39
+ # applying the regex, so callers can match non-String identifier values such
40
+ # as Integer status codes (e.g. {status: { re: "^5" }} against status: 503).
41
+ #
42
+ # Each negation matcher is the plain complement of its positive form, with
43
+ # no special case for a missing key. Evaluator passes identifier[key], so an
44
+ # absent key arrives as nil, and the complement is therefore usually true
45
+ # for one: a request with no path is not a request to an excluded path. A
46
+ # rule that should apply only when the key is present says so, by adding a
47
+ # presence matcher alongside the exclusions:
48
+ #
49
+ # path: [/./, { nre: '\A/api/' }]
32
50
  class Matcher < Data.define(:type, :value)
33
- KNOWN_HASH_KEYS = %i[eq re oneOf].freeze
34
- MAX_REGEX_SOURCE_LENGTH = 200
51
+ KNOWN_HASH_KEYS = %i[eq ne re nre oneOf noneOf].freeze
35
52
  ERROR_INSPECT_LIMIT = 80
53
+ # No Hash or Array may sit this many levels below the match value, which
54
+ # is level 0. The deepest valid shape is an Array (0) holding a matcher
55
+ # Hash (1) whose :oneOf list (2) holds plain values; see
56
+ # too_deeply_nested?.
57
+ MAX_NESTING_DEPTH = 3
36
58
 
37
59
  # Wall-clock budget for a single #match? call. Without it a match is
38
60
  # bounded only by whatever global the host sets (40s in GitLab Rails,
@@ -45,16 +67,23 @@ module Labkit
45
67
  # timeouts were wall-clock stalls, since Ruby counts real time and GC
46
68
  # pauses on GitLab's git fleet have a p50 of ~72ms. Under 4x CPU
47
69
  # oversubscription 50ms still fires on 0.16% of those matches, 5ms 1.33%.
70
+ #
71
+ # This timeout is the only bound on a pattern. There is no cap on regex
72
+ # source length: rules are written by operators rather than end users, and
73
+ # a cap that applied to {re: "..."} but not to a bare Regexp bounded
74
+ # nothing in practice, since callers pass Regexp constants.
48
75
  MATCH_TIMEOUT_SECONDS = 0.05
49
76
 
50
77
  def self.build(input)
78
+ raise ArgumentError, nesting_error(input) if too_deeply_nested?(input)
79
+
51
80
  case input
52
81
  when Regexp
53
- new(type: :re, value: with_match_timeout(input))
82
+ new(type: :re, value: with_match_timeout(:re, input))
54
83
  when Hash
55
84
  from_hash(input)
56
85
  when Array
57
- raise ArgumentError, invalid_shape_error(input)
86
+ compose(input)
58
87
  else
59
88
  new(type: :eq, value: input)
60
89
  end
@@ -64,6 +93,15 @@ module Labkit
64
93
  raise ArgumentError, invalid_shape_error(input) if input.size != 1
65
94
 
66
95
  type, source = input.first
96
+
97
+ # Identifier rejects keys the same way. Without this, a key such as an
98
+ # Integer or nil leaves NoMethodError for the caller instead of the
99
+ # ArgumentError every other bad shape raises.
100
+ unless type.respond_to?(:to_sym)
101
+ raise ArgumentError,
102
+ "rate-limit match value type key #{truncate_for_error(type)} must be a String or Symbol"
103
+ end
104
+
67
105
  type_sym = type.to_sym
68
106
 
69
107
  unless KNOWN_HASH_KEYS.include?(type_sym)
@@ -77,31 +115,45 @@ module Labkit
77
115
 
78
116
  def self.compile(type_sym, source)
79
117
  case type_sym
80
- when :eq
81
- new(type: :eq, value: source)
82
- when :oneOf
118
+ when :eq, :ne
119
+ new(type: type_sym, value: source)
120
+ when :oneOf, :noneOf
83
121
  unless source.is_a?(Array)
84
122
  raise ArgumentError,
85
- "rate-limit match value {oneOf: ...} must be an Array, got #{truncate_for_error(source)}"
123
+ "rate-limit match value {#{type_sym}: ...} must be an Array, got #{truncate_for_error(source)}"
86
124
  end
87
125
 
88
- new(type: :oneOf, value: Set.new(source).freeze)
89
- when :re
90
- if source.to_s.length > MAX_REGEX_SOURCE_LENGTH
126
+ # {oneOf: []} matches nothing, so the rule never applies and no harm
127
+ # follows. Its complement matches everything, which is an exclusion
128
+ # that excludes nothing: rejected for the same reason an empty Array
129
+ # of matchers is.
130
+ if type_sym == :noneOf && source.empty?
91
131
  raise ArgumentError,
92
- "rate-limit match value {re: ...} source exceeds #{MAX_REGEX_SOURCE_LENGTH} characters"
132
+ "rate-limit match value {noneOf: []} must not be empty, it matches everything"
93
133
  end
94
134
 
95
- begin
96
- new(type: :re, value: with_match_timeout(source))
97
- rescue RegexpError, TypeError => e
98
- raise ArgumentError,
99
- "rate-limit match value {re: #{truncate_for_error(source)}} failed to compile: #{e.message}"
100
- end
135
+ new(type: type_sym, value: Set.new(source).freeze)
136
+ when :re, :nre
137
+ new(type: type_sym, value: with_match_timeout(type_sym, source))
101
138
  end
102
139
  end
103
140
  private_class_method :compile
104
141
 
142
+ # Builds the :all matcher behind the Array form. Entries go back through
143
+ # .build, which is what keeps a nested regex bounded by the match timeout.
144
+ def self.compose(input)
145
+ raise ArgumentError, "rate-limit match value [] must not be empty" if input.empty?
146
+
147
+ unless input.all? { |entry| entry.is_a?(Hash) || entry.is_a?(Regexp) }
148
+ raise ArgumentError,
149
+ "rate-limit match value Array entries must each be a matcher Hash or Regexp, " \
150
+ "got #{truncate_for_error(input)}"
151
+ end
152
+
153
+ new(type: :all, value: input.map { |entry| build(entry) }.freeze)
154
+ end
155
+ private_class_method :compose
156
+
105
157
  # Compiles +source+ (a String pattern or an existing Regexp) into a
106
158
  # Regexp bounded by MATCH_TIMEOUT_SECONDS.
107
159
  #
@@ -116,7 +168,7 @@ module Labkit
116
168
  # Recompiling an existing Regexp preserves its source and options
117
169
  # (including the fixed-encoding flags) and therefore +#==+; only object
118
170
  # identity changes.
119
- def self.with_match_timeout(source)
171
+ def self.with_match_timeout(key, source)
120
172
  return source if source.is_a?(Regexp) && source.timeout
121
173
 
122
174
  if source.is_a?(Regexp)
@@ -124,12 +176,19 @@ module Labkit
124
176
  else
125
177
  Regexp.new(source, timeout: MATCH_TIMEOUT_SECONDS)
126
178
  end
179
+ rescue RegexpError, TypeError => e
180
+ raise ArgumentError,
181
+ "rate-limit match value {#{key}: #{truncate_for_error(source)}} failed to compile: #{e.message}"
127
182
  end
128
183
 
129
184
  private_class_method :with_match_timeout
130
185
 
131
186
  def self.invalid_shape_error(input)
132
- "rate-limit match value must be a single-key Hash like {re: \"...\"}, {eq: ...} or {oneOf: [...]}, " \
187
+ # No key list here: this fires on the shape, and from_hash names the
188
+ # accepted keys when the shape is right but the key is not. Keeping the
189
+ # fixed part short is also what lets truncate_for_error bound the whole
190
+ # message.
191
+ "rate-limit match value must be a single-key Hash, or an Array of matchers, " \
133
192
  "got #{truncate_for_error(input)}"
134
193
  end
135
194
  private_class_method :invalid_shape_error
@@ -140,14 +199,65 @@ module Labkit
140
199
  end
141
200
  private_class_method :truncate_for_error
142
201
 
202
+ def self.nesting_error(input)
203
+ # Does not inspect the input: it is too deep to render, which is the
204
+ # reason this fires. The class is the only hint available cheaply, and
205
+ # the nesting may be in a key or a value.
206
+ "rate-limit match value nests deeper than #{MAX_NESTING_DEPTH} levels (#{input.class})"
207
+ end
208
+ private_class_method :nesting_error
209
+
210
+ # A valid match value nests three deep at most: an Array of matcher
211
+ # Hashes whose :oneOf value is an Array of plain values. Anything past
212
+ # MAX_NESTING_DEPTH is rejected rather than built.
213
+ #
214
+ # The check is iterative because the alternative is finding out through
215
+ # #inspect, which recurses once per level while rendering an error
216
+ # message. Rules are built at boot and SystemStackError is not a
217
+ # StandardError, so that would take the process down rather than reject
218
+ # one rule, and rescuing it is not an option either: the VM aborts on a
219
+ # second overflow in the same process.
220
+ def self.too_deeply_nested?(input)
221
+ # A flat stack of node, depth pairs, so a visit allocates no Array. The
222
+ # pops must mirror the pushes: depth comes off first, then its node.
223
+ stack = [input, 0]
224
+
225
+ until stack.empty?
226
+ depth = stack.pop
227
+ node = stack.pop
228
+ next unless node.is_a?(Hash) || node.is_a?(Array)
229
+ return true if depth >= MAX_NESTING_DEPTH
230
+
231
+ # Keys as well as values: #inspect renders both, so a deeply nested
232
+ # key overflows the same way a deeply nested value does.
233
+ if node.is_a?(Hash)
234
+ node.each { |key, value| stack.push(key, depth + 1, value, depth + 1) }
235
+ else
236
+ node.each { |child| stack.push(child, depth + 1) }
237
+ end
238
+ end
239
+
240
+ false
241
+ end
242
+ private_class_method :too_deeply_nested?
243
+
143
244
  def match?(identifier_value)
144
245
  case type
145
246
  when :eq
146
247
  value == identifier_value
248
+ when :ne
249
+ value != identifier_value
147
250
  when :oneOf
148
251
  value.include?(identifier_value)
252
+ when :noneOf
253
+ # exclude? is ActiveSupport, and nothing in rate_limit requires it.
254
+ !value.include?(identifier_value) # rubocop:disable Rails/NegateInclude
149
255
  when :re
150
256
  value.match?(identifier_value.to_s)
257
+ when :nre
258
+ !value.match?(identifier_value.to_s)
259
+ when :all
260
+ value.all? { |matcher| matcher.match?(identifier_value) }
151
261
  else
152
262
  raise ArgumentError, "unknown matcher type: #{type.inspect}"
153
263
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-labkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.2.1
4
+ version: 5.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate