capsium 0.1.2 → 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/.gitignore +1 -0
- data/.rubocop.yml +34 -7
- data/CHANGELOG.md +58 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.adoc +177 -135
- data/Rakefile +8 -1
- data/bin/console +1 -1
- data/capsium.gemspec +11 -19
- 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 +3 -96
- data/lib/capsium/converters/jekyll.rb +5 -7
- data/lib/capsium/converters.rb +7 -0
- data/lib/capsium/package/dataset.rb +67 -34
- data/lib/capsium/package/manifest.rb +33 -22
- data/lib/capsium/package/manifest_config.rb +52 -7
- data/lib/capsium/package/metadata.rb +4 -8
- data/lib/capsium/package/metadata_config.rb +97 -9
- data/lib/capsium/package/routes.rb +39 -41
- data/lib/capsium/package/routes_config.rb +120 -44
- data/lib/capsium/package/security.rb +123 -0
- data/lib/capsium/package/security_config.rb +60 -0
- data/lib/capsium/package/storage.rb +20 -45
- data/lib/capsium/package/storage_config.rb +100 -3
- data/lib/capsium/package/validator.rb +149 -0
- data/lib/capsium/package.rb +68 -66
- data/lib/capsium/packager.rb +42 -6
- data/lib/capsium/reactor/introspection.rb +88 -0
- data/lib/capsium/reactor.rb +89 -41
- 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 +52 -183
- data/lib/capsium/package/dataset_config.rb +0 -28
- data/lib/capsium/protector.rb +0 -103
|
@@ -1,17 +1,105 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "lutaml/model"
|
|
5
|
+
require "uri"
|
|
2
6
|
|
|
3
7
|
module Capsium
|
|
4
8
|
class Package
|
|
5
|
-
class
|
|
6
|
-
attribute :
|
|
7
|
-
attribute :
|
|
9
|
+
class Repository < Lutaml::Model::Serializable
|
|
10
|
+
attribute :type, :string
|
|
11
|
+
attribute :url, :string
|
|
12
|
+
|
|
13
|
+
json do
|
|
14
|
+
map :type, to: :type
|
|
15
|
+
map :url, to: :url
|
|
16
|
+
end
|
|
8
17
|
end
|
|
9
18
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
# Canonical metadata.json model (ARCHITECTURE.md section 2).
|
|
20
|
+
#
|
|
21
|
+
# The legacy gem form of "dependencies" (an array of {name, version}
|
|
22
|
+
# objects) is accepted on read and normalized to the canonical object
|
|
23
|
+
# form; writers emit only the object form.
|
|
24
|
+
class MetadataData < Lutaml::Model::Serializable
|
|
25
|
+
KEBAB_CASE_PATTERN = /\A[a-z0-9]+(-[a-z0-9]+)*\z/
|
|
26
|
+
SEMVER_PATTERN = /\A\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/
|
|
27
|
+
UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
28
|
+
|
|
29
|
+
attribute :name, :string
|
|
30
|
+
attribute :version, :string
|
|
31
|
+
attribute :description, :string
|
|
32
|
+
attribute :guid, :string
|
|
33
|
+
attribute :uuid, :string
|
|
34
|
+
attribute :author, :string
|
|
35
|
+
attribute :license, :string
|
|
36
|
+
attribute :repository, Repository
|
|
37
|
+
attribute :dependencies, :hash, default: {}
|
|
38
|
+
attribute :read_only, :boolean
|
|
39
|
+
|
|
40
|
+
json do
|
|
41
|
+
map :name, to: :name
|
|
42
|
+
map :version, to: :version
|
|
43
|
+
map :description, to: :description
|
|
44
|
+
map :guid, to: :guid
|
|
45
|
+
map :uuid, to: :uuid
|
|
46
|
+
map :author, to: :author
|
|
47
|
+
map :license, to: :license
|
|
48
|
+
map :repository, to: :repository
|
|
49
|
+
map :dependencies, to: :dependencies
|
|
50
|
+
map "readOnly", to: :read_only
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.from_json(json)
|
|
54
|
+
doc = JSON.parse(json)
|
|
55
|
+
doc["dependencies"] = normalize_dependencies(doc["dependencies"])
|
|
56
|
+
super(JSON.generate(doc))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.normalize_dependencies(dependencies)
|
|
60
|
+
return {} if dependencies.nil?
|
|
61
|
+
return dependencies unless dependencies.is_a?(Array)
|
|
62
|
+
|
|
63
|
+
dependencies.to_h { |dep| [dep["name"], dep["version"]] }
|
|
64
|
+
end
|
|
65
|
+
private_class_method :normalize_dependencies
|
|
66
|
+
|
|
67
|
+
# Field-level format validations (ARCHITECTURE.md section 2).
|
|
68
|
+
# Returns a list of human-readable problems; empty when valid.
|
|
69
|
+
def format_errors
|
|
70
|
+
presence_errors + format_field_errors
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def presence_errors
|
|
76
|
+
problems = []
|
|
77
|
+
problems << "name is missing" if name.to_s.empty?
|
|
78
|
+
problems << "version is missing" if version.to_s.empty?
|
|
79
|
+
problems << "description is missing" if description.to_s.empty?
|
|
80
|
+
problems << "guid is missing" if guid.to_s.empty?
|
|
81
|
+
problems << "uuid is missing" if uuid.to_s.empty?
|
|
82
|
+
problems
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def format_field_errors
|
|
86
|
+
problems = []
|
|
87
|
+
problems << "name must be kebab-case" if invalid?(name, KEBAB_CASE_PATTERN)
|
|
88
|
+
problems << "version must be semver" if invalid?(version, SEMVER_PATTERN)
|
|
89
|
+
problems << "guid must be a URI" if guid && !uri?(guid)
|
|
90
|
+
problems << "uuid is not a valid UUID" if invalid?(uuid, UUID_PATTERN)
|
|
91
|
+
problems
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def invalid?(value, pattern)
|
|
95
|
+
!value.nil? && !value.match?(pattern)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def uri?(value)
|
|
99
|
+
URI.parse(value).is_a?(URI::Generic)
|
|
100
|
+
rescue URI::InvalidURIError
|
|
101
|
+
false
|
|
102
|
+
end
|
|
15
103
|
end
|
|
16
104
|
end
|
|
17
105
|
end
|
|
@@ -1,27 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "json"
|
|
5
|
-
require "fileutils"
|
|
6
|
-
require_relative "routes_config"
|
|
3
|
+
require "forwardable"
|
|
7
4
|
|
|
8
5
|
module Capsium
|
|
9
6
|
class Package
|
|
10
7
|
class Routes
|
|
11
8
|
extend Forwardable
|
|
12
|
-
attr_reader :config
|
|
13
9
|
|
|
14
|
-
|
|
10
|
+
attr_reader :path, :config, :manifest, :storage
|
|
15
11
|
|
|
16
|
-
|
|
12
|
+
def_delegators :@config, :to_hash
|
|
17
13
|
|
|
18
|
-
ROUTES_FILE = "routes.json"
|
|
19
|
-
DEFAULT_INDEX_TARGET = "content/index.html"
|
|
20
14
|
INDEX_ROUTE = "/"
|
|
15
|
+
INDEX_RESOURCE = "content/index.html"
|
|
16
|
+
DATASET_ROUTE_PREFIX = "/api/v1/data/"
|
|
21
17
|
|
|
22
18
|
def initialize(path, manifest, storage)
|
|
23
19
|
@path = path
|
|
24
|
-
@dir = File.dirname(path)
|
|
25
20
|
@manifest = manifest
|
|
26
21
|
@storage = storage
|
|
27
22
|
@config = if File.exist?(path)
|
|
@@ -29,8 +24,6 @@ module Capsium
|
|
|
29
24
|
else
|
|
30
25
|
generate_routes
|
|
31
26
|
end
|
|
32
|
-
validate_index_path(@config.resolve(INDEX_ROUTE)&.target&.file)
|
|
33
|
-
validate
|
|
34
27
|
end
|
|
35
28
|
|
|
36
29
|
def resolve(url_path)
|
|
@@ -38,12 +31,10 @@ module Capsium
|
|
|
38
31
|
end
|
|
39
32
|
|
|
40
33
|
def add_route(route, target)
|
|
41
|
-
validate_route_target(route, target)
|
|
42
34
|
@config.add(route, target)
|
|
43
35
|
end
|
|
44
36
|
|
|
45
37
|
def update_route(route, updated_route, updated_target)
|
|
46
|
-
validate_route_target(updated_route, updated_target)
|
|
47
38
|
@config.update(route, updated_route, updated_target)
|
|
48
39
|
end
|
|
49
40
|
|
|
@@ -51,48 +42,55 @@ module Capsium
|
|
|
51
42
|
@config.remove(route)
|
|
52
43
|
end
|
|
53
44
|
|
|
54
|
-
def validate
|
|
55
|
-
@config.routes.each do |route|
|
|
56
|
-
route.target.validate(@manifest, @storage)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
45
|
def to_json(*_args)
|
|
61
|
-
@config.
|
|
46
|
+
@config.to_json
|
|
62
47
|
end
|
|
63
48
|
|
|
64
49
|
def save_to_file(output_path = @path)
|
|
65
50
|
File.write(output_path, to_json)
|
|
66
51
|
end
|
|
67
52
|
|
|
53
|
+
# Auto-generation (ARCHITECTURE.md section 4): every manifest
|
|
54
|
+
# resource gets a route at its path relative to content/; HTML files
|
|
55
|
+
# get two routes (basename without extension and full filename); the
|
|
56
|
+
# index HTML additionally gets "/"; every dataset in storage gets
|
|
57
|
+
# /api/v1/data/<id>. Output is deterministic (sorted by path).
|
|
58
|
+
def generate_routes
|
|
59
|
+
routes = resource_routes + dataset_routes
|
|
60
|
+
RoutesConfig.new(index: index_resource, routes: routes.sort_by(&:path))
|
|
61
|
+
end
|
|
62
|
+
|
|
68
63
|
private
|
|
69
64
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
relative_path = data_item.file.sub(/^#{Package::CONTENT_DIR}/o, "")
|
|
74
|
-
r.add(relative_path, data_item.file)
|
|
75
|
-
|
|
76
|
-
# Ensure the index route is included
|
|
77
|
-
if File.basename(relative_path, ".*") == "index"
|
|
78
|
-
r.add("/index", data_item.file)
|
|
79
|
-
r.add("/", data_item.file)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
65
|
+
def index_resource
|
|
66
|
+
INDEX_RESOURCE if @manifest.resources.key?(INDEX_RESOURCE)
|
|
67
|
+
end
|
|
82
68
|
|
|
83
|
-
|
|
69
|
+
def resource_routes
|
|
70
|
+
@manifest.resources.keys.flat_map do |resource_path|
|
|
71
|
+
routes_for_resource(resource_path)
|
|
72
|
+
end.uniq(&:path)
|
|
84
73
|
end
|
|
85
74
|
|
|
86
|
-
def
|
|
87
|
-
|
|
75
|
+
def routes_for_resource(resource_path)
|
|
76
|
+
url_path = resource_path.sub(%r{\A#{Package::CONTENT_DIR}/}o, "")
|
|
77
|
+
routes = [Route.new(path: "/#{url_path}", resource: resource_path)]
|
|
78
|
+
return routes unless File.extname(resource_path) == ".html"
|
|
88
79
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
80
|
+
basename = File.basename(url_path, ".html")
|
|
81
|
+
routes << Route.new(path: "/#{basename}", resource: resource_path)
|
|
82
|
+
routes << Route.new(path: INDEX_ROUTE, resource: resource_path) if index?(resource_path)
|
|
83
|
+
routes
|
|
92
84
|
end
|
|
93
85
|
|
|
94
|
-
def
|
|
95
|
-
|
|
86
|
+
def index?(resource_path)
|
|
87
|
+
resource_path == INDEX_RESOURCE
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def dataset_routes
|
|
91
|
+
@storage.dataset_names.map do |name|
|
|
92
|
+
Route.new(path: "#{DATASET_ROUTE_PREFIX}#{name}", dataset: name)
|
|
93
|
+
end
|
|
96
94
|
end
|
|
97
95
|
end
|
|
98
96
|
end
|
|
@@ -1,72 +1,148 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "json"
|
|
4
|
+
require "lutaml/model"
|
|
4
5
|
|
|
5
6
|
module Capsium
|
|
6
7
|
class Package
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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/"
|
|
10
16
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
13
26
|
|
|
14
|
-
|
|
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)
|
|
15
58
|
end
|
|
16
59
|
|
|
17
60
|
def mime(manifest)
|
|
18
|
-
manifest.
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def
|
|
22
|
-
if
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
unless storage.datasets.any? { |ds| ds.config.name == dataset }
|
|
29
|
-
raise "Dataset target does not exist: #{dataset}"
|
|
30
|
-
end
|
|
31
|
-
else
|
|
32
|
-
raise "Route target must have either a file or a dataset"
|
|
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}"
|
|
33
71
|
end
|
|
72
|
+
validate_resource_target(package_path)
|
|
34
73
|
end
|
|
35
|
-
end
|
|
36
74
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
40
83
|
end
|
|
41
84
|
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
44
122
|
|
|
45
|
-
def resolve(
|
|
46
|
-
routes.detect { |
|
|
123
|
+
def resolve(path)
|
|
124
|
+
routes.detect { |route| route.path == path }
|
|
47
125
|
end
|
|
48
126
|
|
|
49
|
-
def add(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
r
|
|
127
|
+
def add(path, target)
|
|
128
|
+
route = Route.new(path: path, resource: target)
|
|
129
|
+
self.routes = routes + [route]
|
|
130
|
+
route
|
|
54
131
|
end
|
|
55
132
|
|
|
56
|
-
def update(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
133
|
+
def update(path, updated_path, updated_target)
|
|
134
|
+
route = resolve(path)
|
|
135
|
+
route.path = updated_path
|
|
136
|
+
route.resource = updated_target
|
|
137
|
+
route
|
|
61
138
|
end
|
|
62
139
|
|
|
63
|
-
def remove(
|
|
64
|
-
|
|
65
|
-
@routes.delete(r)
|
|
140
|
+
def remove(path)
|
|
141
|
+
self.routes = routes.reject { |route| route.path == path }
|
|
66
142
|
end
|
|
67
143
|
|
|
68
144
|
def sort!
|
|
69
|
-
|
|
145
|
+
self.routes = routes.sort_by(&:path)
|
|
70
146
|
self
|
|
71
147
|
end
|
|
72
148
|
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
|