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,76 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "json"
|
|
5
|
-
require "shale"
|
|
6
|
-
require_relative "dataset"
|
|
7
|
-
require_relative "storage_config"
|
|
3
|
+
require "forwardable"
|
|
8
4
|
|
|
9
5
|
module Capsium
|
|
10
6
|
class Package
|
|
11
7
|
class Storage
|
|
12
8
|
extend Forwardable
|
|
13
|
-
attr_reader :config
|
|
14
9
|
|
|
15
|
-
|
|
10
|
+
attr_reader :path, :config, :datasets
|
|
16
11
|
|
|
17
|
-
|
|
12
|
+
def_delegators :@config, :to_json, :to_hash
|
|
18
13
|
|
|
19
14
|
def initialize(path)
|
|
20
15
|
@path = path
|
|
21
16
|
@dir = File.dirname(path)
|
|
22
|
-
@datasets_path = File.join(@dir, DATA_DIR)
|
|
23
17
|
@config = if File.exist?(path)
|
|
24
18
|
StorageConfig.from_json(File.read(path))
|
|
25
19
|
else
|
|
26
|
-
StorageConfig.new
|
|
20
|
+
StorageConfig.new
|
|
27
21
|
end
|
|
28
|
-
@datasets = load_datasets
|
|
22
|
+
@datasets = load_datasets
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def data_sets
|
|
26
|
+
@config.data_sets
|
|
29
27
|
end
|
|
30
28
|
|
|
31
29
|
def load_datasets
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
storage_data.datasets.map do |dataset_config|
|
|
35
|
-
dataset_config.to_dataset(@datasets_path)
|
|
36
|
-
end
|
|
30
|
+
data_sets.map do |name, dataset_config|
|
|
31
|
+
dataset_config.to_dataset(name, @dir)
|
|
37
32
|
end
|
|
38
33
|
end
|
|
39
34
|
|
|
40
|
-
def
|
|
41
|
-
|
|
42
|
-
DatasetConfig.from_dataset(dataset)
|
|
43
|
-
end)
|
|
44
|
-
File.write(output_path, storage_config.to_json)
|
|
35
|
+
def dataset(name)
|
|
36
|
+
@datasets.find { |dataset| dataset.name == name }
|
|
45
37
|
end
|
|
46
38
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
Dir.glob(paths).map do |file_path|
|
|
50
|
-
Dataset.new(config: DatasetConfig.new(
|
|
51
|
-
name: File.basename(file_path,
|
|
52
|
-
".*"), source: file_path, format: detect_format(file_path)
|
|
53
|
-
))
|
|
54
|
-
end
|
|
39
|
+
def dataset_names
|
|
40
|
+
data_sets.keys.sort
|
|
55
41
|
end
|
|
56
42
|
|
|
57
|
-
|
|
43
|
+
def empty?
|
|
44
|
+
data_sets.empty?
|
|
45
|
+
end
|
|
58
46
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
when ".yaml", ".yml"
|
|
62
|
-
"yaml"
|
|
63
|
-
when ".json"
|
|
64
|
-
"json"
|
|
65
|
-
when ".csv"
|
|
66
|
-
"csv"
|
|
67
|
-
when ".tsv"
|
|
68
|
-
"tsv"
|
|
69
|
-
when ".sqlite", ".db"
|
|
70
|
-
"sqlite"
|
|
71
|
-
else
|
|
72
|
-
raise "Unsupported data file type: #{File.extname(file_path)}"
|
|
73
|
-
end
|
|
47
|
+
def save_to_file(output_path = @path)
|
|
48
|
+
File.write(output_path, to_json)
|
|
74
49
|
end
|
|
75
50
|
end
|
|
76
51
|
end
|
|
@@ -1,9 +1,106 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "lutaml/model"
|
|
2
5
|
|
|
3
6
|
module Capsium
|
|
4
7
|
class Package
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
7
104
|
end
|
|
8
105
|
end
|
|
9
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
|
data/lib/capsium/package.rb
CHANGED
|
@@ -1,41 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# lib/capsium/package.rb
|
|
4
3
|
require "fileutils"
|
|
5
|
-
require "
|
|
6
|
-
require "
|
|
7
|
-
require "csv"
|
|
8
|
-
require "sqlite3"
|
|
9
|
-
require "zip"
|
|
10
|
-
require_relative "package/manifest"
|
|
11
|
-
require_relative "package/metadata"
|
|
12
|
-
require_relative "package/routes"
|
|
13
|
-
require_relative "package/dataset"
|
|
14
|
-
require_relative "package/storage"
|
|
15
|
-
require_relative "packager"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tmpdir"
|
|
16
6
|
|
|
17
7
|
module Capsium
|
|
18
8
|
class Package
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
autoload :Dataset, "capsium/package/dataset"
|
|
10
|
+
autoload :DatasetConfig, "capsium/package/storage_config"
|
|
11
|
+
autoload :DigitalSignatures, "capsium/package/security_config"
|
|
12
|
+
autoload :IntegrityChecks, "capsium/package/security_config"
|
|
13
|
+
autoload :Manifest, "capsium/package/manifest"
|
|
14
|
+
autoload :ManifestConfig, "capsium/package/manifest_config"
|
|
15
|
+
autoload :Metadata, "capsium/package/metadata"
|
|
16
|
+
autoload :MetadataData, "capsium/package/metadata_config"
|
|
17
|
+
autoload :Repository, "capsium/package/metadata_config"
|
|
18
|
+
autoload :Resource, "capsium/package/manifest_config"
|
|
19
|
+
autoload :Route, "capsium/package/routes_config"
|
|
20
|
+
autoload :Routes, "capsium/package/routes"
|
|
21
|
+
autoload :RoutesConfig, "capsium/package/routes_config"
|
|
22
|
+
autoload :Security, "capsium/package/security"
|
|
23
|
+
autoload :SecurityConfig, "capsium/package/security_config"
|
|
24
|
+
autoload :SecurityData, "capsium/package/security_config"
|
|
25
|
+
autoload :Storage, "capsium/package/storage"
|
|
26
|
+
autoload :StorageConfig, "capsium/package/storage_config"
|
|
27
|
+
autoload :StorageData, "capsium/package/storage_config"
|
|
28
|
+
autoload :Validator, "capsium/package/validator"
|
|
29
|
+
|
|
30
|
+
attr_reader :name, :path, :manifest, :metadata, :routes, :storage,
|
|
31
|
+
:security, :load_type
|
|
21
32
|
|
|
22
33
|
MANIFEST_FILE = "manifest.json"
|
|
23
34
|
METADATA_FILE = "metadata.json"
|
|
24
|
-
PACKAGING_FILE = "packaging.json"
|
|
25
|
-
SIGNATURE_FILE = "signature.json"
|
|
26
35
|
STORAGE_FILE = "storage.json"
|
|
27
36
|
ROUTES_FILE = "routes.json"
|
|
37
|
+
SECURITY_FILE = "security.json"
|
|
28
38
|
CONTENT_DIR = "content"
|
|
29
39
|
DATA_DIR = "data"
|
|
30
|
-
ENCRYPTED_PACKAGING_FILE = "package.enc"
|
|
31
40
|
|
|
32
41
|
def initialize(path, load_type: nil)
|
|
33
42
|
@original_path = Pathname.new(path).expand_path
|
|
34
|
-
@path = prepare_package(@original_path)
|
|
43
|
+
@path = prepare_package(@original_path).to_s
|
|
35
44
|
@load_type = load_type || determine_load_type(path)
|
|
36
45
|
create_package_structure
|
|
37
46
|
load_package
|
|
38
47
|
@name = metadata.name
|
|
48
|
+
verify_integrity!
|
|
39
49
|
end
|
|
40
50
|
|
|
41
51
|
def prepare_package(path)
|
|
@@ -44,45 +54,23 @@ module Capsium
|
|
|
44
54
|
if File.file?(path)
|
|
45
55
|
return decompress_cap_file(path) if File.extname(path) == ".cap"
|
|
46
56
|
|
|
47
|
-
raise
|
|
57
|
+
raise Error, "The package must have a .cap extension"
|
|
48
58
|
end
|
|
49
59
|
|
|
50
|
-
raise "Invalid package path: #{path}"
|
|
60
|
+
raise Error, "Invalid package path: #{path}"
|
|
51
61
|
end
|
|
52
62
|
|
|
53
63
|
def solidify
|
|
54
64
|
@manifest.save_to_file
|
|
55
65
|
@metadata.save_to_file
|
|
56
66
|
@routes.save_to_file
|
|
57
|
-
@storage.save_to_file
|
|
67
|
+
@storage.save_to_file unless @storage.empty?
|
|
58
68
|
end
|
|
59
69
|
|
|
60
70
|
def decompress_cap_file(file_path)
|
|
61
|
-
|
|
62
|
-
metadata_path = File.join(temp_dir, METADATA_FILE)
|
|
63
|
-
|
|
64
|
-
# Extract metadata.json first
|
|
65
|
-
Zip::File.open(file_path) do |zip_file|
|
|
66
|
-
if entry = zip_file.find_entry(METADATA_FILE)
|
|
67
|
-
entry.extract(metadata_path)
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
metadata = Metadata.new(metadata_path)
|
|
72
|
-
package_name = metadata.name
|
|
73
|
-
package_version = metadata.version
|
|
74
|
-
|
|
75
|
-
package_path = File.join(temp_dir, "#{package_name}-#{package_version}")
|
|
71
|
+
package_path = File.join(Dir.mktmpdir, package_stem(file_path))
|
|
76
72
|
FileUtils.mkdir_p(package_path)
|
|
77
|
-
|
|
78
|
-
Zip::File.open(file_path) do |zip_file|
|
|
79
|
-
zip_file.each do |entry|
|
|
80
|
-
entry_path = File.join(package_path, entry.name)
|
|
81
|
-
FileUtils.mkdir_p(File.dirname(entry_path))
|
|
82
|
-
entry.extract(entry_path)
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
73
|
+
Packager.new.unpack(file_path, package_path)
|
|
86
74
|
package_path
|
|
87
75
|
end
|
|
88
76
|
|
|
@@ -94,16 +82,23 @@ module Capsium
|
|
|
94
82
|
@manifest = Manifest.new(manifest_path)
|
|
95
83
|
@storage = Storage.new(storage_path)
|
|
96
84
|
@routes = Routes.new(routes_path, @manifest, @storage)
|
|
85
|
+
@security = Security.new(security_path)
|
|
97
86
|
end
|
|
98
87
|
|
|
99
88
|
def cleanup
|
|
100
|
-
return unless @path != @original_path && File.directory?(@path)
|
|
89
|
+
return unless @path != @original_path.to_s && File.directory?(@path)
|
|
101
90
|
|
|
102
91
|
FileUtils.remove_entry(@path)
|
|
103
92
|
end
|
|
104
93
|
|
|
105
|
-
def
|
|
106
|
-
|
|
94
|
+
def datasets
|
|
95
|
+
storage.datasets
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The .cap file this package was loaded from, or nil when loaded
|
|
99
|
+
# from a directory.
|
|
100
|
+
def cap_file_path
|
|
101
|
+
@original_path.to_s if load_type == :cap_file
|
|
107
102
|
end
|
|
108
103
|
|
|
109
104
|
def content_files
|
|
@@ -119,36 +114,43 @@ module Capsium
|
|
|
119
114
|
:unsaved
|
|
120
115
|
end
|
|
121
116
|
|
|
117
|
+
# Verifies the package against security.json (ARCHITECTURE.md section
|
|
118
|
+
# 6). Returns a list of typed errors; empty when no security.json is
|
|
119
|
+
# present or all checksums match.
|
|
120
|
+
def verify_integrity
|
|
121
|
+
return [] unless @security.present?
|
|
122
|
+
|
|
123
|
+
@security.verify(@path)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def verify_integrity!
|
|
127
|
+
@security.verify!(@path) if @security.present?
|
|
128
|
+
end
|
|
129
|
+
|
|
122
130
|
private
|
|
123
131
|
|
|
132
|
+
def package_stem(file_path)
|
|
133
|
+
File.basename(file_path, ".cap")
|
|
134
|
+
end
|
|
135
|
+
|
|
124
136
|
def create_package_structure
|
|
125
137
|
FileUtils.mkdir_p(@path)
|
|
126
138
|
FileUtils.mkdir_p(content_path)
|
|
127
139
|
FileUtils.mkdir_p(data_path)
|
|
128
140
|
end
|
|
129
141
|
|
|
130
|
-
def content_path
|
|
131
|
-
File.join(@path, CONTENT_DIR)
|
|
132
|
-
end
|
|
142
|
+
def content_path = File.join(@path, CONTENT_DIR)
|
|
133
143
|
|
|
134
|
-
def data_path
|
|
135
|
-
File.join(@path, DATA_DIR)
|
|
136
|
-
end
|
|
144
|
+
def data_path = File.join(@path, DATA_DIR)
|
|
137
145
|
|
|
138
|
-
def routes_path
|
|
139
|
-
File.join(@path, ROUTES_FILE)
|
|
140
|
-
end
|
|
146
|
+
def routes_path = File.join(@path, ROUTES_FILE)
|
|
141
147
|
|
|
142
|
-
def storage_path
|
|
143
|
-
File.join(@path, STORAGE_FILE)
|
|
144
|
-
end
|
|
148
|
+
def storage_path = File.join(@path, STORAGE_FILE)
|
|
145
149
|
|
|
146
|
-
def metadata_path
|
|
147
|
-
File.join(@path, METADATA_FILE)
|
|
148
|
-
end
|
|
150
|
+
def metadata_path = File.join(@path, METADATA_FILE)
|
|
149
151
|
|
|
150
|
-
def manifest_path
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
def manifest_path = File.join(@path, MANIFEST_FILE)
|
|
153
|
+
|
|
154
|
+
def security_path = File.join(@path, SECURITY_FILE)
|
|
153
155
|
end
|
|
154
156
|
end
|
data/lib/capsium/packager.rb
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# lib/capsium/packager.rb
|
|
4
|
-
require "json"
|
|
5
3
|
require "fileutils"
|
|
6
|
-
require "
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tmpdir"
|
|
7
6
|
require "zip"
|
|
8
7
|
|
|
9
8
|
module Capsium
|
|
10
9
|
class Packager
|
|
11
10
|
class FileAlreadyExistsError < StandardError; end
|
|
12
11
|
|
|
12
|
+
# Raised when a .cap archive contains an entry whose name would be
|
|
13
|
+
# written outside the destination directory (absolute paths, drive
|
|
14
|
+
# letters, ".." segments) — zip-slip protection, on par with the Lua
|
|
15
|
+
# reactor's extractor.
|
|
16
|
+
class UnsafeEntryError < Capsium::Error; end
|
|
17
|
+
|
|
18
|
+
DRIVE_LETTER_PATTERN = %r{\A[A-Za-z]:[/\\]}
|
|
19
|
+
|
|
13
20
|
def pack(package, options = {})
|
|
14
21
|
directory = package.path
|
|
15
22
|
output_file_name = "#{package.metadata.name}-#{package.metadata.version}.cap"
|
|
@@ -26,8 +33,10 @@ module Capsium
|
|
|
26
33
|
|
|
27
34
|
Dir.mktmpdir do |dir|
|
|
28
35
|
FileUtils.cp_r("#{directory}/.", dir)
|
|
36
|
+
FileUtils.rm_f(File.join(dir, Package::SECURITY_FILE))
|
|
29
37
|
new_package = Package.new(dir)
|
|
30
38
|
new_package.solidify
|
|
39
|
+
generate_security(new_package)
|
|
31
40
|
new_cap_file_path = File.join(dir, output_file_name)
|
|
32
41
|
compress_package(new_package, new_cap_file_path)
|
|
33
42
|
puts "Package built at: #{new_cap_file_path}"
|
|
@@ -38,9 +47,10 @@ module Capsium
|
|
|
38
47
|
end
|
|
39
48
|
|
|
40
49
|
def unpack(cap_file_path, destination)
|
|
50
|
+
destination = File.expand_path(destination)
|
|
41
51
|
Zip::File.open(cap_file_path) do |zip_file|
|
|
42
52
|
zip_file.each do |entry|
|
|
43
|
-
entry_path =
|
|
53
|
+
entry_path = safe_entry_path(destination, entry.name)
|
|
44
54
|
FileUtils.mkdir_p(File.dirname(entry_path))
|
|
45
55
|
entry.extract(entry_path)
|
|
46
56
|
end
|
|
@@ -48,8 +58,11 @@ module Capsium
|
|
|
48
58
|
end
|
|
49
59
|
|
|
50
60
|
def compress_package(package, cap_file)
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
entries = Dir[File.join(package.path, "**", "**")].reject do |file|
|
|
62
|
+
File.expand_path(file) == File.expand_path(cap_file)
|
|
63
|
+
end
|
|
64
|
+
Zip::File.open(cap_file, create: true) do |zipfile|
|
|
65
|
+
entries.each do |file|
|
|
53
66
|
zipfile.add(file.sub("#{package.path}/", ""), file)
|
|
54
67
|
end
|
|
55
68
|
end
|
|
@@ -58,5 +71,28 @@ module Capsium
|
|
|
58
71
|
def relative_path_current(absolute_path)
|
|
59
72
|
Pathname.new(absolute_path).relative_path_from(Dir.pwd).to_s
|
|
60
73
|
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Resolves an entry name against the destination and returns the
|
|
78
|
+
# absolute target path, raising UnsafeEntryError when the entry would
|
|
79
|
+
# escape the destination (zip-slip).
|
|
80
|
+
def safe_entry_path(destination, entry_name)
|
|
81
|
+
entry_path = File.expand_path(entry_name, destination)
|
|
82
|
+
return entry_path if contained?(entry_path, destination) &&
|
|
83
|
+
!entry_name.match?(DRIVE_LETTER_PATTERN)
|
|
84
|
+
|
|
85
|
+
raise UnsafeEntryError,
|
|
86
|
+
"Refusing to extract unsafe zip entry: #{entry_name}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def contained?(entry_path, destination)
|
|
90
|
+
entry_path.start_with?("#{destination}#{File::SEPARATOR}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def generate_security(package)
|
|
94
|
+
security = Package::Security.generate(package.path)
|
|
95
|
+
security.save_to_file
|
|
96
|
+
end
|
|
61
97
|
end
|
|
62
98
|
end
|