capsium 0.2.0 → 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/.rubocop.yml +12 -0
- data/CHANGELOG.md +125 -0
- data/README.adoc +332 -5
- data/capsium.gemspec +1 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +68 -6
- data/lib/capsium/cli/reactor.rb +9 -1
- data/lib/capsium/cli.rb +1 -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/dependency_resolver.rb +61 -0
- data/lib/capsium/package/encryption_config.rb +38 -0
- data/lib/capsium/package/merged_view.rb +168 -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/signer.rb +201 -0
- data/lib/capsium/package/storage_config.rb +29 -1
- 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 +14 -3
- data/lib/capsium/package/verification.rb +32 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +59 -75
- data/lib/capsium/packager.rb +34 -1
- 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 +4 -1
- 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 +43 -41
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- 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/dependency_resolver.rbs +49 -0
- data/sig/capsium/package/encryption_config.rbs +35 -0
- data/sig/capsium/package/merged_view.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/signer.rbs +59 -0
- data/sig/capsium/package/storage_config.rbs +26 -2
- 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/verification.rbs +24 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +25 -17
- data/sig/capsium/packager.rbs +9 -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/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 +6 -1
- metadata +70 -1
data/lib/capsium/cli/reactor.rb
CHANGED
|
@@ -9,12 +9,20 @@ module Capsium
|
|
|
9
9
|
"Start the Capsium reactor to serve the package"
|
|
10
10
|
option :port, type: :numeric, default: Capsium::Reactor::DEFAULT_PORT
|
|
11
11
|
option :do_not_listen, type: :boolean, default: false
|
|
12
|
+
option :store, type: :string,
|
|
13
|
+
desc: "Package store directory for dependency " \
|
|
14
|
+
"resolution (default: CAPSIUM_STORE)"
|
|
15
|
+
option :deploy, type: :string,
|
|
16
|
+
desc: "deploy.json with reactor-side secrets " \
|
|
17
|
+
"(default: CAPSIUM_DEPLOY)"
|
|
12
18
|
|
|
13
19
|
def serve(path_to_package)
|
|
14
20
|
reactor = Capsium::Reactor.new(
|
|
15
21
|
package: path_to_package,
|
|
16
22
|
port: options[:port],
|
|
17
|
-
do_not_listen: options[:do_not_listen]
|
|
23
|
+
do_not_listen: options[:do_not_listen],
|
|
24
|
+
store: options[:store],
|
|
25
|
+
deploy: options[:deploy]
|
|
18
26
|
)
|
|
19
27
|
reactor.serve
|
|
20
28
|
ensure
|
data/lib/capsium/cli.rb
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Package
|
|
7
|
+
# Loads a package's authentication.json (ARCHITECTURE.md section 4b).
|
|
8
|
+
class Authentication
|
|
9
|
+
extend Forwardable
|
|
10
|
+
|
|
11
|
+
attr_reader :path, :config
|
|
12
|
+
|
|
13
|
+
def_delegators :@config, :to_json, :to_hash
|
|
14
|
+
|
|
15
|
+
def initialize(path)
|
|
16
|
+
@path = path
|
|
17
|
+
@config = File.exist?(path) ? AuthenticationConfig.from_json(File.read(path)) : nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def present?
|
|
21
|
+
!@config.nil?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def basic_auth
|
|
25
|
+
@config&.authentication&.basic_auth
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def oauth2
|
|
29
|
+
@config&.authentication&.oauth2
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Whether any authentication method is enabled.
|
|
33
|
+
def enabled?
|
|
34
|
+
!!(basic_auth&.enabled? || oauth2&.enabled?)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "lutaml/model"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Package
|
|
8
|
+
# The "basicAuth" object of authentication.json (ARCHITECTURE.md
|
|
9
|
+
# section 4b): Apache-style Basic authentication verified against an
|
|
10
|
+
# htpasswd file. "passwdFile" is a package-relative path (password
|
|
11
|
+
# hashes, not plaintext secrets, so it may ship in the package);
|
|
12
|
+
# deploy.json may override it with a reactor-side file.
|
|
13
|
+
class BasicAuthConfig < Lutaml::Model::Serializable
|
|
14
|
+
attribute :enabled, :boolean, default: false
|
|
15
|
+
attribute :passwd_file, :string
|
|
16
|
+
attribute :realm, :string, default: "capsium"
|
|
17
|
+
|
|
18
|
+
json do
|
|
19
|
+
map :enabled, to: :enabled
|
|
20
|
+
map "passwdFile", to: :passwd_file
|
|
21
|
+
map :realm, to: :realm
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def enabled?
|
|
25
|
+
!!enabled
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The "oauth2" object of authentication.json: an OAuth2
|
|
30
|
+
# authorization-code flow. The client secret is NEVER read from the
|
|
31
|
+
# package — it comes from deploy.json or reactor configuration.
|
|
32
|
+
class OAuth2Config < Lutaml::Model::Serializable
|
|
33
|
+
attribute :enabled, :boolean, default: false
|
|
34
|
+
attribute :provider, :string
|
|
35
|
+
attribute :client_id, :string
|
|
36
|
+
attribute :authorization_url, :string
|
|
37
|
+
attribute :token_url, :string
|
|
38
|
+
attribute :userinfo_url, :string
|
|
39
|
+
attribute :redirect_path, :string, default: "/auth/callback"
|
|
40
|
+
attribute :scopes, :string, collection: true, default: []
|
|
41
|
+
|
|
42
|
+
json do
|
|
43
|
+
map :enabled, to: :enabled
|
|
44
|
+
map :provider, to: :provider
|
|
45
|
+
map "clientId", to: :client_id
|
|
46
|
+
map "authorizationUrl", to: :authorization_url
|
|
47
|
+
map "tokenUrl", to: :token_url
|
|
48
|
+
map "userinfoUrl", to: :userinfo_url
|
|
49
|
+
map "redirectPath", to: :redirect_path
|
|
50
|
+
map :scopes, to: :scopes
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def enabled?
|
|
54
|
+
!!enabled
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The "authentication" object of authentication.json.
|
|
59
|
+
class AuthenticationData < Lutaml::Model::Serializable
|
|
60
|
+
attribute :basic_auth, BasicAuthConfig
|
|
61
|
+
attribute :oauth2, OAuth2Config
|
|
62
|
+
|
|
63
|
+
json do
|
|
64
|
+
map "basicAuth", to: :basic_auth
|
|
65
|
+
map :oauth2, to: :oauth2
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Canonical authentication.json model (ARCHITECTURE.md section 4b).
|
|
70
|
+
class AuthenticationConfig < Lutaml::Model::Serializable
|
|
71
|
+
attribute :authentication, AuthenticationData
|
|
72
|
+
|
|
73
|
+
json do
|
|
74
|
+
map :authentication, to: :authentication
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
class MergedView
|
|
30
|
+
# A single read layer: an absolute root directory mirroring the
|
|
31
|
+
# content/ tree, plus its parsed tombstone set.
|
|
32
|
+
Layer = Data.define(:root, :visibility, :tombstones) do
|
|
33
|
+
def file?(content_relative_path)
|
|
34
|
+
File.file?(absolute(content_relative_path))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def absolute(content_relative_path)
|
|
38
|
+
File.join(root, content_relative_path)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
TOMBSTONE_FILE = ".capsium-tombstones"
|
|
43
|
+
CONTENT_PREFIX = "#{Package::CONTENT_DIR}/".freeze
|
|
44
|
+
|
|
45
|
+
attr_reader :package_path, :layers, :dependency_views
|
|
46
|
+
|
|
47
|
+
def initialize(package_path, storage:, manifest:, dependency_views: [],
|
|
48
|
+
exported_only: false)
|
|
49
|
+
@package_path = package_path
|
|
50
|
+
@storage = storage
|
|
51
|
+
@manifest = manifest
|
|
52
|
+
@exported_only = exported_only
|
|
53
|
+
@dependency_views = dependency_views
|
|
54
|
+
@layers = build_layers
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The absolute filesystem path serving a resource reference: a
|
|
58
|
+
# package-relative content path ("content/app.js"), or a dependency
|
|
59
|
+
# reference ("<dependency-guid>/content/app.js"). nil when no layer
|
|
60
|
+
# provides the path or it is tombstoned. Non-content paths never
|
|
61
|
+
# resolve through the view.
|
|
62
|
+
def resolve(reference)
|
|
63
|
+
return unless reference.is_a?(String)
|
|
64
|
+
|
|
65
|
+
if reference.include?("://")
|
|
66
|
+
pair = dependency_pair_for(reference)
|
|
67
|
+
return unless pair
|
|
68
|
+
|
|
69
|
+
return pair[1].resolve(reference.delete_prefix("#{pair[0]}/"))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
content_relative = content_relative(reference)
|
|
73
|
+
return unless content_relative
|
|
74
|
+
return if content_relative == TOMBSTONE_FILE
|
|
75
|
+
|
|
76
|
+
result = resolve_own(content_relative)
|
|
77
|
+
return if result == :tombstoned
|
|
78
|
+
return result if result
|
|
79
|
+
|
|
80
|
+
resolve_dependencies(reference)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The [GUID, view] pair whose GUID prefixes the reference (longest
|
|
84
|
+
# GUID first, so nested GUIDs address the innermost dependency).
|
|
85
|
+
def dependency_pair_for(reference)
|
|
86
|
+
@dependency_views
|
|
87
|
+
.sort_by { |guid, _view| -guid.length }
|
|
88
|
+
.find { |guid, _view| reference.start_with?("#{guid}/") }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# The serving path from the package's own layers, :tombstoned when
|
|
94
|
+
# a tombstone at or above the first hit suppresses it (tombstones
|
|
95
|
+
# also suppress the lower dependency layers), or nil when no own
|
|
96
|
+
# layer provides the path.
|
|
97
|
+
def resolve_own(content_relative)
|
|
98
|
+
tombstoned = false
|
|
99
|
+
@layers.reverse_each do |layer|
|
|
100
|
+
tombstoned ||= layer.tombstones.include?(content_relative)
|
|
101
|
+
next unless layer.file?(content_relative)
|
|
102
|
+
return :tombstoned if tombstoned
|
|
103
|
+
|
|
104
|
+
return layer.absolute(content_relative)
|
|
105
|
+
end
|
|
106
|
+
tombstoned ? :tombstoned : nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def resolve_dependencies(relative_path)
|
|
110
|
+
@dependency_views.each do |pair|
|
|
111
|
+
found = pair.last.resolve(relative_path)
|
|
112
|
+
return found if found
|
|
113
|
+
end
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Own layers, bottom -> top: the implicit content/ layer plus the
|
|
118
|
+
# configured storage.layers. Private layers are dropped when the view
|
|
119
|
+
# is exported-only (hidden from dependents, section 5a).
|
|
120
|
+
def build_layers
|
|
121
|
+
configs = [nil] + @storage.config.layers
|
|
122
|
+
configs = configs.reject { |config| config&.private? } if @exported_only
|
|
123
|
+
configs.filter_map { |config| build_layer(config) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_layer(config)
|
|
127
|
+
root = config ? File.join(@package_path, config.path) : content_root
|
|
128
|
+
Layer.new(root: root,
|
|
129
|
+
visibility: config&.visibility || "exported",
|
|
130
|
+
tombstones: load_tombstones(root))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def content_root
|
|
134
|
+
File.join(@package_path, Package::CONTENT_DIR)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def load_tombstones(root)
|
|
138
|
+
tombstone_path = File.join(root, TOMBSTONE_FILE)
|
|
139
|
+
return Set.new unless File.file?(tombstone_path)
|
|
140
|
+
|
|
141
|
+
entries = JSON.parse(File.read(tombstone_path))
|
|
142
|
+
unless entries.is_a?(Array) && entries.all?(String)
|
|
143
|
+
raise Error, "Malformed #{TOMBSTONE_FILE} (expected a JSON array " \
|
|
144
|
+
"of paths): #{tombstone_path}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
Set.new(entries)
|
|
148
|
+
rescue JSON::ParserError => e
|
|
149
|
+
raise Error, "Malformed #{TOMBSTONE_FILE}: #{tombstone_path}: #{e.message}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def content_relative(relative_path)
|
|
153
|
+
return unless relative_path.is_a?(String)
|
|
154
|
+
return unless relative_path.start_with?(CONTENT_PREFIX)
|
|
155
|
+
|
|
156
|
+
content_relative = relative_path.delete_prefix(CONTENT_PREFIX)
|
|
157
|
+
return if @exported_only && !exported?(relative_path)
|
|
158
|
+
|
|
159
|
+
content_relative
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def exported?(relative_path)
|
|
163
|
+
resource = @manifest.lookup(relative_path)
|
|
164
|
+
resource&.visibility == "exported"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|