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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.rubocop.yml +46 -7
- data/CHANGELOG.md +183 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.adoc +496 -127
- data/Rakefile +8 -1
- data/bin/console +1 -1
- data/capsium.gemspec +12 -19
- data/exe/capsium +1 -1
- data/lib/capsium/cli/convert.rb +18 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +148 -0
- data/lib/capsium/cli/reactor.rb +33 -0
- data/lib/capsium/cli.rb +4 -96
- data/lib/capsium/converters/jekyll.rb +5 -7
- data/lib/capsium/converters.rb +7 -0
- data/lib/capsium/package/authentication.rb +38 -0
- data/lib/capsium/package/authentication_config.rb +78 -0
- data/lib/capsium/package/cipher.rb +197 -0
- data/lib/capsium/package/composition.rb +77 -0
- data/lib/capsium/package/dataset.rb +67 -34
- data/lib/capsium/package/dependency_resolver.rb +61 -0
- data/lib/capsium/package/encryption_config.rb +38 -0
- data/lib/capsium/package/manifest.rb +33 -22
- data/lib/capsium/package/manifest_config.rb +52 -7
- data/lib/capsium/package/merged_view.rb +168 -0
- data/lib/capsium/package/metadata.rb +4 -8
- data/lib/capsium/package/metadata_config.rb +97 -9
- data/lib/capsium/package/preparation.rb +81 -0
- data/lib/capsium/package/routes.rb +39 -41
- data/lib/capsium/package/routes_config.rb +174 -44
- data/lib/capsium/package/security.rb +140 -0
- data/lib/capsium/package/security_config.rb +60 -0
- data/lib/capsium/package/signer.rb +201 -0
- data/lib/capsium/package/storage.rb +20 -45
- data/lib/capsium/package/storage_config.rb +128 -3
- data/lib/capsium/package/store.rb +109 -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 +160 -0
- data/lib/capsium/package/verification.rb +32 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +92 -106
- data/lib/capsium/packager.rb +75 -6
- data/lib/capsium/reactor/authenticator.rb +180 -0
- data/lib/capsium/reactor/deploy.rb +71 -0
- data/lib/capsium/reactor/htpasswd.rb +154 -0
- data/lib/capsium/reactor/introspection.rb +91 -0
- data/lib/capsium/reactor/oauth2.rb +106 -0
- data/lib/capsium/reactor/serving.rb +75 -0
- data/lib/capsium/reactor/session.rb +82 -0
- data/lib/capsium/reactor.rb +93 -43
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +7 -9
- data/sig/capsium/package/authentication.rbs +23 -0
- data/sig/capsium/package/authentication_config.rbs +71 -0
- data/sig/capsium/package/cipher.rbs +51 -0
- data/sig/capsium/package/composition.rbs +20 -0
- data/sig/capsium/package/dataset.rbs +28 -0
- data/sig/capsium/package/dependency_resolver.rbs +49 -0
- data/sig/capsium/package/encryption_config.rbs +35 -0
- data/sig/capsium/package/manifest.rbs +34 -0
- data/sig/capsium/package/manifest_config.rbs +38 -0
- data/sig/capsium/package/merged_view.rbs +46 -0
- data/sig/capsium/package/metadata.rbs +26 -0
- data/sig/capsium/package/metadata_config.rbs +49 -0
- data/sig/capsium/package/preparation.rbs +23 -0
- data/sig/capsium/package/routes.rbs +37 -0
- data/sig/capsium/package/routes_config.rbs +107 -0
- data/sig/capsium/package/security.rbs +72 -0
- data/sig/capsium/package/security_config.rbs +57 -0
- data/sig/capsium/package/signer.rbs +59 -0
- data/sig/capsium/package/storage.rbs +28 -0
- data/sig/capsium/package/storage_config.rbs +85 -0
- data/sig/capsium/package/store.rbs +37 -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/validator.rbs +29 -0
- data/sig/capsium/package/verification.rbs +24 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +61 -0
- data/sig/capsium/packager.rbs +32 -0
- data/sig/capsium/reactor/authenticator.rbs +40 -0
- data/sig/capsium/reactor/deploy.rbs +34 -0
- data/sig/capsium/reactor/htpasswd.rbs +36 -0
- data/sig/capsium/reactor/introspection.rbs +31 -0
- data/sig/capsium/reactor/oauth2.rbs +31 -0
- data/sig/capsium/reactor/serving.rbs +22 -0
- data/sig/capsium/reactor/session.rbs +36 -0
- data/sig/capsium/reactor.rbs +35 -0
- data/sig/capsium.rbs +3 -1
- metadata +108 -170
- data/lib/capsium/package/dataset_config.rb +0 -28
- 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
|