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.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +17 -1
  3. data/CHANGELOG.md +322 -0
  4. data/README.adoc +643 -7
  5. data/capsium.gemspec +3 -0
  6. data/lib/capsium/cli/formatting.rb +27 -0
  7. data/lib/capsium/cli/package.rb +106 -7
  8. data/lib/capsium/cli/reactor.rb +120 -6
  9. data/lib/capsium/cli.rb +30 -0
  10. data/lib/capsium/log_buffer.rb +47 -0
  11. data/lib/capsium/package/authentication.rb +38 -0
  12. data/lib/capsium/package/authentication_config.rb +78 -0
  13. data/lib/capsium/package/bundle.rb +136 -0
  14. data/lib/capsium/package/cipher.rb +253 -0
  15. data/lib/capsium/package/composition.rb +93 -0
  16. data/lib/capsium/package/dataset.rb +20 -0
  17. data/lib/capsium/package/dependency_resolver.rb +91 -0
  18. data/lib/capsium/package/encryption_config.rb +42 -0
  19. data/lib/capsium/package/manifest.rb +1 -1
  20. data/lib/capsium/package/merged_view.rb +170 -0
  21. data/lib/capsium/package/metadata.rb +1 -1
  22. data/lib/capsium/package/metadata_config.rb +9 -1
  23. data/lib/capsium/package/open_pgp.rb +65 -0
  24. data/lib/capsium/package/open_pgp_cipher.rb +66 -0
  25. data/lib/capsium/package/open_pgp_signer.rb +176 -0
  26. data/lib/capsium/package/preparation.rb +81 -0
  27. data/lib/capsium/package/routes_config.rb +60 -6
  28. data/lib/capsium/package/security.rb +21 -4
  29. data/lib/capsium/package/security_config.rb +6 -2
  30. data/lib/capsium/package/signer.rb +216 -0
  31. data/lib/capsium/package/storage_config.rb +29 -1
  32. data/lib/capsium/package/store.rb +136 -0
  33. data/lib/capsium/package/testing/config_test.rb +70 -0
  34. data/lib/capsium/package/testing/context.rb +11 -0
  35. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  36. data/lib/capsium/package/testing/file_test.rb +45 -0
  37. data/lib/capsium/package/testing/report.rb +33 -0
  38. data/lib/capsium/package/testing/route_test.rb +64 -0
  39. data/lib/capsium/package/testing/test_case.rb +60 -0
  40. data/lib/capsium/package/testing/test_suite.rb +86 -0
  41. data/lib/capsium/package/testing.rb +20 -0
  42. data/lib/capsium/package/validator.rb +39 -3
  43. data/lib/capsium/package/verification.rb +34 -0
  44. data/lib/capsium/package/version.rb +204 -0
  45. data/lib/capsium/package.rb +78 -75
  46. data/lib/capsium/packager.rb +97 -22
  47. data/lib/capsium/reactor/authenticator.rb +180 -0
  48. data/lib/capsium/reactor/content_api.rb +110 -0
  49. data/lib/capsium/reactor/data_api.rb +154 -0
  50. data/lib/capsium/reactor/deploy.rb +71 -0
  51. data/lib/capsium/reactor/endpoints.rb +37 -0
  52. data/lib/capsium/reactor/graphql_api.rb +123 -0
  53. data/lib/capsium/reactor/graphql_schema.rb +138 -0
  54. data/lib/capsium/reactor/htpasswd.rb +154 -0
  55. data/lib/capsium/reactor/introspection.rb +151 -31
  56. data/lib/capsium/reactor/metrics.rb +30 -0
  57. data/lib/capsium/reactor/mount.rb +206 -0
  58. data/lib/capsium/reactor/oauth2.rb +106 -0
  59. data/lib/capsium/reactor/overlay.rb +248 -0
  60. data/lib/capsium/reactor/package_saver.rb +99 -0
  61. data/lib/capsium/reactor/responses.rb +41 -0
  62. data/lib/capsium/reactor/serving.rb +154 -0
  63. data/lib/capsium/reactor/session.rb +82 -0
  64. data/lib/capsium/reactor.rb +116 -67
  65. data/lib/capsium/registry/local.rb +120 -0
  66. data/lib/capsium/registry/remote.rb +105 -0
  67. data/lib/capsium/registry.rb +190 -0
  68. data/lib/capsium/thor_ext.rb +1 -1
  69. data/lib/capsium/version.rb +1 -1
  70. data/lib/capsium.rb +2 -0
  71. data/sig/capsium/log_buffer.rbs +30 -0
  72. data/sig/capsium/package/authentication.rbs +23 -0
  73. data/sig/capsium/package/authentication_config.rbs +71 -0
  74. data/sig/capsium/package/bundle.rbs +43 -0
  75. data/sig/capsium/package/cipher.rbs +62 -0
  76. data/sig/capsium/package/composition.rbs +21 -0
  77. data/sig/capsium/package/dataset.rbs +8 -0
  78. data/sig/capsium/package/dependency_resolver.rbs +53 -0
  79. data/sig/capsium/package/encryption_config.rbs +39 -0
  80. data/sig/capsium/package/merged_view.rbs +47 -0
  81. data/sig/capsium/package/metadata.rbs +1 -0
  82. data/sig/capsium/package/open_pgp.rbs +38 -0
  83. data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
  84. data/sig/capsium/package/open_pgp_signer.rbs +46 -0
  85. data/sig/capsium/package/preparation.rbs +23 -0
  86. data/sig/capsium/package/routes_config.rbs +36 -3
  87. data/sig/capsium/package/security.rbs +9 -2
  88. data/sig/capsium/package/security_config.rbs +6 -3
  89. data/sig/capsium/package/signer.rbs +66 -0
  90. data/sig/capsium/package/storage_config.rbs +26 -2
  91. data/sig/capsium/package/store.rbs +43 -0
  92. data/sig/capsium/package/testing/config_test.rbs +21 -0
  93. data/sig/capsium/package/testing/context.rbs +14 -0
  94. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  95. data/sig/capsium/package/testing/file_test.rbs +17 -0
  96. data/sig/capsium/package/testing/report.rbs +20 -0
  97. data/sig/capsium/package/testing/route_test.rbs +22 -0
  98. data/sig/capsium/package/testing/test_case.rbs +41 -0
  99. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  100. data/sig/capsium/package/testing.rbs +7 -0
  101. data/sig/capsium/package/verification.rbs +26 -0
  102. data/sig/capsium/package/version.rbs +39 -0
  103. data/sig/capsium/package.rbs +29 -16
  104. data/sig/capsium/packager.rbs +9 -0
  105. data/sig/capsium/reactor/authenticator.rbs +40 -0
  106. data/sig/capsium/reactor/content_api.rbs +19 -0
  107. data/sig/capsium/reactor/data_api.rbs +23 -0
  108. data/sig/capsium/reactor/deploy.rbs +34 -0
  109. data/sig/capsium/reactor/endpoints.rbs +16 -0
  110. data/sig/capsium/reactor/graphql_api.rbs +27 -0
  111. data/sig/capsium/reactor/graphql_schema.rbs +20 -0
  112. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  113. data/sig/capsium/reactor/introspection.rbs +27 -7
  114. data/sig/capsium/reactor/metrics.rbs +13 -0
  115. data/sig/capsium/reactor/mount.rbs +83 -0
  116. data/sig/capsium/reactor/oauth2.rbs +31 -0
  117. data/sig/capsium/reactor/overlay.rbs +72 -0
  118. data/sig/capsium/reactor/package_saver.rbs +12 -0
  119. data/sig/capsium/reactor/responses.rbs +23 -0
  120. data/sig/capsium/reactor/serving.rbs +46 -0
  121. data/sig/capsium/reactor/session.rbs +36 -0
  122. data/sig/capsium/reactor.rbs +22 -2
  123. data/sig/capsium/registry/local.rbs +16 -0
  124. data/sig/capsium/registry/remote.rbs +18 -0
  125. data/sig/capsium/registry.rbs +83 -0
  126. data/sig/graphql.rbs +12 -0
  127. 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
@@ -71,7 +71,7 @@ module Capsium
71
71
  private
72
72
 
73
73
  def content_files
74
- Dir.glob(File.join(@content_path, "**", "*")).select do |file|
74
+ Dir.glob(File.join(@content_path, "**", "*"), File::FNM_DOTMATCH).select do |file|
75
75
  File.file?(file)
76
76
  end
77
77
  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