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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +12 -0
  3. data/CHANGELOG.md +125 -0
  4. data/README.adoc +332 -5
  5. data/capsium.gemspec +1 -0
  6. data/lib/capsium/cli/formatting.rb +27 -0
  7. data/lib/capsium/cli/package.rb +68 -6
  8. data/lib/capsium/cli/reactor.rb +9 -1
  9. data/lib/capsium/cli.rb +1 -0
  10. data/lib/capsium/package/authentication.rb +38 -0
  11. data/lib/capsium/package/authentication_config.rb +78 -0
  12. data/lib/capsium/package/cipher.rb +197 -0
  13. data/lib/capsium/package/composition.rb +77 -0
  14. data/lib/capsium/package/dependency_resolver.rb +61 -0
  15. data/lib/capsium/package/encryption_config.rb +38 -0
  16. data/lib/capsium/package/merged_view.rb +168 -0
  17. data/lib/capsium/package/preparation.rb +81 -0
  18. data/lib/capsium/package/routes_config.rb +60 -6
  19. data/lib/capsium/package/security.rb +21 -4
  20. data/lib/capsium/package/signer.rb +201 -0
  21. data/lib/capsium/package/storage_config.rb +29 -1
  22. data/lib/capsium/package/store.rb +109 -0
  23. data/lib/capsium/package/testing/config_test.rb +70 -0
  24. data/lib/capsium/package/testing/context.rb +11 -0
  25. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  26. data/lib/capsium/package/testing/file_test.rb +45 -0
  27. data/lib/capsium/package/testing/report.rb +33 -0
  28. data/lib/capsium/package/testing/route_test.rb +64 -0
  29. data/lib/capsium/package/testing/test_case.rb +60 -0
  30. data/lib/capsium/package/testing/test_suite.rb +86 -0
  31. data/lib/capsium/package/testing.rb +20 -0
  32. data/lib/capsium/package/validator.rb +14 -3
  33. data/lib/capsium/package/verification.rb +32 -0
  34. data/lib/capsium/package/version.rb +204 -0
  35. data/lib/capsium/package.rb +59 -75
  36. data/lib/capsium/packager.rb +34 -1
  37. data/lib/capsium/reactor/authenticator.rb +180 -0
  38. data/lib/capsium/reactor/deploy.rb +71 -0
  39. data/lib/capsium/reactor/htpasswd.rb +154 -0
  40. data/lib/capsium/reactor/introspection.rb +4 -1
  41. data/lib/capsium/reactor/oauth2.rb +106 -0
  42. data/lib/capsium/reactor/serving.rb +75 -0
  43. data/lib/capsium/reactor/session.rb +82 -0
  44. data/lib/capsium/reactor.rb +43 -41
  45. data/lib/capsium/thor_ext.rb +1 -1
  46. data/lib/capsium/version.rb +1 -1
  47. data/sig/capsium/package/authentication.rbs +23 -0
  48. data/sig/capsium/package/authentication_config.rbs +71 -0
  49. data/sig/capsium/package/cipher.rbs +51 -0
  50. data/sig/capsium/package/composition.rbs +20 -0
  51. data/sig/capsium/package/dependency_resolver.rbs +49 -0
  52. data/sig/capsium/package/encryption_config.rbs +35 -0
  53. data/sig/capsium/package/merged_view.rbs +46 -0
  54. data/sig/capsium/package/preparation.rbs +23 -0
  55. data/sig/capsium/package/routes_config.rbs +36 -3
  56. data/sig/capsium/package/security.rbs +9 -2
  57. data/sig/capsium/package/signer.rbs +59 -0
  58. data/sig/capsium/package/storage_config.rbs +26 -2
  59. data/sig/capsium/package/store.rbs +37 -0
  60. data/sig/capsium/package/testing/config_test.rbs +21 -0
  61. data/sig/capsium/package/testing/context.rbs +14 -0
  62. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  63. data/sig/capsium/package/testing/file_test.rbs +17 -0
  64. data/sig/capsium/package/testing/report.rbs +20 -0
  65. data/sig/capsium/package/testing/route_test.rbs +22 -0
  66. data/sig/capsium/package/testing/test_case.rbs +41 -0
  67. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  68. data/sig/capsium/package/testing.rbs +7 -0
  69. data/sig/capsium/package/verification.rbs +24 -0
  70. data/sig/capsium/package/version.rbs +39 -0
  71. data/sig/capsium/package.rbs +25 -17
  72. data/sig/capsium/packager.rbs +9 -0
  73. data/sig/capsium/reactor/authenticator.rbs +40 -0
  74. data/sig/capsium/reactor/deploy.rbs +34 -0
  75. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  76. data/sig/capsium/reactor/oauth2.rbs +31 -0
  77. data/sig/capsium/reactor/serving.rbs +22 -0
  78. data/sig/capsium/reactor/session.rbs +36 -0
  79. data/sig/capsium/reactor.rbs +6 -1
  80. metadata +70 -1
@@ -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
@@ -7,6 +7,7 @@ module Capsium
7
7
  extend ThorExt::Start
8
8
 
9
9
  autoload :Convert, "capsium/cli/convert"
10
+ autoload :Formatting, "capsium/cli/formatting"
10
11
  autoload :Package, "capsium/cli/package"
11
12
  autoload :Reactor, "capsium/cli/reactor"
12
13
 
@@ -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