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,216 @@
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). The signer is
58
+ # selected from the declared digitalSignatures certificateType:
59
+ # OpenPGP packages verify through OpenPgpSigner.
60
+ def self.verify_package(path, public_key_path = nil)
61
+ return signer_class_for(path).new(path).verify(public_key_path) unless cap?(path)
62
+
63
+ Packager.new.with_unpacked_cap(path) do |dir|
64
+ signer_class_for(dir).new(dir).verify(public_key_path)
65
+ end
66
+ end
67
+
68
+ # The signer class matching a package's declared signature scheme:
69
+ # OpenPgpSigner when security.json declares
70
+ # digitalSignatures.certificateType "OpenPGP", Signer otherwise.
71
+ def self.signer_class_for(package_path)
72
+ security = Security.new(File.join(package_path, Package::SECURITY_FILE))
73
+ return OpenPgpSigner if security.digital_signatures&.certificate_type ==
74
+ OpenPgpSigner::CERTIFICATE_TYPE
75
+
76
+ self
77
+ end
78
+
79
+ def self.cap?(path)
80
+ File.extname(path) == ".cap"
81
+ end
82
+ private_class_method :cap?
83
+
84
+ # Signs the package directory in place: embeds the public key PEM,
85
+ # regenerates security.json (checksums plus digitalSignatures) and
86
+ # writes the raw RSA-SHA256 signature to signature.sig. When a
87
+ # certificate is given it must match the private key, and the
88
+ # certificate's public key is embedded instead.
89
+ def sign(private_key_path, certificate_path = nil)
90
+ private_key = load_private_key(private_key_path)
91
+ File.write(public_key_path, public_pem_for(private_key, certificate_path))
92
+ security = Security.generate(@package_path, digital_signatures: digital_signatures)
93
+ security.save_to_file
94
+ File.binwrite(signature_path, private_key.sign("SHA256", payload(security)))
95
+ signature_path
96
+ end
97
+
98
+ # True when the declared signature verifies against the payload.
99
+ # Without an explicit key path, the embedded public key is used.
100
+ # Raises typed SignatureError subclasses on structural problems.
101
+ def verify(public_key_path = nil)
102
+ signature = read_signature
103
+ data = payload(declared_security)
104
+ public_key(public_key_path).verify("SHA256", signature, data)
105
+ rescue OpenSSL::PKey::PKeyError
106
+ false
107
+ end
108
+
109
+ def verify!(public_key_path = nil)
110
+ return true if verify(public_key_path)
111
+
112
+ raise SignatureMismatchError,
113
+ "digital signature does not match the package contents"
114
+ end
115
+
116
+ def signed?
117
+ declared_security.signed?
118
+ end
119
+
120
+ private
121
+
122
+ def declared_security
123
+ Security.new(File.join(@package_path, Package::SECURITY_FILE))
124
+ end
125
+
126
+ def digital_signatures
127
+ DigitalSignatures.new(public_key: PUBLIC_KEY_FILE, signature_file: Package::SIGNATURE_FILE)
128
+ end
129
+
130
+ # The canonical signed payload: the concatenation, in sorted
131
+ # package-relative path order, of the bytes of every file covered by
132
+ # the integrity checksums.
133
+ def payload(security)
134
+ security.checksums.keys.sort.map do |relative_path|
135
+ file_path = File.join(@package_path, relative_path)
136
+ unless File.file?(file_path)
137
+ raise SignatureError, "file covered by checksums is missing: #{relative_path}"
138
+ end
139
+
140
+ File.binread(file_path)
141
+ end.join
142
+ end
143
+
144
+ def read_signature
145
+ unless signed?
146
+ raise UnsignedPackageError,
147
+ "package is not signed (security.json declares no digitalSignatures)"
148
+ end
149
+ return File.binread(signature_path) if File.file?(signature_path)
150
+
151
+ raise SignatureError, "signature file missing: #{signature_file_name}"
152
+ end
153
+
154
+ def signature_file_name
155
+ declared_security.digital_signatures.signature_file || Package::SIGNATURE_FILE
156
+ end
157
+
158
+ def signature_path
159
+ File.join(@package_path, signature_file_name)
160
+ end
161
+
162
+ def public_key_path
163
+ File.join(@package_path, PUBLIC_KEY_FILE)
164
+ end
165
+
166
+ def public_key(public_key_path)
167
+ pem, source = public_key_pem(public_key_path)
168
+ OpenSSL::PKey::RSA.new(pem)
169
+ rescue OpenSSL::PKey::PKeyError
170
+ certificate_public_key(pem, source)
171
+ end
172
+
173
+ def public_key_pem(public_key_path)
174
+ return [File.read(public_key_path), public_key_path] if public_key_path
175
+
176
+ embedded = declared_security.digital_signatures&.public_key || PUBLIC_KEY_FILE
177
+ embedded_path = File.join(@package_path, embedded)
178
+ return [File.read(embedded_path), embedded] if File.file?(embedded_path)
179
+
180
+ raise SignatureError, "public key file missing: #{embedded}"
181
+ end
182
+
183
+ def certificate_public_key(pem, source)
184
+ OpenSSL::X509::Certificate.new(pem).public_key
185
+ rescue OpenSSL::X509::CertificateError
186
+ raise SignatureError, "cannot load public key or certificate: #{source}"
187
+ end
188
+
189
+ def load_private_key(private_key_path)
190
+ key = OpenSSL::PKey::RSA.new(File.read(private_key_path))
191
+ return key if key.n.num_bits >= MIN_KEY_BITS
192
+
193
+ raise SignatureError, "RSA key too short: minimum #{MIN_KEY_BITS} bits required"
194
+ rescue OpenSSL::PKey::PKeyError, Errno::ENOENT
195
+ raise SignatureError, "cannot load private key: #{private_key_path}"
196
+ end
197
+
198
+ def public_pem_for(private_key, certificate_path)
199
+ return private_key.public_key.to_pem unless certificate_path
200
+
201
+ certificate = load_certificate(certificate_path)
202
+ unless certificate.check_private_key(private_key)
203
+ raise SignatureError, "certificate does not match the private key: #{certificate_path}"
204
+ end
205
+
206
+ certificate.public_key.to_pem
207
+ end
208
+
209
+ def load_certificate(certificate_path)
210
+ OpenSSL::X509::Certificate.new(File.read(certificate_path))
211
+ rescue OpenSSL::X509::CertificateError, Errno::ENOENT
212
+ raise SignatureError, "cannot load certificate: #{certificate_path}"
213
+ end
214
+ end
215
+ end
216
+ 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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "zip"
6
+
7
+ module Capsium
8
+ class Package
9
+ # A package store directory (ARCHITECTURE.md section 4a): a directory
10
+ # of "<name>-<version>.cap" files plus an optional index.json mapping
11
+ # dependency GUID -> file name. Dependencies resolve to the newest
12
+ # version satisfying their semver range; an index.json entry pins the
13
+ # GUID to a specific file (still range-checked).
14
+ class Store
15
+ INDEX_FILE = "index.json"
16
+ CAP_GLOB = "*.cap"
17
+
18
+ # One store .cap with its identity per its metadata.json.
19
+ CatalogEntry = Data.define(:path, :guid, :version)
20
+
21
+ attr_reader :dir
22
+
23
+ # The store configured via the CAPSIUM_STORE environment variable,
24
+ # or nil when unset.
25
+ def self.default
26
+ dir = ENV.fetch("CAPSIUM_STORE", nil)
27
+ dir.nil? || dir.empty? ? nil : new(dir)
28
+ end
29
+
30
+ def initialize(dir)
31
+ unless File.directory?(dir)
32
+ raise DependencyError, "Package store directory not found: #{dir}"
33
+ end
34
+
35
+ @dir = dir
36
+ end
37
+
38
+ # The newest .cap providing the dependency GUID whose version
39
+ # satisfies the range string.
40
+ def find(guid, range_string)
41
+ range = VersionRange.parse(range_string)
42
+ indexed = indexed_path(guid)
43
+ return indexed_satisfying(indexed, guid, range_string, range) if indexed
44
+
45
+ candidates = catalog.select { |entry| entry.guid == guid }
46
+ if candidates.empty?
47
+ raise DependencyNotFoundError,
48
+ "no package for dependency #{guid} in store #{@dir}"
49
+ end
50
+
51
+ satisfying = candidates.select { |entry| range.satisfied_by?(entry.version) }
52
+ return satisfying.max_by(&:version).path unless satisfying.empty?
53
+
54
+ versions = candidates.map { |entry| entry.version.to_s }.sort.join(", ")
55
+ raise UnsatisfiableDependencyError,
56
+ "no version of #{guid} satisfies '#{range_string}' " \
57
+ "(store has: #{versions})"
58
+ end
59
+
60
+ # Every .cap in the store with its metadata identity (memoized).
61
+ def catalog
62
+ @catalog ||= Dir.glob(File.join(@dir, CAP_GLOB)).map do |path|
63
+ catalog_entry(path)
64
+ end
65
+ end
66
+
67
+ # Installs the .cap at source_path into the store as file_name
68
+ # (conventionally "<name>-<version>.cap"), records guid ->
69
+ # file_name in index.json (atomically, tmp + rename) and returns
70
+ # the installed path. Used by registry installs
71
+ # (Capsium::Registry#install).
72
+ def install(source_path, guid:, file_name:)
73
+ destination = File.join(@dir, file_name)
74
+ tmp = "#{destination}.tmp-#{Process.pid}"
75
+ FileUtils.cp(source_path, tmp)
76
+ File.rename(tmp, destination)
77
+ record_index(guid, file_name)
78
+ destination
79
+ end
80
+
81
+ private
82
+
83
+ # Records guid -> file_name in the store's index.json and drops
84
+ # the memoized catalog so the new .cap is found.
85
+ def record_index(guid, file_name)
86
+ index = load_index.merge(guid => file_name)
87
+ path = File.join(@dir, INDEX_FILE)
88
+ tmp = "#{path}.tmp-#{Process.pid}"
89
+ File.write(tmp, JSON.pretty_generate(index))
90
+ File.rename(tmp, path)
91
+ @load_index = index
92
+ @catalog = nil
93
+ end
94
+
95
+ def indexed_path(guid)
96
+ file = load_index[guid]
97
+ file && File.join(@dir, file)
98
+ end
99
+
100
+ def indexed_satisfying(path, guid, range_string, range)
101
+ unless File.file?(path)
102
+ raise DependencyNotFoundError,
103
+ "#{INDEX_FILE} maps #{guid} to a missing file: #{path}"
104
+ end
105
+
106
+ entry = catalog_entry(path)
107
+ return entry.path if range.satisfied_by?(entry.version)
108
+
109
+ raise UnsatisfiableDependencyError,
110
+ "indexed #{guid} #{entry.version} does not satisfy '#{range_string}'"
111
+ end
112
+
113
+ def catalog_entry(cap_path)
114
+ metadata = read_metadata(cap_path)
115
+ CatalogEntry.new(path: cap_path, guid: metadata["guid"],
116
+ version: Version.parse(metadata["version"].to_s))
117
+ end
118
+
119
+ def read_metadata(cap_path)
120
+ Zip::File.open(cap_path) do |zip|
121
+ entry = zip.find_entry(Package::METADATA_FILE)
122
+ raise DependencyError, "no #{Package::METADATA_FILE} in #{cap_path}" unless entry
123
+
124
+ JSON.parse(entry.get_input_stream.read)
125
+ end
126
+ end
127
+
128
+ def load_index
129
+ @load_index ||= begin
130
+ path = File.join(@dir, INDEX_FILE)
131
+ File.file?(path) ? JSON.parse(File.read(path)) : {}
132
+ end
133
+ end
134
+ end
135
+ end
136
+ 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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ module Testing
6
+ # What a test run needs: the extracted package directory and, when
7
+ # route tests run, the base URL of the reactor serving the package.
8
+ Context = Data.define(:package_path, :base_url)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json-schema"
5
+ require "yaml"
6
+
7
+ module Capsium
8
+ class Package
9
+ module Testing
10
+ # A "data_validation" test (05x-testing): the rows of the data
11
+ # file (format: json or yaml) must validate against the JSON
12
+ # schema file. Array data validates row by row; a single document
13
+ # validates as one row.
14
+ class DataValidationTest < TestCase
15
+ FORMATS = %w[json yaml].freeze
16
+
17
+ attr_reader :format, :data_file, :schema_file
18
+
19
+ def initialize(name:, format:, data_file:, schema_file:)
20
+ super(name: name)
21
+ @format = format
22
+ @data_file = data_file
23
+ @schema_file = schema_file
24
+ end
25
+
26
+ def run(context)
27
+ problems = validation_problems(context)
28
+ Result.new(name: name, ok: problems.empty?, messages: problems)
29
+ end
30
+
31
+ private
32
+
33
+ def validation_problems(context)
34
+ return ["unsupported data format: #{format}"] unless FORMATS.include?(format)
35
+
36
+ schema = load_structured(context, schema_file)
37
+ rows = load_structured(context, data_file)
38
+ rows = [rows] unless rows.is_a?(Array)
39
+ rows.each_with_index.flat_map { |row, index| row_problems(schema, row, index) }
40
+ rescue Errno::ENOENT, JSON::ParserError, Psych::SyntaxError => e
41
+ ["cannot load data or schema: #{e.message}"]
42
+ end
43
+
44
+ def load_structured(context, relative_path)
45
+ full_path = File.join(context.package_path, relative_path.delete_prefix("/"))
46
+ if File.extname(full_path).match?(/\A\.ya?ml\z/i)
47
+ YAML.load_file(full_path)
48
+ else
49
+ JSON.parse(File.read(full_path))
50
+ end
51
+ end
52
+
53
+ def row_problems(schema, row, index)
54
+ JSON::Validator.fully_validate(schema, row).map do |message|
55
+ "row #{index}: #{message}"
56
+ end
57
+ end
58
+ end
59
+
60
+ TestCase.register("data_validation", DataValidationTest)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ module Testing
6
+ # A "file" test (05x-testing): the file must exist in the package;
7
+ # optionally its content must contain a string.
8
+ class FileTest < TestCase
9
+ attr_reader :path, :contains
10
+
11
+ def initialize(name:, path:, contains: nil)
12
+ super(name: name)
13
+ @path = path
14
+ @contains = contains
15
+ end
16
+
17
+ def run(context)
18
+ problems = existence_problems(context) + contains_problems(context)
19
+ Result.new(name: name, ok: problems.empty?, messages: problems)
20
+ end
21
+
22
+ private
23
+
24
+ def full_path(context)
25
+ File.join(context.package_path, path.delete_prefix("/"))
26
+ end
27
+
28
+ def existence_problems(context)
29
+ return [] if File.file?(full_path(context))
30
+
31
+ ["file missing in package: #{path}"]
32
+ end
33
+
34
+ def contains_problems(context)
35
+ return [] unless contains && File.file?(full_path(context))
36
+ return [] if File.read(full_path(context)).include?(contains)
37
+
38
+ ["file #{path} does not contain #{contains.inspect}"]
39
+ end
40
+ end
41
+
42
+ TestCase.register("file", FileTest)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ module Testing
6
+ # The outcome of a test suite run: one Result per test case.
7
+ class Report
8
+ attr_reader :results
9
+
10
+ def initialize(results = [])
11
+ @results = results
12
+ end
13
+
14
+ def <<(result)
15
+ @results << result
16
+ self
17
+ end
18
+
19
+ def ok?
20
+ @results.all?(&:ok?)
21
+ end
22
+
23
+ def failures
24
+ @results.reject(&:ok?)
25
+ end
26
+
27
+ def summary
28
+ "#{@results.size} tests, #{failures.size} failures"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Capsium
7
+ class Package
8
+ module Testing
9
+ # A "route" test (05x-testing): requests the URL path from the
10
+ # reactor serving the package and checks the expected status, and
11
+ # optionally the content type and a response body substring.
12
+ class RouteTest < TestCase
13
+ attr_reader :url, :expected_status, :response_contains, :expected_content_type
14
+
15
+ def initialize(name:, url:, expected_status:, response_contains: nil,
16
+ expected_content_type: nil)
17
+ super(name: name)
18
+ @url = url
19
+ @expected_status = expected_status
20
+ @response_contains = response_contains
21
+ @expected_content_type = expected_content_type
22
+ end
23
+
24
+ def run(context)
25
+ response = Net::HTTP.get_response(URI.join(context.base_url, request_path))
26
+ problems = status_problems(response) + content_type_problems(response) +
27
+ body_problems(response)
28
+ Result.new(name: name, ok: problems.empty?, messages: problems)
29
+ end
30
+
31
+ private
32
+
33
+ # The DSL allows absolute URLs; only their path (and query) is
34
+ # used — the runner always targets its own reactor.
35
+ def request_path
36
+ uri = URI.parse(url)
37
+ uri.host ? uri.request_uri : url
38
+ end
39
+
40
+ def status_problems(response)
41
+ return [] if response.code.to_i == expected_status
42
+
43
+ ["expected status #{expected_status}, got #{response.code}"]
44
+ end
45
+
46
+ def content_type_problems(response)
47
+ return [] unless expected_content_type
48
+ return [] if response.content_type == expected_content_type
49
+
50
+ ["expected content type #{expected_content_type}, got #{response.content_type}"]
51
+ end
52
+
53
+ def body_problems(response)
54
+ return [] unless response_contains
55
+ return [] if response.body.to_s.include?(response_contains)
56
+
57
+ ["response does not contain #{response_contains.inspect}"]
58
+ end
59
+ end
60
+
61
+ TestCase.register("route", RouteTest)
62
+ end
63
+ end
64
+ end