vers 1.2.1 → 1.3.1

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: f5112d8afb101f4e731fc66f0e7705d43653cc07b39cb1a6a92a96f4c4aa5a52
4
- data.tar.gz: ee0a1ba110cd1f5638199ba4420397ba764e2fae1d4ddc6965c82ea0a4f2ae56
3
+ metadata.gz: 9a8066da910f228939dadbc96afeadcf5122c99ed9a06a67b9858c4720d7e070
4
+ data.tar.gz: 59204f511b9adfb7863d97f754915fe96725093e6f0fa4dc7bfc3f2703e5e387
5
5
  SHA512:
6
- metadata.gz: 160685ee9b06f49753fa6ea6d91629657c0e019e466c8c8934e1214113b23193a5013dd9340eda9a51a966c476d3a9691877cabadb9b4f868a29598aa45a33de
7
- data.tar.gz: 84df10d98802dbe190898b62bfe037f11802915993605141fe119fa9cd84d38bf0ec28f470b0bae620c8c4327c1d7e8336bb679e14b186c0707f6f22b135c3db
6
+ metadata.gz: e4af64390c9efd167082d891eb192f051a04e231a889e751a646e7ce93d55d5736c5f98368ca1b83b6e4adaebc7f962ce4282155eabc245c522ed142a63dda27
7
+ data.tar.gz: 119edf96cc65e3fc88d2f89b543ecbae593c6afeb136db0144793c981999476925ae5c671508965676b4d31d2a2cf1d4858feaaab45f443110561a5b74c00b06
data/Rakefile CHANGED
@@ -187,74 +187,122 @@ namespace :benchmark do
187
187
  task :memory do
188
188
  require "benchmark"
189
189
  require "json"
190
+ require "objspace"
190
191
  require_relative "lib/vers"
191
-
192
- puts "💾 VERS Memory Usage Benchmarks"
192
+
193
+ puts "VERS Memory & Allocation Benchmarks"
193
194
  puts "=" * 50
194
-
195
- # Load sample ranges
195
+
196
196
  test_data_file = File.join(__dir__, "test-suite-data.json")
197
-
197
+
198
198
  unless File.exist?(test_data_file)
199
- puts "test-suite-data.json not found. Using fallback examples."
199
+ puts "test-suite-data.json not found. Using fallback examples."
200
200
  sample_ranges = [
201
- { input: "^1.2.3", scheme: "npm" },
202
- { input: "~> 1.2", scheme: "gem" },
203
- { input: ">=1.0,<2.0", scheme: "pypi" }
201
+ { "input" => "^1.2.3", "scheme" => "npm" },
202
+ { "input" => "~> 1.2", "scheme" => "gem" },
203
+ { "input" => ">=1.0,<2.0", "scheme" => "pypi" }
204
204
  ]
205
205
  else
206
206
  test_data = JSON.parse(File.read(test_data_file))
207
207
  sample_ranges = test_data.select { |data| !data["is_invalid"] }.first(100)
208
208
  end
209
-
210
- puts "📊 Testing with #{sample_ranges.length} version ranges"
209
+
210
+ puts "Sample size: #{sample_ranges.length} version ranges"
211
211
  puts
212
-
213
- # Parse all ranges and store objects
214
- puts "🔍 Parsing and storing #{sample_ranges.length} VersionRange objects..."
212
+
213
+ # Measure object allocations during cold parsing (no cache)
214
+ Vers::Version.class_variable_set(:@@version_cache, {})
215
+ Vers::Constraint.class_variable_set(:@@constraint_cache, {})
216
+ Vers::Parser.class_variable_set(:@@parser_cache, {})
217
+
218
+ GC.start
219
+ GC.disable
220
+ before = ObjectSpace.count_objects.dup
221
+
215
222
  version_ranges = []
216
-
217
- parsing_time = Benchmark.realtime do
218
- sample_ranges.each do |range|
219
- begin
220
- parsed = Vers.parse_native(range['input'], range['scheme'])
221
- version_ranges << parsed
222
- rescue
223
- # Skip invalid ranges
224
- end
225
- end
223
+ sample_ranges.each do |range|
224
+ parsed = Vers.parse_native(range['input'], range['scheme']) rescue nil
225
+ version_ranges << parsed if parsed
226
226
  end
227
-
228
- puts " Parsing completed in #{(parsing_time * 1000).round(2)}ms"
229
- puts " Successfully parsed #{version_ranges.length} ranges"
227
+
228
+ after = ObjectSpace.count_objects
229
+ GC.enable
230
+
231
+ string_alloc = after[:T_STRING] - before[:T_STRING]
232
+ array_alloc = after[:T_ARRAY] - before[:T_ARRAY]
233
+ hash_alloc = after[:T_HASH] - before[:T_HASH]
234
+ object_alloc = after[:T_OBJECT] - before[:T_OBJECT]
235
+ match_alloc = (after[:T_MATCH] || 0) - (before[:T_MATCH] || 0)
236
+ total_alloc = string_alloc + array_alloc + hash_alloc + object_alloc + match_alloc
237
+
238
+ puts "Cold parse allocations (#{version_ranges.length} ranges, no cache):"
239
+ puts " Total objects: #{total_alloc}"
240
+ puts " Strings: #{string_alloc}"
241
+ puts " Arrays: #{array_alloc}"
242
+ puts " Hashes: #{hash_alloc}"
243
+ puts " Objects: #{object_alloc}"
244
+ puts " MatchData: #{match_alloc}"
245
+ puts " Per range: #{(total_alloc.to_f / version_ranges.length).round(1)}"
230
246
  puts
231
-
232
- # Estimate memory usage
233
- estimated_memory = version_ranges.length * 300 # ~300 bytes per VersionRange object estimate
234
- puts "💾 Memory Usage Estimation:"
235
- puts " #{version_ranges.length} VersionRange objects: ~#{estimated_memory} bytes"
236
- puts " Average per object: ~300 bytes"
247
+
248
+ # Measure cached parse allocations (everything already cached)
249
+ GC.start
250
+ GC.disable
251
+ before = ObjectSpace.count_objects.dup
252
+
253
+ sample_ranges.each do |range|
254
+ Vers.parse_native(range['input'], range['scheme']) rescue nil
255
+ end
256
+
257
+ after = ObjectSpace.count_objects
258
+ GC.enable
259
+
260
+ cached_strings = after[:T_STRING] - before[:T_STRING]
261
+ cached_arrays = after[:T_ARRAY] - before[:T_ARRAY]
262
+ cached_objects = after[:T_OBJECT] - before[:T_OBJECT]
263
+ cached_match = (after[:T_MATCH] || 0) - (before[:T_MATCH] || 0)
264
+ cached_alloc = cached_strings + cached_arrays + cached_objects + cached_match
265
+
266
+ puts "Cached parse allocations (#{version_ranges.length} ranges, warm cache):"
267
+ puts " Total objects: #{cached_alloc}"
268
+ puts " Strings: #{cached_strings}"
269
+ puts " MatchData: #{cached_match}"
270
+ puts " Per range: #{(cached_alloc.to_f / version_ranges.length).round(1)}"
237
271
  puts
238
-
239
- # Test repeated operations
240
- puts "🔄 Repeated Operations Test:"
241
-
272
+
273
+ # Measure memory size of retained objects
274
+ total_memsize = version_ranges.sum { |r| ObjectSpace.memsize_of(r) }
275
+ puts "Retained memory:"
276
+ puts " #{version_ranges.length} VersionRange objects: #{total_memsize} bytes"
277
+ puts " Average per object: #{(total_memsize.to_f / version_ranges.length).round(0)} bytes"
278
+ puts
279
+
280
+ # Repeated operation allocations
281
+ puts "Repeated operation allocations (#{version_ranges.length} calls each):"
282
+
242
283
  operations = {
243
- "to_s conversion" => proc { version_ranges.each(&:to_s) },
244
- "contains? check" => proc { version_ranges.each { |r| r.contains?("1.5.0") } },
245
- "empty? check" => proc { version_ranges.each(&:empty?) },
246
- "unbounded? check" => proc { version_ranges.each(&:unbounded?) }
284
+ "to_s" => proc { version_ranges.each(&:to_s) },
285
+ "contains?" => proc { version_ranges.each { |r| r.contains?("1.5.0") } },
286
+ "empty?" => proc { version_ranges.each(&:empty?) },
247
287
  }
248
-
249
- operations.each do |op_name, op_proc|
250
- time = Benchmark.realtime { op_proc.call }
251
- ops_per_second = version_ranges.length / time
252
-
253
- puts " #{op_name.ljust(20)}: #{(time * 1000).round(2)}ms (#{ops_per_second.round(0)} ops/sec)"
288
+
289
+ operations.each do |name, op|
290
+ # Warm up
291
+ op.call
292
+
293
+ GC.start
294
+ GC.disable
295
+ before = ObjectSpace.count_objects.dup
296
+ op.call
297
+ after = ObjectSpace.count_objects
298
+ GC.enable
299
+
300
+ allocs = [:T_STRING, :T_ARRAY, :T_OBJECT, :T_MATCH, :T_HASH].sum { |k| (after[k] || 0) - (before[k] || 0) }
301
+ puts " #{name.ljust(15)}: #{allocs} allocations"
254
302
  end
255
-
303
+
256
304
  puts
257
- puts "Memory benchmark completed!"
305
+ puts "Memory benchmark completed!"
258
306
  end
259
307
 
260
308
  desc "Run complexity stress tests"
@@ -23,7 +23,7 @@ module Vers
23
23
 
24
24
  # Cache for parsed constraints
25
25
  @@constraint_cache = {}
26
- @@cache_size_limit = 500
26
+ @@cache_size_limit = 1000
27
27
 
28
28
  attr_reader :operator, :version
29
29
 
@@ -54,14 +54,19 @@ module Vers
54
54
  # Vers::Constraint.parse("!=2.0.0") # => #<Vers::Constraint:0x... @operator="!=", @version="2.0.0">
55
55
  #
56
56
  def self.parse(constraint_string)
57
- # Limit cache size to prevent memory bloat
58
- if @@constraint_cache.size >= @@cache_size_limit
59
- @@constraint_cache.clear
57
+ # Bound input length before cache lookup so oversized strings never
58
+ # become cache keys and never trigger eviction.
59
+ if constraint_string.length > Version::MAX_LENGTH
60
+ raise ArgumentError, "Constraint string too long (#{constraint_string.length} > #{Version::MAX_LENGTH})"
60
61
  end
61
-
62
- # Return cached constraint if available
62
+
63
63
  return @@constraint_cache[constraint_string] if @@constraint_cache.key?(constraint_string)
64
-
64
+
65
+ if @@constraint_cache.size >= @@cache_size_limit
66
+ keys = @@constraint_cache.keys
67
+ keys.first(keys.size / 2).each { |k| @@constraint_cache.delete(k) }
68
+ end
69
+
65
70
  constraint = parse_uncached(constraint_string)
66
71
  @@constraint_cache[constraint_string] = constraint
67
72
  constraint
data/lib/vers/interval.rb CHANGED
@@ -12,8 +12,7 @@ module Vers
12
12
  @min_inclusive = min_inclusive
13
13
  @max_inclusive = max_inclusive
14
14
  @scheme = scheme
15
-
16
- validate_bounds!
15
+ @empty = compute_empty
17
16
  end
18
17
 
19
18
  def self.empty(scheme: nil)
@@ -37,9 +36,7 @@ module Vers
37
36
  end
38
37
 
39
38
  def empty?
40
- return true if min && max && version_compare(min, max) > 0
41
- return true if min && max && version_compare(min, max) == 0 && (!min_inclusive || !max_inclusive)
42
- false
39
+ @empty
43
40
  end
44
41
 
45
42
  def unbounded?
@@ -180,7 +177,22 @@ module Vers
180
177
 
181
178
  def overlaps?(other)
182
179
  return false if empty? || other.empty?
183
- !intersect(other).empty?
180
+ return true if unbounded? || other.unbounded?
181
+
182
+ # Check if the intervals can't overlap by comparing bounds directly
183
+ if max && other.min
184
+ cmp = version_compare(max, other.min)
185
+ return false if cmp < 0
186
+ return false if cmp == 0 && (!max_inclusive || !other.min_inclusive)
187
+ end
188
+
189
+ if min && other.max
190
+ cmp = version_compare(min, other.max)
191
+ return false if cmp > 0
192
+ return false if cmp == 0 && (!min_inclusive || !other.max_inclusive)
193
+ end
194
+
195
+ true
184
196
  end
185
197
 
186
198
  def adjacent?(other)
@@ -211,14 +223,12 @@ module Vers
211
223
 
212
224
  private
213
225
 
214
- def validate_bounds!
215
- return unless min && max
216
-
217
- comparison = version_compare(min, max)
218
- if comparison > 0
219
- return
220
- elsif comparison == 0 && (!min_inclusive || !max_inclusive)
221
- return
226
+ def compute_empty
227
+ if min && max
228
+ cmp = version_compare(min, max)
229
+ cmp > 0 || (cmp == 0 && (!min_inclusive || !max_inclusive))
230
+ else
231
+ false
222
232
  end
223
233
  end
224
234
 
data/lib/vers/parser.rb CHANGED
@@ -26,10 +26,24 @@ module Vers
26
26
  NPM_HYPHEN_REGEX = /\A(.+?)\s+-\s+(.+)\z/
27
27
  NPM_X_RANGE_MAJOR_REGEX = /\A(\d+)\.x\z/
28
28
  NPM_X_RANGE_MINOR_REGEX = /\A(\d+)\.(\d+)\.x\z/
29
+ OPERATOR_PREFIX_REGEX = /\A[><=!]+/
29
30
 
30
31
  # Cache for parsed ranges to improve performance
31
32
  @@parser_cache = {}
32
- @@cache_size_limit = 200
33
+ @@cache_size_limit = 500
34
+
35
+ # Maximum accepted length for a range string at parse/parse_native
36
+ # entry points. Range strings concatenate multiple constraints so this
37
+ # is set higher than Version::MAX_LENGTH while still bounding
38
+ # split/regex work to a few KB.
39
+ MAX_INPUT_LENGTH = 2048
40
+
41
+ # Maximum number of |-separated or ||-separated constraints in a
42
+ # single range. The exclusion loop in parse_constraints does
43
+ # O(n^2 log n) work as each != splits an interval and reconstructs the
44
+ # range; capping n keeps the worst case under a few thousand interval
45
+ # operations.
46
+ MAX_CONSTRAINTS = 64
33
47
 
34
48
  ##
35
49
  # Parses a vers URI string into a VersionRange
@@ -46,6 +60,8 @@ module Vers
46
60
  # parser.parse("vers:pypi/==1.2.3")
47
61
  #
48
62
  def parse(vers_string)
63
+ validate_input_length!(vers_string)
64
+
49
65
  if vers_string == "*"
50
66
  return VersionRange.unbounded
51
67
  end
@@ -74,6 +90,8 @@ module Vers
74
90
  # parser.parse_native(">=1.0,<2.0", "pypi")
75
91
  #
76
92
  def parse_native(range_string, scheme)
93
+ validate_input_length!(range_string)
94
+
77
95
  case scheme
78
96
  when "npm"
79
97
  parse_npm_range(range_string)
@@ -150,14 +168,25 @@ module Vers
150
168
 
151
169
  private
152
170
 
171
+ def validate_input_length!(input)
172
+ return if input.nil?
173
+ return if input.length <= MAX_INPUT_LENGTH
174
+ raise ArgumentError, "Range string too long (#{input.length} > #{MAX_INPUT_LENGTH})"
175
+ end
176
+
153
177
  def sort_key_for_constraint(constraint)
154
- version = constraint.sub(/\A[><=!]+/, '')
178
+ version = constraint.sub(OPERATOR_PREFIX_REGEX, '')
155
179
  v = Version.cached_new(version)
156
180
  [v, constraint]
157
181
  end
158
182
 
159
183
  def parse_constraints(constraints_string, scheme)
160
- constraint_strings = constraints_string.split(/[|,]/)
184
+ # Limit constraint count to bound the O(n^2 log n) exclusion loop
185
+ # below: each != splits an interval and reconstructs the range.
186
+ constraint_strings = constraints_string.split(/[|,]/, MAX_CONSTRAINTS + 1)
187
+ if constraint_strings.length > MAX_CONSTRAINTS
188
+ raise ArgumentError, "Too many constraints (> #{MAX_CONSTRAINTS})"
189
+ end
161
190
  intervals = []
162
191
  exclusions = []
163
192
  interval_scheme = %w[maven nuget].include?(scheme) ? scheme : nil
@@ -199,7 +228,10 @@ module Vers
199
228
 
200
229
  # Handle || (OR) operator
201
230
  if range_string.include?('||')
202
- or_parts = range_string.split('||').map(&:strip)
231
+ or_parts = range_string.split('||', MAX_CONSTRAINTS + 1).map(&:strip)
232
+ if or_parts.length > MAX_CONSTRAINTS
233
+ raise ArgumentError, "Too many || clauses (> #{MAX_CONSTRAINTS})"
234
+ end
203
235
  ranges = or_parts.map { |part| parse_npm_range(part) }
204
236
  return ranges.reduce { |acc, range| acc.union(range) }
205
237
  end
@@ -231,15 +263,12 @@ module Vers
231
263
  end
232
264
 
233
265
  def parse_npm_single_range(range_string)
234
- # Check cache first
235
266
  cache_key = "npm:#{range_string}"
236
- if @@parser_cache.key?(cache_key)
237
- return @@parser_cache[cache_key]
238
- end
239
-
240
- # Limit cache size
267
+ return @@parser_cache[cache_key] if @@parser_cache.key?(cache_key)
268
+
241
269
  if @@parser_cache.size >= @@cache_size_limit
242
- @@parser_cache.clear
270
+ keys = @@parser_cache.keys
271
+ keys.first(keys.size / 2).each { |k| @@parser_cache.delete(k) }
243
272
  end
244
273
 
245
274
  result = case range_string
@@ -500,7 +529,7 @@ module Vers
500
529
  begin
501
530
  parsed_range = parse_maven_range(range_part)
502
531
  ranges << parsed_range
503
- rescue
532
+ rescue ArgumentError
504
533
  # If parsing fails, skip this part
505
534
  end
506
535
  end
data/lib/vers/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vers
4
- VERSION = "1.2.1"
4
+ VERSION = "1.3.1"
5
5
 
6
6
  ##
7
7
  # Handles version comparison and normalization across different package ecosystems.
@@ -18,19 +18,28 @@ module Vers
18
18
  class Version
19
19
  # Cache for parsed versions to avoid repeated parsing
20
20
  @@version_cache = {}
21
- @@cache_size_limit = 1000
21
+ @@cache_size_limit = 2000
22
22
  # Regex for parsing semantic version components including build metadata
23
23
  SEMANTIC_VERSION_REGEX = /\A(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([^+]+))?(?:\+(.+))?\z/
24
24
 
25
+ # Maximum accepted length for a version string. Real-world version
26
+ # strings rarely exceed 100 characters; 256 leaves headroom for unusual
27
+ # prerelease tags while bounding regex/split work and cache key size.
28
+ MAX_LENGTH = 256
29
+
25
30
  attr_reader :major, :minor, :patch, :prerelease, :build
26
31
 
27
32
  ##
28
33
  # Creates a new Version object
29
34
  #
30
35
  # @param version_string [String] The version string to parse
36
+ # @raise [ArgumentError] if the version string exceeds MAX_LENGTH
31
37
  #
32
38
  def initialize(version_string)
33
39
  @original = version_string.to_s
40
+ if @original.length > MAX_LENGTH
41
+ raise ArgumentError, "Version string too long (#{@original.length} > #{MAX_LENGTH})"
42
+ end
34
43
  parse_version
35
44
  end
36
45
 
@@ -41,11 +50,16 @@ module Vers
41
50
  # @return [Version] Cached or new Version object
42
51
  #
43
52
  def self.cached_new(version_string)
44
- # Limit cache size to prevent memory bloat
53
+ # Skip caching for oversized keys to bound cache memory by entry
54
+ # count, not by attacker-controlled key length.
55
+ return new(version_string) if version_string.to_s.length > MAX_LENGTH
56
+
45
57
  if @@version_cache.size >= @@cache_size_limit
46
- @@version_cache.clear
58
+ # Keep the most recent half instead of clearing everything
59
+ keys = @@version_cache.keys
60
+ keys.first(keys.size / 2).each { |k| @@version_cache.delete(k) }
47
61
  end
48
-
62
+
49
63
  @@version_cache[version_string] ||= new(version_string)
50
64
  end
51
65
 
@@ -145,15 +159,17 @@ module Vers
145
159
  # @return [String] The normalized version string
146
160
  #
147
161
  def to_s
148
- version = "#{major || 0}"
149
- version += ".#{minor || 0}"
150
- version += ".#{patch || 0}"
151
- version += "-#{prerelease}" if prerelease
152
- version
162
+ @to_s ||= begin
163
+ version = "#{major || 0}"
164
+ version += ".#{minor || 0}"
165
+ version += ".#{patch || 0}"
166
+ version += "-#{prerelease}" if prerelease
167
+ version.freeze
168
+ end
153
169
  end
154
170
 
155
171
  def ==(other)
156
- other.is_a?(Version) && self <=> other == 0
172
+ other.is_a?(Version) && (self <=> other) == 0
157
173
  end
158
174
 
159
175
  def <(other)
@@ -320,7 +336,7 @@ module Vers
320
336
  @original = @original.sub(/\Av/i, '')
321
337
 
322
338
  # Handle simple numeric versions (optimized case)
323
- if @original.match(/^\d+$/)
339
+ if @original.match?(/^\d+$/)
324
340
  @major = @original.to_i
325
341
  return
326
342
  end
@@ -382,7 +398,7 @@ module Vers
382
398
  return 1 if part_b.nil?
383
399
 
384
400
  # Try numeric comparison first
385
- if part_a.match(/^\d+$/) && part_b.match(/^\d+$/)
401
+ if part_a.match?(/^\d+$/) && part_b.match?(/^\d+$/)
386
402
  numeric_cmp = part_a.to_i <=> part_b.to_i
387
403
  return numeric_cmp unless numeric_cmp == 0
388
404
  else
@@ -9,7 +9,7 @@ module Vers
9
9
 
10
10
  def initialize(intervals = [], raw_constraints: nil, scheme: nil)
11
11
  @scheme = scheme
12
- @intervals = intervals.compact.reject(&:empty?)
12
+ @intervals = intervals.select { |i| i && !i.empty? }
13
13
  if @scheme
14
14
  @intervals.sort! { |a, b| compare_interval_bounds(a, b) }
15
15
  else
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vers
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt