gitlab-labkit 1.13.0 → 1.14.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 +4 -4
- data/lib/gitlab-labkit.rb +6 -0
- data/lib/labkit/rate_limit/configuration.rb +9 -0
- data/lib/labkit/rate_limit/evaluator.rb +43 -131
- data/lib/labkit/rate_limit/identifier.rb +4 -1
- data/lib/labkit/rate_limit/limiter.rb +55 -0
- data/lib/labkit/rate_limit/result.rb +29 -0
- data/lib/labkit/rate_limit/rule.rb +10 -2
- data/lib/labkit/rate_limit.rb +38 -22
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b563d434908c8c7473aeebf5be13bf8f1f6d4f9634cbbdb389581bb5c4b14953
|
|
4
|
+
data.tar.gz: 0b004077e12eb342c449856c7eadfcae558ea78b024eca86f4686cb7b06ef0b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 03cd36c29407a01003104a8893d4afefdf452875e286b7535590ddae7febabf8fede9193de3946ac25008b11a3210b7a4454fd85638c654270039b94383cfb01
|
|
7
|
+
data.tar.gz: c9679d0fcb0aa67389801afeabc0fe1e625193f77eab5d41911451df82145a26e020761132ae0a1646b3e3720fbc7f7743d420f7ad1745c5d8f88443151ee777
|
data/lib/gitlab-labkit.rb
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
# infrastructural concerns, partcularly related to
|
|
6
6
|
# observability.
|
|
7
7
|
module Labkit
|
|
8
|
+
class << self
|
|
9
|
+
def dev_or_test?
|
|
10
|
+
%w[development test].include?(ENV.fetch("RAILS_ENV", nil))
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
8
14
|
autoload :System, "labkit/system"
|
|
9
15
|
|
|
10
16
|
autoload :Context, "labkit/context"
|
|
@@ -1,123 +1,78 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "openssl"
|
|
4
|
-
require "labkit/logging/json_logger"
|
|
5
4
|
|
|
6
5
|
module Labkit
|
|
7
6
|
module RateLimit
|
|
8
|
-
# Evaluator
|
|
7
|
+
# Evaluator holds the static parts of a rate limit check (name, rules, Redis)
|
|
8
|
+
# and exposes a per-request #check(identifier) method.
|
|
9
|
+
# @api private
|
|
9
10
|
class Evaluator
|
|
10
|
-
KNOWN_CHARACTERISTICS = [:user, :ip, :namespace, :plan, :endpoint].freeze
|
|
11
|
-
KNOWN_ACTIONS = [:block, :log].freeze
|
|
12
11
|
REDIS_KEY_PREFIX = "labkit:rl"
|
|
13
12
|
CHAR_VALUE_MAX_LENGTH = 200
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@
|
|
19
|
-
@
|
|
20
|
-
@
|
|
21
|
-
@redis = redis
|
|
22
|
-
@logger = logger || build_default_logger
|
|
13
|
+
MISSING_VALUE_SENTINEL = "_unknown_"
|
|
14
|
+
|
|
15
|
+
def initialize(name:, rules:, redis:, logger:)
|
|
16
|
+
@name = name
|
|
17
|
+
@rules = rules
|
|
18
|
+
@redis = redis
|
|
19
|
+
@logger = logger
|
|
23
20
|
end
|
|
24
21
|
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
evaluate_rules
|
|
28
|
-
rescue ArgumentError
|
|
29
|
-
raise
|
|
22
|
+
def check(identifier)
|
|
23
|
+
check_rules(identifier)
|
|
30
24
|
rescue StandardError => e
|
|
31
25
|
# Intentionally broad: fail-open applies to any unexpected error (network,
|
|
32
|
-
# timeout, OOM
|
|
33
|
-
|
|
34
|
-
:
|
|
26
|
+
# timeout, OOM) not only Redis protocol errors.
|
|
27
|
+
log_error(e, identifier)
|
|
28
|
+
Result.new(matched: false, error: true)
|
|
35
29
|
end
|
|
36
30
|
|
|
37
31
|
private
|
|
38
32
|
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@rules.each_with_index do |rule, index|
|
|
43
|
-
next unless rule_matches?(rule, @identifier)
|
|
33
|
+
def check_rules(identifier)
|
|
34
|
+
@rules.each do |rule|
|
|
35
|
+
next unless rule_matches?(rule, identifier)
|
|
44
36
|
|
|
45
|
-
|
|
46
|
-
aggregate = :block if result == :block
|
|
37
|
+
return evaluate_rule(rule, identifier)
|
|
47
38
|
end
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def validate_call_site!
|
|
53
|
-
return if CALL_SITE_PATTERN.match?(@call_site)
|
|
54
|
-
|
|
55
|
-
raise ArgumentError, "Invalid call_site: #{@call_site.inspect}. Must match /\\A[a-z0-9_]+\\z/" if dev_or_test?
|
|
56
|
-
|
|
57
|
-
sanitized = @call_site.gsub(/[^a-z0-9_]/, "_")
|
|
58
|
-
@logger.warn(
|
|
59
|
-
message: "rate_limit_invalid_call_site",
|
|
60
|
-
call_site: @call_site,
|
|
61
|
-
sanitized: sanitized
|
|
62
|
-
)
|
|
63
|
-
@call_site = sanitized
|
|
40
|
+
Result.new(matched: false)
|
|
64
41
|
end
|
|
65
42
|
|
|
66
43
|
def rule_matches?(rule, identifier)
|
|
67
|
-
rule.match.all?
|
|
68
|
-
identifier[key] == value
|
|
69
|
-
end
|
|
44
|
+
rule.match.all? { |key, value| identifier[key] == value }
|
|
70
45
|
end
|
|
71
46
|
|
|
72
|
-
def evaluate_rule(rule,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
rule.
|
|
76
|
-
char_value = resolve_characteristic(char, @identifier)
|
|
77
|
-
|
|
78
|
-
if char_value.nil?
|
|
79
|
-
log_skipped_characteristic(rule, index, char)
|
|
80
|
-
next
|
|
81
|
-
end
|
|
47
|
+
def evaluate_rule(rule, identifier)
|
|
48
|
+
redis_key = build_redis_key(rule, identifier)
|
|
49
|
+
resolved_limit = Integer(resolve_value(rule.limit))
|
|
50
|
+
resolved_period = Integer(resolve_value(rule.period))
|
|
82
51
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
count = incr_with_ttl(redis_key, rule.period)
|
|
86
|
-
rule_exceeded = count > rule.limit
|
|
87
|
-
|
|
88
|
-
exceeded = true if rule_exceeded
|
|
89
|
-
|
|
90
|
-
log_rule(rule, index, count, redis_key, rule_exceeded)
|
|
91
|
-
end
|
|
52
|
+
count = incr_with_ttl(redis_key, resolved_period)
|
|
53
|
+
exceeded = count > resolved_limit
|
|
92
54
|
|
|
93
|
-
|
|
55
|
+
Result.new(matched: true, exceeded: exceeded, action: rule.action, rule: rule)
|
|
94
56
|
end
|
|
95
57
|
|
|
96
|
-
def
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
message: "rate_limit_unknown_characteristic",
|
|
102
|
-
characteristic: char
|
|
103
|
-
)
|
|
104
|
-
return UNKNOWN_SENTINEL
|
|
58
|
+
def build_redis_key(rule, identifier)
|
|
59
|
+
key = "#{REDIS_KEY_PREFIX}:#{@name}:#{rule.name}"
|
|
60
|
+
rule.characteristics.each do |char|
|
|
61
|
+
value = resolve_char_value(char, identifier)
|
|
62
|
+
key += ":#{char}:#{encode_char_value(value)}"
|
|
105
63
|
end
|
|
64
|
+
key
|
|
65
|
+
end
|
|
106
66
|
|
|
67
|
+
def resolve_char_value(char, identifier)
|
|
107
68
|
value = identifier[char]
|
|
108
|
-
|
|
109
|
-
# Normalize endpoint: strip query string
|
|
110
|
-
value = Identifier.normalize_endpoint(value) if char == :endpoint
|
|
111
|
-
|
|
112
|
-
# Treat nil and empty-string the same: anonymous traffic must not collide on a shared bucket.
|
|
113
|
-
return nil if value.nil? || value.to_s.empty?
|
|
69
|
+
return MISSING_VALUE_SENTINEL if value.nil? || value.to_s.empty?
|
|
114
70
|
|
|
115
71
|
value.to_s
|
|
116
72
|
end
|
|
117
73
|
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
"#{REDIS_KEY_PREFIX}:#{call_site}:#{rule_index}:#{char}:#{safe_value}"
|
|
74
|
+
def resolve_value(val)
|
|
75
|
+
val.respond_to?(:call) ? val.call : val
|
|
121
76
|
end
|
|
122
77
|
|
|
123
78
|
def encode_char_value(value)
|
|
@@ -135,57 +90,14 @@ module Labkit
|
|
|
135
90
|
count
|
|
136
91
|
end
|
|
137
92
|
|
|
138
|
-
def
|
|
139
|
-
@logger.info(
|
|
140
|
-
message: "rate_limit_check",
|
|
141
|
-
call_site: @call_site,
|
|
142
|
-
rule_index: index,
|
|
143
|
-
action: rule.action.to_s,
|
|
144
|
-
limit: rule.limit,
|
|
145
|
-
period: rule.period,
|
|
146
|
-
count: count,
|
|
147
|
-
matched: true,
|
|
148
|
-
exceeded: exceeded,
|
|
149
|
-
identifier: @identifier.to_h,
|
|
150
|
-
redis_key: redis_key
|
|
151
|
-
)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def log_skipped_characteristic(rule, index, char)
|
|
155
|
-
@logger.info(
|
|
156
|
-
message: "rate_limit_check",
|
|
157
|
-
call_site: @call_site,
|
|
158
|
-
rule_index: index,
|
|
159
|
-
action: rule.action.to_s,
|
|
160
|
-
limit: rule.limit,
|
|
161
|
-
period: rule.period,
|
|
162
|
-
characteristic: char,
|
|
163
|
-
matched: true,
|
|
164
|
-
skipped: true,
|
|
165
|
-
identifier: @identifier.to_h
|
|
166
|
-
)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def log_evaluate_error(error)
|
|
93
|
+
def log_error(error, identifier)
|
|
170
94
|
@logger.warn(
|
|
171
|
-
message: "
|
|
172
|
-
|
|
95
|
+
message: "rate_limit_error",
|
|
96
|
+
name: @name,
|
|
173
97
|
error: error.class.to_s,
|
|
174
|
-
|
|
98
|
+
identifier: identifier&.to_h
|
|
175
99
|
)
|
|
176
100
|
end
|
|
177
|
-
|
|
178
|
-
def dev_or_test?
|
|
179
|
-
# Memoized: ENV access is not free under concurrency.
|
|
180
|
-
return @dev_or_test unless @dev_or_test.nil?
|
|
181
|
-
|
|
182
|
-
env = ENV.fetch("LABKIT_ENV", nil)
|
|
183
|
-
@dev_or_test = env == "test" || env == "development"
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def build_default_logger
|
|
187
|
-
Labkit::Logging::JsonLogger.new($stdout)
|
|
188
|
-
end
|
|
189
101
|
end
|
|
190
102
|
end
|
|
191
103
|
end
|
|
@@ -4,6 +4,7 @@ module Labkit
|
|
|
4
4
|
module RateLimit
|
|
5
5
|
# Identifier is a value object wrapping a hash of key-value pairs that
|
|
6
6
|
# describe the caller (e.g. user, ip, endpoint).
|
|
7
|
+
# Endpoint values are normalised at construction time (query string stripped).
|
|
7
8
|
class Identifier
|
|
8
9
|
# Normalize an endpoint value: strip query string.
|
|
9
10
|
def self.normalize_endpoint(value)
|
|
@@ -15,7 +16,9 @@ module Labkit
|
|
|
15
16
|
attr_reader :attributes
|
|
16
17
|
|
|
17
18
|
def initialize(attributes = {})
|
|
18
|
-
|
|
19
|
+
normalised = attributes.transform_keys(&:to_sym)
|
|
20
|
+
normalised[:endpoint] = self.class.normalize_endpoint(normalised[:endpoint]) if normalised.key?(:endpoint)
|
|
21
|
+
@attributes = normalised.freeze
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
# Return the value for a characteristic key.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "labkit/logging/json_logger"
|
|
4
|
+
|
|
5
|
+
module Labkit
|
|
6
|
+
module RateLimit
|
|
7
|
+
# Limiter is the primary public API for rate limiting.
|
|
8
|
+
# Instantiate once per call site (e.g. at application boot), then call
|
|
9
|
+
# #check(identifier) on every request. The internal Evaluator is reused
|
|
10
|
+
# across calls, avoiding per-request object allocation.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# limiter = Labkit::RateLimit::Limiter.new(
|
|
14
|
+
# name: "rack_request",
|
|
15
|
+
# rules: [Labkit::RateLimit::Rule.new(name: "api_user", limit: 100, period: 60, characteristics: [:user])]
|
|
16
|
+
# )
|
|
17
|
+
# result = limiter.check({ user: 42, ip: "1.2.3.4" })
|
|
18
|
+
# render_429 if result.exceeded? && result.action == :block
|
|
19
|
+
class Limiter
|
|
20
|
+
NAME_PATTERN = /\A[a-z0-9_]+\z/
|
|
21
|
+
|
|
22
|
+
def initialize(name:, rules:, redis: nil, logger: nil)
|
|
23
|
+
resolved_logger = logger || RateLimit.config.logger || Labkit::Logging::JsonLogger.new($stdout)
|
|
24
|
+
validated_name = validate_name!(name, resolved_logger)
|
|
25
|
+
|
|
26
|
+
@evaluator = Evaluator.new(
|
|
27
|
+
name: validated_name,
|
|
28
|
+
rules: rules,
|
|
29
|
+
redis: redis || RateLimit.config.redis,
|
|
30
|
+
logger: resolved_logger
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param identifier [Identifier, Hash] caller attributes for this request
|
|
35
|
+
# @return [Result]
|
|
36
|
+
def check(identifier)
|
|
37
|
+
id = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
|
|
38
|
+
@evaluator.check(id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate_name!(name, logger)
|
|
44
|
+
raise ArgumentError, "name must be a non-empty String" unless name.is_a?(String) && !name.empty?
|
|
45
|
+
return name if NAME_PATTERN.match?(name)
|
|
46
|
+
|
|
47
|
+
raise ArgumentError, "Invalid name: #{name.inspect}. Must match /\\A[a-z0-9_]+\\z/" if Labkit.dev_or_test?
|
|
48
|
+
|
|
49
|
+
sanitized = name.gsub(/[^a-z0-9_]/, "_")
|
|
50
|
+
logger.warn(message: "rate_limit_invalid_name", name: name, sanitized: sanitized)
|
|
51
|
+
sanitized
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Labkit
|
|
4
|
+
module RateLimit
|
|
5
|
+
# Result is the return value of Limiter#check.
|
|
6
|
+
# matched? - true if a rule's match conditions were satisfied
|
|
7
|
+
# exceeded? - true if the matched rule's counter exceeded its limit
|
|
8
|
+
# action - :block or :log (nil when matched? is false)
|
|
9
|
+
# rule - the matched Rule object (nil when matched? is false)
|
|
10
|
+
# error? - true if Redis was unavailable; result fails open (exceeded? is false)
|
|
11
|
+
Result = Data.define(:matched, :exceeded, :action, :rule, :error) do
|
|
12
|
+
def initialize(matched:, exceeded: false, action: nil, rule: nil, error: false)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def matched?
|
|
17
|
+
matched
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def exceeded?
|
|
21
|
+
exceeded
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def error?
|
|
25
|
+
error
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
module Labkit
|
|
4
4
|
module RateLimit
|
|
5
5
|
# Rule is a value object describing a single rate limit rule.
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
# name - stable identifier used in Redis keys and log entries
|
|
7
|
+
# match - hash of identifier key/value pairs that must all match for
|
|
8
|
+
# the rule to apply; empty hash matches any identifier
|
|
9
|
+
# limit - request threshold; may be a callable (resolved per check)
|
|
10
|
+
# period - window in seconds; may be a callable (resolved per check)
|
|
11
|
+
# action - :block (enforce) or :log (count and log, but do not block)
|
|
12
|
+
# characteristics - identifier keys used to build the compound Redis counter key
|
|
13
|
+
Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics) do
|
|
14
|
+
def initialize(name:, limit:, period:, characteristics:, match: {}, action: :block)
|
|
8
15
|
super(
|
|
16
|
+
name: name.to_s.tr(":", "_"),
|
|
9
17
|
match: match.transform_keys(&:to_sym).freeze,
|
|
10
18
|
limit: limit,
|
|
11
19
|
period: period,
|
data/lib/labkit/rate_limit.rb
CHANGED
|
@@ -1,34 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Labkit
|
|
4
|
-
# RateLimit provides a
|
|
4
|
+
# RateLimit provides a rules-based rate limiting API backed by Redis counters.
|
|
5
|
+
# Primary usage: instantiate a Limiter once per call site and reuse it.
|
|
6
|
+
#
|
|
7
|
+
# @example Configuration (e.g. in a Rails initializer)
|
|
8
|
+
# Labkit::RateLimit.configure do |c|
|
|
9
|
+
# c.redis = Redis.current
|
|
10
|
+
# c.logger = Labkit::Logging::JsonLogger.new($stdout)
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# @example Per-call-site setup
|
|
14
|
+
# RACK_LIMITER = Labkit::RateLimit::Limiter.new(
|
|
15
|
+
# name: "rack_request",
|
|
16
|
+
# rules: [...]
|
|
17
|
+
# )
|
|
18
|
+
# result = RACK_LIMITER.check(identifier)
|
|
5
19
|
module RateLimit
|
|
20
|
+
autoload :Configuration, "labkit/rate_limit/configuration"
|
|
6
21
|
autoload :Identifier, "labkit/rate_limit/identifier"
|
|
22
|
+
autoload :Result, "labkit/rate_limit/result"
|
|
7
23
|
autoload :Rule, "labkit/rate_limit/rule"
|
|
8
24
|
autoload :Evaluator, "labkit/rate_limit/evaluator"
|
|
25
|
+
autoload :Limiter, "labkit/rate_limit/limiter"
|
|
9
26
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
27
|
+
class << self
|
|
28
|
+
def configure
|
|
29
|
+
yield config
|
|
30
|
+
end
|
|
13
31
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
rules: rules,
|
|
29
|
-
|
|
30
|
-
logger: logger
|
|
31
|
-
).evaluate
|
|
32
|
+
def config
|
|
33
|
+
@config ||= Configuration.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Convenience wrapper - creates a throw-away Limiter.
|
|
37
|
+
# Prefer Limiter for call sites that can cache the object.
|
|
38
|
+
#
|
|
39
|
+
# @param name [String] call site name
|
|
40
|
+
# @param identifier [Identifier, Hash] caller attributes
|
|
41
|
+
# @param rules [Array<Rule>] ordered list of rules (first match wins)
|
|
42
|
+
# @param redis [Object, nil] Redis client; falls back to config.redis
|
|
43
|
+
# @param logger [Logger, nil] logger; falls back to config.logger
|
|
44
|
+
# @return [Result]
|
|
45
|
+
def check(name:, identifier:, rules:, redis: nil, logger: nil)
|
|
46
|
+
Limiter.new(name: name, rules: rules, redis: redis, logger: logger).check(identifier)
|
|
47
|
+
end
|
|
32
48
|
end
|
|
33
49
|
end
|
|
34
50
|
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: 1.
|
|
4
|
+
version: 1.14.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Newdigate
|
|
@@ -599,8 +599,11 @@ files:
|
|
|
599
599
|
- lib/labkit/middleware/sidekiq/user_experience_sli/server.rb
|
|
600
600
|
- lib/labkit/net_http_publisher.rb
|
|
601
601
|
- lib/labkit/rate_limit.rb
|
|
602
|
+
- lib/labkit/rate_limit/configuration.rb
|
|
602
603
|
- lib/labkit/rate_limit/evaluator.rb
|
|
603
604
|
- lib/labkit/rate_limit/identifier.rb
|
|
605
|
+
- lib/labkit/rate_limit/limiter.rb
|
|
606
|
+
- lib/labkit/rate_limit/result.rb
|
|
604
607
|
- lib/labkit/rate_limit/rule.rb
|
|
605
608
|
- lib/labkit/rspec/README.md
|
|
606
609
|
- lib/labkit/rspec/matchers.rb
|