capsium 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.rubocop.yml +46 -7
- data/CHANGELOG.md +183 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.adoc +496 -127
- data/Rakefile +8 -1
- data/bin/console +1 -1
- data/capsium.gemspec +12 -19
- data/exe/capsium +1 -1
- data/lib/capsium/cli/convert.rb +18 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +148 -0
- data/lib/capsium/cli/reactor.rb +33 -0
- data/lib/capsium/cli.rb +4 -96
- data/lib/capsium/converters/jekyll.rb +5 -7
- data/lib/capsium/converters.rb +7 -0
- data/lib/capsium/package/authentication.rb +38 -0
- data/lib/capsium/package/authentication_config.rb +78 -0
- data/lib/capsium/package/cipher.rb +197 -0
- data/lib/capsium/package/composition.rb +77 -0
- data/lib/capsium/package/dataset.rb +67 -34
- data/lib/capsium/package/dependency_resolver.rb +61 -0
- data/lib/capsium/package/encryption_config.rb +38 -0
- data/lib/capsium/package/manifest.rb +33 -22
- data/lib/capsium/package/manifest_config.rb +52 -7
- data/lib/capsium/package/merged_view.rb +168 -0
- data/lib/capsium/package/metadata.rb +4 -8
- data/lib/capsium/package/metadata_config.rb +97 -9
- data/lib/capsium/package/preparation.rb +81 -0
- data/lib/capsium/package/routes.rb +39 -41
- data/lib/capsium/package/routes_config.rb +174 -44
- data/lib/capsium/package/security.rb +140 -0
- data/lib/capsium/package/security_config.rb +60 -0
- data/lib/capsium/package/signer.rb +201 -0
- data/lib/capsium/package/storage.rb +20 -45
- data/lib/capsium/package/storage_config.rb +128 -3
- data/lib/capsium/package/store.rb +109 -0
- data/lib/capsium/package/testing/config_test.rb +70 -0
- data/lib/capsium/package/testing/context.rb +11 -0
- data/lib/capsium/package/testing/data_validation_test.rb +63 -0
- data/lib/capsium/package/testing/file_test.rb +45 -0
- data/lib/capsium/package/testing/report.rb +33 -0
- data/lib/capsium/package/testing/route_test.rb +64 -0
- data/lib/capsium/package/testing/test_case.rb +60 -0
- data/lib/capsium/package/testing/test_suite.rb +86 -0
- data/lib/capsium/package/testing.rb +20 -0
- data/lib/capsium/package/validator.rb +160 -0
- data/lib/capsium/package/verification.rb +32 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +92 -106
- data/lib/capsium/packager.rb +75 -6
- data/lib/capsium/reactor/authenticator.rb +180 -0
- data/lib/capsium/reactor/deploy.rb +71 -0
- data/lib/capsium/reactor/htpasswd.rb +154 -0
- data/lib/capsium/reactor/introspection.rb +91 -0
- data/lib/capsium/reactor/oauth2.rb +106 -0
- data/lib/capsium/reactor/serving.rb +75 -0
- data/lib/capsium/reactor/session.rb +82 -0
- data/lib/capsium/reactor.rb +93 -43
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +7 -9
- data/sig/capsium/package/authentication.rbs +23 -0
- data/sig/capsium/package/authentication_config.rbs +71 -0
- data/sig/capsium/package/cipher.rbs +51 -0
- data/sig/capsium/package/composition.rbs +20 -0
- data/sig/capsium/package/dataset.rbs +28 -0
- data/sig/capsium/package/dependency_resolver.rbs +49 -0
- data/sig/capsium/package/encryption_config.rbs +35 -0
- data/sig/capsium/package/manifest.rbs +34 -0
- data/sig/capsium/package/manifest_config.rbs +38 -0
- data/sig/capsium/package/merged_view.rbs +46 -0
- data/sig/capsium/package/metadata.rbs +26 -0
- data/sig/capsium/package/metadata_config.rbs +49 -0
- data/sig/capsium/package/preparation.rbs +23 -0
- data/sig/capsium/package/routes.rbs +37 -0
- data/sig/capsium/package/routes_config.rbs +107 -0
- data/sig/capsium/package/security.rbs +72 -0
- data/sig/capsium/package/security_config.rbs +57 -0
- data/sig/capsium/package/signer.rbs +59 -0
- data/sig/capsium/package/storage.rbs +28 -0
- data/sig/capsium/package/storage_config.rbs +85 -0
- data/sig/capsium/package/store.rbs +37 -0
- data/sig/capsium/package/testing/config_test.rbs +21 -0
- data/sig/capsium/package/testing/context.rbs +14 -0
- data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
- data/sig/capsium/package/testing/file_test.rbs +17 -0
- data/sig/capsium/package/testing/report.rbs +20 -0
- data/sig/capsium/package/testing/route_test.rbs +22 -0
- data/sig/capsium/package/testing/test_case.rbs +41 -0
- data/sig/capsium/package/testing/test_suite.rbs +20 -0
- data/sig/capsium/package/testing.rbs +7 -0
- data/sig/capsium/package/validator.rbs +29 -0
- data/sig/capsium/package/verification.rbs +24 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +61 -0
- data/sig/capsium/packager.rbs +32 -0
- data/sig/capsium/reactor/authenticator.rbs +40 -0
- data/sig/capsium/reactor/deploy.rbs +34 -0
- data/sig/capsium/reactor/htpasswd.rbs +36 -0
- data/sig/capsium/reactor/introspection.rbs +31 -0
- data/sig/capsium/reactor/oauth2.rbs +31 -0
- data/sig/capsium/reactor/serving.rbs +22 -0
- data/sig/capsium/reactor/session.rbs +36 -0
- data/sig/capsium/reactor.rbs +35 -0
- data/sig/capsium.rbs +3 -1
- metadata +108 -170
- data/lib/capsium/package/dataset_config.rb +0 -28
- data/lib/capsium/protector.rb +0 -103
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require "zip"
|
|
9
|
+
|
|
10
|
+
module Capsium
|
|
11
|
+
class Package
|
|
12
|
+
# Encrypts and decrypts whole Capsium packages (05x-packaging
|
|
13
|
+
# "Encryption", 05x-security "Encrypted information").
|
|
14
|
+
#
|
|
15
|
+
# An encrypted .cap is a zip containing exactly:
|
|
16
|
+
#
|
|
17
|
+
# metadata.json (cleartext, per the standard: name/version stay
|
|
18
|
+
# readable without the key)
|
|
19
|
+
# signature.json (the encryption envelope)
|
|
20
|
+
# package.enc (AES-256-GCM ciphertext of the inner, plaintext
|
|
21
|
+
# .cap zip holding content/, the configuration
|
|
22
|
+
# files and data/)
|
|
23
|
+
#
|
|
24
|
+
# The envelope:
|
|
25
|
+
#
|
|
26
|
+
# {"encryption": {"algorithm": "AES-256-GCM",
|
|
27
|
+
# "keyManagement": "RSA-OAEP-SHA256",
|
|
28
|
+
# "encryptedDek": <base64>, "iv": <base64>,
|
|
29
|
+
# "authTag": <base64>}}
|
|
30
|
+
#
|
|
31
|
+
# A random 256-bit data encryption key (DEK) encrypts the inner zip;
|
|
32
|
+
# the DEK is wrapped with the recipient's RSA public key using OAEP
|
|
33
|
+
# with SHA-256 (MGF1-SHA-256). The OCB/OpenPGP alternatives mentioned
|
|
34
|
+
# by the standard are intentionally out of scope.
|
|
35
|
+
class Cipher
|
|
36
|
+
ALGORITHM = "AES-256-GCM"
|
|
37
|
+
KEY_MANAGEMENT = "RSA-OAEP-SHA256"
|
|
38
|
+
ENCRYPTED_FILE = "package.enc"
|
|
39
|
+
ENVELOPE_FILE = "signature.json"
|
|
40
|
+
RSA_OPTIONS = { "rsa_padding_mode" => "oaep",
|
|
41
|
+
"rsa_oaep_md" => "SHA256", "rsa_mgf1_md" => "SHA256" }.freeze
|
|
42
|
+
|
|
43
|
+
# Structural problems: unreadable input, missing zip entries,
|
|
44
|
+
# unsupported envelope algorithms, unloadable keys.
|
|
45
|
+
class CipherError < Capsium::Error; end
|
|
46
|
+
|
|
47
|
+
# An encrypted package was opened without a private key.
|
|
48
|
+
class KeyRequiredError < CipherError; end
|
|
49
|
+
|
|
50
|
+
# Decryption failed: wrong private key or tampered ciphertext.
|
|
51
|
+
class DecryptionError < CipherError; end
|
|
52
|
+
|
|
53
|
+
# Whether the path (.cap file or uncompressed directory) is an
|
|
54
|
+
# encrypted package, i.e. contains package.enc.
|
|
55
|
+
def self.encrypted?(path)
|
|
56
|
+
return File.file?(File.join(path, ENCRYPTED_FILE)) if File.directory?(path)
|
|
57
|
+
return false unless File.file?(path)
|
|
58
|
+
|
|
59
|
+
Zip::File.open(path) { |zip| !zip.find_entry(ENCRYPTED_FILE).nil? }
|
|
60
|
+
rescue Zip::Error
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Encrypts the package at source_path (a .cap file, or a package
|
|
65
|
+
# directory which is packed first) for the recipient's RSA public
|
|
66
|
+
# key (or X.509 certificate) and writes the encrypted .cap to
|
|
67
|
+
# output_path.
|
|
68
|
+
def encrypt(source_path, public_key_path, output_path)
|
|
69
|
+
public_key = load_public_key(public_key_path)
|
|
70
|
+
with_cap_file(source_path) do |cap_path|
|
|
71
|
+
envelope, ciphertext = encrypt_bytes(File.binread(cap_path), public_key)
|
|
72
|
+
write_encrypted_cap(output_path, read_source(cap_path, Package::METADATA_FILE),
|
|
73
|
+
envelope, ciphertext)
|
|
74
|
+
end
|
|
75
|
+
output_path
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Decrypts the encrypted package at encrypted_path (.cap file or
|
|
79
|
+
# uncompressed directory) with the recipient's RSA private key and
|
|
80
|
+
# writes the plaintext .cap to output_path.
|
|
81
|
+
def decrypt(encrypted_path, private_key_path, output_path)
|
|
82
|
+
private_key = load_private_key(private_key_path)
|
|
83
|
+
envelope = load_envelope(encrypted_path)
|
|
84
|
+
ciphertext = read_source(encrypted_path, ENCRYPTED_FILE)
|
|
85
|
+
File.binwrite(output_path, decrypt_bytes(ciphertext, envelope, private_key))
|
|
86
|
+
output_path
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Decrypts an encrypted package into a fresh temporary directory
|
|
90
|
+
# and returns the directory path.
|
|
91
|
+
def self.decrypt_to_directory(source_path, private_key_path)
|
|
92
|
+
Dir.mktmpdir.tap do |tmp|
|
|
93
|
+
inner_cap = File.join(tmp, "inner.cap")
|
|
94
|
+
new.decrypt(source_path, private_key_path, inner_cap)
|
|
95
|
+
package_path = File.join(tmp, File.basename(source_path.to_s, ".cap"))
|
|
96
|
+
FileUtils.mkdir_p(package_path)
|
|
97
|
+
Packager.new.unpack(inner_cap, package_path)
|
|
98
|
+
FileUtils.rm_f(inner_cap)
|
|
99
|
+
return package_path
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def with_cap_file(source_path)
|
|
106
|
+
return yield source_path if File.file?(source_path) && File.extname(source_path) == ".cap"
|
|
107
|
+
raise CipherError, "cannot encrypt: #{source_path}" unless File.directory?(source_path)
|
|
108
|
+
|
|
109
|
+
Dir.mktmpdir do |dir|
|
|
110
|
+
cap_path = File.join(dir, "inner.cap")
|
|
111
|
+
Packager.new.compress_package(Capsium::Package.new(source_path), cap_path)
|
|
112
|
+
yield cap_path
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def encrypt_bytes(plaintext, public_key)
|
|
117
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM.downcase)
|
|
118
|
+
cipher.encrypt
|
|
119
|
+
dek = cipher.random_key
|
|
120
|
+
iv = cipher.random_iv
|
|
121
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
122
|
+
envelope = EncryptionEnvelope.new(
|
|
123
|
+
algorithm: ALGORITHM, key_management: KEY_MANAGEMENT,
|
|
124
|
+
encrypted_dek: Base64.strict_encode64(public_key.encrypt(dek, RSA_OPTIONS)),
|
|
125
|
+
iv: Base64.strict_encode64(iv), auth_tag: Base64.strict_encode64(cipher.auth_tag)
|
|
126
|
+
)
|
|
127
|
+
[EncryptionConfig.new(encryption: envelope), ciphertext]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def decrypt_bytes(ciphertext, envelope, private_key)
|
|
131
|
+
dek = private_key.decrypt(Base64.strict_decode64(envelope.encrypted_dek), RSA_OPTIONS)
|
|
132
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM.downcase)
|
|
133
|
+
cipher.decrypt
|
|
134
|
+
cipher.key = dek
|
|
135
|
+
cipher.iv = Base64.strict_decode64(envelope.iv)
|
|
136
|
+
cipher.auth_tag = Base64.strict_decode64(envelope.auth_tag)
|
|
137
|
+
cipher.update(ciphertext) + cipher.final
|
|
138
|
+
rescue ArgumentError
|
|
139
|
+
raise CipherError, "invalid Base64 in the encryption envelope"
|
|
140
|
+
rescue OpenSSL::PKey::PKeyError, OpenSSL::Cipher::CipherError
|
|
141
|
+
# DEK unwrap failure or GCM tag verification failure (wrong key
|
|
142
|
+
# or tampered ciphertext), or a malformed envelope iv/authTag.
|
|
143
|
+
raise DecryptionError, "decryption failed: wrong key or tampered package"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def write_encrypted_cap(output_path, metadata_json, envelope, ciphertext)
|
|
147
|
+
FileUtils.rm_f(output_path)
|
|
148
|
+
Zip::File.open(output_path, create: true) do |zip|
|
|
149
|
+
zip.get_output_stream(Package::METADATA_FILE) { |stream| stream.write(metadata_json) }
|
|
150
|
+
zip.get_output_stream(ENVELOPE_FILE) { |stream| stream.write(envelope.to_json) }
|
|
151
|
+
zip.get_output_stream(ENCRYPTED_FILE) { |stream| stream.write(ciphertext) }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def load_envelope(encrypted_path)
|
|
156
|
+
envelope = EncryptionConfig.from_json(read_source(encrypted_path, ENVELOPE_FILE)).encryption
|
|
157
|
+
return envelope if envelope && supported_envelope?(envelope)
|
|
158
|
+
|
|
159
|
+
raise CipherError, "missing or unsupported encryption envelope in: #{encrypted_path}"
|
|
160
|
+
rescue Lutaml::Model::Error, JSON::ParserError => e
|
|
161
|
+
raise CipherError, "invalid encryption envelope in #{encrypted_path}: #{e.message}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def supported_envelope?(envelope)
|
|
165
|
+
[envelope.algorithm, envelope.key_management] == [ALGORITHM, KEY_MANAGEMENT]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def read_source(path, entry_name)
|
|
169
|
+
return File.binread(File.join(path, entry_name)) if File.directory?(path)
|
|
170
|
+
|
|
171
|
+
Zip::File.open(path) do |zip|
|
|
172
|
+
entry = zip.find_entry(entry_name)
|
|
173
|
+
raise CipherError, "entry missing in #{path}: #{entry_name}" unless entry
|
|
174
|
+
|
|
175
|
+
return entry.get_input_stream.read
|
|
176
|
+
end
|
|
177
|
+
rescue Errno::ENOENT, Zip::Error => e
|
|
178
|
+
raise CipherError, "cannot read #{entry_name} in #{path}: #{e.message}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def load_public_key(public_key_path)
|
|
182
|
+
pem = File.read(public_key_path)
|
|
183
|
+
OpenSSL::PKey::RSA.new(pem)
|
|
184
|
+
rescue OpenSSL::PKey::PKeyError
|
|
185
|
+
OpenSSL::X509::Certificate.new(pem).public_key
|
|
186
|
+
rescue OpenSSL::X509::CertificateError, Errno::ENOENT
|
|
187
|
+
raise CipherError, "cannot load public key or certificate: #{public_key_path}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def load_private_key(private_key_path)
|
|
191
|
+
OpenSSL::PKey::RSA.new(File.read(private_key_path))
|
|
192
|
+
rescue OpenSSL::PKey::PKeyError, Errno::ENOENT
|
|
193
|
+
raise CipherError, "cannot load private key: #{private_key_path}"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capsium
|
|
4
|
+
class Package
|
|
5
|
+
# Composite-package support (ARCHITECTURE.md section 4a), mixed into
|
|
6
|
+
# Package: metadata.dependencies resolution against a package store
|
|
7
|
+
# and load-time validation of dependency resource references.
|
|
8
|
+
module Composition
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Resolves metadata.dependencies against the package store (the
|
|
12
|
+
# `store` argument, else CAPSIUM_STORE). Each resolved dependency
|
|
13
|
+
# loads recursively with an extended circularity chain; its
|
|
14
|
+
# exported content becomes lower read-only layers of this
|
|
15
|
+
# package's merged view.
|
|
16
|
+
def resolve_dependencies(store, chain)
|
|
17
|
+
declared = @metadata.dependencies
|
|
18
|
+
return [] if declared.empty?
|
|
19
|
+
|
|
20
|
+
resolver = DependencyResolver.new(store || Store.default)
|
|
21
|
+
chain += [@metadata.guid]
|
|
22
|
+
declared.map do |guid, range|
|
|
23
|
+
cap_path = resolver.resolve_path(guid, range, chain: chain)
|
|
24
|
+
ResolvedDependency.new(
|
|
25
|
+
guid: guid, range: range, path: cap_path,
|
|
26
|
+
package: Package.new(cap_path, store: resolver.store,
|
|
27
|
+
dependency_chain: chain)
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Every route resource given as a URI ("<dependency-guid>/<path>")
|
|
33
|
+
# must address a resolved dependency and a resource that dependency
|
|
34
|
+
# exports; anything else is a load-time error (section 4a).
|
|
35
|
+
def validate_dependency_references!
|
|
36
|
+
@routes.config.routes.each do |route|
|
|
37
|
+
reference = route.resource
|
|
38
|
+
next unless route.dependency_reference?
|
|
39
|
+
|
|
40
|
+
dependency = dependency_for(reference)
|
|
41
|
+
unless dependency
|
|
42
|
+
raise DependencyError,
|
|
43
|
+
"route #{route.path} references unknown dependency: #{reference}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
validate_dependency_reference(route, dependency, reference)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_dependency_reference(route, dependency, reference)
|
|
51
|
+
inner = reference.delete_prefix("#{dependency.guid}/")
|
|
52
|
+
return if dependency.package.merged_view(exported_only: true).resolve(inner)
|
|
53
|
+
|
|
54
|
+
if dependency.package.merged_view.resolve(inner)
|
|
55
|
+
raise DependencyVisibilityError,
|
|
56
|
+
"route #{route.path} references a private resource of " \
|
|
57
|
+
"dependency #{dependency.guid}: #{inner}"
|
|
58
|
+
end
|
|
59
|
+
raise DependencyError,
|
|
60
|
+
"route #{route.path} references a resource missing from " \
|
|
61
|
+
"dependency #{dependency.guid}: #{inner}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def dependency_for(reference)
|
|
65
|
+
@resolved_dependencies
|
|
66
|
+
.sort_by { |dependency| -dependency.guid.length }
|
|
67
|
+
.find { |dependency| reference.start_with?("#{dependency.guid}/") }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def dependency_views
|
|
71
|
+
@resolved_dependencies.map do |dependency|
|
|
72
|
+
[dependency.guid, dependency.package.merged_view(exported_only: true)]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -1,72 +1,105 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "yaml"
|
|
5
3
|
require "csv"
|
|
6
|
-
require "
|
|
7
|
-
require "
|
|
4
|
+
require "forwardable"
|
|
5
|
+
require "json"
|
|
8
6
|
require "json-schema"
|
|
9
|
-
|
|
7
|
+
require "sqlite3"
|
|
8
|
+
require "yaml"
|
|
10
9
|
|
|
11
10
|
module Capsium
|
|
12
11
|
class Package
|
|
12
|
+
# A loaded dataset. "source"/"schemaFile"/"databaseFile" are
|
|
13
|
+
# package-relative paths (ARCHITECTURE.md section 5), resolved against
|
|
14
|
+
# the package directory.
|
|
13
15
|
class Dataset
|
|
14
|
-
attr_reader :config, :data, :
|
|
16
|
+
attr_reader :name, :config, :data, :package_path
|
|
15
17
|
|
|
16
18
|
extend Forwardable
|
|
17
19
|
|
|
18
20
|
def_delegators :@config, :to_json
|
|
19
21
|
|
|
20
|
-
def initialize(config:,
|
|
22
|
+
def initialize(name:, config:, package_path:)
|
|
23
|
+
@name = name
|
|
21
24
|
@config = config
|
|
22
|
-
@
|
|
25
|
+
@package_path = package_path
|
|
23
26
|
@data = load_data
|
|
24
27
|
end
|
|
25
28
|
|
|
29
|
+
def source_path
|
|
30
|
+
File.join(@package_path, @config.backing_file)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def schema_path
|
|
34
|
+
return unless @config.schema_file
|
|
35
|
+
|
|
36
|
+
File.join(@package_path, @config.schema_file)
|
|
37
|
+
end
|
|
38
|
+
|
|
26
39
|
def load_data
|
|
40
|
+
return nil unless File.file?(source_path)
|
|
41
|
+
|
|
27
42
|
case @config.format
|
|
28
|
-
when "yaml" then YAML.load_file(
|
|
29
|
-
when "json" then JSON.parse(File.read(
|
|
30
|
-
when "csv" then CSV.read(
|
|
31
|
-
when "tsv" then CSV.read(
|
|
43
|
+
when "yaml" then YAML.load_file(source_path)
|
|
44
|
+
when "json" then JSON.parse(File.read(source_path))
|
|
45
|
+
when "csv" then CSV.read(source_path, headers: true)
|
|
46
|
+
when "tsv" then CSV.read(source_path, col_sep: "\t", headers: true)
|
|
32
47
|
when "sqlite" then load_sqlite_data
|
|
33
48
|
else
|
|
34
|
-
raise "Unsupported data file type: #{@config.format}"
|
|
49
|
+
raise Error, "Unsupported data file type: #{@config.format}"
|
|
35
50
|
end
|
|
36
51
|
end
|
|
37
52
|
|
|
38
53
|
def validate
|
|
39
|
-
return unless @config.schema
|
|
54
|
+
return true unless @config.schema_file && @config.schema_type == "json-schema"
|
|
40
55
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
schema = JSON.parse(File.read(schema_path)) if @config.format == "json"
|
|
56
|
+
JSON::Validator.validate!(load_schema, @data.to_json)
|
|
57
|
+
end
|
|
44
58
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
# File-existence and schema validations for this dataset. Returns a
|
|
60
|
+
# list of human-readable problems; empty when valid.
|
|
61
|
+
def validation_errors
|
|
62
|
+
problems = []
|
|
63
|
+
unless File.file?(source_path)
|
|
64
|
+
problems << "dataset source missing on disk: #{@config.backing_file}"
|
|
50
65
|
end
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
problems.concat(schema_validation_errors)
|
|
67
|
+
problems
|
|
53
68
|
end
|
|
54
69
|
|
|
55
|
-
|
|
56
|
-
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def schema_validation_errors
|
|
73
|
+
return [] unless @config.schema_file
|
|
74
|
+
unless File.file?(schema_path)
|
|
75
|
+
return ["dataset schema missing on disk: #{@config.schema_file}"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
validate
|
|
79
|
+
[]
|
|
80
|
+
rescue JSON::Schema::ValidationError => e
|
|
81
|
+
["dataset #{@name} fails schema validation: #{e.message}"]
|
|
57
82
|
end
|
|
58
83
|
|
|
59
|
-
|
|
84
|
+
def load_schema
|
|
85
|
+
case File.extname(schema_path).downcase
|
|
86
|
+
when ".yaml", ".yml" then YAML.load_file(schema_path)
|
|
87
|
+
else JSON.parse(File.read(schema_path))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
60
90
|
|
|
61
91
|
def load_sqlite_data
|
|
62
|
-
db = SQLite3::Database.new(
|
|
63
|
-
tables =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
table_name = table.first
|
|
67
|
-
data[table_name] = db.execute("SELECT * FROM #{table_name};")
|
|
92
|
+
db = SQLite3::Database.new(source_path)
|
|
93
|
+
tables = @config.table ? [@config.table] : sqlite_tables(db)
|
|
94
|
+
tables.to_h do |table_name|
|
|
95
|
+
[table_name, db.execute("SELECT * FROM #{table_name};")]
|
|
68
96
|
end
|
|
69
|
-
|
|
97
|
+
ensure
|
|
98
|
+
db&.close
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def sqlite_tables(db)
|
|
102
|
+
db.execute("SELECT name FROM sqlite_master WHERE type='table';").map(&:first)
|
|
70
103
|
end
|
|
71
104
|
end
|
|
72
105
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capsium
|
|
4
|
+
class Package
|
|
5
|
+
# Base error for composite-package dependency resolution
|
|
6
|
+
# (ARCHITECTURE.md section 4a).
|
|
7
|
+
class DependencyError < Capsium::Error; end
|
|
8
|
+
|
|
9
|
+
# No package in the store provides the dependency GUID.
|
|
10
|
+
class DependencyNotFoundError < DependencyError; end
|
|
11
|
+
|
|
12
|
+
# The store provides the GUID but no version satisfies the range.
|
|
13
|
+
class UnsatisfiableDependencyError < DependencyError; end
|
|
14
|
+
|
|
15
|
+
# A dependency cycle was detected while resolving.
|
|
16
|
+
class CircularDependencyError < DependencyError; end
|
|
17
|
+
|
|
18
|
+
# A dependent route references a dependency resource that is not
|
|
19
|
+
# exported (private) and therefore not visible to dependents.
|
|
20
|
+
class DependencyVisibilityError < DependencyError; end
|
|
21
|
+
|
|
22
|
+
# One resolved dependency: the declared GUID and range, the .cap it
|
|
23
|
+
# resolved to and the loaded package.
|
|
24
|
+
ResolvedDependency = Data.define(:guid, :range, :path, :package) do
|
|
25
|
+
def version
|
|
26
|
+
package.metadata.version
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Orchestrates metadata.dependencies resolution against a package
|
|
31
|
+
# store: circularity checks plus store lookup. Package loads the
|
|
32
|
+
# resolved .cap itself (recursively, with an extended chain).
|
|
33
|
+
class DependencyResolver
|
|
34
|
+
attr_reader :store
|
|
35
|
+
|
|
36
|
+
def initialize(store)
|
|
37
|
+
if store.nil?
|
|
38
|
+
raise DependencyError,
|
|
39
|
+
"metadata.dependencies declared but no package store " \
|
|
40
|
+
"configured (set CAPSIUM_STORE or pass store:)"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@store = store.is_a?(Store) ? store : Store.new(store.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The newest store .cap satisfying the dependency. Raises
|
|
47
|
+
# CircularDependencyError when the GUID is already being resolved
|
|
48
|
+
# up-chain, DependencyNotFoundError when the store has no package
|
|
49
|
+
# for it and UnsatisfiableDependencyError when no stored version
|
|
50
|
+
# satisfies the range.
|
|
51
|
+
def resolve_path(guid, range, chain:)
|
|
52
|
+
if chain.include?(guid)
|
|
53
|
+
raise CircularDependencyError,
|
|
54
|
+
"circular dependency: #{(chain + [guid]).join(' -> ')}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@store.find(guid, range)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Package
|
|
7
|
+
# The "encryption" object of signature.json in an encrypted package
|
|
8
|
+
# (05x-packaging "Encryption"): the AES-256-GCM envelope with the
|
|
9
|
+
# RSA-OAEP-SHA256 wrapped data encryption key (DEK), all binary
|
|
10
|
+
# values Base64-encoded.
|
|
11
|
+
class EncryptionEnvelope < Lutaml::Model::Serializable
|
|
12
|
+
attribute :algorithm, :string
|
|
13
|
+
attribute :key_management, :string
|
|
14
|
+
attribute :encrypted_dek, :string
|
|
15
|
+
attribute :iv, :string
|
|
16
|
+
attribute :auth_tag, :string
|
|
17
|
+
|
|
18
|
+
json do
|
|
19
|
+
map :algorithm, to: :algorithm
|
|
20
|
+
map "keyManagement", to: :key_management
|
|
21
|
+
map "encryptedDek", to: :encrypted_dek
|
|
22
|
+
map :iv, to: :iv
|
|
23
|
+
map "authTag", to: :auth_tag
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The signature.json file of an encrypted package. In this phase it
|
|
28
|
+
# carries only the encryption envelope (digital signatures over
|
|
29
|
+
# encrypted packages are a later phase).
|
|
30
|
+
class EncryptionConfig < Lutaml::Model::Serializable
|
|
31
|
+
attribute :encryption, EncryptionEnvelope
|
|
32
|
+
|
|
33
|
+
json do
|
|
34
|
+
map :encryption, to: :encryption
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -1,51 +1,56 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "json"
|
|
3
|
+
require "forwardable"
|
|
5
4
|
require "marcel"
|
|
6
|
-
require "
|
|
7
|
-
require_relative "manifest_config"
|
|
5
|
+
require "pathname"
|
|
8
6
|
|
|
9
7
|
module Capsium
|
|
10
8
|
class Package
|
|
11
9
|
class Manifest
|
|
12
10
|
extend Forwardable
|
|
13
|
-
attr_accessor :path, :content_path, :config
|
|
14
11
|
|
|
15
|
-
|
|
12
|
+
attr_reader :path, :content_path, :config
|
|
13
|
+
|
|
14
|
+
def_delegators :@config, :to_hash
|
|
16
15
|
|
|
17
16
|
def initialize(path)
|
|
18
17
|
@path = path
|
|
19
18
|
@content_path = File.join(File.dirname(@path), Package::CONTENT_DIR)
|
|
20
|
-
|
|
21
19
|
@config = if File.exist?(path)
|
|
22
20
|
ManifestConfig.from_json(File.read(path))
|
|
23
21
|
else
|
|
24
|
-
ManifestConfig.new(
|
|
22
|
+
ManifestConfig.new(resources: generate_manifest)
|
|
25
23
|
end
|
|
26
24
|
end
|
|
27
25
|
|
|
26
|
+
# Auto-generation (ARCHITECTURE.md section 3): scan content/
|
|
27
|
+
# recursively, detect MIME types, default visibility "exported",
|
|
28
|
+
# deterministic (sorted) output.
|
|
28
29
|
def generate_manifest
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
content_files.sort.to_h do |file_path|
|
|
31
|
+
[relative_path(file_path),
|
|
32
|
+
Resource.new(type: mime_from_path(file_path), visibility: "exported")]
|
|
31
33
|
end
|
|
34
|
+
end
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
file: relative_path(file_path),
|
|
36
|
-
mime: mime_from_path(file_path),
|
|
37
|
-
)
|
|
38
|
-
end
|
|
36
|
+
def resources
|
|
37
|
+
@config.resources
|
|
39
38
|
end
|
|
40
39
|
|
|
41
|
-
def lookup(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
def lookup(path)
|
|
41
|
+
resources[path]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def type_for(path)
|
|
45
|
+
resource = lookup(path)
|
|
46
|
+
resource&.type
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_json(*_args)
|
|
50
|
+
@config.to_json
|
|
45
51
|
end
|
|
46
52
|
|
|
47
53
|
def save_to_file(output_path = @path)
|
|
48
|
-
@config.content.sort_by!(&:file)
|
|
49
54
|
File.write(output_path, to_json)
|
|
50
55
|
end
|
|
51
56
|
|
|
@@ -65,11 +70,17 @@ module Capsium
|
|
|
65
70
|
|
|
66
71
|
private
|
|
67
72
|
|
|
73
|
+
def content_files
|
|
74
|
+
Dir.glob(File.join(@content_path, "**", "*")).select do |file|
|
|
75
|
+
File.file?(file)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
68
79
|
def mime_from_path(path)
|
|
69
80
|
Marcel::MimeType.for(
|
|
70
81
|
Pathname.new(path),
|
|
71
82
|
name: File.basename(path),
|
|
72
|
-
extension: File.extname(path)
|
|
83
|
+
extension: File.extname(path)
|
|
73
84
|
)
|
|
74
85
|
end
|
|
75
86
|
end
|
|
@@ -1,17 +1,62 @@
|
|
|
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
|
-
|
|
7
|
-
|
|
8
|
+
# A single manifest resource entry (ARCHITECTURE.md section 3).
|
|
9
|
+
class Resource < Lutaml::Model::Serializable
|
|
10
|
+
VISIBILITIES = %w[exported private].freeze
|
|
11
|
+
|
|
12
|
+
attribute :type, :string
|
|
13
|
+
attribute :visibility, :string, values: VISIBILITIES, default: "exported"
|
|
14
|
+
attribute :version, :string
|
|
15
|
+
|
|
16
|
+
json do
|
|
17
|
+
map :type, to: :type
|
|
18
|
+
map :visibility, to: :visibility
|
|
19
|
+
map :version, to: :version
|
|
20
|
+
end
|
|
8
21
|
end
|
|
9
22
|
|
|
10
|
-
|
|
11
|
-
|
|
23
|
+
# Canonical manifest.json model: an object keyed by package-relative
|
|
24
|
+
# resource path. The legacy gem form ({"content": [{file, mime}]}) is
|
|
25
|
+
# accepted on read and normalized; writers emit only the object form.
|
|
26
|
+
class ManifestConfig < Lutaml::Model::Serializable
|
|
27
|
+
attribute :resources, :hash, default: {}
|
|
28
|
+
|
|
29
|
+
json do
|
|
30
|
+
map :resources, with: { from: :resources_from_json, to: :resources_to_json }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.from_json(json)
|
|
34
|
+
doc = JSON.parse(json)
|
|
35
|
+
doc["resources"] ||= legacy_resources(doc.delete("content")) if doc.key?("content")
|
|
36
|
+
super(JSON.generate(doc))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.legacy_resources(content)
|
|
40
|
+
(content || []).to_h do |item|
|
|
41
|
+
[item["file"], { "type" => item["mime"], "visibility" => "exported" }]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
private_class_method :legacy_resources
|
|
45
|
+
|
|
46
|
+
def resources_from_json(model, value)
|
|
47
|
+
model.resources = (value || {}).to_h do |path, attributes|
|
|
48
|
+
[path, Resource.from_json(JSON.generate(attributes))]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resources_to_json(model, doc)
|
|
53
|
+
doc["resources"] = model.resources.sort.to_h do |path, resource|
|
|
54
|
+
[path, JSON.parse(resource.to_json)]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
12
57
|
|
|
13
58
|
def sort!
|
|
14
|
-
|
|
59
|
+
self.resources = resources.sort.to_h
|
|
15
60
|
self
|
|
16
61
|
end
|
|
17
62
|
end
|