capsium 0.2.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +17 -1
- data/CHANGELOG.md +322 -0
- data/README.adoc +643 -7
- data/capsium.gemspec +3 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +106 -7
- data/lib/capsium/cli/reactor.rb +120 -6
- data/lib/capsium/cli.rb +30 -0
- data/lib/capsium/log_buffer.rb +47 -0
- data/lib/capsium/package/authentication.rb +38 -0
- data/lib/capsium/package/authentication_config.rb +78 -0
- data/lib/capsium/package/bundle.rb +136 -0
- data/lib/capsium/package/cipher.rb +253 -0
- data/lib/capsium/package/composition.rb +93 -0
- data/lib/capsium/package/dataset.rb +20 -0
- data/lib/capsium/package/dependency_resolver.rb +91 -0
- data/lib/capsium/package/encryption_config.rb +42 -0
- data/lib/capsium/package/manifest.rb +1 -1
- data/lib/capsium/package/merged_view.rb +170 -0
- data/lib/capsium/package/metadata.rb +1 -1
- data/lib/capsium/package/metadata_config.rb +9 -1
- data/lib/capsium/package/open_pgp.rb +65 -0
- data/lib/capsium/package/open_pgp_cipher.rb +66 -0
- data/lib/capsium/package/open_pgp_signer.rb +176 -0
- data/lib/capsium/package/preparation.rb +81 -0
- data/lib/capsium/package/routes_config.rb +60 -6
- data/lib/capsium/package/security.rb +21 -4
- data/lib/capsium/package/security_config.rb +6 -2
- data/lib/capsium/package/signer.rb +216 -0
- data/lib/capsium/package/storage_config.rb +29 -1
- data/lib/capsium/package/store.rb +136 -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 +39 -3
- data/lib/capsium/package/verification.rb +34 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +78 -75
- data/lib/capsium/packager.rb +97 -22
- data/lib/capsium/reactor/authenticator.rb +180 -0
- data/lib/capsium/reactor/content_api.rb +110 -0
- data/lib/capsium/reactor/data_api.rb +154 -0
- data/lib/capsium/reactor/deploy.rb +71 -0
- data/lib/capsium/reactor/endpoints.rb +37 -0
- data/lib/capsium/reactor/graphql_api.rb +123 -0
- data/lib/capsium/reactor/graphql_schema.rb +138 -0
- data/lib/capsium/reactor/htpasswd.rb +154 -0
- data/lib/capsium/reactor/introspection.rb +151 -31
- data/lib/capsium/reactor/metrics.rb +30 -0
- data/lib/capsium/reactor/mount.rb +206 -0
- data/lib/capsium/reactor/oauth2.rb +106 -0
- data/lib/capsium/reactor/overlay.rb +248 -0
- data/lib/capsium/reactor/package_saver.rb +99 -0
- data/lib/capsium/reactor/responses.rb +41 -0
- data/lib/capsium/reactor/serving.rb +154 -0
- data/lib/capsium/reactor/session.rb +82 -0
- data/lib/capsium/reactor.rb +116 -67
- data/lib/capsium/registry/local.rb +120 -0
- data/lib/capsium/registry/remote.rb +105 -0
- data/lib/capsium/registry.rb +190 -0
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +2 -0
- data/sig/capsium/log_buffer.rbs +30 -0
- data/sig/capsium/package/authentication.rbs +23 -0
- data/sig/capsium/package/authentication_config.rbs +71 -0
- data/sig/capsium/package/bundle.rbs +43 -0
- data/sig/capsium/package/cipher.rbs +62 -0
- data/sig/capsium/package/composition.rbs +21 -0
- data/sig/capsium/package/dataset.rbs +8 -0
- data/sig/capsium/package/dependency_resolver.rbs +53 -0
- data/sig/capsium/package/encryption_config.rbs +39 -0
- data/sig/capsium/package/merged_view.rbs +47 -0
- data/sig/capsium/package/metadata.rbs +1 -0
- data/sig/capsium/package/open_pgp.rbs +38 -0
- data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
- data/sig/capsium/package/open_pgp_signer.rbs +46 -0
- data/sig/capsium/package/preparation.rbs +23 -0
- data/sig/capsium/package/routes_config.rbs +36 -3
- data/sig/capsium/package/security.rbs +9 -2
- data/sig/capsium/package/security_config.rbs +6 -3
- data/sig/capsium/package/signer.rbs +66 -0
- data/sig/capsium/package/storage_config.rbs +26 -2
- data/sig/capsium/package/store.rbs +43 -0
- data/sig/capsium/package/testing/config_test.rbs +21 -0
- data/sig/capsium/package/testing/context.rbs +14 -0
- data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
- data/sig/capsium/package/testing/file_test.rbs +17 -0
- data/sig/capsium/package/testing/report.rbs +20 -0
- data/sig/capsium/package/testing/route_test.rbs +22 -0
- data/sig/capsium/package/testing/test_case.rbs +41 -0
- data/sig/capsium/package/testing/test_suite.rbs +20 -0
- data/sig/capsium/package/testing.rbs +7 -0
- data/sig/capsium/package/verification.rbs +26 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +29 -16
- data/sig/capsium/packager.rbs +9 -0
- data/sig/capsium/reactor/authenticator.rbs +40 -0
- data/sig/capsium/reactor/content_api.rbs +19 -0
- data/sig/capsium/reactor/data_api.rbs +23 -0
- data/sig/capsium/reactor/deploy.rbs +34 -0
- data/sig/capsium/reactor/endpoints.rbs +16 -0
- data/sig/capsium/reactor/graphql_api.rbs +27 -0
- data/sig/capsium/reactor/graphql_schema.rbs +20 -0
- data/sig/capsium/reactor/htpasswd.rbs +36 -0
- data/sig/capsium/reactor/introspection.rbs +27 -7
- data/sig/capsium/reactor/metrics.rbs +13 -0
- data/sig/capsium/reactor/mount.rbs +83 -0
- data/sig/capsium/reactor/oauth2.rbs +31 -0
- data/sig/capsium/reactor/overlay.rbs +72 -0
- data/sig/capsium/reactor/package_saver.rbs +12 -0
- data/sig/capsium/reactor/responses.rbs +23 -0
- data/sig/capsium/reactor/serving.rbs +46 -0
- data/sig/capsium/reactor/session.rbs +36 -0
- data/sig/capsium/reactor.rbs +22 -2
- data/sig/capsium/registry/local.rbs +16 -0
- data/sig/capsium/registry/remote.rbs +18 -0
- data/sig/capsium/registry.rbs +83 -0
- data/sig/graphql.rbs +12 -0
- metadata +135 -1
|
@@ -0,0 +1,253 @@
|
|
|
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). OpenPGP key management (an armored
|
|
34
|
+
# OpenPGP message carrying the DEK) is provided by the parallel
|
|
35
|
+
# OpenPgpCipher subclass; the OCB alternative mentioned by the
|
|
36
|
+
# standard remains out of scope.
|
|
37
|
+
class Cipher
|
|
38
|
+
ALGORITHM = "AES-256-GCM"
|
|
39
|
+
KEY_MANAGEMENT = "RSA-OAEP-SHA256"
|
|
40
|
+
ENCRYPTED_FILE = "package.enc"
|
|
41
|
+
ENVELOPE_FILE = "signature.json"
|
|
42
|
+
RSA_OPTIONS = { "rsa_padding_mode" => "oaep",
|
|
43
|
+
"rsa_oaep_md" => "SHA256", "rsa_mgf1_md" => "SHA256" }.freeze
|
|
44
|
+
|
|
45
|
+
# Structural problems: unreadable input, missing zip entries,
|
|
46
|
+
# unsupported envelope algorithms, unloadable keys.
|
|
47
|
+
class CipherError < Capsium::Error; end
|
|
48
|
+
|
|
49
|
+
# An encrypted package was opened without a private key.
|
|
50
|
+
class KeyRequiredError < CipherError; end
|
|
51
|
+
|
|
52
|
+
# Decryption failed: wrong private key or tampered ciphertext.
|
|
53
|
+
class DecryptionError < CipherError; end
|
|
54
|
+
|
|
55
|
+
# Whether the path (.cap file or uncompressed directory) is an
|
|
56
|
+
# encrypted package, i.e. contains package.enc.
|
|
57
|
+
def self.encrypted?(path)
|
|
58
|
+
return File.file?(File.join(path, ENCRYPTED_FILE)) if File.directory?(path)
|
|
59
|
+
return false unless File.file?(path)
|
|
60
|
+
|
|
61
|
+
Zip::File.open(path) { |zip| !zip.find_entry(ENCRYPTED_FILE).nil? }
|
|
62
|
+
rescue Zip::Error
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The keyManagement declared by the encryption envelope of the
|
|
67
|
+
# path (.cap file or uncompressed directory), or nil when the
|
|
68
|
+
# envelope is absent or unreadable.
|
|
69
|
+
def self.key_management(path)
|
|
70
|
+
source = envelope_source(path)
|
|
71
|
+
source && EncryptionConfig.from_json(source).encryption&.key_management
|
|
72
|
+
rescue Lutaml::Model::Error, JSON::ParserError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The cipher instance matching the envelope's keyManagement:
|
|
77
|
+
# OpenPgpCipher for OpenPGP envelopes, the RSA cipher otherwise.
|
|
78
|
+
def self.for_encrypted(path)
|
|
79
|
+
key_management(path) == OpenPgpCipher::KEY_MANAGEMENT ? OpenPgpCipher.new : new
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.envelope_source(path)
|
|
83
|
+
envelope = File.join(path, ENVELOPE_FILE)
|
|
84
|
+
return File.read(envelope) if File.directory?(path) && File.file?(envelope)
|
|
85
|
+
return nil if File.directory?(path)
|
|
86
|
+
|
|
87
|
+
Zip::File.open(path) { |zip| zip.find_entry(ENVELOPE_FILE)&.get_input_stream&.read }
|
|
88
|
+
rescue Errno::ENOENT, Zip::Error
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
private_class_method :envelope_source
|
|
92
|
+
|
|
93
|
+
# Encrypts the package at source_path (a .cap file, or a package
|
|
94
|
+
# directory which is packed first) for the recipient's RSA public
|
|
95
|
+
# key (or X.509 certificate) and writes the encrypted .cap to
|
|
96
|
+
# output_path.
|
|
97
|
+
def encrypt(source_path, public_key_path, output_path)
|
|
98
|
+
public_key = load_public_key(public_key_path)
|
|
99
|
+
with_cap_file(source_path) do |cap_path|
|
|
100
|
+
envelope, ciphertext = encrypt_bytes(File.binread(cap_path), public_key)
|
|
101
|
+
write_encrypted_cap(output_path, read_source(cap_path, Package::METADATA_FILE),
|
|
102
|
+
envelope, ciphertext)
|
|
103
|
+
end
|
|
104
|
+
output_path
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Decrypts the encrypted package at encrypted_path (.cap file or
|
|
108
|
+
# uncompressed directory) with the recipient's RSA private key and
|
|
109
|
+
# writes the plaintext .cap to output_path.
|
|
110
|
+
def decrypt(encrypted_path, private_key_path, output_path)
|
|
111
|
+
private_key = load_private_key(private_key_path)
|
|
112
|
+
envelope = load_envelope(encrypted_path)
|
|
113
|
+
ciphertext = read_source(encrypted_path, ENCRYPTED_FILE)
|
|
114
|
+
File.binwrite(output_path, decrypt_bytes(ciphertext, envelope, private_key))
|
|
115
|
+
output_path
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Decrypts an encrypted package into a fresh temporary directory
|
|
119
|
+
# and returns the directory path. The cipher is selected from the
|
|
120
|
+
# envelope's keyManagement (for_encrypted), so
|
|
121
|
+
# Package.new(decryption_key:) accepts either key format
|
|
122
|
+
# transparently.
|
|
123
|
+
def self.decrypt_to_directory(source_path, private_key_path)
|
|
124
|
+
Dir.mktmpdir.tap do |tmp|
|
|
125
|
+
inner_cap = File.join(tmp, "inner.cap")
|
|
126
|
+
for_encrypted(source_path).decrypt(source_path, private_key_path, inner_cap)
|
|
127
|
+
package_path = File.join(tmp, File.basename(source_path.to_s, ".cap"))
|
|
128
|
+
FileUtils.mkdir_p(package_path)
|
|
129
|
+
Packager.new.unpack(inner_cap, package_path)
|
|
130
|
+
FileUtils.rm_f(inner_cap)
|
|
131
|
+
return package_path
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def with_cap_file(source_path)
|
|
138
|
+
return yield source_path if File.file?(source_path) && File.extname(source_path) == ".cap"
|
|
139
|
+
raise CipherError, "cannot encrypt: #{source_path}" unless File.directory?(source_path)
|
|
140
|
+
|
|
141
|
+
Dir.mktmpdir do |dir|
|
|
142
|
+
cap_path = File.join(dir, "inner.cap")
|
|
143
|
+
Packager.new.compress_package(Capsium::Package.new(source_path), cap_path)
|
|
144
|
+
yield cap_path
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def encrypt_bytes(plaintext, public_key)
|
|
149
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM.downcase)
|
|
150
|
+
cipher.encrypt
|
|
151
|
+
dek = cipher.random_key
|
|
152
|
+
gcm_iv = cipher.random_iv
|
|
153
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
154
|
+
[envelope_for(dek, gcm_iv, cipher.auth_tag, public_key), ciphertext]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The encryption envelope carrying the DEK protected for the
|
|
158
|
+
# recipient (RSA-OAEP-SHA256 wrapped here; OpenPgpCipher overrides
|
|
159
|
+
# this with an OpenPGP message). The subclass seam for key
|
|
160
|
+
# management; ALGORITHM/iv/authTag stay as-is.
|
|
161
|
+
def envelope_for(dek, gcm_iv, auth_tag, public_key)
|
|
162
|
+
EncryptionConfig.new(
|
|
163
|
+
encryption: EncryptionEnvelope.new(
|
|
164
|
+
algorithm: ALGORITHM, key_management: KEY_MANAGEMENT,
|
|
165
|
+
encrypted_dek: Base64.strict_encode64(public_key.encrypt(dek, RSA_OPTIONS)),
|
|
166
|
+
iv: Base64.strict_encode64(gcm_iv), auth_tag: Base64.strict_encode64(auth_tag)
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def decrypt_bytes(ciphertext, envelope, private_key)
|
|
172
|
+
dek = unwrap_dek(envelope, private_key)
|
|
173
|
+
gcm_decrypt(ciphertext, dek, envelope)
|
|
174
|
+
rescue ArgumentError
|
|
175
|
+
raise CipherError, "invalid Base64 in the encryption envelope"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Recovers the DEK from the envelope (RSA-OAEP-SHA256 unwrap here;
|
|
179
|
+
# OpenPgpCipher overrides this with an OpenPGP decryption).
|
|
180
|
+
def unwrap_dek(envelope, private_key)
|
|
181
|
+
private_key.decrypt(Base64.strict_decode64(envelope.encrypted_dek), RSA_OPTIONS)
|
|
182
|
+
rescue OpenSSL::PKey::PKeyError
|
|
183
|
+
# DEK unwrap failure (wrong key or a malformed envelope).
|
|
184
|
+
raise DecryptionError, "decryption failed: wrong key or tampered package"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def gcm_decrypt(ciphertext, dek, envelope)
|
|
188
|
+
cipher = OpenSSL::Cipher.new(ALGORITHM.downcase)
|
|
189
|
+
cipher.decrypt
|
|
190
|
+
cipher.key = dek
|
|
191
|
+
cipher.iv = Base64.strict_decode64(envelope.iv)
|
|
192
|
+
cipher.auth_tag = Base64.strict_decode64(envelope.auth_tag)
|
|
193
|
+
cipher.update(ciphertext) + cipher.final
|
|
194
|
+
rescue OpenSSL::Cipher::CipherError
|
|
195
|
+
# GCM tag verification failure (wrong key or tampered
|
|
196
|
+
# ciphertext), or a malformed envelope iv/authTag.
|
|
197
|
+
raise DecryptionError, "decryption failed: wrong key or tampered package"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def write_encrypted_cap(output_path, metadata_json, envelope, ciphertext)
|
|
201
|
+
FileUtils.rm_f(output_path)
|
|
202
|
+
Zip::File.open(output_path, create: true) do |zip|
|
|
203
|
+
zip.get_output_stream(Package::METADATA_FILE) { |stream| stream.write(metadata_json) }
|
|
204
|
+
zip.get_output_stream(ENVELOPE_FILE) { |stream| stream.write(envelope.to_json) }
|
|
205
|
+
zip.get_output_stream(ENCRYPTED_FILE) { |stream| stream.write(ciphertext) }
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def load_envelope(encrypted_path)
|
|
210
|
+
envelope = EncryptionConfig.from_json(read_source(encrypted_path, ENVELOPE_FILE)).encryption
|
|
211
|
+
return envelope if envelope && supported_envelope?(envelope)
|
|
212
|
+
|
|
213
|
+
raise CipherError, "missing or unsupported encryption envelope in: #{encrypted_path}"
|
|
214
|
+
rescue Lutaml::Model::Error, JSON::ParserError => e
|
|
215
|
+
raise CipherError, "invalid encryption envelope in #{encrypted_path}: #{e.message}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def supported_envelope?(envelope)
|
|
219
|
+
# self.class:: so subclasses (OpenPgpCipher) match their own
|
|
220
|
+
# KEY_MANAGEMENT despite Ruby's lexical constant lookup.
|
|
221
|
+
[envelope.algorithm, envelope.key_management] == [ALGORITHM, self.class::KEY_MANAGEMENT]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def read_source(path, entry_name)
|
|
225
|
+
return File.binread(File.join(path, entry_name)) if File.directory?(path)
|
|
226
|
+
|
|
227
|
+
Zip::File.open(path) do |zip|
|
|
228
|
+
entry = zip.find_entry(entry_name)
|
|
229
|
+
raise CipherError, "entry missing in #{path}: #{entry_name}" unless entry
|
|
230
|
+
|
|
231
|
+
return entry.get_input_stream.read
|
|
232
|
+
end
|
|
233
|
+
rescue Errno::ENOENT, Zip::Error => e
|
|
234
|
+
raise CipherError, "cannot read #{entry_name} in #{path}: #{e.message}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def load_public_key(public_key_path)
|
|
238
|
+
pem = File.read(public_key_path)
|
|
239
|
+
OpenSSL::PKey::RSA.new(pem)
|
|
240
|
+
rescue OpenSSL::PKey::PKeyError
|
|
241
|
+
OpenSSL::X509::Certificate.new(pem).public_key
|
|
242
|
+
rescue OpenSSL::X509::CertificateError, Errno::ENOENT
|
|
243
|
+
raise CipherError, "cannot load public key or certificate: #{public_key_path}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def load_private_key(private_key_path)
|
|
247
|
+
OpenSSL::PKey::RSA.new(File.read(private_key_path))
|
|
248
|
+
rescue OpenSSL::PKey::PKeyError, Errno::ENOENT
|
|
249
|
+
raise CipherError, "cannot load private key: #{private_key_path}"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
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 — bundled dependencies
|
|
7
|
+
# (Capsium::Package::Bundle) first, then the package store — and
|
|
8
|
+
# load-time validation of dependency resource references.
|
|
9
|
+
module Composition
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Resolves metadata.dependencies: bundled dependencies first
|
|
13
|
+
# (Capsium::Package::Bundle — encapsulated packages embed their
|
|
14
|
+
# dependencies under packages/, so they activate with no store or
|
|
15
|
+
# registry), then the store (the `store` argument, else
|
|
16
|
+
# CAPSIUM_STORE) with the given `registry` (else CAPSIUM_REGISTRY)
|
|
17
|
+
# as install fallback for store misses. The store resolver is only
|
|
18
|
+
# constructed when a dependency is not bundled, so a fully
|
|
19
|
+
# bundled package needs neither. Each resolved dependency loads
|
|
20
|
+
# recursively with an extended circularity chain and this
|
|
21
|
+
# package's bundle underneath its own (re-declared transitive
|
|
22
|
+
# dependencies resolve from it); its exported content becomes
|
|
23
|
+
# lower read-only layers of this package's merged view.
|
|
24
|
+
def resolve_dependencies(store, registry, chain, bundle = nil)
|
|
25
|
+
declared = @metadata.dependencies
|
|
26
|
+
return [] if declared.empty?
|
|
27
|
+
|
|
28
|
+
chain += [@metadata.guid]
|
|
29
|
+
bundle = Bundle.new(@path).merged_with(bundle)
|
|
30
|
+
resolver = nil
|
|
31
|
+
declared.map do |guid, range|
|
|
32
|
+
cap_path = bundle.resolve_path(guid, range, chain: chain)
|
|
33
|
+
if cap_path.nil?
|
|
34
|
+
resolver ||= DependencyResolver.new(store || Store.default, registry: registry)
|
|
35
|
+
cap_path = resolver.resolve_path(guid, range, chain: chain)
|
|
36
|
+
end
|
|
37
|
+
ResolvedDependency.new(
|
|
38
|
+
guid: guid, range: range, path: cap_path,
|
|
39
|
+
package: Package.new(cap_path,
|
|
40
|
+
store: resolver ? resolver.store : store,
|
|
41
|
+
registry: resolver ? resolver.registry : registry,
|
|
42
|
+
dependency_chain: chain,
|
|
43
|
+
bundle: bundle)
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Every route resource given as a URI ("<dependency-guid>/<path>")
|
|
49
|
+
# must address a resolved dependency and a resource that dependency
|
|
50
|
+
# exports; anything else is a load-time error (section 4a).
|
|
51
|
+
def validate_dependency_references!
|
|
52
|
+
@routes.config.routes.each do |route|
|
|
53
|
+
reference = route.resource
|
|
54
|
+
next unless route.dependency_reference?
|
|
55
|
+
|
|
56
|
+
dependency = dependency_for(reference)
|
|
57
|
+
unless dependency
|
|
58
|
+
raise DependencyError,
|
|
59
|
+
"route #{route.path} references unknown dependency: #{reference}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
validate_dependency_reference(route, dependency, reference)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_dependency_reference(route, dependency, reference)
|
|
67
|
+
inner = reference.delete_prefix("#{dependency.guid}/")
|
|
68
|
+
return if dependency.package.merged_view(exported_only: true).resolve(inner)
|
|
69
|
+
|
|
70
|
+
if dependency.package.merged_view.resolve(inner)
|
|
71
|
+
raise DependencyVisibilityError,
|
|
72
|
+
"route #{route.path} references a private resource of " \
|
|
73
|
+
"dependency #{dependency.guid}: #{inner}"
|
|
74
|
+
end
|
|
75
|
+
raise DependencyError,
|
|
76
|
+
"route #{route.path} references a resource missing from " \
|
|
77
|
+
"dependency #{dependency.guid}: #{inner}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def dependency_for(reference)
|
|
81
|
+
@resolved_dependencies
|
|
82
|
+
.sort_by { |dependency| -dependency.guid.length }
|
|
83
|
+
.find { |dependency| reference.start_with?("#{dependency.guid}/") }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def dependency_views
|
|
87
|
+
@resolved_dependencies.map do |dependency|
|
|
88
|
+
[dependency.guid, dependency.package.merged_view(exported_only: true)]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -67,6 +67,26 @@ module Capsium
|
|
|
67
67
|
problems
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# The parsed JSON schema for this dataset, or nil when none is
|
|
71
|
+
# declared or the file is unreadable.
|
|
72
|
+
def json_schema
|
|
73
|
+
return nil unless @config.schema_file && @config.schema_type == "json-schema"
|
|
74
|
+
return nil unless File.file?(schema_path)
|
|
75
|
+
|
|
76
|
+
load_schema
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Schema validation of an arbitrary candidate document (used by
|
|
80
|
+
# the reactor's writable overlay before persisting a mutation):
|
|
81
|
+
# human-readable schema problems, empty when valid or when the
|
|
82
|
+
# dataset has no JSON schema.
|
|
83
|
+
def schema_errors_for(data)
|
|
84
|
+
return [] unless @config.schema_file && @config.schema_type == "json-schema"
|
|
85
|
+
return [] unless File.file?(schema_path)
|
|
86
|
+
|
|
87
|
+
JSON::Validator.fully_validate(load_schema, JSON.parse(JSON.generate(data)))
|
|
88
|
+
end
|
|
89
|
+
|
|
70
90
|
private
|
|
71
91
|
|
|
72
92
|
def schema_validation_errors
|
|
@@ -0,0 +1,91 @@
|
|
|
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, with a registry
|
|
32
|
+
# (Capsium::Registry) as install fallback for store misses.
|
|
33
|
+
# Package loads the resolved .cap itself (recursively, with an
|
|
34
|
+
# extended chain).
|
|
35
|
+
class DependencyResolver
|
|
36
|
+
attr_reader :store, :registry
|
|
37
|
+
|
|
38
|
+
def initialize(store, registry: nil)
|
|
39
|
+
if store.nil?
|
|
40
|
+
raise DependencyError,
|
|
41
|
+
"metadata.dependencies declared but no package store " \
|
|
42
|
+
"configured (set CAPSIUM_STORE or pass store:)"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@store = store.is_a?(Store) ? store : Store.new(store.to_s)
|
|
46
|
+
@registry = if registry.nil? || registry.is_a?(Capsium::Registry)
|
|
47
|
+
registry
|
|
48
|
+
else
|
|
49
|
+
Capsium::Registry.fetch(registry)
|
|
50
|
+
end
|
|
51
|
+
@registry ||= Capsium::Registry.default
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The newest store .cap satisfying the dependency. Falls back to
|
|
55
|
+
# installing from the configured registry when the store has no
|
|
56
|
+
# package for the GUID. Raises CircularDependencyError when the
|
|
57
|
+
# GUID is already being resolved up-chain,
|
|
58
|
+
# DependencyNotFoundError when neither the store nor a registry
|
|
59
|
+
# provides it and UnsatisfiableDependencyError when no available
|
|
60
|
+
# version satisfies the range.
|
|
61
|
+
def resolve_path(guid, range, chain:)
|
|
62
|
+
if chain.include?(guid)
|
|
63
|
+
raise CircularDependencyError,
|
|
64
|
+
"circular dependency: #{(chain + [guid]).join(' -> ')}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@store.find(guid, range)
|
|
68
|
+
rescue DependencyNotFoundError => e
|
|
69
|
+
resolve_from_registry(guid, range, e)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Registry fallback for store misses (fallback chain: store ->
|
|
75
|
+
# registry -> typed error). Installs the newest satisfying version
|
|
76
|
+
# into the store and returns its path; re-raises the store error
|
|
77
|
+
# when no registry is configured.
|
|
78
|
+
def resolve_from_registry(guid, range, store_error)
|
|
79
|
+
raise store_error if @registry.nil?
|
|
80
|
+
|
|
81
|
+
@registry.install(guid, range, store: @store)
|
|
82
|
+
rescue Registry::PackageNotFoundError
|
|
83
|
+
raise DependencyNotFoundError,
|
|
84
|
+
"#{store_error.message}; no package #{guid} in registry " \
|
|
85
|
+
"#{@registry.location} either"
|
|
86
|
+
rescue Registry::UnsatisfiableConstraintError => e
|
|
87
|
+
raise UnsatisfiableDependencyError, e.message
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
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, all binary
|
|
9
|
+
# values Base64-encoded. keyManagement selects how the data
|
|
10
|
+
# encryption key (DEK) is protected: "RSA-OAEP-SHA256" carries it in
|
|
11
|
+
# encryptedDek (RSA-wrapped); "OpenPGP" carries an armored OpenPGP
|
|
12
|
+
# message containing the DEK in message.
|
|
13
|
+
class EncryptionEnvelope < Lutaml::Model::Serializable
|
|
14
|
+
attribute :algorithm, :string
|
|
15
|
+
attribute :key_management, :string
|
|
16
|
+
attribute :encrypted_dek, :string
|
|
17
|
+
attribute :message, :string
|
|
18
|
+
attribute :iv, :string
|
|
19
|
+
attribute :auth_tag, :string
|
|
20
|
+
|
|
21
|
+
json do
|
|
22
|
+
map :algorithm, to: :algorithm
|
|
23
|
+
map "keyManagement", to: :key_management
|
|
24
|
+
map "encryptedDek", to: :encrypted_dek
|
|
25
|
+
map :message, to: :message
|
|
26
|
+
map :iv, to: :iv
|
|
27
|
+
map "authTag", to: :auth_tag
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The signature.json file of an encrypted package. In this phase it
|
|
32
|
+
# carries only the encryption envelope (digital signatures over
|
|
33
|
+
# encrypted packages are a later phase).
|
|
34
|
+
class EncryptionConfig < Lutaml::Model::Serializable
|
|
35
|
+
attribute :encryption, EncryptionEnvelope
|
|
36
|
+
|
|
37
|
+
json do
|
|
38
|
+
map :encryption, to: :encryption
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Package
|
|
7
|
+
# The merged (overlay) view of a package's content, per ARCHITECTURE.md
|
|
8
|
+
# section 5a. Shared by Package (validation) and Reactor (serving).
|
|
9
|
+
#
|
|
10
|
+
# The content/ tree is always the implicit bottom layer; layers from
|
|
11
|
+
# storage.layers stack on top of it, bottom -> top in declaration
|
|
12
|
+
# order. Resolution scans from the TOP down and the first hit wins.
|
|
13
|
+
# Deletions are recorded as tombstones: a `.capsium-tombstones` JSON
|
|
14
|
+
# file in a writable layer listing content/-relative paths; a
|
|
15
|
+
# tombstoned path resolves to nil even when a lower layer (including a
|
|
16
|
+
# dependency's content) holds the file, while a file reappearing in a
|
|
17
|
+
# layer ABOVE the tombstone is served again.
|
|
18
|
+
#
|
|
19
|
+
# Composite packages (section 4a): `dependency_views` are
|
|
20
|
+
# [dependency GUID, MergedView] pairs acting as read-only layers below
|
|
21
|
+
# ALL own layers. A resource reference that is a URI of the form
|
|
22
|
+
# "<dependency-guid>/<path>" resolves explicitly against that
|
|
23
|
+
# dependency's view; plain content paths fall through to the
|
|
24
|
+
# dependency layers in declaration order after all own layers miss.
|
|
25
|
+
#
|
|
26
|
+
# With `exported_only: true` (the view a dependent package gets):
|
|
27
|
+
# layers whose visibility is "private" are hidden entirely, and a path
|
|
28
|
+
# resolves only when the manifest lists the resource as "exported".
|
|
29
|
+
# `extra_layers` stack on top of ALL own layers (e.g. the reactor's
|
|
30
|
+
# writable overlay, section 5a).
|
|
31
|
+
class MergedView
|
|
32
|
+
# A single read layer: an absolute root directory mirroring the
|
|
33
|
+
# content/ tree, plus its parsed tombstone set.
|
|
34
|
+
Layer = Data.define(:root, :visibility, :tombstones) do
|
|
35
|
+
def file?(content_relative_path)
|
|
36
|
+
File.file?(absolute(content_relative_path))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def absolute(content_relative_path)
|
|
40
|
+
File.join(root, content_relative_path)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
TOMBSTONE_FILE = ".capsium-tombstones"
|
|
45
|
+
CONTENT_PREFIX = "#{Package::CONTENT_DIR}/".freeze
|
|
46
|
+
|
|
47
|
+
attr_reader :package_path, :layers, :dependency_views
|
|
48
|
+
|
|
49
|
+
def initialize(package_path, storage:, manifest:, dependency_views: [],
|
|
50
|
+
exported_only: false, extra_layers: [])
|
|
51
|
+
@package_path = package_path
|
|
52
|
+
@storage = storage
|
|
53
|
+
@manifest = manifest
|
|
54
|
+
@exported_only = exported_only
|
|
55
|
+
@dependency_views = dependency_views
|
|
56
|
+
@layers = build_layers + extra_layers
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The absolute filesystem path serving a resource reference: a
|
|
60
|
+
# package-relative content path ("content/app.js"), or a dependency
|
|
61
|
+
# reference ("<dependency-guid>/content/app.js"). nil when no layer
|
|
62
|
+
# provides the path or it is tombstoned. Non-content paths never
|
|
63
|
+
# resolve through the view.
|
|
64
|
+
def resolve(reference)
|
|
65
|
+
return unless reference.is_a?(String)
|
|
66
|
+
|
|
67
|
+
if reference.include?("://")
|
|
68
|
+
pair = dependency_pair_for(reference)
|
|
69
|
+
return unless pair
|
|
70
|
+
|
|
71
|
+
return pair[1].resolve(reference.delete_prefix("#{pair[0]}/"))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
content_relative = content_relative(reference)
|
|
75
|
+
return unless content_relative
|
|
76
|
+
return if content_relative == TOMBSTONE_FILE
|
|
77
|
+
|
|
78
|
+
result = resolve_own(content_relative)
|
|
79
|
+
return if result == :tombstoned
|
|
80
|
+
return result if result
|
|
81
|
+
|
|
82
|
+
resolve_dependencies(reference)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The [GUID, view] pair whose GUID prefixes the reference (longest
|
|
86
|
+
# GUID first, so nested GUIDs address the innermost dependency).
|
|
87
|
+
def dependency_pair_for(reference)
|
|
88
|
+
@dependency_views
|
|
89
|
+
.sort_by { |guid, _view| -guid.length }
|
|
90
|
+
.find { |guid, _view| reference.start_with?("#{guid}/") }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# The serving path from the package's own layers, :tombstoned when
|
|
96
|
+
# a tombstone at or above the first hit suppresses it (tombstones
|
|
97
|
+
# also suppress the lower dependency layers), or nil when no own
|
|
98
|
+
# layer provides the path.
|
|
99
|
+
def resolve_own(content_relative)
|
|
100
|
+
tombstoned = false
|
|
101
|
+
@layers.reverse_each do |layer|
|
|
102
|
+
tombstoned ||= layer.tombstones.include?(content_relative)
|
|
103
|
+
next unless layer.file?(content_relative)
|
|
104
|
+
return :tombstoned if tombstoned
|
|
105
|
+
|
|
106
|
+
return layer.absolute(content_relative)
|
|
107
|
+
end
|
|
108
|
+
tombstoned ? :tombstoned : nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resolve_dependencies(relative_path)
|
|
112
|
+
@dependency_views.each do |pair|
|
|
113
|
+
found = pair.last.resolve(relative_path)
|
|
114
|
+
return found if found
|
|
115
|
+
end
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Own layers, bottom -> top: the implicit content/ layer plus the
|
|
120
|
+
# configured storage.layers. Private layers are dropped when the view
|
|
121
|
+
# is exported-only (hidden from dependents, section 5a).
|
|
122
|
+
def build_layers
|
|
123
|
+
configs = [nil] + @storage.config.layers
|
|
124
|
+
configs = configs.reject { |config| config&.private? } if @exported_only
|
|
125
|
+
configs.filter_map { |config| build_layer(config) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_layer(config)
|
|
129
|
+
root = config ? File.join(@package_path, config.path) : content_root
|
|
130
|
+
Layer.new(root: root,
|
|
131
|
+
visibility: config&.visibility || "exported",
|
|
132
|
+
tombstones: load_tombstones(root))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def content_root
|
|
136
|
+
File.join(@package_path, Package::CONTENT_DIR)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def load_tombstones(root)
|
|
140
|
+
tombstone_path = File.join(root, TOMBSTONE_FILE)
|
|
141
|
+
return Set.new unless File.file?(tombstone_path)
|
|
142
|
+
|
|
143
|
+
entries = JSON.parse(File.read(tombstone_path))
|
|
144
|
+
unless entries.is_a?(Array) && entries.all?(String)
|
|
145
|
+
raise Error, "Malformed #{TOMBSTONE_FILE} (expected a JSON array " \
|
|
146
|
+
"of paths): #{tombstone_path}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
Set.new(entries)
|
|
150
|
+
rescue JSON::ParserError => e
|
|
151
|
+
raise Error, "Malformed #{TOMBSTONE_FILE}: #{tombstone_path}: #{e.message}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def content_relative(relative_path)
|
|
155
|
+
return unless relative_path.is_a?(String)
|
|
156
|
+
return unless relative_path.start_with?(CONTENT_PREFIX)
|
|
157
|
+
|
|
158
|
+
content_relative = relative_path.delete_prefix(CONTENT_PREFIX)
|
|
159
|
+
return if @exported_only && !exported?(relative_path)
|
|
160
|
+
|
|
161
|
+
content_relative
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def exported?(relative_path)
|
|
165
|
+
resource = @manifest.lookup(relative_path)
|
|
166
|
+
resource&.visibility == "exported"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|