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
@@ -11,7 +11,7 @@ module Capsium
11
11
 
12
12
  def_delegators :@config, :to_json, :to_hash, :name, :version,
13
13
  :description, :guid, :uuid, :author, :license,
14
- :repository, :dependencies
14
+ :repository, :dependencies, :read_only
15
15
 
16
16
  def initialize(path)
17
17
  @path = path
@@ -36,6 +36,7 @@ module Capsium
36
36
  attribute :repository, Repository
37
37
  attribute :dependencies, :hash, default: {}
38
38
  attribute :read_only, :boolean
39
+ attribute :modules, :string, collection: true
39
40
 
40
41
  json do
41
42
  map :name, to: :name
@@ -48,6 +49,7 @@ module Capsium
48
49
  map :repository, to: :repository
49
50
  map :dependencies, to: :dependencies
50
51
  map "readOnly", to: :read_only
52
+ map :modules, to: :modules
51
53
  end
52
54
 
53
55
  def self.from_json(json)
@@ -88,7 +90,13 @@ module Capsium
88
90
  problems << "version must be semver" if invalid?(version, SEMVER_PATTERN)
89
91
  problems << "guid must be a URI" if guid && !uri?(guid)
90
92
  problems << "uuid is not a valid UUID" if invalid?(uuid, UUID_PATTERN)
91
- problems
93
+ problems + module_errors
94
+ end
95
+
96
+ def module_errors
97
+ return [] if modules.nil? || modules.all? { |mod| mod.match?(KEBAB_CASE_PATTERN) }
98
+
99
+ ["modules must be kebab-case module identifiers"]
92
100
  end
93
101
 
94
102
  def invalid?(value, pattern)
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ # OpenPGP support (signatures per ARCHITECTURE.md section 6a and
6
+ # encryption per section 6b) through the rnp gem's binding to
7
+ # librnp.
8
+ #
9
+ # rnp is a soft dependency: it is only required when an OpenPGP
10
+ # feature is actually used, and this module is the single place that
11
+ # loads it. When the rnp gem or the librnp shared library cannot be
12
+ # loaded, the entry points raise OpenPgpUnavailableError with
13
+ # installation guidance.
14
+ module OpenPgp
15
+ # The rnp gem or the librnp shared library is not available.
16
+ class OpenPgpUnavailableError < Capsium::Error; end
17
+
18
+ # A key file is unreadable or holds no suitable OpenPGP key.
19
+ class KeyError < Capsium::Error; end
20
+
21
+ UNAVAILABLE_MESSAGE =
22
+ "OpenPGP support requires librnp and the rnp gem " \
23
+ "(e.g. `brew install rnp` and `gem install rnp`)"
24
+
25
+ # One loaded key file: the Rnp context holding the key material
26
+ # and the selected key within it.
27
+ LoadedKey = Data.define(:rnp, :key)
28
+
29
+ # A fresh Rnp context. Raises OpenPgpUnavailableError when the rnp
30
+ # gem or librnp cannot be loaded.
31
+ def self.rnp
32
+ require "rnp"
33
+ Rnp.new
34
+ rescue LoadError
35
+ raise OpenPgpUnavailableError, UNAVAILABLE_MESSAGE
36
+ end
37
+
38
+ # Loads the OpenPGP key at key_path (armored or binary, public or
39
+ # secret — the format is auto-detected) into a fresh Rnp context.
40
+ # With secret: true the selected key must hold secret key material
41
+ # (signing, decryption); otherwise any loaded key is selected
42
+ # (verification, encryption). Raises KeyError when the file is
43
+ # unreadable or holds no suitable key.
44
+ def self.load_key(key_path, secret: false)
45
+ instance = rnp
46
+ instance.load_keys(input: Rnp::Input.from_string(File.binread(key_path)),
47
+ format: "GPG")
48
+ key = select_key(instance, secret: secret)
49
+ return LoadedKey.new(rnp: instance, key: key) if key
50
+
51
+ raise KeyError, "no suitable #{secret ? 'secret' : 'public'} OpenPGP key in: #{key_path}"
52
+ rescue Errno::ENOENT, Rnp::Error
53
+ raise KeyError, "cannot load OpenPGP key: #{key_path}"
54
+ end
55
+
56
+ def self.select_key(instance, secret:)
57
+ keys = instance.keyids.map { |keyid| instance.find_key(keyid: keyid) }
58
+ return keys.find(&:secret_key_present?) if secret
59
+
60
+ keys.first
61
+ end
62
+ private_class_method :select_key
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Capsium
6
+ class Package
7
+ # Encrypts and decrypts whole Capsium packages with OpenPGP key
8
+ # management (ARCHITECTURE.md section 6b) through librnp
9
+ # (Capsium::Package::OpenPgp). Parallel to the RSA-OAEP-SHA256
10
+ # Cipher — same encrypted .cap layout, same AES-256-GCM content
11
+ # encryption, same typed errors — with the DEK carried as an armored
12
+ # OpenPGP message instead of an RSA-wrapped blob:
13
+ #
14
+ # {"encryption": {"algorithm": "AES-256-GCM",
15
+ # "keyManagement": "OpenPGP",
16
+ # "message": <armored OpenPGP message with the DEK>,
17
+ # "iv": <base64>, "authTag": <base64>}}
18
+ #
19
+ # Keys are OpenPGP key files (armored or binary, auto-detected):
20
+ # encryption needs the recipient's public key, decryption the
21
+ # secret key.
22
+ class OpenPgpCipher < Cipher
23
+ KEY_MANAGEMENT = "OpenPGP"
24
+
25
+ # The OpenPGP symmetric cipher protecting the DEK message.
26
+ CIPHER_NAME = "AES256"
27
+
28
+ private
29
+
30
+ # The encryption envelope carrying the DEK as an armored OpenPGP
31
+ # message to the recipient's public key.
32
+ def envelope_for(dek, gcm_iv, auth_tag, recipient)
33
+ message = recipient.rnp.encrypt(
34
+ input: Rnp::Input.from_string(dek),
35
+ recipients: recipient.key, cipher: CIPHER_NAME, armored: true
36
+ )
37
+ EncryptionConfig.new(
38
+ encryption: EncryptionEnvelope.new(
39
+ algorithm: ALGORITHM, key_management: KEY_MANAGEMENT,
40
+ message: message,
41
+ iv: Base64.strict_encode64(gcm_iv), auth_tag: Base64.strict_encode64(auth_tag)
42
+ )
43
+ )
44
+ end
45
+
46
+ # Recovers the DEK by decrypting the envelope's OpenPGP message.
47
+ # Reached only after the rnp binding loaded successfully (the
48
+ # secret key is loaded first), so the Rnp error constants are
49
+ # defined here.
50
+ def unwrap_dek(envelope, secret_key)
51
+ secret_key.rnp.decrypt(input: Rnp::Input.from_string(envelope.message.to_s))
52
+ rescue Rnp::Error
53
+ # No suitable key, or a malformed or tampered message.
54
+ raise DecryptionError, "decryption failed: wrong key or tampered package"
55
+ end
56
+
57
+ def load_public_key(public_key_path)
58
+ OpenPgp.load_key(public_key_path)
59
+ end
60
+
61
+ def load_private_key(private_key_path)
62
+ OpenPgp.load_key(private_key_path, secret: true)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capsium
4
+ class Package
5
+ # Signs and verifies Capsium packages with OpenPGP detached
6
+ # signatures (ARCHITECTURE.md section 6a) through librnp
7
+ # (Capsium::Package::OpenPgp). Parallel to the RSA-SHA256/X.509
8
+ # Signer with the same construction semantics and the same error
9
+ # taxonomy (Signer::SignatureError subclasses), so loaders and the
10
+ # CLI handle both schemes uniformly.
11
+ #
12
+ # The signed payload is identical to the Signer's: the
13
+ # concatenation, in sorted package-relative path order, of the raw
14
+ # bytes of every file covered by the security.json integrity
15
+ # checksums. signature.sig holds the armored detached OpenPGP
16
+ # signature (SHA-256) over that payload; the signer's armored
17
+ # public key is embedded as signature.pub.asc and security.json
18
+ # records:
19
+ #
20
+ # "digitalSignatures": { "certificateType": "OpenPGP",
21
+ # "publicKey": "signature.pub.asc",
22
+ # "signatureFile": "signature.sig" }
23
+ #
24
+ # Keys are OpenPGP key files (armored or binary, auto-detected);
25
+ # signing needs a secret key, verification a public one.
26
+ class OpenPgpSigner
27
+ ALGORITHM = "OpenPGP"
28
+ CERTIFICATE_TYPE = "OpenPGP"
29
+ HASH = "SHA256"
30
+
31
+ # Package-relative name of the embedded armored OpenPGP public key
32
+ # recorded in security.json digitalSignatures.publicKey.
33
+ PUBLIC_KEY_FILE = "signature.pub.asc"
34
+
35
+ attr_reader :package_path
36
+
37
+ def initialize(package_path)
38
+ @package_path = package_path
39
+ end
40
+
41
+ # Signs a package directory in place, or a .cap file (unpacked,
42
+ # signed, recompressed), with the OpenPGP secret key at
43
+ # secret_key_path. Returns the signed path.
44
+ def self.sign_package(path, secret_key_path)
45
+ return new(path).sign(secret_key_path) unless cap?(path)
46
+
47
+ Packager.new.transform_cap(path) { |dir| new(dir).sign(secret_key_path) }
48
+ path
49
+ end
50
+
51
+ # Verifies the declared OpenPGP signature of a package directory
52
+ # or .cap file (false on mismatch; raises typed
53
+ # Signer::SignatureError subclasses on structural problems, e.g.
54
+ # an unsigned package). public_key_path is an OpenPGP public key
55
+ # file; without it the key embedded in the package is used.
56
+ def self.verify_package(path, public_key_path = nil)
57
+ return new(path).verify(public_key_path) unless cap?(path)
58
+
59
+ Packager.new.with_unpacked_cap(path) { |dir| new(dir).verify(public_key_path) }
60
+ end
61
+
62
+ def self.cap?(path)
63
+ File.extname(path) == ".cap"
64
+ end
65
+ private_class_method :cap?
66
+
67
+ # Signs the package directory in place: embeds the armored public
68
+ # key, regenerates security.json (checksums plus digitalSignatures
69
+ # with certificateType "OpenPGP") and writes the armored detached
70
+ # OpenPGP signature to signature.sig.
71
+ def sign(secret_key_path)
72
+ loaded = OpenPgp.load_key(secret_key_path, secret: true)
73
+ File.write(public_key_path, loaded.key.export_public(armored: true))
74
+ security = Security.generate(@package_path, digital_signatures: digital_signatures)
75
+ security.save_to_file
76
+ signature = loaded.rnp.detached_sign(
77
+ input: Rnp::Input.from_string(payload(security)),
78
+ signers: loaded.key, hash: HASH, armored: true
79
+ )
80
+ File.write(signature_path, signature)
81
+ signature_path
82
+ end
83
+
84
+ # True when the declared signature verifies against the payload.
85
+ # Without an explicit key path, the embedded public key is used.
86
+ def verify(public_key_path = nil)
87
+ signature = read_signature
88
+ data = payload(declared_security)
89
+ loaded = verification_key(public_key_path)
90
+ signature_matches(loaded, data, signature)
91
+ end
92
+
93
+ def verify!(public_key_path = nil)
94
+ return true if verify(public_key_path)
95
+
96
+ raise Signer::SignatureMismatchError,
97
+ "digital signature does not match the package contents"
98
+ end
99
+
100
+ def signed?
101
+ declared_security.signed?
102
+ end
103
+
104
+ private
105
+
106
+ # Reached only after the rnp binding loaded successfully (the
107
+ # verification key is loaded first), so the Rnp error constants
108
+ # are defined here.
109
+ def signature_matches(loaded, data, signature)
110
+ loaded.rnp.detached_verify(data: Rnp::Input.from_string(data),
111
+ signature: Rnp::Input.from_string(signature))
112
+ true
113
+ rescue Rnp::InvalidSignatureError
114
+ false
115
+ end
116
+
117
+ def declared_security
118
+ Security.new(File.join(@package_path, Package::SECURITY_FILE))
119
+ end
120
+
121
+ def digital_signatures
122
+ DigitalSignatures.new(certificate_type: CERTIFICATE_TYPE,
123
+ public_key: PUBLIC_KEY_FILE,
124
+ signature_file: Package::SIGNATURE_FILE)
125
+ end
126
+
127
+ # The canonical signed payload (ARCHITECTURE.md section 6a,
128
+ # identical to Signer's): the concatenation, in sorted
129
+ # package-relative path order, of the bytes of every file covered
130
+ # by the integrity checksums.
131
+ def payload(security)
132
+ security.checksums.keys.sort.map do |relative_path|
133
+ file_path = File.join(@package_path, relative_path)
134
+ unless File.file?(file_path)
135
+ raise Signer::SignatureError,
136
+ "file covered by checksums is missing: #{relative_path}"
137
+ end
138
+
139
+ File.binread(file_path)
140
+ end.join
141
+ end
142
+
143
+ def read_signature
144
+ unless signed?
145
+ raise Signer::UnsignedPackageError,
146
+ "package is not signed (security.json declares no digitalSignatures)"
147
+ end
148
+ return File.binread(signature_path) if File.file?(signature_path)
149
+
150
+ raise Signer::SignatureError, "signature file missing: #{signature_file_name}"
151
+ end
152
+
153
+ def signature_file_name
154
+ declared_security.digital_signatures.signature_file || Package::SIGNATURE_FILE
155
+ end
156
+
157
+ def signature_path
158
+ File.join(@package_path, signature_file_name)
159
+ end
160
+
161
+ def public_key_path
162
+ File.join(@package_path, PUBLIC_KEY_FILE)
163
+ end
164
+
165
+ def verification_key(public_key_path)
166
+ return OpenPgp.load_key(public_key_path) if public_key_path
167
+
168
+ embedded = declared_security.digital_signatures&.public_key || PUBLIC_KEY_FILE
169
+ embedded_path = File.join(@package_path, embedded)
170
+ return OpenPgp.load_key(embedded_path) if File.file?(embedded_path)
171
+
172
+ raise Signer::SignatureError, "public key file missing: #{embedded}"
173
+ end
174
+ end
175
+ end
176
+ end
@@ -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
@@ -19,13 +19,17 @@ module Capsium
19
19
  end
20
20
  end
21
21
 
22
- # The "digitalSignatures" object of security.json. Signing is a later
23
- # phase; the model parses the canonical fields only.
22
+ # The "digitalSignatures" object of security.json. certificateType
23
+ # selects the signature scheme: absent means RSA-SHA256 (X.509),
24
+ # "OpenPGP" an OpenPGP signature (the publicKey then holds an
25
+ # armored OpenPGP public key).
24
26
  class DigitalSignatures < Lutaml::Model::Serializable
27
+ attribute :certificate_type, :string
25
28
  attribute :public_key, :string
26
29
  attribute :signature_file, :string
27
30
 
28
31
  json do
32
+ map "certificateType", to: :certificate_type
29
33
  map "publicKey", to: :public_key
30
34
  map "signatureFile", to: :signature_file
31
35
  end