capsium 0.2.0 → 0.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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +12 -0
  3. data/CHANGELOG.md +125 -0
  4. data/README.adoc +332 -5
  5. data/capsium.gemspec +1 -0
  6. data/lib/capsium/cli/formatting.rb +27 -0
  7. data/lib/capsium/cli/package.rb +68 -6
  8. data/lib/capsium/cli/reactor.rb +9 -1
  9. data/lib/capsium/cli.rb +1 -0
  10. data/lib/capsium/package/authentication.rb +38 -0
  11. data/lib/capsium/package/authentication_config.rb +78 -0
  12. data/lib/capsium/package/cipher.rb +197 -0
  13. data/lib/capsium/package/composition.rb +77 -0
  14. data/lib/capsium/package/dependency_resolver.rb +61 -0
  15. data/lib/capsium/package/encryption_config.rb +38 -0
  16. data/lib/capsium/package/merged_view.rb +168 -0
  17. data/lib/capsium/package/preparation.rb +81 -0
  18. data/lib/capsium/package/routes_config.rb +60 -6
  19. data/lib/capsium/package/security.rb +21 -4
  20. data/lib/capsium/package/signer.rb +201 -0
  21. data/lib/capsium/package/storage_config.rb +29 -1
  22. data/lib/capsium/package/store.rb +109 -0
  23. data/lib/capsium/package/testing/config_test.rb +70 -0
  24. data/lib/capsium/package/testing/context.rb +11 -0
  25. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  26. data/lib/capsium/package/testing/file_test.rb +45 -0
  27. data/lib/capsium/package/testing/report.rb +33 -0
  28. data/lib/capsium/package/testing/route_test.rb +64 -0
  29. data/lib/capsium/package/testing/test_case.rb +60 -0
  30. data/lib/capsium/package/testing/test_suite.rb +86 -0
  31. data/lib/capsium/package/testing.rb +20 -0
  32. data/lib/capsium/package/validator.rb +14 -3
  33. data/lib/capsium/package/verification.rb +32 -0
  34. data/lib/capsium/package/version.rb +204 -0
  35. data/lib/capsium/package.rb +59 -75
  36. data/lib/capsium/packager.rb +34 -1
  37. data/lib/capsium/reactor/authenticator.rb +180 -0
  38. data/lib/capsium/reactor/deploy.rb +71 -0
  39. data/lib/capsium/reactor/htpasswd.rb +154 -0
  40. data/lib/capsium/reactor/introspection.rb +4 -1
  41. data/lib/capsium/reactor/oauth2.rb +106 -0
  42. data/lib/capsium/reactor/serving.rb +75 -0
  43. data/lib/capsium/reactor/session.rb +82 -0
  44. data/lib/capsium/reactor.rb +43 -41
  45. data/lib/capsium/thor_ext.rb +1 -1
  46. data/lib/capsium/version.rb +1 -1
  47. data/sig/capsium/package/authentication.rbs +23 -0
  48. data/sig/capsium/package/authentication_config.rbs +71 -0
  49. data/sig/capsium/package/cipher.rbs +51 -0
  50. data/sig/capsium/package/composition.rbs +20 -0
  51. data/sig/capsium/package/dependency_resolver.rbs +49 -0
  52. data/sig/capsium/package/encryption_config.rbs +35 -0
  53. data/sig/capsium/package/merged_view.rbs +46 -0
  54. data/sig/capsium/package/preparation.rbs +23 -0
  55. data/sig/capsium/package/routes_config.rbs +36 -3
  56. data/sig/capsium/package/security.rbs +9 -2
  57. data/sig/capsium/package/signer.rbs +59 -0
  58. data/sig/capsium/package/storage_config.rbs +26 -2
  59. data/sig/capsium/package/store.rbs +37 -0
  60. data/sig/capsium/package/testing/config_test.rbs +21 -0
  61. data/sig/capsium/package/testing/context.rbs +14 -0
  62. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  63. data/sig/capsium/package/testing/file_test.rbs +17 -0
  64. data/sig/capsium/package/testing/report.rbs +20 -0
  65. data/sig/capsium/package/testing/route_test.rbs +22 -0
  66. data/sig/capsium/package/testing/test_case.rbs +41 -0
  67. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  68. data/sig/capsium/package/testing.rbs +7 -0
  69. data/sig/capsium/package/verification.rbs +24 -0
  70. data/sig/capsium/package/version.rbs +39 -0
  71. data/sig/capsium/package.rbs +25 -17
  72. data/sig/capsium/packager.rbs +9 -0
  73. data/sig/capsium/reactor/authenticator.rbs +40 -0
  74. data/sig/capsium/reactor/deploy.rbs +34 -0
  75. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  76. data/sig/capsium/reactor/oauth2.rbs +31 -0
  77. data/sig/capsium/reactor/serving.rbs +22 -0
  78. data/sig/capsium/reactor/session.rbs +36 -0
  79. data/sig/capsium/reactor.rbs +6 -1
  80. metadata +70 -1
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ module Testing
6
+ # What a test run needs: the extracted package directory and, when
7
+ # route tests run, the base URL of the reactor serving the package.
8
+ Context = Data.define(:package_path, :base_url)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json-schema"
5
+ require "yaml"
6
+
7
+ module Capsium
8
+ class Package
9
+ module Testing
10
+ # A "data_validation" test (05x-testing): the rows of the data
11
+ # file (format: json or yaml) must validate against the JSON
12
+ # schema file. Array data validates row by row; a single document
13
+ # validates as one row.
14
+ class DataValidationTest < TestCase
15
+ FORMATS = %w[json yaml].freeze
16
+
17
+ attr_reader :format, :data_file, :schema_file
18
+
19
+ def initialize(name:, format:, data_file:, schema_file:)
20
+ super(name: name)
21
+ @format = format
22
+ @data_file = data_file
23
+ @schema_file = schema_file
24
+ end
25
+
26
+ def run(context)
27
+ problems = validation_problems(context)
28
+ Result.new(name: name, ok: problems.empty?, messages: problems)
29
+ end
30
+
31
+ private
32
+
33
+ def validation_problems(context)
34
+ return ["unsupported data format: #{format}"] unless FORMATS.include?(format)
35
+
36
+ schema = load_structured(context, schema_file)
37
+ rows = load_structured(context, data_file)
38
+ rows = [rows] unless rows.is_a?(Array)
39
+ rows.each_with_index.flat_map { |row, index| row_problems(schema, row, index) }
40
+ rescue Errno::ENOENT, JSON::ParserError, Psych::SyntaxError => e
41
+ ["cannot load data or schema: #{e.message}"]
42
+ end
43
+
44
+ def load_structured(context, relative_path)
45
+ full_path = File.join(context.package_path, relative_path.delete_prefix("/"))
46
+ if File.extname(full_path).match?(/\A\.ya?ml\z/i)
47
+ YAML.load_file(full_path)
48
+ else
49
+ JSON.parse(File.read(full_path))
50
+ end
51
+ end
52
+
53
+ def row_problems(schema, row, index)
54
+ JSON::Validator.fully_validate(schema, row).map do |message|
55
+ "row #{index}: #{message}"
56
+ end
57
+ end
58
+ end
59
+
60
+ TestCase.register("data_validation", DataValidationTest)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ module Testing
6
+ # A "file" test (05x-testing): the file must exist in the package;
7
+ # optionally its content must contain a string.
8
+ class FileTest < TestCase
9
+ attr_reader :path, :contains
10
+
11
+ def initialize(name:, path:, contains: nil)
12
+ super(name: name)
13
+ @path = path
14
+ @contains = contains
15
+ end
16
+
17
+ def run(context)
18
+ problems = existence_problems(context) + contains_problems(context)
19
+ Result.new(name: name, ok: problems.empty?, messages: problems)
20
+ end
21
+
22
+ private
23
+
24
+ def full_path(context)
25
+ File.join(context.package_path, path.delete_prefix("/"))
26
+ end
27
+
28
+ def existence_problems(context)
29
+ return [] if File.file?(full_path(context))
30
+
31
+ ["file missing in package: #{path}"]
32
+ end
33
+
34
+ def contains_problems(context)
35
+ return [] unless contains && File.file?(full_path(context))
36
+ return [] if File.read(full_path(context)).include?(contains)
37
+
38
+ ["file #{path} does not contain #{contains.inspect}"]
39
+ end
40
+ end
41
+
42
+ TestCase.register("file", FileTest)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ module Testing
6
+ # The outcome of a test suite run: one Result per test case.
7
+ class Report
8
+ attr_reader :results
9
+
10
+ def initialize(results = [])
11
+ @results = results
12
+ end
13
+
14
+ def <<(result)
15
+ @results << result
16
+ self
17
+ end
18
+
19
+ def ok?
20
+ @results.all?(&:ok?)
21
+ end
22
+
23
+ def failures
24
+ @results.reject(&:ok?)
25
+ end
26
+
27
+ def summary
28
+ "#{@results.size} tests, #{failures.size} failures"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Capsium
7
+ class Package
8
+ module Testing
9
+ # A "route" test (05x-testing): requests the URL path from the
10
+ # reactor serving the package and checks the expected status, and
11
+ # optionally the content type and a response body substring.
12
+ class RouteTest < TestCase
13
+ attr_reader :url, :expected_status, :response_contains, :expected_content_type
14
+
15
+ def initialize(name:, url:, expected_status:, response_contains: nil,
16
+ expected_content_type: nil)
17
+ super(name: name)
18
+ @url = url
19
+ @expected_status = expected_status
20
+ @response_contains = response_contains
21
+ @expected_content_type = expected_content_type
22
+ end
23
+
24
+ def run(context)
25
+ response = Net::HTTP.get_response(URI.join(context.base_url, request_path))
26
+ problems = status_problems(response) + content_type_problems(response) +
27
+ body_problems(response)
28
+ Result.new(name: name, ok: problems.empty?, messages: problems)
29
+ end
30
+
31
+ private
32
+
33
+ # The DSL allows absolute URLs; only their path (and query) is
34
+ # used — the runner always targets its own reactor.
35
+ def request_path
36
+ uri = URI.parse(url)
37
+ uri.host ? uri.request_uri : url
38
+ end
39
+
40
+ def status_problems(response)
41
+ return [] if response.code.to_i == expected_status
42
+
43
+ ["expected status #{expected_status}, got #{response.code}"]
44
+ end
45
+
46
+ def content_type_problems(response)
47
+ return [] unless expected_content_type
48
+ return [] if response.content_type == expected_content_type
49
+
50
+ ["expected content type #{expected_content_type}, got #{response.content_type}"]
51
+ end
52
+
53
+ def body_problems(response)
54
+ return [] unless response_contains
55
+ return [] if response.body.to_s.include?(response_contains)
56
+
57
+ ["response does not contain #{response_contains.inspect}"]
58
+ end
59
+ end
60
+
61
+ TestCase.register("route", RouteTest)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ module Testing
6
+ # Base class for one test of the Capsium testing YAML DSL
7
+ # (05x-testing). Subclasses implement a test kind and register
8
+ # themselves under their DSL type name (open/closed registry).
9
+ class TestCase
10
+ # The outcome of running one test.
11
+ Result = Data.define(:name, :ok, :messages) do
12
+ def ok? = ok
13
+ end
14
+
15
+ # Raised for invalid test definitions (unknown type,
16
+ # missing/unknown attributes, non-hash entries).
17
+ class DefinitionError < Capsium::Error; end
18
+
19
+ attr_reader :name
20
+
21
+ def initialize(name:)
22
+ @name = name
23
+ end
24
+
25
+ # Runs the test against the context and returns a Result.
26
+ def run(context)
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def self.register(type, klass)
31
+ types[type] = klass
32
+ end
33
+
34
+ def self.types
35
+ @types ||= {}
36
+ end
37
+
38
+ # Builds a test case from a YAML definition hash (string keys).
39
+ def self.build(definition)
40
+ unless definition.is_a?(Hash)
41
+ raise DefinitionError, "test definition is not a mapping: #{definition.inspect}"
42
+ end
43
+
44
+ type = definition["type"] ||
45
+ raise(DefinitionError, "test #{definition['name'].inspect} has no type")
46
+ klass = types[type] || raise(DefinitionError, "unknown test type: #{type}")
47
+ klass.from_h(definition)
48
+ rescue ArgumentError => e
49
+ raise DefinitionError, "invalid #{type} test #{definition['name'].inspect}: #{e.message}"
50
+ end
51
+
52
+ def self.from_h(definition)
53
+ attributes = definition.transform_keys(&:to_sym)
54
+ attributes.delete(:type)
55
+ new(**attributes)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require "yaml"
5
+
6
+ module Capsium
7
+ class Package
8
+ module Testing
9
+ # Loads and runs the package's tests/*.yaml suites (05x-testing).
10
+ # Route tests are served by a reactor on an ephemeral port for the
11
+ # duration of the run. Invalid test definitions are reported as
12
+ # failed results instead of aborting the run.
13
+ class TestSuite
14
+ TESTS_DIR = "tests"
15
+
16
+ # Referencing the built-in test kinds triggers their autoload,
17
+ # which registers them in TestCase.types (each kind file
18
+ # self-registers on load).
19
+ BUILTIN_TEST_KINDS = [RouteTest, FileTest, DataValidationTest, ConfigTest].freeze
20
+
21
+ attr_reader :package
22
+
23
+ def initialize(package)
24
+ @package = package
25
+ end
26
+
27
+ def test_files
28
+ Dir.glob(File.join(@package.path, TESTS_DIR, "*.{yaml,yml}"))
29
+ end
30
+
31
+ def run
32
+ report = Report.new
33
+ loaded = load_cases(report)
34
+ with_reactor(loaded) do |base_url|
35
+ context = Context.new(package_path: @package.path, base_url: base_url)
36
+ loaded.each { |test_case| report << run_case(test_case, context) }
37
+ end
38
+ report
39
+ end
40
+
41
+ private
42
+
43
+ def load_cases(report)
44
+ test_files.flat_map do |file|
45
+ definitions = YAML.load_file(file)
46
+ tests = definitions.is_a?(Hash) ? definitions["tests"] : nil
47
+ Array(tests).map { |definition| TestCase.build(definition) }
48
+ rescue Psych::SyntaxError, TestCase::DefinitionError => e
49
+ report << TestCase::Result.new(name: File.basename(file), ok: false,
50
+ messages: [e.message])
51
+ []
52
+ end
53
+ end
54
+
55
+ def run_case(test_case, context)
56
+ test_case.run(context)
57
+ rescue StandardError => e
58
+ TestCase::Result.new(name: test_case.name, ok: false,
59
+ messages: ["#{e.class}: #{e.message}"])
60
+ end
61
+
62
+ def with_reactor(loaded)
63
+ return yield nil unless loaded.any?(RouteTest)
64
+
65
+ reactor = Capsium::Reactor.new(package: @package, port: 0)
66
+ server_thread = Thread.new { reactor.server.start }
67
+ # WEBrick race: shutdown before start reaches :Running would
68
+ # leave the server running forever.
69
+ wait_until_running(reactor.server)
70
+ yield "http://127.0.0.1:#{reactor.server.listeners.first.addr[1]}"
71
+ ensure
72
+ shutdown_reactor(reactor, server_thread) if reactor
73
+ end
74
+
75
+ def wait_until_running(server)
76
+ Timeout.timeout(5) { sleep 0.01 until server.status == :Running }
77
+ end
78
+
79
+ def shutdown_reactor(reactor, server_thread)
80
+ reactor.server.shutdown
81
+ server_thread.join
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ # Runner for the Capsium package testing YAML DSL (05x-testing): a
6
+ # package declares tests in tests/*.yaml files (top-level "tests"
7
+ # list) of four kinds — route, file, data_validation and config —
8
+ # and the suite runner executes them against the package.
9
+ module Testing
10
+ autoload :ConfigTest, "capsium/package/testing/config_test"
11
+ autoload :Context, "capsium/package/testing/context"
12
+ autoload :DataValidationTest, "capsium/package/testing/data_validation_test"
13
+ autoload :FileTest, "capsium/package/testing/file_test"
14
+ autoload :Report, "capsium/package/testing/report"
15
+ autoload :RouteTest, "capsium/package/testing/route_test"
16
+ autoload :TestCase, "capsium/package/testing/test_case"
17
+ autoload :TestSuite, "capsium/package/testing/test_suite"
18
+ end
19
+ end
20
+ end
@@ -58,7 +58,7 @@ module Capsium
58
58
  def manifest_check
59
59
  manifest = Manifest.new(metadata_path(MANIFEST_FILE))
60
60
  missing = manifest.resources.keys.reject do |path|
61
- File.file?(File.join(@package_path, path))
61
+ merged_view.resolve(path) || File.file?(File.join(@package_path, path))
62
62
  end
63
63
  result("manifest", missing.map { |path| "resource missing on disk: #{path}" })
64
64
  rescue StandardError => e
@@ -86,7 +86,7 @@ module Capsium
86
86
  if route.dataset_route? && !route.path.start_with?(Route::DATASET_PATH_PREFIX)
87
87
  problems << "dataset route #{route.path} not under #{Route::DATASET_PATH_PREFIX}"
88
88
  end
89
- route.validate_target(@package_path, storage)
89
+ route.validate_target(@package_path, storage, merged_view: merged_view)
90
90
  problems
91
91
  rescue Error => e
92
92
  problems + [e.message]
@@ -95,7 +95,7 @@ module Capsium
95
95
  def index_problems(routes)
96
96
  index = routes.config.index
97
97
  return ["index route is missing"] if index.nil?
98
- unless File.file?(File.join(@package_path, index))
98
+ unless merged_view.resolve(index) || File.file?(File.join(@package_path, index))
99
99
  return ["index missing on disk: #{index}"]
100
100
  end
101
101
  return [] if File.extname(index).downcase == ".html"
@@ -111,6 +111,17 @@ module Capsium
111
111
  failure("storage", ["storage.json is not valid: #{e.message}"])
112
112
  end
113
113
 
114
+ # The merged content view (ARCHITECTURE.md section 5a) over the
115
+ # package being validated; layered and tombstoned resources resolve
116
+ # the same way the reactor resolves them.
117
+ def merged_view
118
+ @merged_view ||= MergedView.new(
119
+ @package_path,
120
+ storage: Storage.new(metadata_path(STORAGE_FILE)),
121
+ manifest: Manifest.new(metadata_path(MANIFEST_FILE))
122
+ )
123
+ end
124
+
114
125
  def security_check
115
126
  security = Security.new(metadata_path(SECURITY_FILE))
116
127
  return result("security", []) unless security.present?
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ # Integrity and digital-signature verification of a loaded package
6
+ # (ARCHITECTURE.md section 6), mixed into Package.
7
+ module Verification
8
+ # Verifies the package against security.json. Returns a list of
9
+ # typed errors; empty when no security.json is present or all
10
+ # checksums match.
11
+ def verify_integrity
12
+ @security.present? ? @security.verify(@path) : []
13
+ end
14
+
15
+ def verify_integrity!
16
+ @security.verify!(@path) if @security.present?
17
+ end
18
+
19
+ # Whether security.json declares a digital signature for this package.
20
+ def signed? = @security.signed?
21
+
22
+ # Verifies the declared digital signature (RSA-SHA256) against the
23
+ # checksum-covered payload. True when the package is unsigned (nothing
24
+ # declared) or the signature verifies; false on mismatch.
25
+ def verify_signature = !signed? || Signer.new(@path).verify
26
+
27
+ def verify_signature!
28
+ Signer.new(@path).verify! if signed?
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ # A semantic version (MAJOR.MINOR.PATCH[-prerelease][+build]) with
6
+ # semver precedence ordering. Build metadata is parsed but ignored
7
+ # for precedence, per semver.org item 10.
8
+ class Version
9
+ include Comparable
10
+
11
+ PATTERN = /\A(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?\z/
12
+
13
+ attr_reader :major, :minor, :patch, :prerelease
14
+
15
+ def self.parse(string)
16
+ match = PATTERN.match(string.to_s.strip)
17
+ raise Error, "Invalid semver version: #{string.inspect}" unless match
18
+
19
+ new(match[1].to_i, match[2].to_i, match[3].to_i, match[4])
20
+ end
21
+
22
+ def initialize(major, minor, patch, prerelease = nil)
23
+ @major = major
24
+ @minor = minor
25
+ @patch = patch
26
+ @prerelease = prerelease
27
+ end
28
+
29
+ def <=>(other)
30
+ core = [major, minor, patch] <=> [other.major, other.minor, other.patch]
31
+ return core unless core.zero?
32
+
33
+ compare_prerelease(other)
34
+ end
35
+
36
+ def to_s
37
+ "#{major}.#{minor}.#{patch}#{"-#{prerelease}" if prerelease}"
38
+ end
39
+
40
+ private
41
+
42
+ # semver.org item 11: a release outranks its prereleases; numeric
43
+ # identifiers compare numerically and rank below alphanumeric ones;
44
+ # a longer identifier list outranks a shorter prefix.
45
+ def compare_prerelease(other)
46
+ own_rank = prerelease.nil? ? 1 : 0
47
+ their_rank = other.prerelease.nil? ? 1 : 0
48
+ return own_rank <=> their_rank unless own_rank == their_rank
49
+ return 0 if prerelease.nil?
50
+
51
+ compare_identifier_lists(prerelease.split("."),
52
+ other.prerelease.split("."))
53
+ end
54
+
55
+ def compare_identifier_lists(own_list, their_list)
56
+ [own_list.length, their_list.length].max.times do |index|
57
+ own = own_list[index]
58
+ theirs = their_list[index]
59
+ return 1 if theirs.nil?
60
+ return -1 if own.nil?
61
+
62
+ comparison = compare_identifier(own, theirs)
63
+ return comparison unless comparison.zero?
64
+ end
65
+ 0
66
+ end
67
+
68
+ def compare_identifier(own, theirs)
69
+ own_numeric = own.match?(/\A\d+\z/)
70
+ theirs_numeric = theirs.match?(/\A\d+\z/)
71
+ return own.to_i <=> theirs.to_i if own_numeric && theirs_numeric
72
+ return -1 if own_numeric
73
+ return 1 if theirs_numeric
74
+
75
+ own <=> theirs
76
+ end
77
+ end
78
+
79
+ # A semver range for metadata.dependencies (ARCHITECTURE.md section
80
+ # 4a), covering the standard's examples: "*", exact versions,
81
+ # wildcards and partials ("1.x", "1.2.x", "1.2"), caret ("^1.2.3"),
82
+ # tilde ("~1.2.3"), comparison operators (>=, <=, >, <, =) and
83
+ # conjunctions joined by comma and/or space (">=1.0.0, <2.0.0").
84
+ class VersionRange
85
+ NUMERIC_PART = /\A(\d+)(?:\.(\d+)|\.(?:x|X|\*))?(?:\.(?:\d+|x|X|\*))?(?:-[^\s,]+)?\z/
86
+ WILDCARD_PART = /\A(?:x|X|\*)\z/
87
+
88
+ def self.parse(string)
89
+ terms = string.to_s.strip.split(/[,\s]+/).reject(&:empty?)
90
+ return new([[">=", Version.new(0, 0, 0)]]) if terms.empty? || terms == ["*"]
91
+
92
+ new(terms.flat_map { |term| expand_term(term) })
93
+ end
94
+
95
+ # Expands one range term to a list of [operator, Version] bounds.
96
+ def self.expand_term(term)
97
+ case term
98
+ when /\A\^(.+)\z/ then caret_bounds_for(Regexp.last_match(1))
99
+ when /\A~(.+)\z/ then tilde_bounds_for(Regexp.last_match(1))
100
+ when /\A(>=|<=|>|<|==?)(.+)\z/
101
+ [[normalize_operator(Regexp.last_match(1)), Version.parse(pad(Regexp.last_match(2)))]]
102
+ else
103
+ expand_bare(term)
104
+ end
105
+ end
106
+ private_class_method :expand_term
107
+
108
+ # Bare terms: exact versions, partials ("1", "1.2") and wildcards
109
+ # ("1.x", "1.2.x"). A partial behaves like the same-position
110
+ # wildcard: "1" is "1.x", "1.2" is "1.2.x".
111
+ def self.expand_bare(term)
112
+ raise Error, "Invalid semver range term: #{term.inspect}" unless NUMERIC_PART.match?(term)
113
+
114
+ parts = term.split("-", 2).first.split(".")
115
+ return caret_bounds(Version.new(parts[0].to_i, 0, 0)) if wildcard_at?(parts, 1)
116
+ return tilde_bounds(Version.new(parts[0].to_i, parts[1].to_i, 0)) if wildcard_at?(parts, 2)
117
+
118
+ [["=", Version.parse(term)]]
119
+ end
120
+ private_class_method :expand_bare
121
+
122
+ def self.wildcard_at?(parts, index)
123
+ parts.length <= index || WILDCARD_PART.match?(parts[index])
124
+ end
125
+ private_class_method :wildcard_at?
126
+
127
+ # Pads partial versions ("1.2") with zeros so operators accept them.
128
+ def self.pad(partial)
129
+ parts = partial.split("-", 2)
130
+ core = parts.first.split(".")
131
+ core << "0" while core.length < 3
132
+ [core.join("."), parts[1]].compact.join("-")
133
+ end
134
+ private_class_method :pad
135
+
136
+ # Caret bounds keeping the given precision: "^1" is < 2.0.0 and
137
+ # "^0.0" is < 0.1.0 (npm semantics); full versions use caret rules.
138
+ def self.caret_bounds_for(partial)
139
+ parts = partial.split("-", 2).first.split(".")
140
+ version = Version.parse(pad(partial))
141
+ return [[">=", version], ["<", Version.new(version.major + 1, 0, 0)]] if parts.length < 2
142
+ if parts.length < 3 && version.major.zero?
143
+ return [[">=", version], ["<", Version.new(0, version.minor + 1, 0)]]
144
+ end
145
+
146
+ caret_bounds(version)
147
+ end
148
+ private_class_method :caret_bounds_for
149
+
150
+ # Tilde bounds keeping the given precision: "~1" is < 2.0.0.
151
+ def self.tilde_bounds_for(partial)
152
+ parts = partial.split("-", 2).first.split(".")
153
+ version = Version.parse(pad(partial))
154
+ return [[">=", version], ["<", Version.new(version.major + 1, 0, 0)]] if parts.length < 2
155
+
156
+ tilde_bounds(version)
157
+ end
158
+ private_class_method :tilde_bounds_for
159
+
160
+ def self.caret_bounds(version)
161
+ upper = if version.major.positive?
162
+ Version.new(version.major + 1, 0, 0)
163
+ elsif version.minor.positive?
164
+ Version.new(0, version.minor + 1, 0)
165
+ else
166
+ Version.new(0, 0, version.patch + 1)
167
+ end
168
+ [[">=", version], ["<", upper]]
169
+ end
170
+ private_class_method :caret_bounds
171
+
172
+ def self.tilde_bounds(version)
173
+ [[">=", version], ["<", Version.new(version.major, version.minor + 1, 0)]]
174
+ end
175
+ private_class_method :tilde_bounds
176
+
177
+ def self.normalize_operator(operator)
178
+ operator == "==" ? "=" : operator
179
+ end
180
+ private_class_method :normalize_operator
181
+
182
+ def initialize(bounds)
183
+ @bounds = bounds
184
+ end
185
+
186
+ def satisfied_by?(version)
187
+ version = Version.parse(version) unless version.is_a?(Version)
188
+ @bounds.all? { |operator, bound| holds?(operator, version <=> bound) }
189
+ end
190
+
191
+ private
192
+
193
+ def holds?(operator, comparison)
194
+ case operator
195
+ when ">=" then comparison >= 0
196
+ when "<=" then comparison <= 0
197
+ when ">" then comparison.positive?
198
+ when "<" then comparison.negative?
199
+ else comparison.zero?
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end