capsium 0.1.2 → 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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +46 -7
  4. data/CHANGELOG.md +183 -0
  5. data/Gemfile +6 -0
  6. data/LICENSE +21 -0
  7. data/README.adoc +496 -127
  8. data/Rakefile +8 -1
  9. data/bin/console +1 -1
  10. data/capsium.gemspec +12 -19
  11. data/exe/capsium +1 -1
  12. data/lib/capsium/cli/convert.rb +18 -0
  13. data/lib/capsium/cli/formatting.rb +27 -0
  14. data/lib/capsium/cli/package.rb +148 -0
  15. data/lib/capsium/cli/reactor.rb +33 -0
  16. data/lib/capsium/cli.rb +4 -96
  17. data/lib/capsium/converters/jekyll.rb +5 -7
  18. data/lib/capsium/converters.rb +7 -0
  19. data/lib/capsium/package/authentication.rb +38 -0
  20. data/lib/capsium/package/authentication_config.rb +78 -0
  21. data/lib/capsium/package/cipher.rb +197 -0
  22. data/lib/capsium/package/composition.rb +77 -0
  23. data/lib/capsium/package/dataset.rb +67 -34
  24. data/lib/capsium/package/dependency_resolver.rb +61 -0
  25. data/lib/capsium/package/encryption_config.rb +38 -0
  26. data/lib/capsium/package/manifest.rb +33 -22
  27. data/lib/capsium/package/manifest_config.rb +52 -7
  28. data/lib/capsium/package/merged_view.rb +168 -0
  29. data/lib/capsium/package/metadata.rb +4 -8
  30. data/lib/capsium/package/metadata_config.rb +97 -9
  31. data/lib/capsium/package/preparation.rb +81 -0
  32. data/lib/capsium/package/routes.rb +39 -41
  33. data/lib/capsium/package/routes_config.rb +174 -44
  34. data/lib/capsium/package/security.rb +140 -0
  35. data/lib/capsium/package/security_config.rb +60 -0
  36. data/lib/capsium/package/signer.rb +201 -0
  37. data/lib/capsium/package/storage.rb +20 -45
  38. data/lib/capsium/package/storage_config.rb +128 -3
  39. data/lib/capsium/package/store.rb +109 -0
  40. data/lib/capsium/package/testing/config_test.rb +70 -0
  41. data/lib/capsium/package/testing/context.rb +11 -0
  42. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  43. data/lib/capsium/package/testing/file_test.rb +45 -0
  44. data/lib/capsium/package/testing/report.rb +33 -0
  45. data/lib/capsium/package/testing/route_test.rb +64 -0
  46. data/lib/capsium/package/testing/test_case.rb +60 -0
  47. data/lib/capsium/package/testing/test_suite.rb +86 -0
  48. data/lib/capsium/package/testing.rb +20 -0
  49. data/lib/capsium/package/validator.rb +160 -0
  50. data/lib/capsium/package/verification.rb +32 -0
  51. data/lib/capsium/package/version.rb +204 -0
  52. data/lib/capsium/package.rb +92 -106
  53. data/lib/capsium/packager.rb +75 -6
  54. data/lib/capsium/reactor/authenticator.rb +180 -0
  55. data/lib/capsium/reactor/deploy.rb +71 -0
  56. data/lib/capsium/reactor/htpasswd.rb +154 -0
  57. data/lib/capsium/reactor/introspection.rb +91 -0
  58. data/lib/capsium/reactor/oauth2.rb +106 -0
  59. data/lib/capsium/reactor/serving.rb +75 -0
  60. data/lib/capsium/reactor/session.rb +82 -0
  61. data/lib/capsium/reactor.rb +93 -43
  62. data/lib/capsium/thor_ext.rb +1 -1
  63. data/lib/capsium/version.rb +1 -1
  64. data/lib/capsium.rb +7 -9
  65. data/sig/capsium/package/authentication.rbs +23 -0
  66. data/sig/capsium/package/authentication_config.rbs +71 -0
  67. data/sig/capsium/package/cipher.rbs +51 -0
  68. data/sig/capsium/package/composition.rbs +20 -0
  69. data/sig/capsium/package/dataset.rbs +28 -0
  70. data/sig/capsium/package/dependency_resolver.rbs +49 -0
  71. data/sig/capsium/package/encryption_config.rbs +35 -0
  72. data/sig/capsium/package/manifest.rbs +34 -0
  73. data/sig/capsium/package/manifest_config.rbs +38 -0
  74. data/sig/capsium/package/merged_view.rbs +46 -0
  75. data/sig/capsium/package/metadata.rbs +26 -0
  76. data/sig/capsium/package/metadata_config.rbs +49 -0
  77. data/sig/capsium/package/preparation.rbs +23 -0
  78. data/sig/capsium/package/routes.rbs +37 -0
  79. data/sig/capsium/package/routes_config.rbs +107 -0
  80. data/sig/capsium/package/security.rbs +72 -0
  81. data/sig/capsium/package/security_config.rbs +57 -0
  82. data/sig/capsium/package/signer.rbs +59 -0
  83. data/sig/capsium/package/storage.rbs +28 -0
  84. data/sig/capsium/package/storage_config.rbs +85 -0
  85. data/sig/capsium/package/store.rbs +37 -0
  86. data/sig/capsium/package/testing/config_test.rbs +21 -0
  87. data/sig/capsium/package/testing/context.rbs +14 -0
  88. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  89. data/sig/capsium/package/testing/file_test.rbs +17 -0
  90. data/sig/capsium/package/testing/report.rbs +20 -0
  91. data/sig/capsium/package/testing/route_test.rbs +22 -0
  92. data/sig/capsium/package/testing/test_case.rbs +41 -0
  93. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  94. data/sig/capsium/package/testing.rbs +7 -0
  95. data/sig/capsium/package/validator.rbs +29 -0
  96. data/sig/capsium/package/verification.rbs +24 -0
  97. data/sig/capsium/package/version.rbs +39 -0
  98. data/sig/capsium/package.rbs +61 -0
  99. data/sig/capsium/packager.rbs +32 -0
  100. data/sig/capsium/reactor/authenticator.rbs +40 -0
  101. data/sig/capsium/reactor/deploy.rbs +34 -0
  102. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  103. data/sig/capsium/reactor/introspection.rbs +31 -0
  104. data/sig/capsium/reactor/oauth2.rbs +31 -0
  105. data/sig/capsium/reactor/serving.rbs +22 -0
  106. data/sig/capsium/reactor/session.rbs +36 -0
  107. data/sig/capsium/reactor.rbs +35 -0
  108. data/sig/capsium.rbs +3 -1
  109. metadata +108 -170
  110. data/lib/capsium/package/dataset_config.rb +0 -28
  111. data/lib/capsium/protector.rb +0 -103
@@ -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
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "marcel"
5
+ require "pathname"
6
+ require "tmpdir"
7
+
8
+ module Capsium
9
+ class Package
10
+ # Validates a package directory or .cap file against the canonical
11
+ # schemas (ARCHITECTURE.md sections 2-6) and reports one result per
12
+ # check. Used by `capsium package validate`.
13
+ class Validator
14
+ CheckResult = Data.define(:name, :ok, :messages) do
15
+ def ok?
16
+ ok
17
+ end
18
+ end
19
+
20
+ EXTERNAL_REFERENCE_PATTERN = %r{(?:src|href)\s*=\s*["']https?://}in
21
+ TEXT_CONTENT_MIME = %r{\Atext/|application/(json|javascript|xml)|image/svg\+xml}
22
+
23
+ def initialize(package_path)
24
+ @given_path = package_path
25
+ end
26
+
27
+ def run
28
+ Dir.mktmpdir do |dir|
29
+ @package_path = prepare(dir)
30
+ [metadata_check, manifest_check, routes_check,
31
+ storage_check, security_check, content_check]
32
+ end
33
+ end
34
+
35
+ def valid?
36
+ run.all?(&:ok?)
37
+ end
38
+
39
+ private
40
+
41
+ def prepare(dir)
42
+ return @given_path if File.directory?(@given_path)
43
+
44
+ destination = File.join(dir, "package")
45
+ Packager.new.unpack(@given_path, destination)
46
+ destination
47
+ end
48
+
49
+ def metadata_check
50
+ path = metadata_path(METADATA_FILE)
51
+ return failure("metadata", ["metadata.json is missing"]) unless File.exist?(path)
52
+
53
+ result("metadata", Metadata.new(path).config.format_errors)
54
+ rescue StandardError => e
55
+ failure("metadata", ["metadata.json is not valid: #{e.message}"])
56
+ end
57
+
58
+ def manifest_check
59
+ manifest = Manifest.new(metadata_path(MANIFEST_FILE))
60
+ missing = manifest.resources.keys.reject do |path|
61
+ merged_view.resolve(path) || File.file?(File.join(@package_path, path))
62
+ end
63
+ result("manifest", missing.map { |path| "resource missing on disk: #{path}" })
64
+ rescue StandardError => e
65
+ failure("manifest", ["manifest.json is not valid: #{e.message}"])
66
+ end
67
+
68
+ def routes_check
69
+ manifest = Manifest.new(metadata_path(MANIFEST_FILE))
70
+ storage = Storage.new(metadata_path(STORAGE_FILE))
71
+ routes = Routes.new(metadata_path(ROUTES_FILE), manifest, storage)
72
+ problems = route_problems(routes, storage) + index_problems(routes)
73
+ result("routes", problems)
74
+ rescue StandardError => e
75
+ failure("routes", ["routes.json is not valid: #{e.message}"])
76
+ end
77
+
78
+ def route_problems(routes, storage)
79
+ routes.config.routes.flat_map do |route|
80
+ route_target_problems(route, storage)
81
+ end
82
+ end
83
+
84
+ def route_target_problems(route, storage)
85
+ problems = []
86
+ if route.dataset_route? && !route.path.start_with?(Route::DATASET_PATH_PREFIX)
87
+ problems << "dataset route #{route.path} not under #{Route::DATASET_PATH_PREFIX}"
88
+ end
89
+ route.validate_target(@package_path, storage, merged_view: merged_view)
90
+ problems
91
+ rescue Error => e
92
+ problems + [e.message]
93
+ end
94
+
95
+ def index_problems(routes)
96
+ index = routes.config.index
97
+ return ["index route is missing"] if index.nil?
98
+ unless merged_view.resolve(index) || File.file?(File.join(@package_path, index))
99
+ return ["index missing on disk: #{index}"]
100
+ end
101
+ return [] if File.extname(index).downcase == ".html"
102
+
103
+ ["index is not an HTML file: #{index}"]
104
+ end
105
+
106
+ def storage_check
107
+ storage = Storage.new(metadata_path(STORAGE_FILE))
108
+ problems = storage.datasets.flat_map(&:validation_errors)
109
+ result("storage", problems)
110
+ rescue StandardError => e
111
+ failure("storage", ["storage.json is not valid: #{e.message}"])
112
+ end
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
+
125
+ def security_check
126
+ security = Security.new(metadata_path(SECURITY_FILE))
127
+ return result("security", []) unless security.present?
128
+
129
+ result("security", security.verify(@package_path).map(&:message))
130
+ end
131
+
132
+ def content_check
133
+ offenders = Dir.glob(File.join(@package_path, CONTENT_DIR, "**", "*")).select do |file|
134
+ File.file?(file) && text_file?(file) &&
135
+ File.binread(file).match?(EXTERNAL_REFERENCE_PATTERN)
136
+ end
137
+ result("content", offenders.map do |file|
138
+ "external reference in #{file.delete_prefix("#{@package_path}/")}"
139
+ end)
140
+ end
141
+
142
+ def text_file?(path)
143
+ mime = Marcel::MimeType.for(Pathname.new(path), name: File.basename(path))
144
+ mime.match?(TEXT_CONTENT_MIME)
145
+ end
146
+
147
+ def metadata_path(file_name)
148
+ File.join(@package_path, file_name)
149
+ end
150
+
151
+ def result(name, problems)
152
+ CheckResult.new(name: name, ok: problems.empty?, messages: problems)
153
+ end
154
+
155
+ def failure(name, messages)
156
+ CheckResult.new(name: name, ok: false, messages: messages)
157
+ end
158
+ end
159
+ end
160
+ end
@@ -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