vers 1.3.0 → 2.0.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.
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "version_comparison"
5
+
6
+ module Vers
7
+ module APKVersion
8
+ extend self
9
+
10
+ TOKEN_INITIAL_DIGIT = 0
11
+ TOKEN_DIGIT = 1
12
+ TOKEN_LETTER = 2
13
+ TOKEN_SUFFIX = 3
14
+ TOKEN_SUFFIX_NUMBER = 4
15
+ TOKEN_COMMIT_HASH = 5
16
+ TOKEN_REVISION_NUMBER = 6
17
+ TOKEN_END = 7
18
+ TOKEN_INVALID = 8
19
+ SUFFIXES = {
20
+ "alpha" => 1,
21
+ "beta" => 2,
22
+ "pre" => 3,
23
+ "rc" => 4,
24
+ "cvs" => 6,
25
+ "svn" => 7,
26
+ "git" => 8,
27
+ "hg" => 9,
28
+ "p" => 10
29
+ }.freeze
30
+ SUFFIX_NONE = 5
31
+ UINT64_MASK = (1 << 64) - 1
32
+
33
+ def compare(left, right)
34
+ left_tokens = parse(left)
35
+ right_tokens = parse(right)
36
+
37
+ index = 0
38
+ while token_type(left_tokens, index) == token_type(right_tokens, index) && token_type(left_tokens, index) < TOKEN_END
39
+ comparison = compare_token(left_tokens.fetch(index), right_tokens.fetch(index))
40
+ return comparison unless comparison.zero?
41
+
42
+ index += 1
43
+ end
44
+
45
+ left_type = token_type(left_tokens, index)
46
+ right_type = token_type(right_tokens, index)
47
+ return 0 if left_type == right_type
48
+
49
+ left_token = left_tokens[index]
50
+ right_token = right_tokens[index]
51
+ return -1 if left_type == TOKEN_SUFFIX && left_token.fetch(1) < SUFFIX_NONE
52
+ return 1 if right_type == TOKEN_SUFFIX && right_token.fetch(1) < SUFFIX_NONE
53
+ return -1 if left_type > right_type
54
+ return 1 if right_type > left_type
55
+
56
+ 0
57
+ end
58
+
59
+ def valid?(value)
60
+ parse(value.to_s.strip).none? { |type, _value| type == TOKEN_INVALID }
61
+ end
62
+
63
+ def normalize(value)
64
+ value.to_s.strip
65
+ end
66
+
67
+ def prerelease?(value)
68
+ tokens = parse(value.to_s.strip)
69
+ return false if tokens.any? { |type, _value| type == TOKEN_INVALID }
70
+
71
+ tokens.any? { |type, suffix| type == TOKEN_SUFFIX && suffix < SUFFIX_NONE }
72
+ end
73
+
74
+ def parse(value)
75
+ string = value.to_s.strip
76
+ tokens = []
77
+ first, index = scan_digits(string, 0)
78
+ return invalid_tokens(tokens) if first.empty?
79
+
80
+ tokens << [TOKEN_INITIAL_DIGIT, first]
81
+ previous = TOKEN_INITIAL_DIGIT
82
+
83
+ while index < string.length
84
+ byte = string.getbyte(index)
85
+ case byte
86
+ when 97..122
87
+ return invalid_tokens(tokens) if previous > TOKEN_DIGIT
88
+
89
+ tokens << [TOKEN_LETTER, byte]
90
+ previous = TOKEN_LETTER
91
+ index += 1
92
+ when 46
93
+ return invalid_tokens(tokens) if previous > TOKEN_DIGIT
94
+
95
+ digits, index = scan_digits(string, index + 1)
96
+ return invalid_tokens(tokens) if digits.empty?
97
+
98
+ tokens << [TOKEN_DIGIT, digits]
99
+ previous = TOKEN_DIGIT
100
+ when 48..57
101
+ type = if previous == TOKEN_INITIAL_DIGIT || previous == TOKEN_DIGIT
102
+ TOKEN_DIGIT
103
+ elsif previous == TOKEN_SUFFIX
104
+ TOKEN_SUFFIX_NUMBER
105
+ end
106
+ return invalid_tokens(tokens) unless type
107
+
108
+ digits, index = scan_digits(string, index)
109
+ tokens << [type, digits]
110
+ previous = type
111
+ when 95
112
+ return invalid_tokens(tokens) if previous > TOKEN_SUFFIX_NUMBER
113
+
114
+ suffix, index = scan_lowercase(string, index + 1)
115
+ rank = SUFFIXES[suffix]
116
+ return invalid_tokens(tokens) unless rank
117
+
118
+ tokens << [TOKEN_SUFFIX, rank]
119
+ previous = TOKEN_SUFFIX
120
+ when 126
121
+ return invalid_tokens(tokens) if previous >= TOKEN_COMMIT_HASH
122
+
123
+ commit, index = scan_hex(string, index + 1)
124
+ return invalid_tokens(tokens) if commit.empty?
125
+
126
+ tokens << [TOKEN_COMMIT_HASH, commit]
127
+ previous = TOKEN_COMMIT_HASH
128
+ when 45
129
+ if previous >= TOKEN_REVISION_NUMBER || string[index, 2] != "-r"
130
+ return invalid_tokens(tokens)
131
+ end
132
+
133
+ revision, index = scan_digits(string, index + 2)
134
+ return invalid_tokens(tokens) if revision.empty?
135
+
136
+ tokens << [TOKEN_REVISION_NUMBER, revision]
137
+ previous = TOKEN_REVISION_NUMBER
138
+ else
139
+ return invalid_tokens(tokens)
140
+ end
141
+ end
142
+
143
+ tokens
144
+ end
145
+
146
+ def invalid_tokens(tokens)
147
+ tokens << [TOKEN_INVALID, nil]
148
+ end
149
+
150
+ def compare_token(left, right)
151
+ type = left.fetch(0)
152
+ left_value = left.fetch(1)
153
+ right_value = right.fetch(1)
154
+
155
+ case type
156
+ when TOKEN_DIGIT
157
+ return left_value <=> right_value if left_value.start_with?("0") || right_value.start_with?("0")
158
+
159
+ uint64(left_value) <=> uint64(right_value)
160
+ when TOKEN_INITIAL_DIGIT, TOKEN_SUFFIX_NUMBER, TOKEN_REVISION_NUMBER
161
+ uint64(left_value) <=> uint64(right_value)
162
+ else
163
+ left_value <=> right_value
164
+ end
165
+ end
166
+
167
+ def token_type(tokens, index)
168
+ tokens[index]&.fetch(0) || TOKEN_END
169
+ end
170
+
171
+ def uint64(value)
172
+ value.each_byte.reduce(0) { |number, byte| ((number * 10) + byte - 48) & UINT64_MASK }
173
+ end
174
+
175
+ def scan_digits(value, index)
176
+ ending = index
177
+ ending += 1 while ending < value.length && value.getbyte(ending).between?(48, 57)
178
+ [value[index...ending], ending]
179
+ end
180
+
181
+ def scan_lowercase(value, index)
182
+ ending = index
183
+ ending += 1 while ending < value.length && value.getbyte(ending).between?(97, 122)
184
+ [value[index...ending], ending]
185
+ end
186
+
187
+ def scan_hex(value, index)
188
+ ending = index
189
+ while ending < value.length
190
+ byte = value.getbyte(ending)
191
+ break unless byte.between?(48, 57) || byte.between?(65, 70) || byte.between?(97, 102)
192
+
193
+ ending += 1
194
+ end
195
+ [value[index...ending], ending]
196
+ end
197
+ end
198
+
199
+ module OpenSSLVersion
200
+ extend self
201
+
202
+ Parsed = Data.define(:core, :patch)
203
+
204
+ def compare(left, right)
205
+ parsed_left = parse(left)
206
+ parsed_right = parse(right)
207
+ return Version.compare(left, right) unless parsed_left && parsed_right
208
+ return SemverVersion.compare(left, right) if version_three_or_newer?(parsed_left, parsed_right)
209
+
210
+ parsed_left.core.zip(parsed_right.core).each do |left_part, right_part|
211
+ comparison = VersionComparison.compare_numbers(left_part, right_part)
212
+ return comparison unless comparison.zero?
213
+ end
214
+
215
+ left_prerelease = prerelease_patch?(parsed_left.patch)
216
+ right_prerelease = prerelease_patch?(parsed_right.patch)
217
+ return -1 if left_prerelease && !right_prerelease
218
+ return 1 if !left_prerelease && right_prerelease
219
+
220
+ parsed_left.patch <=> parsed_right.patch
221
+ end
222
+
223
+ def parse(value)
224
+ parts = value.to_s.split(".", 3)
225
+ return nil unless parts.length == 3
226
+ return nil unless parts[0].match?(/\A\d+\z/) && parts[1].match?(/\A\d+\z/)
227
+
228
+ match = /\A(\d+)(.*)\z/.match(parts[2])
229
+ return nil unless match
230
+
231
+ Parsed.new([parts[0], parts[1], match[1]].freeze, match[2])
232
+ end
233
+
234
+ def version_three_or_newer?(left, right)
235
+ VersionComparison.compare_numbers(left.core[0], "3") >= 0 &&
236
+ VersionComparison.compare_numbers(right.core[0], "3") >= 0
237
+ end
238
+
239
+ def prerelease_patch?(patch)
240
+ patch.start_with?("-alpha", "-beta")
241
+ end
242
+
243
+ def valid?(value)
244
+ !parse(value.to_s.strip).nil?
245
+ end
246
+
247
+ def normalize(value)
248
+ value.to_s.strip
249
+ end
250
+
251
+ def prerelease?(value)
252
+ parsed = parse(value.to_s.strip)
253
+ return false unless parsed
254
+
255
+ if VersionComparison.compare_numbers(parsed.core[0], "3") >= 0
256
+ parsed.patch.start_with?("-")
257
+ else
258
+ prerelease_patch?(parsed.patch)
259
+ end
260
+ end
261
+ end
262
+
263
+ module DatetimeVersion
264
+ extend self
265
+
266
+ def compare(left, right)
267
+ Time.iso8601(left) <=> Time.iso8601(right)
268
+ rescue ArgumentError
269
+ left <=> right
270
+ end
271
+
272
+ def valid?(value)
273
+ Time.iso8601(value.to_s.strip)
274
+ value.to_s.strip.match?(/T/) && !value.to_s.strip.match?(/[[:space:]]/)
275
+ rescue ArgumentError
276
+ false
277
+ end
278
+
279
+ def normalize(value)
280
+ value.to_s.strip
281
+ end
282
+
283
+ def prerelease?(_value)
284
+ false
285
+ end
286
+ end
287
+
288
+ module IntDotVersion
289
+ extend self
290
+
291
+ def compare(left, right)
292
+ left_parts = left.to_s.split(".", -1)
293
+ right_parts = right.to_s.split(".", -1)
294
+
295
+ [left_parts.length, right_parts.length].max.times do |index|
296
+ comparison = VersionComparison.compare_numbers(left_parts[index], right_parts[index])
297
+ return comparison unless comparison.zero?
298
+ end
299
+
300
+ 0
301
+ end
302
+
303
+ def valid?(value)
304
+ value.to_s.strip.match?(/\A\d+(?:\.\d+)*\z/)
305
+ end
306
+
307
+ def normalize(value)
308
+ value.to_s.strip
309
+ end
310
+
311
+ def prerelease?(_value)
312
+ false
313
+ end
314
+ end
315
+
316
+ module LexicographicVersion
317
+ extend self
318
+
319
+ def compare(left, right)
320
+ left <=> right
321
+ end
322
+
323
+ def valid?(value)
324
+ string = value.to_s.strip
325
+ !string.empty? && !string.match?(/[[:space:]]/)
326
+ end
327
+
328
+ def normalize(value)
329
+ value.to_s.strip
330
+ end
331
+
332
+ def prerelease?(_value)
333
+ false
334
+ end
335
+ end
336
+ end
data/lib/vers/version.rb CHANGED
@@ -1,7 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "bazel_version"
4
+ require_relative "maven_version"
5
+ require_relative "nuget_version"
6
+ require_relative "semver_version"
7
+ require_relative "gem_version"
8
+ require_relative "pypi_version"
9
+ require_relative "composer_version"
10
+ require_relative "pub_version"
11
+ require_relative "distribution_version"
12
+ require_relative "conan_version"
13
+ require_relative "special_version"
14
+ require_relative "scheme"
15
+
3
16
  module Vers
4
- VERSION = "1.3.0"
17
+ VERSION = "2.0.0"
5
18
 
6
19
  ##
7
20
  # Handles version comparison and normalization across different package ecosystems.
@@ -22,15 +35,24 @@ module Vers
22
35
  # Regex for parsing semantic version components including build metadata
23
36
  SEMANTIC_VERSION_REGEX = /\A(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([^+]+))?(?:\+(.+))?\z/
24
37
 
38
+ # Maximum accepted length for a version string. Real-world version
39
+ # strings rarely exceed 100 characters; 256 leaves headroom for unusual
40
+ # prerelease tags while bounding regex/split work and cache key size.
41
+ MAX_LENGTH = 256
42
+
25
43
  attr_reader :major, :minor, :patch, :prerelease, :build
26
44
 
27
45
  ##
28
46
  # Creates a new Version object
29
47
  #
30
48
  # @param version_string [String] The version string to parse
49
+ # @raise [ArgumentError] if the version string exceeds MAX_LENGTH
31
50
  #
32
51
  def initialize(version_string)
33
52
  @original = version_string.to_s
53
+ if @original.length > MAX_LENGTH
54
+ raise ArgumentError, "Version string too long (#{@original.length} > #{MAX_LENGTH})"
55
+ end
34
56
  parse_version
35
57
  end
36
58
 
@@ -41,6 +63,10 @@ module Vers
41
63
  # @return [Version] Cached or new Version object
42
64
  #
43
65
  def self.cached_new(version_string)
66
+ # Skip caching for oversized keys to bound cache memory by entry
67
+ # count, not by attacker-controlled key length.
68
+ return new(version_string) if version_string.to_s.length > MAX_LENGTH
69
+
44
70
  if @@version_cache.size >= @@cache_size_limit
45
71
  # Keep the most recent half instead of clearing everything
46
72
  keys = @@version_cache.keys
@@ -74,17 +100,30 @@ module Vers
74
100
  #
75
101
  # @param a [String] First version string
76
102
  # @param b [String] Second version string
77
- # @param scheme [String, nil] Package manager scheme (maven, nuget, or nil for generic)
103
+ # @param scheme [String, nil] Package manager scheme (bazel, maven, nuget, or nil for generic)
78
104
  # @return [Integer] -1 if a < b, 0 if a == b, 1 if a > b
79
105
  #
80
106
  def self.compare_with_scheme(a, b, scheme)
81
- case scheme
82
- when "maven"
83
- MavenVersion.compare(a, b)
84
- when "nuget"
85
- NuGetVersion.compare(a, b)
107
+ return 0 if a == b
108
+ return -1 if a.nil?
109
+ return 1 if b.nil?
110
+
111
+ handler = Scheme.handler(scheme)
112
+ handler ? handler.compare(a, b) : compare(a, b)
113
+ end
114
+
115
+ def self.compare_for_range(a, b, scheme)
116
+ return 0 if a == b
117
+ return -1 if a.nil?
118
+ return 1 if b.nil?
119
+
120
+ case Scheme.canonical(scheme)
121
+ when "cargo"
122
+ SemverVersion.compare(a, b)
123
+ when "pypi"
124
+ PyPIVersion.compare_public(a, b)
86
125
  else
87
- compare(a, b)
126
+ compare_with_scheme(a, b, scheme)
88
127
  end
89
128
  end
90
129
 
@@ -94,22 +133,51 @@ module Vers
94
133
  # @param version_string [String] The version string to normalize
95
134
  # @return [String] The normalized version string
96
135
  #
97
- def self.normalize(version_string)
98
- cached_new(version_string).to_s
136
+ def self.normalize(version_string, scheme = nil)
137
+ handler = Scheme.handler(scheme)
138
+ return cached_new(version_string).to_s unless handler
139
+
140
+ raise ArgumentError, "Invalid #{Scheme.canonical(scheme)} version: #{version_string}" unless handler.valid?(version_string)
141
+
142
+ handler.respond_to?(:normalize) ? handler.normalize(version_string) : version_string.to_s.strip
99
143
  end
100
144
 
101
145
  ##
102
146
  # Checks if a version string is valid
103
147
  #
104
148
  # @param version_string [String] The version string to validate
149
+ # @param scheme [String, nil] Package manager scheme or nil for generic validation
105
150
  # @return [Boolean] true if the version is valid
106
151
  #
107
- def self.valid?(version_string)
152
+ def self.valid?(version_string, scheme = nil)
153
+ handler = Scheme.handler(scheme)
154
+ return handler.valid?(version_string) if handler
155
+
108
156
  version_string.to_s.match?(/\Av?\d+\.\d+\.\d+/)
109
157
  end
110
158
 
111
- def self.clean(version_string)
112
- return nil unless valid?(version_string)
159
+ def self.stable?(version_string, scheme = nil)
160
+ handler = Scheme.handler(scheme)
161
+ return handler.valid?(version_string) && !handler.prerelease?(version_string) if handler
162
+
163
+ cached_new(version_string).stable?
164
+ rescue ArgumentError
165
+ false
166
+ end
167
+
168
+ def self.prerelease?(version_string, scheme = nil)
169
+ handler = Scheme.handler(scheme)
170
+ return handler.valid?(version_string) && handler.prerelease?(version_string) if handler
171
+
172
+ cached_new(version_string).prerelease?
173
+ rescue ArgumentError
174
+ false
175
+ end
176
+
177
+ def self.clean(version_string, scheme = nil)
178
+ return nil unless valid?(version_string, scheme)
179
+ return normalize(version_string, scheme) if Scheme.handler(scheme)
180
+
113
181
  version_string.to_s.sub(/\Av/, '')
114
182
  end
115
183
 
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vers
4
+ module VersionComparison
5
+ extend self
6
+
7
+ def compare_numbers(left, right)
8
+ normalized_left = normalize_number(left)
9
+ normalized_right = normalize_number(right)
10
+
11
+ length_comparison = normalized_left.length <=> normalized_right.length
12
+ return length_comparison unless length_comparison.zero?
13
+
14
+ normalized_left <=> normalized_right
15
+ end
16
+
17
+ def normalize_number(value)
18
+ normalized = value.to_s.sub(/\A0+/, "")
19
+ normalized.empty? ? "0" : normalized
20
+ end
21
+
22
+ def numeric?(value)
23
+ value.match?(/\A\d+\z/)
24
+ end
25
+ end
26
+ end