capsium 0.1.2 → 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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +46 -7
  4. data/CHANGELOG.md +183 -0
  5. data/Gemfile +6 -0
  6. data/LICENSE +21 -0
  7. data/README.adoc +496 -127
  8. data/Rakefile +8 -1
  9. data/bin/console +1 -1
  10. data/capsium.gemspec +12 -19
  11. data/exe/capsium +1 -1
  12. data/lib/capsium/cli/convert.rb +18 -0
  13. data/lib/capsium/cli/formatting.rb +27 -0
  14. data/lib/capsium/cli/package.rb +148 -0
  15. data/lib/capsium/cli/reactor.rb +33 -0
  16. data/lib/capsium/cli.rb +4 -96
  17. data/lib/capsium/converters/jekyll.rb +5 -7
  18. data/lib/capsium/converters.rb +7 -0
  19. data/lib/capsium/package/authentication.rb +38 -0
  20. data/lib/capsium/package/authentication_config.rb +78 -0
  21. data/lib/capsium/package/cipher.rb +197 -0
  22. data/lib/capsium/package/composition.rb +77 -0
  23. data/lib/capsium/package/dataset.rb +67 -34
  24. data/lib/capsium/package/dependency_resolver.rb +61 -0
  25. data/lib/capsium/package/encryption_config.rb +38 -0
  26. data/lib/capsium/package/manifest.rb +33 -22
  27. data/lib/capsium/package/manifest_config.rb +52 -7
  28. data/lib/capsium/package/merged_view.rb +168 -0
  29. data/lib/capsium/package/metadata.rb +4 -8
  30. data/lib/capsium/package/metadata_config.rb +97 -9
  31. data/lib/capsium/package/preparation.rb +81 -0
  32. data/lib/capsium/package/routes.rb +39 -41
  33. data/lib/capsium/package/routes_config.rb +174 -44
  34. data/lib/capsium/package/security.rb +140 -0
  35. data/lib/capsium/package/security_config.rb +60 -0
  36. data/lib/capsium/package/signer.rb +201 -0
  37. data/lib/capsium/package/storage.rb +20 -45
  38. data/lib/capsium/package/storage_config.rb +128 -3
  39. data/lib/capsium/package/store.rb +109 -0
  40. data/lib/capsium/package/testing/config_test.rb +70 -0
  41. data/lib/capsium/package/testing/context.rb +11 -0
  42. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  43. data/lib/capsium/package/testing/file_test.rb +45 -0
  44. data/lib/capsium/package/testing/report.rb +33 -0
  45. data/lib/capsium/package/testing/route_test.rb +64 -0
  46. data/lib/capsium/package/testing/test_case.rb +60 -0
  47. data/lib/capsium/package/testing/test_suite.rb +86 -0
  48. data/lib/capsium/package/testing.rb +20 -0
  49. data/lib/capsium/package/validator.rb +160 -0
  50. data/lib/capsium/package/verification.rb +32 -0
  51. data/lib/capsium/package/version.rb +204 -0
  52. data/lib/capsium/package.rb +92 -106
  53. data/lib/capsium/packager.rb +75 -6
  54. data/lib/capsium/reactor/authenticator.rb +180 -0
  55. data/lib/capsium/reactor/deploy.rb +71 -0
  56. data/lib/capsium/reactor/htpasswd.rb +154 -0
  57. data/lib/capsium/reactor/introspection.rb +91 -0
  58. data/lib/capsium/reactor/oauth2.rb +106 -0
  59. data/lib/capsium/reactor/serving.rb +75 -0
  60. data/lib/capsium/reactor/session.rb +82 -0
  61. data/lib/capsium/reactor.rb +93 -43
  62. data/lib/capsium/thor_ext.rb +1 -1
  63. data/lib/capsium/version.rb +1 -1
  64. data/lib/capsium.rb +7 -9
  65. data/sig/capsium/package/authentication.rbs +23 -0
  66. data/sig/capsium/package/authentication_config.rbs +71 -0
  67. data/sig/capsium/package/cipher.rbs +51 -0
  68. data/sig/capsium/package/composition.rbs +20 -0
  69. data/sig/capsium/package/dataset.rbs +28 -0
  70. data/sig/capsium/package/dependency_resolver.rbs +49 -0
  71. data/sig/capsium/package/encryption_config.rbs +35 -0
  72. data/sig/capsium/package/manifest.rbs +34 -0
  73. data/sig/capsium/package/manifest_config.rbs +38 -0
  74. data/sig/capsium/package/merged_view.rbs +46 -0
  75. data/sig/capsium/package/metadata.rbs +26 -0
  76. data/sig/capsium/package/metadata_config.rbs +49 -0
  77. data/sig/capsium/package/preparation.rbs +23 -0
  78. data/sig/capsium/package/routes.rbs +37 -0
  79. data/sig/capsium/package/routes_config.rbs +107 -0
  80. data/sig/capsium/package/security.rbs +72 -0
  81. data/sig/capsium/package/security_config.rbs +57 -0
  82. data/sig/capsium/package/signer.rbs +59 -0
  83. data/sig/capsium/package/storage.rbs +28 -0
  84. data/sig/capsium/package/storage_config.rbs +85 -0
  85. data/sig/capsium/package/store.rbs +37 -0
  86. data/sig/capsium/package/testing/config_test.rbs +21 -0
  87. data/sig/capsium/package/testing/context.rbs +14 -0
  88. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  89. data/sig/capsium/package/testing/file_test.rbs +17 -0
  90. data/sig/capsium/package/testing/report.rbs +20 -0
  91. data/sig/capsium/package/testing/route_test.rbs +22 -0
  92. data/sig/capsium/package/testing/test_case.rbs +41 -0
  93. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  94. data/sig/capsium/package/testing.rbs +7 -0
  95. data/sig/capsium/package/validator.rbs +29 -0
  96. data/sig/capsium/package/verification.rbs +24 -0
  97. data/sig/capsium/package/version.rbs +39 -0
  98. data/sig/capsium/package.rbs +61 -0
  99. data/sig/capsium/packager.rbs +32 -0
  100. data/sig/capsium/reactor/authenticator.rbs +40 -0
  101. data/sig/capsium/reactor/deploy.rbs +34 -0
  102. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  103. data/sig/capsium/reactor/introspection.rbs +31 -0
  104. data/sig/capsium/reactor/oauth2.rbs +31 -0
  105. data/sig/capsium/reactor/serving.rbs +22 -0
  106. data/sig/capsium/reactor/session.rbs +36 -0
  107. data/sig/capsium/reactor.rbs +35 -0
  108. data/sig/capsium.rbs +3 -1
  109. metadata +108 -170
  110. data/lib/capsium/package/dataset_config.rb +0 -28
  111. data/lib/capsium/protector.rb +0 -103
@@ -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
@@ -1,72 +1,105 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "yaml"
5
3
  require "csv"
6
- require "sqlite3"
7
- require "shale"
4
+ require "forwardable"
5
+ require "json"
8
6
  require "json-schema"
9
- require_relative "dataset_config"
7
+ require "sqlite3"
8
+ require "yaml"
10
9
 
11
10
  module Capsium
12
11
  class Package
12
+ # A loaded dataset. "source"/"schemaFile"/"databaseFile" are
13
+ # package-relative paths (ARCHITECTURE.md section 5), resolved against
14
+ # the package directory.
13
15
  class Dataset
14
- attr_reader :config, :data, :data_path
16
+ attr_reader :name, :config, :data, :package_path
15
17
 
16
18
  extend Forwardable
17
19
 
18
20
  def_delegators :@config, :to_json
19
21
 
20
- def initialize(config:, data_path: nil)
22
+ def initialize(name:, config:, package_path:)
23
+ @name = name
21
24
  @config = config
22
- @data_path = data_path || config.source
25
+ @package_path = package_path
23
26
  @data = load_data
24
27
  end
25
28
 
29
+ def source_path
30
+ File.join(@package_path, @config.backing_file)
31
+ end
32
+
33
+ def schema_path
34
+ return unless @config.schema_file
35
+
36
+ File.join(@package_path, @config.schema_file)
37
+ end
38
+
26
39
  def load_data
40
+ return nil unless File.file?(source_path)
41
+
27
42
  case @config.format
28
- when "yaml" then YAML.load_file(@data_path)
29
- when "json" then JSON.parse(File.read(@data_path))
30
- when "csv" then CSV.read(@data_path, headers: true)
31
- when "tsv" then CSV.read(@data_path, col_sep: "\t", headers: true)
43
+ when "yaml" then YAML.load_file(source_path)
44
+ when "json" then JSON.parse(File.read(source_path))
45
+ when "csv" then CSV.read(source_path, headers: true)
46
+ when "tsv" then CSV.read(source_path, col_sep: "\t", headers: true)
32
47
  when "sqlite" then load_sqlite_data
33
48
  else
34
- raise "Unsupported data file type: #{@config.format}"
49
+ raise Error, "Unsupported data file type: #{@config.format}"
35
50
  end
36
51
  end
37
52
 
38
53
  def validate
39
- return unless @config.schema
54
+ return true unless @config.schema_file && @config.schema_type == "json-schema"
40
55
 
41
- schema_path = File.join(File.dirname(@data_path), @config.schema)
42
- schema = YAML.load_file(schema_path) if @config.format == "yaml"
43
- schema = JSON.parse(File.read(schema_path)) if @config.format == "json"
56
+ JSON::Validator.validate!(load_schema, @data.to_json)
57
+ end
44
58
 
45
- case @config.format
46
- when "yaml" then YAML.load_file(@data_path)
47
- when "json" then JSON.parse(File.read(@data_path))
48
- else
49
- raise "Validation is only supported for YAML and JSON formats"
59
+ # File-existence and schema validations for this dataset. Returns a
60
+ # list of human-readable problems; empty when valid.
61
+ def validation_errors
62
+ problems = []
63
+ unless File.file?(source_path)
64
+ problems << "dataset source missing on disk: #{@config.backing_file}"
50
65
  end
51
-
52
- JSON::Validator.validate!(schema, @data.to_json)
66
+ problems.concat(schema_validation_errors)
67
+ problems
53
68
  end
54
69
 
55
- def save_to_file(output_path)
56
- File.write(output_path, to_json)
70
+ private
71
+
72
+ def schema_validation_errors
73
+ return [] unless @config.schema_file
74
+ unless File.file?(schema_path)
75
+ return ["dataset schema missing on disk: #{@config.schema_file}"]
76
+ end
77
+
78
+ validate
79
+ []
80
+ rescue JSON::Schema::ValidationError => e
81
+ ["dataset #{@name} fails schema validation: #{e.message}"]
57
82
  end
58
83
 
59
- private
84
+ def load_schema
85
+ case File.extname(schema_path).downcase
86
+ when ".yaml", ".yml" then YAML.load_file(schema_path)
87
+ else JSON.parse(File.read(schema_path))
88
+ end
89
+ end
60
90
 
61
91
  def load_sqlite_data
62
- db = SQLite3::Database.new(@data_path)
63
- tables = db.execute("SELECT name FROM sqlite_master WHERE type='table';")
64
- data = {}
65
- tables.each do |table|
66
- table_name = table.first
67
- data[table_name] = db.execute("SELECT * FROM #{table_name};")
92
+ db = SQLite3::Database.new(source_path)
93
+ tables = @config.table ? [@config.table] : sqlite_tables(db)
94
+ tables.to_h do |table_name|
95
+ [table_name, db.execute("SELECT * FROM #{table_name};")]
68
96
  end
69
- data
97
+ ensure
98
+ db&.close
99
+ end
100
+
101
+ def sqlite_tables(db)
102
+ db.execute("SELECT name FROM sqlite_master WHERE type='table';").map(&:first)
70
103
  end
71
104
  end
72
105
  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
@@ -1,51 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/capsium/package/manifest.rb
4
- require "json"
3
+ require "forwardable"
5
4
  require "marcel"
6
- require "shale"
7
- require_relative "manifest_config"
5
+ require "pathname"
8
6
 
9
7
  module Capsium
10
8
  class Package
11
9
  class Manifest
12
10
  extend Forwardable
13
- attr_accessor :path, :content_path, :config
14
11
 
15
- def_delegators :@config, :to_json
12
+ attr_reader :path, :content_path, :config
13
+
14
+ def_delegators :@config, :to_hash
16
15
 
17
16
  def initialize(path)
18
17
  @path = path
19
18
  @content_path = File.join(File.dirname(@path), Package::CONTENT_DIR)
20
-
21
19
  @config = if File.exist?(path)
22
20
  ManifestConfig.from_json(File.read(path))
23
21
  else
24
- ManifestConfig.new(content: generate_manifest)
22
+ ManifestConfig.new(resources: generate_manifest)
25
23
  end
26
24
  end
27
25
 
26
+ # Auto-generation (ARCHITECTURE.md section 3): scan content/
27
+ # recursively, detect MIME types, default visibility "exported",
28
+ # deterministic (sorted) output.
28
29
  def generate_manifest
29
- files = Dir[File.join(@content_path, "**", "*")].reject do |f|
30
- File.directory?(f)
30
+ content_files.sort.to_h do |file_path|
31
+ [relative_path(file_path),
32
+ Resource.new(type: mime_from_path(file_path), visibility: "exported")]
31
33
  end
34
+ end
32
35
 
33
- files.sort.map do |file_path|
34
- ManifestConfigItem.new(
35
- file: relative_path(file_path),
36
- mime: mime_from_path(file_path),
37
- )
38
- end
36
+ def resources
37
+ @config.resources
39
38
  end
40
39
 
41
- def lookup(filename)
42
- @config.content.detect do |data_item|
43
- data_item.file == filename
44
- end
40
+ def lookup(path)
41
+ resources[path]
42
+ end
43
+
44
+ def type_for(path)
45
+ resource = lookup(path)
46
+ resource&.type
47
+ end
48
+
49
+ def to_json(*_args)
50
+ @config.to_json
45
51
  end
46
52
 
47
53
  def save_to_file(output_path = @path)
48
- @config.content.sort_by!(&:file)
49
54
  File.write(output_path, to_json)
50
55
  end
51
56
 
@@ -65,11 +70,17 @@ module Capsium
65
70
 
66
71
  private
67
72
 
73
+ def content_files
74
+ Dir.glob(File.join(@content_path, "**", "*")).select do |file|
75
+ File.file?(file)
76
+ end
77
+ end
78
+
68
79
  def mime_from_path(path)
69
80
  Marcel::MimeType.for(
70
81
  Pathname.new(path),
71
82
  name: File.basename(path),
72
- extension: File.extname(path),
83
+ extension: File.extname(path)
73
84
  )
74
85
  end
75
86
  end
@@ -1,17 +1,62 @@
1
- require "shale"
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "lutaml/model"
2
5
 
3
6
  module Capsium
4
7
  class Package
5
- class ManifestConfigItem < Shale::Mapper
6
- attribute :file, Shale::Type::String
7
- attribute :mime, Shale::Type::String
8
+ # A single manifest resource entry (ARCHITECTURE.md section 3).
9
+ class Resource < Lutaml::Model::Serializable
10
+ VISIBILITIES = %w[exported private].freeze
11
+
12
+ attribute :type, :string
13
+ attribute :visibility, :string, values: VISIBILITIES, default: "exported"
14
+ attribute :version, :string
15
+
16
+ json do
17
+ map :type, to: :type
18
+ map :visibility, to: :visibility
19
+ map :version, to: :version
20
+ end
8
21
  end
9
22
 
10
- class ManifestConfig < Shale::Mapper
11
- attribute :content, ManifestConfigItem, collection: true
23
+ # Canonical manifest.json model: an object keyed by package-relative
24
+ # resource path. The legacy gem form ({"content": [{file, mime}]}) is
25
+ # accepted on read and normalized; writers emit only the object form.
26
+ class ManifestConfig < Lutaml::Model::Serializable
27
+ attribute :resources, :hash, default: {}
28
+
29
+ json do
30
+ map :resources, with: { from: :resources_from_json, to: :resources_to_json }
31
+ end
32
+
33
+ def self.from_json(json)
34
+ doc = JSON.parse(json)
35
+ doc["resources"] ||= legacy_resources(doc.delete("content")) if doc.key?("content")
36
+ super(JSON.generate(doc))
37
+ end
38
+
39
+ def self.legacy_resources(content)
40
+ (content || []).to_h do |item|
41
+ [item["file"], { "type" => item["mime"], "visibility" => "exported" }]
42
+ end
43
+ end
44
+ private_class_method :legacy_resources
45
+
46
+ def resources_from_json(model, value)
47
+ model.resources = (value || {}).to_h do |path, attributes|
48
+ [path, Resource.from_json(JSON.generate(attributes))]
49
+ end
50
+ end
51
+
52
+ def resources_to_json(model, doc)
53
+ doc["resources"] = model.resources.sort.to_h do |path, resource|
54
+ [path, JSON.parse(resource.to_json)]
55
+ end
56
+ end
12
57
 
13
58
  def sort!
14
- @content.sort_by!(&:file)
59
+ self.resources = resources.sort.to_h
15
60
  self
16
61
  end
17
62
  end