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
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module Capsium
7
+ class Package
8
+ # Package-source preparation (ARCHITECTURE.md section 1), mixed into
9
+ # Package: directory/.cap/encrypted-cap detection, extraction and
10
+ # decryption into a readable package directory.
11
+ module Preparation
12
+ def prepare_package(path)
13
+ return decrypt_cap_file(path) if Cipher.encrypted?(path)
14
+ return path if File.directory?(path)
15
+ raise Error, "Invalid package path: #{path}" unless File.file?(path)
16
+ raise Error, "The package must have a .cap extension" unless File.extname(path) == ".cap"
17
+
18
+ decompress_cap_file(path)
19
+ end
20
+
21
+ def decompress_cap_file(file_path)
22
+ package_path = File.join(Dir.mktmpdir, package_stem(file_path))
23
+ FileUtils.mkdir_p(package_path)
24
+ Packager.new.unpack(file_path, package_path)
25
+ package_path
26
+ end
27
+
28
+ # Decrypts an encrypted package (.cap file or uncompressed directory)
29
+ # into a temporary directory and returns its path. The metadata.json
30
+ # of an encrypted package stays cleartext, but everything else is
31
+ # only readable with the recipient's private key.
32
+ def decrypt_cap_file(source_path)
33
+ unless @decryption_key
34
+ raise Cipher::KeyRequiredError,
35
+ "Package is encrypted; provide the private key via decryption_key:"
36
+ end
37
+
38
+ Cipher.decrypt_to_directory(source_path, @decryption_key)
39
+ end
40
+
41
+ def determine_load_type(path)
42
+ return :directory if File.directory?(path)
43
+
44
+ File.extname(path) == ".cap" ? :cap_file : :unsaved
45
+ end
46
+
47
+ # Whether the package was loaded from an encrypted source
48
+ # (ARCHITECTURE.md section 6b layout).
49
+ def encrypted?
50
+ Cipher.encrypted?(@original_path)
51
+ end
52
+
53
+ private
54
+
55
+ def package_stem(file_path)
56
+ File.basename(file_path, ".cap")
57
+ end
58
+
59
+ def create_package_structure
60
+ FileUtils.mkdir_p(content_path)
61
+ FileUtils.mkdir_p(data_path)
62
+ end
63
+
64
+ def content_path = File.join(@path, CONTENT_DIR)
65
+
66
+ def data_path = File.join(@path, DATA_DIR)
67
+
68
+ def routes_path = File.join(@path, ROUTES_FILE)
69
+
70
+ def storage_path = File.join(@path, STORAGE_FILE)
71
+
72
+ def metadata_path = File.join(@path, METADATA_FILE)
73
+
74
+ def manifest_path = File.join(@path, MANIFEST_FILE)
75
+
76
+ def security_path = File.join(@path, SECURITY_FILE)
77
+
78
+ def authentication_path = File.join(@path, AUTHENTICATION_FILE)
79
+ end
80
+ end
81
+ end
@@ -5,12 +5,33 @@ require "lutaml/model"
5
5
 
6
6
  module Capsium
7
7
  class Package
8
+ # The "responseRewrite" object of an inherited route (05x-routing
9
+ # section "Route Inheritance"): replaces the served body and/or
10
+ # overrides response headers.
11
+ class ResponseRewrite < Lutaml::Model::Serializable
12
+ attribute :body, :string
13
+ attribute :headers, :hash
14
+
15
+ json do
16
+ map :body, to: :body
17
+ map :headers, to: :headers
18
+ end
19
+ end
20
+
8
21
  # A single route entry (ARCHITECTURE.md section 4). Kinds are
9
22
  # discriminated by key, MECE:
10
23
  # - {path, resource, headers?, visibility?} -- static file
11
24
  # - {path, dataset, accessControl?} -- dataset route
12
25
  # - {path, method, handler, ...} -- dynamic handler (parsed,
13
26
  # accepted-and-ignored; reactors respond 501)
27
+ #
28
+ # Route inheritance (05x-routing): a resource of the form
29
+ # "<dependency-guid>/<path>" (a URI, i.e. containing "://") pulls
30
+ # content from a dependency package; "remap" replaces the serving
31
+ # path; "responseRewrite"/"responseHeaders" post-process the
32
+ # response; "requestHeaders" are recorded for forwarding reactors
33
+ # (the Ruby reactor serves statically and does not forward, so they
34
+ # do not alter its responses).
14
35
  class Route < Lutaml::Model::Serializable
15
36
  DATASET_PATH_PREFIX = "/api/v1/data/"
16
37
 
@@ -23,6 +44,10 @@ module Capsium
23
44
  attribute :access_control, :hash
24
45
  attribute :http_method, :string
25
46
  attribute :handler, :string
47
+ attribute :remap, :string
48
+ attribute :response_rewrite, ResponseRewrite
49
+ attribute :response_headers, :hash
50
+ attribute :request_headers, :hash
26
51
 
27
52
  json do
28
53
  map :path, to: :path
@@ -34,6 +59,10 @@ module Capsium
34
59
  map "accessControl", to: :access_control
35
60
  map "method", to: :http_method
36
61
  map :handler, to: :handler
62
+ map :remap, to: :remap
63
+ map "responseRewrite", to: :response_rewrite
64
+ map "responseHeaders", to: :response_headers
65
+ map "requestHeaders", to: :request_headers
37
66
  end
38
67
 
39
68
  def kind
@@ -51,6 +80,25 @@ module Capsium
51
80
  kind == :handler
52
81
  end
53
82
 
83
+ # The URL path this route answers at: the remapped path when the
84
+ # route remaps an inherited route, its own path otherwise.
85
+ def serving_path
86
+ remap || path
87
+ end
88
+
89
+ # Whether the resource addresses content of a dependency package
90
+ # ("<dependency-guid>/<path>" — a URI rather than a
91
+ # package-relative path).
92
+ def dependency_reference?
93
+ resource.is_a?(String) && resource.include?("://")
94
+ end
95
+
96
+ # Whether the route carries route-inheritance attributes.
97
+ def inherited?
98
+ dependency_reference? || !remap.nil? || !response_rewrite.nil? ||
99
+ !response_headers.nil? || !request_headers.nil?
100
+ end
101
+
54
102
  def fs_path(package_path)
55
103
  return unless resource
56
104
 
@@ -61,22 +109,28 @@ module Capsium
61
109
  manifest.type_for(resource)
62
110
  end
63
111
 
64
- def validate_target(package_path, storage)
112
+ def validate_target(package_path, storage, merged_view: nil)
65
113
  return if handler_route?
114
+ return if dependency_reference?
66
115
 
67
116
  if dataset_route?
68
117
  return if storage.dataset(dataset)
69
118
 
70
119
  raise Error, "Route dataset does not exist: #{dataset}"
71
120
  end
72
- validate_resource_target(package_path)
121
+ validate_resource_target(package_path, merged_view)
73
122
  end
74
123
 
75
124
  private
76
125
 
77
- def validate_resource_target(package_path)
78
- target_path = fs_path(package_path)
79
- return if target_path && File.exist?(target_path)
126
+ def validate_resource_target(package_path, merged_view)
127
+ found = if merged_view
128
+ merged_view.resolve(resource)
129
+ else
130
+ target_path = fs_path(package_path)
131
+ target_path && File.exist?(target_path)
132
+ end
133
+ return if found
80
134
 
81
135
  raise Error, "Route resource does not exist: #{resource}"
82
136
  end
@@ -121,7 +175,7 @@ module Capsium
121
175
  end
122
176
 
123
177
  def resolve(path)
124
- routes.detect { |route| route.path == path }
178
+ routes.detect { |route| route.serving_path == path }
125
179
  end
126
180
 
127
181
  def add(path, target)
@@ -8,7 +8,8 @@ module Capsium
8
8
  class Package
9
9
  # Loads, generates and verifies security.json (ARCHITECTURE.md
10
10
  # section 6). Checksums cover every file in the package except
11
- # security.json itself.
11
+ # security.json itself and signature.sig (the signature signs the
12
+ # checksum-covered payload, so it cannot be part of it).
12
13
  class Security
13
14
  extend Forwardable
14
15
 
@@ -39,13 +40,14 @@ module Capsium
39
40
  @config = config || load_config
40
41
  end
41
42
 
42
- def self.generate(package_path)
43
+ def self.generate(package_path, digital_signatures: nil)
43
44
  config = SecurityConfig.new(
44
45
  security: SecurityData.new(
45
46
  integrity_checks: IntegrityChecks.new(
46
47
  checksum_algorithm: "SHA-256",
47
48
  checksums: checksums_for(package_path)
48
- )
49
+ ),
50
+ digital_signatures: digital_signatures
49
51
  )
50
52
  )
51
53
  new(File.join(package_path, SECURITY_FILE), config)
@@ -62,13 +64,28 @@ module Capsium
62
64
  File.file?(file)
63
65
  end
64
66
  files.map { |file| file.delete_prefix("#{package_path}/") }
65
- .reject { |relative_path| relative_path == SECURITY_FILE }
67
+ .reject { |relative_path| excluded?(relative_path) }
68
+ end
69
+
70
+ def self.excluded?(relative_path)
71
+ [SECURITY_FILE, SIGNATURE_FILE].include?(relative_path)
66
72
  end
67
73
 
68
74
  def present?
69
75
  !@config.nil?
70
76
  end
71
77
 
78
+ # The declared digitalSignatures block, or nil when absent.
79
+ def digital_signatures
80
+ return unless present? && config.security
81
+
82
+ config.security.digital_signatures
83
+ end
84
+
85
+ def signed?
86
+ !digital_signatures&.signature_file.nil?
87
+ end
88
+
72
89
  def checksums
73
90
  present? ? config.checksums : {}
74
91
  end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Capsium
6
+ class Package
7
+ # Signs and verifies Capsium packages with RSA-SHA256 digital
8
+ # signatures (05x-packaging "Digital Signature Using X.509",
9
+ # 05x-security "Digital Signatures").
10
+ #
11
+ # Signed payload construction (deterministic, openssl-verifiable): the
12
+ # concatenation, in sorted package-relative path order, of the raw
13
+ # bytes of every file covered by the security.json integrity checksums
14
+ # — i.e. every package file except security.json and signature.sig.
15
+ # The signature itself is the raw RSA-SHA256 signature over that
16
+ # payload, stored in signature.sig. Equivalent openssl verification:
17
+ #
18
+ # openssl dgst -sha256 -verify pubkey.pem -signature signature.sig payload.bin
19
+ #
20
+ # where payload.bin is the concatenation described above.
21
+ class Signer
22
+ ALGORITHM = "RSA-SHA256"
23
+ MIN_KEY_BITS = 2048
24
+
25
+ # Package-relative name of the embedded public key PEM recorded in
26
+ # security.json digitalSignatures.publicKey.
27
+ PUBLIC_KEY_FILE = "signature.pub.pem"
28
+
29
+ # Structural problems: no signature declared, missing signature or
30
+ # key files, unloadable keys, checksum-covered files missing.
31
+ class SignatureError < Capsium::Error; end
32
+
33
+ # A signature is declared but does not match the package contents.
34
+ class SignatureMismatchError < SignatureError; end
35
+
36
+ # Signature operations requested on a package whose security.json
37
+ # declares no digitalSignatures.
38
+ class UnsignedPackageError < SignatureError; end
39
+
40
+ attr_reader :package_path
41
+
42
+ def initialize(package_path)
43
+ @package_path = package_path
44
+ end
45
+
46
+ # Signs a package directory in place, or a .cap file (unpacked,
47
+ # signed, recompressed). Returns the signed path.
48
+ def self.sign_package(path, private_key_path, certificate_path = nil)
49
+ return new(path).sign(private_key_path, certificate_path) unless cap?(path)
50
+
51
+ Packager.new.transform_cap(path) { |dir| new(dir).sign(private_key_path, certificate_path) }
52
+ path
53
+ end
54
+
55
+ # Verifies the declared signature of a package directory or .cap
56
+ # file (false on mismatch; raises typed SignatureError subclasses
57
+ # on structural problems, e.g. an unsigned package).
58
+ def self.verify_package(path, public_key_path = nil)
59
+ return new(path).verify(public_key_path) unless cap?(path)
60
+
61
+ Packager.new.with_unpacked_cap(path) { |dir| new(dir).verify(public_key_path) }
62
+ end
63
+
64
+ def self.cap?(path)
65
+ File.extname(path) == ".cap"
66
+ end
67
+ private_class_method :cap?
68
+
69
+ # Signs the package directory in place: embeds the public key PEM,
70
+ # regenerates security.json (checksums plus digitalSignatures) and
71
+ # writes the raw RSA-SHA256 signature to signature.sig. When a
72
+ # certificate is given it must match the private key, and the
73
+ # certificate's public key is embedded instead.
74
+ def sign(private_key_path, certificate_path = nil)
75
+ private_key = load_private_key(private_key_path)
76
+ File.write(public_key_path, public_pem_for(private_key, certificate_path))
77
+ security = Security.generate(@package_path, digital_signatures: digital_signatures)
78
+ security.save_to_file
79
+ File.binwrite(signature_path, private_key.sign("SHA256", payload(security)))
80
+ signature_path
81
+ end
82
+
83
+ # True when the declared signature verifies against the payload.
84
+ # Without an explicit key path, the embedded public key is used.
85
+ # Raises typed SignatureError subclasses on structural problems.
86
+ def verify(public_key_path = nil)
87
+ signature = read_signature
88
+ data = payload(declared_security)
89
+ public_key(public_key_path).verify("SHA256", signature, data)
90
+ rescue OpenSSL::PKey::PKeyError
91
+ false
92
+ end
93
+
94
+ def verify!(public_key_path = nil)
95
+ return true if verify(public_key_path)
96
+
97
+ raise SignatureMismatchError,
98
+ "digital signature does not match the package contents"
99
+ end
100
+
101
+ def signed?
102
+ declared_security.signed?
103
+ end
104
+
105
+ private
106
+
107
+ def declared_security
108
+ Security.new(File.join(@package_path, Package::SECURITY_FILE))
109
+ end
110
+
111
+ def digital_signatures
112
+ DigitalSignatures.new(public_key: PUBLIC_KEY_FILE, signature_file: Package::SIGNATURE_FILE)
113
+ end
114
+
115
+ # The canonical signed payload: the concatenation, in sorted
116
+ # package-relative path order, of the bytes of every file covered by
117
+ # the integrity checksums.
118
+ def payload(security)
119
+ security.checksums.keys.sort.map do |relative_path|
120
+ file_path = File.join(@package_path, relative_path)
121
+ unless File.file?(file_path)
122
+ raise SignatureError, "file covered by checksums is missing: #{relative_path}"
123
+ end
124
+
125
+ File.binread(file_path)
126
+ end.join
127
+ end
128
+
129
+ def read_signature
130
+ unless signed?
131
+ raise UnsignedPackageError,
132
+ "package is not signed (security.json declares no digitalSignatures)"
133
+ end
134
+ return File.binread(signature_path) if File.file?(signature_path)
135
+
136
+ raise SignatureError, "signature file missing: #{signature_file_name}"
137
+ end
138
+
139
+ def signature_file_name
140
+ declared_security.digital_signatures.signature_file || Package::SIGNATURE_FILE
141
+ end
142
+
143
+ def signature_path
144
+ File.join(@package_path, signature_file_name)
145
+ end
146
+
147
+ def public_key_path
148
+ File.join(@package_path, PUBLIC_KEY_FILE)
149
+ end
150
+
151
+ def public_key(public_key_path)
152
+ pem, source = public_key_pem(public_key_path)
153
+ OpenSSL::PKey::RSA.new(pem)
154
+ rescue OpenSSL::PKey::PKeyError
155
+ certificate_public_key(pem, source)
156
+ end
157
+
158
+ def public_key_pem(public_key_path)
159
+ return [File.read(public_key_path), public_key_path] if public_key_path
160
+
161
+ embedded = declared_security.digital_signatures&.public_key || PUBLIC_KEY_FILE
162
+ embedded_path = File.join(@package_path, embedded)
163
+ return [File.read(embedded_path), embedded] if File.file?(embedded_path)
164
+
165
+ raise SignatureError, "public key file missing: #{embedded}"
166
+ end
167
+
168
+ def certificate_public_key(pem, source)
169
+ OpenSSL::X509::Certificate.new(pem).public_key
170
+ rescue OpenSSL::X509::CertificateError
171
+ raise SignatureError, "cannot load public key or certificate: #{source}"
172
+ end
173
+
174
+ def load_private_key(private_key_path)
175
+ key = OpenSSL::PKey::RSA.new(File.read(private_key_path))
176
+ return key if key.n.num_bits >= MIN_KEY_BITS
177
+
178
+ raise SignatureError, "RSA key too short: minimum #{MIN_KEY_BITS} bits required"
179
+ rescue OpenSSL::PKey::PKeyError, Errno::ENOENT
180
+ raise SignatureError, "cannot load private key: #{private_key_path}"
181
+ end
182
+
183
+ def public_pem_for(private_key, certificate_path)
184
+ return private_key.public_key.to_pem unless certificate_path
185
+
186
+ certificate = load_certificate(certificate_path)
187
+ unless certificate.check_private_key(private_key)
188
+ raise SignatureError, "certificate does not match the private key: #{certificate_path}"
189
+ end
190
+
191
+ certificate.public_key.to_pem
192
+ end
193
+
194
+ def load_certificate(certificate_path)
195
+ OpenSSL::X509::Certificate.new(File.read(certificate_path))
196
+ rescue OpenSSL::X509::CertificateError, Errno::ENOENT
197
+ raise SignatureError, "cannot load certificate: #{certificate_path}"
198
+ end
199
+ end
200
+ end
201
+ end
@@ -49,12 +49,34 @@ module Capsium
49
49
  end
50
50
  end
51
51
 
52
- # The "storage" object holding the dataSets map.
52
+ # A single storage layer entry (ARCHITECTURE.md section 5a). "path" is
53
+ # a package-relative directory mirroring the content/ tree; layers are
54
+ # stacked bottom -> top in declaration order. "visibility: private"
55
+ # layers are hidden from dependent packages.
56
+ class LayerConfig < Lutaml::Model::Serializable
57
+ attribute :path, :string
58
+ attribute :writable, :boolean, default: false
59
+ attribute :visibility, :string, values: Resource::VISIBILITIES, default: "exported"
60
+
61
+ json do
62
+ map :path, to: :path
63
+ map :writable, to: :writable
64
+ map :visibility, to: :visibility
65
+ end
66
+
67
+ def private?
68
+ visibility == "private"
69
+ end
70
+ end
71
+
72
+ # The "storage" object holding the dataSets map and the layers stack.
53
73
  class StorageData < Lutaml::Model::Serializable
54
74
  attribute :data_sets, :hash, default: {}
75
+ attribute :layers, LayerConfig, collection: true, default: []
55
76
 
56
77
  json do
57
78
  map "dataSets", with: { from: :data_sets_from_json, to: :data_sets_to_json }
79
+ map :layers, to: :layers
58
80
  end
59
81
 
60
82
  def data_sets_from_json(model, value)
@@ -101,6 +123,12 @@ module Capsium
101
123
  def data_sets
102
124
  storage ? storage.data_sets : {}
103
125
  end
126
+
127
+ # The configured storage layers, bottom -> top (empty when the
128
+ # package uses the single implicit content/ layer).
129
+ def layers
130
+ storage ? storage.layers : []
131
+ end
104
132
  end
105
133
  end
106
134
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "zip"
5
+
6
+ module Capsium
7
+ class Package
8
+ # A package store directory (ARCHITECTURE.md section 4a): a directory
9
+ # of "<name>-<version>.cap" files plus an optional index.json mapping
10
+ # dependency GUID -> file name. Dependencies resolve to the newest
11
+ # version satisfying their semver range; an index.json entry pins the
12
+ # GUID to a specific file (still range-checked).
13
+ class Store
14
+ INDEX_FILE = "index.json"
15
+ CAP_GLOB = "*.cap"
16
+
17
+ # One store .cap with its identity per its metadata.json.
18
+ CatalogEntry = Data.define(:path, :guid, :version)
19
+
20
+ attr_reader :dir
21
+
22
+ # The store configured via the CAPSIUM_STORE environment variable,
23
+ # or nil when unset.
24
+ def self.default
25
+ dir = ENV.fetch("CAPSIUM_STORE", nil)
26
+ dir.nil? || dir.empty? ? nil : new(dir)
27
+ end
28
+
29
+ def initialize(dir)
30
+ unless File.directory?(dir)
31
+ raise DependencyError, "Package store directory not found: #{dir}"
32
+ end
33
+
34
+ @dir = dir
35
+ end
36
+
37
+ # The newest .cap providing the dependency GUID whose version
38
+ # satisfies the range string.
39
+ def find(guid, range_string)
40
+ range = VersionRange.parse(range_string)
41
+ indexed = indexed_path(guid)
42
+ return indexed_satisfying(indexed, guid, range_string, range) if indexed
43
+
44
+ candidates = catalog.select { |entry| entry.guid == guid }
45
+ if candidates.empty?
46
+ raise DependencyNotFoundError,
47
+ "no package for dependency #{guid} in store #{@dir}"
48
+ end
49
+
50
+ satisfying = candidates.select { |entry| range.satisfied_by?(entry.version) }
51
+ return satisfying.max_by(&:version).path unless satisfying.empty?
52
+
53
+ versions = candidates.map { |entry| entry.version.to_s }.sort.join(", ")
54
+ raise UnsatisfiableDependencyError,
55
+ "no version of #{guid} satisfies '#{range_string}' " \
56
+ "(store has: #{versions})"
57
+ end
58
+
59
+ # Every .cap in the store with its metadata identity (memoized).
60
+ def catalog
61
+ @catalog ||= Dir.glob(File.join(@dir, CAP_GLOB)).map do |path|
62
+ catalog_entry(path)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def indexed_path(guid)
69
+ file = load_index[guid]
70
+ file && File.join(@dir, file)
71
+ end
72
+
73
+ def indexed_satisfying(path, guid, range_string, range)
74
+ unless File.file?(path)
75
+ raise DependencyNotFoundError,
76
+ "#{INDEX_FILE} maps #{guid} to a missing file: #{path}"
77
+ end
78
+
79
+ entry = catalog_entry(path)
80
+ return entry.path if range.satisfied_by?(entry.version)
81
+
82
+ raise UnsatisfiableDependencyError,
83
+ "indexed #{guid} #{entry.version} does not satisfy '#{range_string}'"
84
+ end
85
+
86
+ def catalog_entry(cap_path)
87
+ metadata = read_metadata(cap_path)
88
+ CatalogEntry.new(path: cap_path, guid: metadata["guid"],
89
+ version: Version.parse(metadata["version"].to_s))
90
+ end
91
+
92
+ def read_metadata(cap_path)
93
+ Zip::File.open(cap_path) do |zip|
94
+ entry = zip.find_entry(Package::METADATA_FILE)
95
+ raise DependencyError, "no #{Package::METADATA_FILE} in #{cap_path}" unless entry
96
+
97
+ JSON.parse(entry.get_input_stream.read)
98
+ end
99
+ end
100
+
101
+ def load_index
102
+ @load_index ||= begin
103
+ path = File.join(@dir, INDEX_FILE)
104
+ File.file?(path) ? JSON.parse(File.read(path)) : {}
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Capsium
7
+ class Package
8
+ module Testing
9
+ # A "config" test (05x-testing): the configuration file must exist
10
+ # in the package and parse as the declared format (json or yaml).
11
+ # The known Capsium package configs (metadata.json, manifest.json,
12
+ # routes.json, storage.json, security.json) are additionally
13
+ # validated against their canonical models.
14
+ class ConfigTest < TestCase
15
+ FORMATS = %w[json yaml].freeze
16
+
17
+ attr_reader :format, :config_file
18
+
19
+ def initialize(name:, format:, config_file:)
20
+ super(name: name)
21
+ @format = format
22
+ @config_file = config_file
23
+ end
24
+
25
+ def run(context)
26
+ problems = config_problems(context)
27
+ Result.new(name: name, ok: problems.empty?, messages: problems)
28
+ end
29
+
30
+ private
31
+
32
+ def config_problems(context)
33
+ return ["unsupported config format: #{format}"] unless FORMATS.include?(format)
34
+
35
+ full_path = File.join(context.package_path, config_file.delete_prefix("/"))
36
+ return ["config file missing in package: #{config_file}"] unless File.file?(full_path)
37
+
38
+ parse(full_path)
39
+ model_problems(full_path)
40
+ rescue JSON::ParserError, Psych::SyntaxError => e
41
+ ["cannot parse #{config_file}: #{e.message}"]
42
+ end
43
+
44
+ def parse(full_path)
45
+ format == "yaml" ? YAML.load_file(full_path) : JSON.parse(File.read(full_path))
46
+ end
47
+
48
+ def model_problems(full_path)
49
+ case File.basename(config_file)
50
+ when Package::METADATA_FILE then Metadata.new(full_path).config.format_errors
51
+ when Package::MANIFEST_FILE then probe { ManifestConfig.from_json(File.read(full_path)) }
52
+ when Package::ROUTES_FILE then probe { RoutesConfig.from_json(File.read(full_path)) }
53
+ when Package::STORAGE_FILE then probe { StorageConfig.from_json(File.read(full_path)) }
54
+ when Package::SECURITY_FILE then probe { SecurityConfig.from_json(File.read(full_path)) }
55
+ else []
56
+ end
57
+ end
58
+
59
+ def probe
60
+ yield
61
+ []
62
+ rescue StandardError => e
63
+ [e.message]
64
+ end
65
+ end
66
+
67
+ TestCase.register("config", ConfigTest)
68
+ end
69
+ end
70
+ end