capsium 0.2.0 → 0.6.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +17 -1
  3. data/CHANGELOG.md +322 -0
  4. data/README.adoc +643 -7
  5. data/capsium.gemspec +3 -0
  6. data/lib/capsium/cli/formatting.rb +27 -0
  7. data/lib/capsium/cli/package.rb +106 -7
  8. data/lib/capsium/cli/reactor.rb +120 -6
  9. data/lib/capsium/cli.rb +30 -0
  10. data/lib/capsium/log_buffer.rb +47 -0
  11. data/lib/capsium/package/authentication.rb +38 -0
  12. data/lib/capsium/package/authentication_config.rb +78 -0
  13. data/lib/capsium/package/bundle.rb +136 -0
  14. data/lib/capsium/package/cipher.rb +253 -0
  15. data/lib/capsium/package/composition.rb +93 -0
  16. data/lib/capsium/package/dataset.rb +20 -0
  17. data/lib/capsium/package/dependency_resolver.rb +91 -0
  18. data/lib/capsium/package/encryption_config.rb +42 -0
  19. data/lib/capsium/package/manifest.rb +1 -1
  20. data/lib/capsium/package/merged_view.rb +170 -0
  21. data/lib/capsium/package/metadata.rb +1 -1
  22. data/lib/capsium/package/metadata_config.rb +9 -1
  23. data/lib/capsium/package/open_pgp.rb +65 -0
  24. data/lib/capsium/package/open_pgp_cipher.rb +66 -0
  25. data/lib/capsium/package/open_pgp_signer.rb +176 -0
  26. data/lib/capsium/package/preparation.rb +81 -0
  27. data/lib/capsium/package/routes_config.rb +60 -6
  28. data/lib/capsium/package/security.rb +21 -4
  29. data/lib/capsium/package/security_config.rb +6 -2
  30. data/lib/capsium/package/signer.rb +216 -0
  31. data/lib/capsium/package/storage_config.rb +29 -1
  32. data/lib/capsium/package/store.rb +136 -0
  33. data/lib/capsium/package/testing/config_test.rb +70 -0
  34. data/lib/capsium/package/testing/context.rb +11 -0
  35. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  36. data/lib/capsium/package/testing/file_test.rb +45 -0
  37. data/lib/capsium/package/testing/report.rb +33 -0
  38. data/lib/capsium/package/testing/route_test.rb +64 -0
  39. data/lib/capsium/package/testing/test_case.rb +60 -0
  40. data/lib/capsium/package/testing/test_suite.rb +86 -0
  41. data/lib/capsium/package/testing.rb +20 -0
  42. data/lib/capsium/package/validator.rb +39 -3
  43. data/lib/capsium/package/verification.rb +34 -0
  44. data/lib/capsium/package/version.rb +204 -0
  45. data/lib/capsium/package.rb +78 -75
  46. data/lib/capsium/packager.rb +97 -22
  47. data/lib/capsium/reactor/authenticator.rb +180 -0
  48. data/lib/capsium/reactor/content_api.rb +110 -0
  49. data/lib/capsium/reactor/data_api.rb +154 -0
  50. data/lib/capsium/reactor/deploy.rb +71 -0
  51. data/lib/capsium/reactor/endpoints.rb +37 -0
  52. data/lib/capsium/reactor/graphql_api.rb +123 -0
  53. data/lib/capsium/reactor/graphql_schema.rb +138 -0
  54. data/lib/capsium/reactor/htpasswd.rb +154 -0
  55. data/lib/capsium/reactor/introspection.rb +151 -31
  56. data/lib/capsium/reactor/metrics.rb +30 -0
  57. data/lib/capsium/reactor/mount.rb +206 -0
  58. data/lib/capsium/reactor/oauth2.rb +106 -0
  59. data/lib/capsium/reactor/overlay.rb +248 -0
  60. data/lib/capsium/reactor/package_saver.rb +99 -0
  61. data/lib/capsium/reactor/responses.rb +41 -0
  62. data/lib/capsium/reactor/serving.rb +154 -0
  63. data/lib/capsium/reactor/session.rb +82 -0
  64. data/lib/capsium/reactor.rb +116 -67
  65. data/lib/capsium/registry/local.rb +120 -0
  66. data/lib/capsium/registry/remote.rb +105 -0
  67. data/lib/capsium/registry.rb +190 -0
  68. data/lib/capsium/thor_ext.rb +1 -1
  69. data/lib/capsium/version.rb +1 -1
  70. data/lib/capsium.rb +2 -0
  71. data/sig/capsium/log_buffer.rbs +30 -0
  72. data/sig/capsium/package/authentication.rbs +23 -0
  73. data/sig/capsium/package/authentication_config.rbs +71 -0
  74. data/sig/capsium/package/bundle.rbs +43 -0
  75. data/sig/capsium/package/cipher.rbs +62 -0
  76. data/sig/capsium/package/composition.rbs +21 -0
  77. data/sig/capsium/package/dataset.rbs +8 -0
  78. data/sig/capsium/package/dependency_resolver.rbs +53 -0
  79. data/sig/capsium/package/encryption_config.rbs +39 -0
  80. data/sig/capsium/package/merged_view.rbs +47 -0
  81. data/sig/capsium/package/metadata.rbs +1 -0
  82. data/sig/capsium/package/open_pgp.rbs +38 -0
  83. data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
  84. data/sig/capsium/package/open_pgp_signer.rbs +46 -0
  85. data/sig/capsium/package/preparation.rbs +23 -0
  86. data/sig/capsium/package/routes_config.rbs +36 -3
  87. data/sig/capsium/package/security.rbs +9 -2
  88. data/sig/capsium/package/security_config.rbs +6 -3
  89. data/sig/capsium/package/signer.rbs +66 -0
  90. data/sig/capsium/package/storage_config.rbs +26 -2
  91. data/sig/capsium/package/store.rbs +43 -0
  92. data/sig/capsium/package/testing/config_test.rbs +21 -0
  93. data/sig/capsium/package/testing/context.rbs +14 -0
  94. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  95. data/sig/capsium/package/testing/file_test.rbs +17 -0
  96. data/sig/capsium/package/testing/report.rbs +20 -0
  97. data/sig/capsium/package/testing/route_test.rbs +22 -0
  98. data/sig/capsium/package/testing/test_case.rbs +41 -0
  99. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  100. data/sig/capsium/package/testing.rbs +7 -0
  101. data/sig/capsium/package/verification.rbs +26 -0
  102. data/sig/capsium/package/version.rbs +39 -0
  103. data/sig/capsium/package.rbs +29 -16
  104. data/sig/capsium/packager.rbs +9 -0
  105. data/sig/capsium/reactor/authenticator.rbs +40 -0
  106. data/sig/capsium/reactor/content_api.rbs +19 -0
  107. data/sig/capsium/reactor/data_api.rbs +23 -0
  108. data/sig/capsium/reactor/deploy.rbs +34 -0
  109. data/sig/capsium/reactor/endpoints.rbs +16 -0
  110. data/sig/capsium/reactor/graphql_api.rbs +27 -0
  111. data/sig/capsium/reactor/graphql_schema.rbs +20 -0
  112. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  113. data/sig/capsium/reactor/introspection.rbs +27 -7
  114. data/sig/capsium/reactor/metrics.rbs +13 -0
  115. data/sig/capsium/reactor/mount.rbs +83 -0
  116. data/sig/capsium/reactor/oauth2.rbs +31 -0
  117. data/sig/capsium/reactor/overlay.rbs +72 -0
  118. data/sig/capsium/reactor/package_saver.rbs +12 -0
  119. data/sig/capsium/reactor/responses.rbs +23 -0
  120. data/sig/capsium/reactor/serving.rbs +46 -0
  121. data/sig/capsium/reactor/session.rbs +36 -0
  122. data/sig/capsium/reactor.rbs +22 -2
  123. data/sig/capsium/registry/local.rbs +16 -0
  124. data/sig/capsium/registry/remote.rbs +18 -0
  125. data/sig/capsium/registry.rbs +83 -0
  126. data/sig/graphql.rbs +12 -0
  127. metadata +135 -1
@@ -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
@@ -26,6 +26,7 @@ module Capsium
26
26
 
27
27
  def run
28
28
  Dir.mktmpdir do |dir|
29
+ @workdir = dir
29
30
  @package_path = prepare(dir)
30
31
  [metadata_check, manifest_check, routes_check,
31
32
  storage_check, security_check, content_check]
@@ -58,7 +59,7 @@ module Capsium
58
59
  def manifest_check
59
60
  manifest = Manifest.new(metadata_path(MANIFEST_FILE))
60
61
  missing = manifest.resources.keys.reject do |path|
61
- File.file?(File.join(@package_path, path))
62
+ merged_view.resolve(path) || File.file?(File.join(@package_path, path))
62
63
  end
63
64
  result("manifest", missing.map { |path| "resource missing on disk: #{path}" })
64
65
  rescue StandardError => e
@@ -86,7 +87,7 @@ module Capsium
86
87
  if route.dataset_route? && !route.path.start_with?(Route::DATASET_PATH_PREFIX)
87
88
  problems << "dataset route #{route.path} not under #{Route::DATASET_PATH_PREFIX}"
88
89
  end
89
- route.validate_target(@package_path, storage)
90
+ route.validate_target(@package_path, storage, merged_view: merged_view)
90
91
  problems
91
92
  rescue Error => e
92
93
  problems + [e.message]
@@ -95,7 +96,7 @@ module Capsium
95
96
  def index_problems(routes)
96
97
  index = routes.config.index
97
98
  return ["index route is missing"] if index.nil?
98
- unless File.file?(File.join(@package_path, index))
99
+ unless merged_view.resolve(index) || File.file?(File.join(@package_path, index))
99
100
  return ["index missing on disk: #{index}"]
100
101
  end
101
102
  return [] if File.extname(index).downcase == ".html"
@@ -111,6 +112,41 @@ module Capsium
111
112
  failure("storage", ["storage.json is not valid: #{e.message}"])
112
113
  end
113
114
 
115
+ # The merged content view (ARCHITECTURE.md section 5a) over the
116
+ # package being validated; layered and tombstoned resources resolve
117
+ # the same way the reactor resolves them. For encapsulated
118
+ # packages the bundled dependencies (packages/, recursively) act
119
+ # as read-only exported layers exactly as at activation, so
120
+ # fallthrough routes referencing dependency content validate.
121
+ def merged_view
122
+ @merged_view ||= MergedView.new(
123
+ @package_path,
124
+ storage: Storage.new(metadata_path(STORAGE_FILE)),
125
+ manifest: Manifest.new(metadata_path(MANIFEST_FILE)),
126
+ dependency_views: dependency_views(@package_path)
127
+ )
128
+ end
129
+
130
+ # The [guid, exported MergedView] layers of the dependencies
131
+ # bundled under the package directory's packages/ (empty when the
132
+ # package embeds no bundle).
133
+ def dependency_views(package_path)
134
+ bundle = Bundle.new(package_path)
135
+ return [] unless bundle.present?
136
+
137
+ bundle.entries.map do |guid, entry|
138
+ dependency_dir = File.join(@workdir, "bundles", guid.gsub(/[^A-Za-z0-9.-]/, "_"))
139
+ Packager.new.unpack(entry.file, dependency_dir)
140
+ [guid, MergedView.new(
141
+ dependency_dir,
142
+ storage: Storage.new(File.join(dependency_dir, STORAGE_FILE)),
143
+ manifest: Manifest.new(File.join(dependency_dir, MANIFEST_FILE)),
144
+ dependency_views: dependency_views(dependency_dir),
145
+ exported_only: true
146
+ )]
147
+ end
148
+ end
149
+
114
150
  def security_check
115
151
  security = Security.new(metadata_path(SECURITY_FILE))
116
152
  return result("security", []) unless security.present?
@@ -0,0 +1,34 @@
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 against the
23
+ # checksum-covered payload, through the signer matching the
24
+ # declared certificateType (OpenPgpSigner for OpenPGP, the
25
+ # RSA-SHA256 Signer otherwise). True when the package is unsigned
26
+ # (nothing declared) or the signature verifies; false on mismatch.
27
+ def verify_signature = !signed? || Signer.signer_class_for(@path).new(@path).verify
28
+
29
+ def verify_signature!
30
+ Signer.signer_class_for(@path).new(@path).verify! if signed?
31
+ end
32
+ end
33
+ end
34
+ 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