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.
- checksums.yaml +4 -4
- data/.rubocop.yml +17 -1
- data/CHANGELOG.md +322 -0
- data/README.adoc +643 -7
- data/capsium.gemspec +3 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +106 -7
- data/lib/capsium/cli/reactor.rb +120 -6
- data/lib/capsium/cli.rb +30 -0
- data/lib/capsium/log_buffer.rb +47 -0
- data/lib/capsium/package/authentication.rb +38 -0
- data/lib/capsium/package/authentication_config.rb +78 -0
- data/lib/capsium/package/bundle.rb +136 -0
- data/lib/capsium/package/cipher.rb +253 -0
- data/lib/capsium/package/composition.rb +93 -0
- data/lib/capsium/package/dataset.rb +20 -0
- data/lib/capsium/package/dependency_resolver.rb +91 -0
- data/lib/capsium/package/encryption_config.rb +42 -0
- data/lib/capsium/package/manifest.rb +1 -1
- data/lib/capsium/package/merged_view.rb +170 -0
- data/lib/capsium/package/metadata.rb +1 -1
- data/lib/capsium/package/metadata_config.rb +9 -1
- data/lib/capsium/package/open_pgp.rb +65 -0
- data/lib/capsium/package/open_pgp_cipher.rb +66 -0
- data/lib/capsium/package/open_pgp_signer.rb +176 -0
- data/lib/capsium/package/preparation.rb +81 -0
- data/lib/capsium/package/routes_config.rb +60 -6
- data/lib/capsium/package/security.rb +21 -4
- data/lib/capsium/package/security_config.rb +6 -2
- data/lib/capsium/package/signer.rb +216 -0
- data/lib/capsium/package/storage_config.rb +29 -1
- data/lib/capsium/package/store.rb +136 -0
- data/lib/capsium/package/testing/config_test.rb +70 -0
- data/lib/capsium/package/testing/context.rb +11 -0
- data/lib/capsium/package/testing/data_validation_test.rb +63 -0
- data/lib/capsium/package/testing/file_test.rb +45 -0
- data/lib/capsium/package/testing/report.rb +33 -0
- data/lib/capsium/package/testing/route_test.rb +64 -0
- data/lib/capsium/package/testing/test_case.rb +60 -0
- data/lib/capsium/package/testing/test_suite.rb +86 -0
- data/lib/capsium/package/testing.rb +20 -0
- data/lib/capsium/package/validator.rb +39 -3
- data/lib/capsium/package/verification.rb +34 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +78 -75
- data/lib/capsium/packager.rb +97 -22
- data/lib/capsium/reactor/authenticator.rb +180 -0
- data/lib/capsium/reactor/content_api.rb +110 -0
- data/lib/capsium/reactor/data_api.rb +154 -0
- data/lib/capsium/reactor/deploy.rb +71 -0
- data/lib/capsium/reactor/endpoints.rb +37 -0
- data/lib/capsium/reactor/graphql_api.rb +123 -0
- data/lib/capsium/reactor/graphql_schema.rb +138 -0
- data/lib/capsium/reactor/htpasswd.rb +154 -0
- data/lib/capsium/reactor/introspection.rb +151 -31
- data/lib/capsium/reactor/metrics.rb +30 -0
- data/lib/capsium/reactor/mount.rb +206 -0
- data/lib/capsium/reactor/oauth2.rb +106 -0
- data/lib/capsium/reactor/overlay.rb +248 -0
- data/lib/capsium/reactor/package_saver.rb +99 -0
- data/lib/capsium/reactor/responses.rb +41 -0
- data/lib/capsium/reactor/serving.rb +154 -0
- data/lib/capsium/reactor/session.rb +82 -0
- data/lib/capsium/reactor.rb +116 -67
- data/lib/capsium/registry/local.rb +120 -0
- data/lib/capsium/registry/remote.rb +105 -0
- data/lib/capsium/registry.rb +190 -0
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +2 -0
- data/sig/capsium/log_buffer.rbs +30 -0
- data/sig/capsium/package/authentication.rbs +23 -0
- data/sig/capsium/package/authentication_config.rbs +71 -0
- data/sig/capsium/package/bundle.rbs +43 -0
- data/sig/capsium/package/cipher.rbs +62 -0
- data/sig/capsium/package/composition.rbs +21 -0
- data/sig/capsium/package/dataset.rbs +8 -0
- data/sig/capsium/package/dependency_resolver.rbs +53 -0
- data/sig/capsium/package/encryption_config.rbs +39 -0
- data/sig/capsium/package/merged_view.rbs +47 -0
- data/sig/capsium/package/metadata.rbs +1 -0
- data/sig/capsium/package/open_pgp.rbs +38 -0
- data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
- data/sig/capsium/package/open_pgp_signer.rbs +46 -0
- data/sig/capsium/package/preparation.rbs +23 -0
- data/sig/capsium/package/routes_config.rbs +36 -3
- data/sig/capsium/package/security.rbs +9 -2
- data/sig/capsium/package/security_config.rbs +6 -3
- data/sig/capsium/package/signer.rbs +66 -0
- data/sig/capsium/package/storage_config.rbs +26 -2
- data/sig/capsium/package/store.rbs +43 -0
- data/sig/capsium/package/testing/config_test.rbs +21 -0
- data/sig/capsium/package/testing/context.rbs +14 -0
- data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
- data/sig/capsium/package/testing/file_test.rbs +17 -0
- data/sig/capsium/package/testing/report.rbs +20 -0
- data/sig/capsium/package/testing/route_test.rbs +22 -0
- data/sig/capsium/package/testing/test_case.rbs +41 -0
- data/sig/capsium/package/testing/test_suite.rbs +20 -0
- data/sig/capsium/package/testing.rbs +7 -0
- data/sig/capsium/package/verification.rbs +26 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +29 -16
- data/sig/capsium/packager.rbs +9 -0
- data/sig/capsium/reactor/authenticator.rbs +40 -0
- data/sig/capsium/reactor/content_api.rbs +19 -0
- data/sig/capsium/reactor/data_api.rbs +23 -0
- data/sig/capsium/reactor/deploy.rbs +34 -0
- data/sig/capsium/reactor/endpoints.rbs +16 -0
- data/sig/capsium/reactor/graphql_api.rbs +27 -0
- data/sig/capsium/reactor/graphql_schema.rbs +20 -0
- data/sig/capsium/reactor/htpasswd.rbs +36 -0
- data/sig/capsium/reactor/introspection.rbs +27 -7
- data/sig/capsium/reactor/metrics.rbs +13 -0
- data/sig/capsium/reactor/mount.rbs +83 -0
- data/sig/capsium/reactor/oauth2.rbs +31 -0
- data/sig/capsium/reactor/overlay.rbs +72 -0
- data/sig/capsium/reactor/package_saver.rbs +12 -0
- data/sig/capsium/reactor/responses.rbs +23 -0
- data/sig/capsium/reactor/serving.rbs +46 -0
- data/sig/capsium/reactor/session.rbs +36 -0
- data/sig/capsium/reactor.rbs +22 -2
- data/sig/capsium/registry/local.rbs +16 -0
- data/sig/capsium/registry/remote.rbs +18 -0
- data/sig/capsium/registry.rbs +83 -0
- data/sig/graphql.rbs +12 -0
- 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
|