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