capsium 0.1.1 → 0.2.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/.github/workflows/rake.yml +1 -4
- data/.github/workflows/release.yml +1 -2
- data/.gitignore +1 -0
- data/.rubocop.yml +34 -3
- data/CHANGELOG.md +58 -0
- data/Gemfile +4 -4
- data/LICENSE +21 -0
- data/README.adoc +177 -135
- data/Rakefile +8 -1
- data/bin/console +1 -1
- data/capsium.gemspec +16 -23
- data/exe/capsium +1 -1
- data/lib/capsium/cli/convert.rb +18 -0
- data/lib/capsium/cli/package.rb +86 -0
- data/lib/capsium/cli/reactor.rb +25 -0
- data/lib/capsium/cli.rb +5 -79
- data/lib/capsium/converters/jekyll.rb +62 -31
- data/lib/capsium/converters.rb +7 -0
- data/lib/capsium/package/dataset.rb +76 -56
- data/lib/capsium/package/manifest.rb +41 -50
- data/lib/capsium/package/manifest_config.rb +64 -0
- data/lib/capsium/package/metadata.rb +11 -29
- data/lib/capsium/package/metadata_config.rb +105 -0
- data/lib/capsium/package/routes.rb +51 -111
- data/lib/capsium/package/routes_config.rb +150 -0
- data/lib/capsium/package/security.rb +123 -0
- data/lib/capsium/package/security_config.rb +60 -0
- data/lib/capsium/package/storage.rb +26 -26
- data/lib/capsium/package/storage_config.rb +106 -0
- data/lib/capsium/package/validator.rb +149 -0
- data/lib/capsium/package.rb +84 -70
- data/lib/capsium/packager.rb +60 -62
- data/lib/capsium/reactor/introspection.rb +88 -0
- data/lib/capsium/reactor.rb +108 -36
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +7 -9
- data/sig/capsium/package/dataset.rbs +28 -0
- data/sig/capsium/package/manifest.rbs +34 -0
- data/sig/capsium/package/manifest_config.rbs +38 -0
- data/sig/capsium/package/metadata.rbs +26 -0
- data/sig/capsium/package/metadata_config.rbs +49 -0
- data/sig/capsium/package/routes.rbs +37 -0
- data/sig/capsium/package/routes_config.rbs +74 -0
- data/sig/capsium/package/security.rbs +65 -0
- data/sig/capsium/package/security_config.rbs +57 -0
- data/sig/capsium/package/storage.rbs +28 -0
- data/sig/capsium/package/storage_config.rbs +61 -0
- data/sig/capsium/package/validator.rbs +29 -0
- data/sig/capsium/package.rbs +53 -0
- data/sig/capsium/packager.rbs +23 -0
- data/sig/capsium/reactor/introspection.rbs +31 -0
- data/sig/capsium/reactor.rbs +30 -0
- data/sig/capsium.rbs +3 -1
- metadata +63 -146
- data/images/005df7415a331c466ad2d9a42efdd212b6f42fe50b5fd3b3174c86c706f58244.png +0 -0
- data/images/0374025b3af99b8a473282c8cbbf9fcd29573cf41e586982f328f86c0690f43d.png +0 -0
- data/images/0bb4da785be40ef58e219470ebccb979325928b75453dc46bac23c6ee8a7a7cb.png +0 -0
- data/images/6aa294dccc81af594aacbe804e7ddffdc17eacc28357338108aea5d021d831ff.png +0 -0
- data/images/72dd3fbf3f4b475e27a0e7fb8137c475c32c41f8d222bcf62d6a9ccf102d9532.png +0 -0
- data/images/8772b6961d169738d7b0fa0b669b06fc2f40632d4c62586c7634fc17b93182a3.png +0 -0
- data/images/a998d842405933d45723606ff3f70162ec95b4ef30db25464a366184fd08fb9b.png +0 -0
- data/images/aa8980547e8c003d33273ab4d80e62da7f317bd7581b293c06d67f5331f24f31.png +0 -0
- data/images/bb78a872b539e0e9b2d80dee58acbb688f3f2727b324a5bf8bf417a69d94a166.png +0 -0
- data/images/c48fc83b17725d85fbb64d971196ebfccd8c5c757fe6aa5845303f6e315879b6.png +0 -0
- data/images/f08ef07308d08119ac2124bb7428c8bef17ef1ca70045696604d6e83015a9b91.png +0 -0
- data/images/f7514206111b695647eae9adfcf498ba3e0ff83ecfe25f3fc3ed8e9f04c5c726-1.png +0 -0
- data/images/f7514206111b695647eae9adfcf498ba3e0ff83ecfe25f3fc3ed8e9f04c5c726.png +0 -0
- data/lib/capsium/protector.rb +0 -95
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "lutaml/model"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Package
|
|
8
|
+
# A single route entry (ARCHITECTURE.md section 4). Kinds are
|
|
9
|
+
# discriminated by key, MECE:
|
|
10
|
+
# - {path, resource, headers?, visibility?} -- static file
|
|
11
|
+
# - {path, dataset, accessControl?} -- dataset route
|
|
12
|
+
# - {path, method, handler, ...} -- dynamic handler (parsed,
|
|
13
|
+
# accepted-and-ignored; reactors respond 501)
|
|
14
|
+
class Route < Lutaml::Model::Serializable
|
|
15
|
+
DATASET_PATH_PREFIX = "/api/v1/data/"
|
|
16
|
+
|
|
17
|
+
attribute :path, :string
|
|
18
|
+
attribute :resource, :string
|
|
19
|
+
attribute :headers, :hash
|
|
20
|
+
attribute :headers_file, :string
|
|
21
|
+
attribute :visibility, :string, values: Resource::VISIBILITIES
|
|
22
|
+
attribute :dataset, :string
|
|
23
|
+
attribute :access_control, :hash
|
|
24
|
+
attribute :http_method, :string
|
|
25
|
+
attribute :handler, :string
|
|
26
|
+
|
|
27
|
+
json do
|
|
28
|
+
map :path, to: :path
|
|
29
|
+
map :resource, to: :resource
|
|
30
|
+
map :headers, to: :headers
|
|
31
|
+
map "headersFile", to: :headers_file
|
|
32
|
+
map :visibility, to: :visibility
|
|
33
|
+
map :dataset, to: :dataset
|
|
34
|
+
map "accessControl", to: :access_control
|
|
35
|
+
map "method", to: :http_method
|
|
36
|
+
map :handler, to: :handler
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def kind
|
|
40
|
+
return :resource if resource
|
|
41
|
+
return :dataset if dataset
|
|
42
|
+
|
|
43
|
+
:handler
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def dataset_route?
|
|
47
|
+
kind == :dataset
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def handler_route?
|
|
51
|
+
kind == :handler
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fs_path(package_path)
|
|
55
|
+
return unless resource
|
|
56
|
+
|
|
57
|
+
File.join(package_path, resource)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def mime(manifest)
|
|
61
|
+
manifest.type_for(resource)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_target(package_path, storage)
|
|
65
|
+
return if handler_route?
|
|
66
|
+
|
|
67
|
+
if dataset_route?
|
|
68
|
+
return if storage.dataset(dataset)
|
|
69
|
+
|
|
70
|
+
raise Error, "Route dataset does not exist: #{dataset}"
|
|
71
|
+
end
|
|
72
|
+
validate_resource_target(package_path)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def validate_resource_target(package_path)
|
|
78
|
+
target_path = fs_path(package_path)
|
|
79
|
+
return if target_path && File.exist?(target_path)
|
|
80
|
+
|
|
81
|
+
raise Error, "Route resource does not exist: #{resource}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Canonical routes.json model: optional top-level "index" plus a
|
|
86
|
+
# "routes" array. The legacy gem form ({path, target: {file|dataset}})
|
|
87
|
+
# is accepted on read and normalized; writers emit only the canonical
|
|
88
|
+
# form.
|
|
89
|
+
class RoutesConfig < Lutaml::Model::Serializable
|
|
90
|
+
attribute :index, :string
|
|
91
|
+
attribute :routes, Route, collection: true, default: []
|
|
92
|
+
|
|
93
|
+
json do
|
|
94
|
+
map :index, to: :index
|
|
95
|
+
map :routes, with: { from: :routes_from_json, to: :routes_to_json }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.from_json(json)
|
|
99
|
+
doc = JSON.parse(json)
|
|
100
|
+
doc["routes"] = (doc["routes"] || []).map { |route| normalize_route(route) }
|
|
101
|
+
super(JSON.generate(doc))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.normalize_route(route)
|
|
105
|
+
target = route.delete("target")
|
|
106
|
+
return route unless target.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
route.merge("resource" => target["file"], "dataset" => target["dataset"]).compact
|
|
109
|
+
end
|
|
110
|
+
private_class_method :normalize_route
|
|
111
|
+
|
|
112
|
+
def routes_from_json(model, value)
|
|
113
|
+
model.routes = (value || []).map { |route| Route.from_json(JSON.generate(route)) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Writers emit the canonical form only: deterministic, sorted by path.
|
|
117
|
+
def routes_to_json(model, doc)
|
|
118
|
+
doc["routes"] = model.routes.sort_by(&:path).map do |route|
|
|
119
|
+
JSON.parse(route.to_json)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def resolve(path)
|
|
124
|
+
routes.detect { |route| route.path == path }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def add(path, target)
|
|
128
|
+
route = Route.new(path: path, resource: target)
|
|
129
|
+
self.routes = routes + [route]
|
|
130
|
+
route
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def update(path, updated_path, updated_target)
|
|
134
|
+
route = resolve(path)
|
|
135
|
+
route.path = updated_path
|
|
136
|
+
route.resource = updated_target
|
|
137
|
+
route
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def remove(path)
|
|
141
|
+
self.routes = routes.reject { |route| route.path == path }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def sort!
|
|
145
|
+
self.routes = routes.sort_by(&:path)
|
|
146
|
+
self
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "forwardable"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Capsium
|
|
8
|
+
class Package
|
|
9
|
+
# Loads, generates and verifies security.json (ARCHITECTURE.md
|
|
10
|
+
# section 6). Checksums cover every file in the package except
|
|
11
|
+
# security.json itself.
|
|
12
|
+
class Security
|
|
13
|
+
extend Forwardable
|
|
14
|
+
|
|
15
|
+
class IntegrityError < Capsium::Error; end
|
|
16
|
+
|
|
17
|
+
ChecksumMismatch = Data.define(:path, :expected, :actual) do
|
|
18
|
+
def message
|
|
19
|
+
"checksum mismatch: #{path}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
MissingFile = Data.define(:path, :expected) do
|
|
23
|
+
def message
|
|
24
|
+
"checksum listed but file missing: #{path}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
UncheckedFile = Data.define(:path) do
|
|
28
|
+
def message
|
|
29
|
+
"file not covered by checksums: #{path}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :path, :config
|
|
34
|
+
|
|
35
|
+
def_delegators :@config, :to_json, :to_hash
|
|
36
|
+
|
|
37
|
+
def initialize(path, config = nil)
|
|
38
|
+
@path = path
|
|
39
|
+
@config = config || load_config
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.generate(package_path)
|
|
43
|
+
config = SecurityConfig.new(
|
|
44
|
+
security: SecurityData.new(
|
|
45
|
+
integrity_checks: IntegrityChecks.new(
|
|
46
|
+
checksum_algorithm: "SHA-256",
|
|
47
|
+
checksums: checksums_for(package_path)
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
new(File.join(package_path, SECURITY_FILE), config)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.checksums_for(package_path)
|
|
55
|
+
package_files(package_path).sort.to_h do |relative_path|
|
|
56
|
+
[relative_path, Digest::SHA256.file(File.join(package_path, relative_path)).hexdigest]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.package_files(package_path)
|
|
61
|
+
files = Dir.glob(File.join(package_path, "**", "*")).select do |file|
|
|
62
|
+
File.file?(file)
|
|
63
|
+
end
|
|
64
|
+
files.map { |file| file.delete_prefix("#{package_path}/") }
|
|
65
|
+
.reject { |relative_path| relative_path == SECURITY_FILE }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def present?
|
|
69
|
+
!@config.nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def checksums
|
|
73
|
+
present? ? config.checksums : {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def verify(package_path)
|
|
77
|
+
expected = checksums
|
|
78
|
+
errors = verify_present_files(package_path, expected)
|
|
79
|
+
expected.each do |relative_path, checksum|
|
|
80
|
+
next if File.file?(File.join(package_path, relative_path))
|
|
81
|
+
|
|
82
|
+
errors << MissingFile.new(path: relative_path, expected: checksum)
|
|
83
|
+
end
|
|
84
|
+
errors
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def verify!(package_path)
|
|
88
|
+
errors = verify(package_path)
|
|
89
|
+
return if errors.empty?
|
|
90
|
+
|
|
91
|
+
raise IntegrityError,
|
|
92
|
+
"Package integrity check failed: #{errors.map(&:path).join(', ')}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def save_to_file(output_path = @path)
|
|
96
|
+
File.write(output_path, to_json)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def load_config
|
|
102
|
+
return unless File.exist?(@path)
|
|
103
|
+
|
|
104
|
+
SecurityConfig.from_json(File.read(@path))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def verify_present_files(package_path, expected)
|
|
108
|
+
self.class.package_files(package_path).filter_map do |relative_path|
|
|
109
|
+
file_path = File.join(package_path, relative_path)
|
|
110
|
+
if expected.key?(relative_path)
|
|
111
|
+
actual = Digest::SHA256.file(file_path).hexdigest
|
|
112
|
+
unless actual == expected[relative_path]
|
|
113
|
+
next ChecksumMismatch.new(path: relative_path, expected: expected[relative_path],
|
|
114
|
+
actual: actual)
|
|
115
|
+
end
|
|
116
|
+
else
|
|
117
|
+
UncheckedFile.new(path: relative_path)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Package
|
|
7
|
+
# The "integrityChecks" object of security.json (ARCHITECTURE.md
|
|
8
|
+
# section 6): SHA-256 checksums over every package file except
|
|
9
|
+
# security.json itself, keyed by package-relative path.
|
|
10
|
+
class IntegrityChecks < Lutaml::Model::Serializable
|
|
11
|
+
ALGORITHMS = %w[SHA-256].freeze
|
|
12
|
+
|
|
13
|
+
attribute :checksum_algorithm, :string, values: ALGORITHMS, default: "SHA-256"
|
|
14
|
+
attribute :checksums, :hash, default: {}
|
|
15
|
+
|
|
16
|
+
json do
|
|
17
|
+
map "checksumAlgorithm", to: :checksum_algorithm
|
|
18
|
+
map :checksums, to: :checksums
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The "digitalSignatures" object of security.json. Signing is a later
|
|
23
|
+
# phase; the model parses the canonical fields only.
|
|
24
|
+
class DigitalSignatures < Lutaml::Model::Serializable
|
|
25
|
+
attribute :public_key, :string
|
|
26
|
+
attribute :signature_file, :string
|
|
27
|
+
|
|
28
|
+
json do
|
|
29
|
+
map "publicKey", to: :public_key
|
|
30
|
+
map "signatureFile", to: :signature_file
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The "security" object of security.json.
|
|
35
|
+
class SecurityData < Lutaml::Model::Serializable
|
|
36
|
+
attribute :integrity_checks, IntegrityChecks
|
|
37
|
+
attribute :digital_signatures, DigitalSignatures
|
|
38
|
+
|
|
39
|
+
json do
|
|
40
|
+
map "integrityChecks", to: :integrity_checks
|
|
41
|
+
map "digitalSignatures", to: :digital_signatures
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Canonical security.json model (generated at pack time).
|
|
46
|
+
class SecurityConfig < Lutaml::Model::Serializable
|
|
47
|
+
attribute :security, SecurityData
|
|
48
|
+
|
|
49
|
+
json do
|
|
50
|
+
map :security, to: :security
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def checksums
|
|
54
|
+
return {} unless security&.integrity_checks
|
|
55
|
+
|
|
56
|
+
security.integrity_checks.checksums
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -1,51 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "json"
|
|
3
|
+
require "forwardable"
|
|
5
4
|
|
|
6
5
|
module Capsium
|
|
7
6
|
class Package
|
|
8
7
|
class Storage
|
|
9
|
-
|
|
8
|
+
extend Forwardable
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
attr_reader :path, :config, :datasets
|
|
11
|
+
|
|
12
|
+
def_delegators :@config, :to_json, :to_hash
|
|
12
13
|
|
|
13
14
|
def initialize(path)
|
|
14
15
|
@path = path
|
|
15
16
|
@dir = File.dirname(path)
|
|
16
|
-
@
|
|
17
|
-
|
|
17
|
+
@config = if File.exist?(path)
|
|
18
|
+
StorageConfig.from_json(File.read(path))
|
|
19
|
+
else
|
|
20
|
+
StorageConfig.new
|
|
21
|
+
end
|
|
22
|
+
@datasets = load_datasets
|
|
18
23
|
end
|
|
19
24
|
|
|
20
|
-
def
|
|
21
|
-
|
|
25
|
+
def data_sets
|
|
26
|
+
@config.data_sets
|
|
27
|
+
end
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
def load_datasets
|
|
30
|
+
data_sets.map do |name, dataset_config|
|
|
31
|
+
dataset_config.to_dataset(name, @dir)
|
|
32
|
+
end
|
|
25
33
|
end
|
|
26
34
|
|
|
27
|
-
def
|
|
28
|
-
{
|
|
35
|
+
def dataset(name)
|
|
36
|
+
@datasets.find { |dataset| dataset.name == name }
|
|
29
37
|
end
|
|
30
38
|
|
|
31
|
-
def
|
|
32
|
-
|
|
39
|
+
def dataset_names
|
|
40
|
+
data_sets.keys.sort
|
|
33
41
|
end
|
|
34
42
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
file.write(to_json)
|
|
38
|
-
end
|
|
43
|
+
def empty?
|
|
44
|
+
data_sets.empty?
|
|
39
45
|
end
|
|
40
46
|
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
paths = File.join(@datasets_path, "*.{yaml,yml,json,csv,tsv,sqlite,db}")
|
|
44
|
-
Dir.glob(paths).each do |file_path|
|
|
45
|
-
datasets << Dataset.new(file_path, @datasets_path)
|
|
46
|
-
# dataset_info[:table] = dataset.table_name if dataset.type == :sqlite
|
|
47
|
-
end
|
|
48
|
-
datasets
|
|
47
|
+
def save_to_file(output_path = @path)
|
|
48
|
+
File.write(output_path, to_json)
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "lutaml/model"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Package
|
|
8
|
+
# A single dataset entry (ARCHITECTURE.md section 5). Paths are
|
|
9
|
+
# package-relative POSIX paths. Kinds: schema-backed file (via
|
|
10
|
+
# "source") or SQLite ("databaseFile" + "table").
|
|
11
|
+
class DatasetConfig < Lutaml::Model::Serializable
|
|
12
|
+
FORMATS = {
|
|
13
|
+
".yaml" => "yaml", ".yml" => "yaml", ".json" => "json",
|
|
14
|
+
".csv" => "csv", ".tsv" => "tsv",
|
|
15
|
+
".sqlite" => "sqlite", ".db" => "sqlite"
|
|
16
|
+
}.freeze
|
|
17
|
+
SCHEMA_TYPES = %w[json-schema].freeze
|
|
18
|
+
|
|
19
|
+
attribute :source, :string
|
|
20
|
+
attribute :schema_file, :string
|
|
21
|
+
attribute :schema_type, :string, values: SCHEMA_TYPES
|
|
22
|
+
attribute :database_file, :string
|
|
23
|
+
attribute :table, :string
|
|
24
|
+
|
|
25
|
+
json do
|
|
26
|
+
map :source, to: :source
|
|
27
|
+
map "schemaFile", to: :schema_file
|
|
28
|
+
map "schemaType", to: :schema_type
|
|
29
|
+
map "databaseFile", to: :database_file
|
|
30
|
+
map :table, to: :table
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def format
|
|
34
|
+
FORMATS.fetch(File.extname(backing_file).downcase) do
|
|
35
|
+
raise Error, "Unsupported data file type: #{File.extname(backing_file)}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sqlite?
|
|
40
|
+
!database_file.nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def backing_file
|
|
44
|
+
database_file || source.to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_dataset(name, package_path)
|
|
48
|
+
Dataset.new(name: name, config: self, package_path: package_path)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The "storage" object holding the dataSets map.
|
|
53
|
+
class StorageData < Lutaml::Model::Serializable
|
|
54
|
+
attribute :data_sets, :hash, default: {}
|
|
55
|
+
|
|
56
|
+
json do
|
|
57
|
+
map "dataSets", with: { from: :data_sets_from_json, to: :data_sets_to_json }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def data_sets_from_json(model, value)
|
|
61
|
+
model.data_sets = (value || {}).to_h do |name, attributes|
|
|
62
|
+
[name, DatasetConfig.from_json(JSON.generate(attributes))]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def data_sets_to_json(model, doc)
|
|
67
|
+
doc["dataSets"] = model.data_sets.sort.to_h do |name, dataset|
|
|
68
|
+
[name, JSON.parse(dataset.to_json)]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Canonical storage.json model. The legacy gem form
|
|
74
|
+
# ({"datasets": [{name, source, format, schema}]}) is accepted on read
|
|
75
|
+
# and normalized; writers emit only the canonical form.
|
|
76
|
+
class StorageConfig < Lutaml::Model::Serializable
|
|
77
|
+
attribute :storage, StorageData
|
|
78
|
+
|
|
79
|
+
json do
|
|
80
|
+
map :storage, to: :storage
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.from_json(json)
|
|
84
|
+
doc = JSON.parse(json)
|
|
85
|
+
doc["storage"] ||= { "dataSets" => legacy_data_sets(doc.delete("datasets")) }
|
|
86
|
+
super(JSON.generate(doc))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.legacy_data_sets(datasets)
|
|
90
|
+
(datasets || []).to_h do |item|
|
|
91
|
+
entry = { "source" => item["source"] }
|
|
92
|
+
if item["schema"]
|
|
93
|
+
entry["schemaFile"] = item["schema"]
|
|
94
|
+
entry["schemaType"] = "json-schema"
|
|
95
|
+
end
|
|
96
|
+
[item["name"], entry]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
private_class_method :legacy_data_sets
|
|
100
|
+
|
|
101
|
+
def data_sets
|
|
102
|
+
storage ? storage.data_sets : {}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
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)
|
|
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 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
|
+
def security_check
|
|
115
|
+
security = Security.new(metadata_path(SECURITY_FILE))
|
|
116
|
+
return result("security", []) unless security.present?
|
|
117
|
+
|
|
118
|
+
result("security", security.verify(@package_path).map(&:message))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def content_check
|
|
122
|
+
offenders = Dir.glob(File.join(@package_path, CONTENT_DIR, "**", "*")).select do |file|
|
|
123
|
+
File.file?(file) && text_file?(file) &&
|
|
124
|
+
File.binread(file).match?(EXTERNAL_REFERENCE_PATTERN)
|
|
125
|
+
end
|
|
126
|
+
result("content", offenders.map do |file|
|
|
127
|
+
"external reference in #{file.delete_prefix("#{@package_path}/")}"
|
|
128
|
+
end)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def text_file?(path)
|
|
132
|
+
mime = Marcel::MimeType.for(Pathname.new(path), name: File.basename(path))
|
|
133
|
+
mime.match?(TEXT_CONTENT_MIME)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def metadata_path(file_name)
|
|
137
|
+
File.join(@package_path, file_name)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def result(name, problems)
|
|
141
|
+
CheckResult.new(name: name, ok: problems.empty?, messages: problems)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def failure(name, messages)
|
|
145
|
+
CheckResult.new(name: name, ok: false, messages: messages)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|