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
@@ -6,58 +6,98 @@ require "tmpdir"
6
6
 
7
7
  module Capsium
8
8
  class Package
9
+ autoload :Cipher, "capsium/package/cipher"
10
+ autoload :CircularDependencyError, "capsium/package/dependency_resolver"
11
+ autoload :Composition, "capsium/package/composition"
12
+ autoload :Authentication, "capsium/package/authentication"
13
+ autoload :AuthenticationConfig, "capsium/package/authentication_config"
14
+ autoload :AuthenticationData, "capsium/package/authentication_config"
15
+ autoload :BasicAuthConfig, "capsium/package/authentication_config"
16
+ autoload :Bundle, "capsium/package/bundle"
9
17
  autoload :Dataset, "capsium/package/dataset"
10
18
  autoload :DatasetConfig, "capsium/package/storage_config"
19
+ autoload :DependencyError, "capsium/package/dependency_resolver"
20
+ autoload :DependencyNotFoundError, "capsium/package/dependency_resolver"
21
+ autoload :DependencyResolver, "capsium/package/dependency_resolver"
22
+ autoload :DependencyVisibilityError, "capsium/package/dependency_resolver"
11
23
  autoload :DigitalSignatures, "capsium/package/security_config"
24
+ autoload :EncryptionConfig, "capsium/package/encryption_config"
25
+ autoload :EncryptionEnvelope, "capsium/package/encryption_config"
12
26
  autoload :IntegrityChecks, "capsium/package/security_config"
27
+ autoload :LayerConfig, "capsium/package/storage_config"
13
28
  autoload :Manifest, "capsium/package/manifest"
14
29
  autoload :ManifestConfig, "capsium/package/manifest_config"
30
+ autoload :MergedView, "capsium/package/merged_view"
15
31
  autoload :Metadata, "capsium/package/metadata"
16
32
  autoload :MetadataData, "capsium/package/metadata_config"
33
+ autoload :OAuth2Config, "capsium/package/authentication_config"
34
+ autoload :OpenPgp, "capsium/package/open_pgp"
35
+ autoload :OpenPgpCipher, "capsium/package/open_pgp_cipher"
36
+ autoload :OpenPgpSigner, "capsium/package/open_pgp_signer"
37
+ autoload :Preparation, "capsium/package/preparation"
17
38
  autoload :Repository, "capsium/package/metadata_config"
39
+ autoload :ResolvedDependency, "capsium/package/dependency_resolver"
18
40
  autoload :Resource, "capsium/package/manifest_config"
41
+ autoload :ResponseRewrite, "capsium/package/routes_config"
19
42
  autoload :Route, "capsium/package/routes_config"
20
43
  autoload :Routes, "capsium/package/routes"
21
44
  autoload :RoutesConfig, "capsium/package/routes_config"
22
45
  autoload :Security, "capsium/package/security"
23
46
  autoload :SecurityConfig, "capsium/package/security_config"
24
47
  autoload :SecurityData, "capsium/package/security_config"
48
+ autoload :Signer, "capsium/package/signer"
25
49
  autoload :Storage, "capsium/package/storage"
26
50
  autoload :StorageConfig, "capsium/package/storage_config"
27
51
  autoload :StorageData, "capsium/package/storage_config"
52
+ autoload :Store, "capsium/package/store"
53
+ autoload :Testing, "capsium/package/testing"
54
+ autoload :UnsatisfiableDependencyError, "capsium/package/dependency_resolver"
28
55
  autoload :Validator, "capsium/package/validator"
56
+ autoload :Verification, "capsium/package/verification"
57
+ autoload :Version, "capsium/package/version"
58
+ autoload :VersionRange, "capsium/package/version"
59
+
60
+ include Verification
61
+ include Composition
62
+ include Preparation
29
63
 
30
64
  attr_reader :name, :path, :manifest, :metadata, :routes, :storage,
31
- :security, :load_type
65
+ :security, :load_type, :resolved_dependencies, :authentication
32
66
 
33
67
  MANIFEST_FILE = "manifest.json"
34
68
  METADATA_FILE = "metadata.json"
35
69
  STORAGE_FILE = "storage.json"
36
70
  ROUTES_FILE = "routes.json"
37
71
  SECURITY_FILE = "security.json"
72
+ SIGNATURE_FILE = "signature.sig"
73
+ AUTHENTICATION_FILE = "authentication.json"
38
74
  CONTENT_DIR = "content"
39
75
  DATA_DIR = "data"
40
76
 
41
- def initialize(path, load_type: nil)
77
+ # Loads a package directory or .cap file. Composite packages
78
+ # (metadata.dependencies, ARCHITECTURE.md section 4a) resolve against
79
+ # the package store given as `store` (directory path or Store) or via
80
+ # CAPSIUM_STORE, falling back to the registry given as `registry`
81
+ # (Capsium::Registry or reference) or via CAPSIUM_REGISTRY when the
82
+ # store has no package for a dependency GUID. Dependencies bundled
83
+ # under packages/ (encapsulated packages, Capsium::Package::Bundle)
84
+ # resolve first, without any store or registry. `dependency_chain`
85
+ # is internal: the ancestor GUIDs used for circular-dependency
86
+ # detection during recursive resolution. `bundle` is internal too:
87
+ # the ancestor bundle serving re-declared transitive dependencies.
88
+ def initialize(path, load_type: nil, decryption_key: nil, store: nil,
89
+ registry: nil, dependency_chain: [], bundle: nil)
90
+ @decryption_key = decryption_key
42
91
  @original_path = Pathname.new(path).expand_path
43
92
  @path = prepare_package(@original_path).to_s
44
93
  @load_type = load_type || determine_load_type(path)
45
94
  create_package_structure
46
95
  load_package
47
96
  @name = metadata.name
97
+ @resolved_dependencies = resolve_dependencies(store, registry, dependency_chain, bundle)
98
+ validate_dependency_references!
48
99
  verify_integrity!
49
- end
50
-
51
- def prepare_package(path)
52
- return path if File.directory?(path)
53
-
54
- if File.file?(path)
55
- return decompress_cap_file(path) if File.extname(path) == ".cap"
56
-
57
- raise Error, "The package must have a .cap extension"
58
- end
59
-
60
- raise Error, "Invalid package path: #{path}"
100
+ verify_signature!
61
101
  end
62
102
 
63
103
  def solidify
@@ -67,13 +107,6 @@ module Capsium
67
107
  @storage.save_to_file unless @storage.empty?
68
108
  end
69
109
 
70
- def decompress_cap_file(file_path)
71
- package_path = File.join(Dir.mktmpdir, package_stem(file_path))
72
- FileUtils.mkdir_p(package_path)
73
- Packager.new.unpack(file_path, package_path)
74
- package_path
75
- end
76
-
77
110
  def load_package
78
111
  # Mandatory
79
112
  @metadata = Metadata.new(metadata_path)
@@ -83,16 +116,34 @@ module Capsium
83
116
  @storage = Storage.new(storage_path)
84
117
  @routes = Routes.new(routes_path, @manifest, @storage)
85
118
  @security = Security.new(security_path)
119
+ @authentication = Authentication.new(authentication_path)
86
120
  end
87
121
 
88
122
  def cleanup
89
- return unless @path != @original_path.to_s && File.directory?(@path)
90
-
91
- FileUtils.remove_entry(@path)
123
+ @resolved_dependencies.each { |dependency| dependency.package.cleanup }
124
+ FileUtils.remove_entry(@path) if @path != @original_path.to_s && File.directory?(@path)
92
125
  end
93
126
 
94
- def datasets
95
- storage.datasets
127
+ def datasets = storage.datasets
128
+
129
+ # The merged content view across the storage layers (ARCHITECTURE.md
130
+ # section 5a); exported_only gives the dependent-package view (section 4a).
131
+ # Resolved dependencies act as read-only layers below all own layers.
132
+ # extra_layers stack above everything (the reactor's writable overlay);
133
+ # views with extra layers are built fresh, not memoized.
134
+ def merged_view(exported_only: false, extra_layers: [])
135
+ unless extra_layers.empty?
136
+ return MergedView.new(@path, storage: @storage, manifest: @manifest,
137
+ dependency_views: dependency_views,
138
+ exported_only: exported_only,
139
+ extra_layers: extra_layers)
140
+ end
141
+
142
+ @merged_views ||= {}
143
+ @merged_views[exported_only] ||=
144
+ MergedView.new(@path, storage: @storage, manifest: @manifest,
145
+ dependency_views: dependency_views,
146
+ exported_only: exported_only)
96
147
  end
97
148
 
98
149
  # The .cap file this package was loaded from, or nil when loaded
@@ -102,55 +153,7 @@ module Capsium
102
153
  end
103
154
 
104
155
  def content_files
105
- Dir.glob(File.join(content_path, "**", "*")).select do |file|
106
- File.file?(file)
107
- end
156
+ Dir.glob(File.join(content_path, "**", "*")).select { |file| File.file?(file) }
108
157
  end
109
-
110
- def determine_load_type(path)
111
- return :directory if File.directory?(path)
112
- return :cap_file if File.extname(path) == ".cap"
113
-
114
- :unsaved
115
- end
116
-
117
- # Verifies the package against security.json (ARCHITECTURE.md section
118
- # 6). Returns a list of typed errors; empty when no security.json is
119
- # present or all checksums match.
120
- def verify_integrity
121
- return [] unless @security.present?
122
-
123
- @security.verify(@path)
124
- end
125
-
126
- def verify_integrity!
127
- @security.verify!(@path) if @security.present?
128
- end
129
-
130
- private
131
-
132
- def package_stem(file_path)
133
- File.basename(file_path, ".cap")
134
- end
135
-
136
- def create_package_structure
137
- FileUtils.mkdir_p(@path)
138
- FileUtils.mkdir_p(content_path)
139
- FileUtils.mkdir_p(data_path)
140
- end
141
-
142
- def content_path = File.join(@path, CONTENT_DIR)
143
-
144
- def data_path = File.join(@path, DATA_DIR)
145
-
146
- def routes_path = File.join(@path, ROUTES_FILE)
147
-
148
- def storage_path = File.join(@path, STORAGE_FILE)
149
-
150
- def metadata_path = File.join(@path, METADATA_FILE)
151
-
152
- def manifest_path = File.join(@path, MANIFEST_FILE)
153
-
154
- def security_path = File.join(@path, SECURITY_FILE)
155
158
  end
156
159
  end
@@ -16,31 +16,16 @@ module Capsium
16
16
  class UnsafeEntryError < Capsium::Error; end
17
17
 
18
18
  DRIVE_LETTER_PATTERN = %r{\A[A-Za-z]:[/\\]}
19
+ DOT_ENTRIES = [".", ".."].freeze
19
20
 
20
21
  def pack(package, options = {})
21
- directory = package.path
22
22
  output_file_name = "#{package.metadata.name}-#{package.metadata.version}.cap"
23
- output_directory = File.dirname(directory)
24
- cap_file_path = File.join(output_directory, output_file_name)
25
-
26
- if File.exist?(cap_file_path) && !options[:force]
27
- raise FileAlreadyExistsError,
28
- "Package target already exists, aborting: `#{relative_path_current(cap_file_path)}`"
29
- elsif File.exist?(cap_file_path)
30
- puts "Package target already exists, overwriting: `#{relative_path_current(cap_file_path)}`"
31
- FileUtils.rm_f(cap_file_path)
32
- end
23
+ cap_file_path = File.join(File.dirname(package.path), output_file_name)
24
+ check_target(cap_file_path, options)
33
25
 
34
26
  Dir.mktmpdir do |dir|
35
- FileUtils.cp_r("#{directory}/.", dir)
36
- FileUtils.rm_f(File.join(dir, Package::SECURITY_FILE))
37
- new_package = Package.new(dir)
38
- new_package.solidify
39
- generate_security(new_package)
40
- new_cap_file_path = File.join(dir, output_file_name)
41
- compress_package(new_package, new_cap_file_path)
42
- puts "Package built at: #{new_cap_file_path}"
43
- FileUtils.mv(new_cap_file_path, cap_file_path)
27
+ build_cap(package, dir, output_file_name, options)
28
+ FileUtils.mv(File.join(dir, output_file_name), cap_file_path)
44
29
  puts "Package created: #{relative_path_current(cap_file_path)}"
45
30
  return cap_file_path
46
31
  end
@@ -58,8 +43,9 @@ module Capsium
58
43
  end
59
44
 
60
45
  def compress_package(package, cap_file)
61
- entries = Dir[File.join(package.path, "**", "**")].reject do |file|
62
- File.expand_path(file) == File.expand_path(cap_file)
46
+ entries = Dir.glob(File.join(package.path, "**", "**"), File::FNM_DOTMATCH).reject do |file|
47
+ File.expand_path(file) == File.expand_path(cap_file) ||
48
+ DOT_ENTRIES.include?(File.basename(file))
63
49
  end
64
50
  Zip::File.open(cap_file, create: true) do |zipfile|
65
51
  entries.each do |file|
@@ -68,12 +54,93 @@ module Capsium
68
54
  end
69
55
  end
70
56
 
57
+ # Unpacks a .cap into a temporary directory, yields it for read-only
58
+ # inspection and returns the block's value.
59
+ def with_unpacked_cap(cap_path)
60
+ Dir.mktmpdir do |dir|
61
+ unpack(cap_path, dir)
62
+ yield dir
63
+ end
64
+ end
65
+
66
+ # Unpacks a .cap into a temporary directory, yields it for
67
+ # modification, then recompresses the result back over cap_path.
68
+ # The modified package is loaded (verifying integrity and any
69
+ # declared signature) before recompressing.
70
+ def transform_cap(cap_path)
71
+ with_unpacked_cap(cap_path) do |dir|
72
+ yield dir
73
+ Dir.mktmpdir do |tmp|
74
+ tmp_cap = File.join(tmp, File.basename(cap_path))
75
+ compress_package(Package.new(dir), tmp_cap)
76
+ FileUtils.mv(tmp_cap, cap_path)
77
+ end
78
+ end
79
+ cap_path
80
+ end
81
+
71
82
  def relative_path_current(absolute_path)
72
83
  Pathname.new(absolute_path).relative_path_from(Dir.pwd).to_s
73
84
  end
74
85
 
75
86
  private
76
87
 
88
+ # Guards the pack target: without :force an existing .cap aborts,
89
+ # with :force it is removed first.
90
+ def check_target(cap_file_path, options)
91
+ if File.exist?(cap_file_path) && !options[:force]
92
+ raise FileAlreadyExistsError,
93
+ "Package target already exists, aborting: `#{relative_path_current(cap_file_path)}`"
94
+ end
95
+ return unless File.exist?(cap_file_path)
96
+
97
+ puts "Package target already exists, overwriting: `#{relative_path_current(cap_file_path)}`"
98
+ FileUtils.rm_f(cap_file_path)
99
+ end
100
+
101
+ # Builds the .cap inside the temporary directory: copy, optional
102
+ # dependency bundling, solidify, security.json, compress.
103
+ def build_cap(package, directory, output_file_name, options)
104
+ FileUtils.cp_r("#{package.path}/.", directory)
105
+ strip_security_artifacts(directory)
106
+ bundle_dependencies(directory, options) if options[:bundle_deps]
107
+ new_package = load_packed_package(directory, options)
108
+ new_package.solidify
109
+ generate_security(new_package)
110
+ built_path = File.join(directory, output_file_name)
111
+ compress_package(new_package, built_path)
112
+ puts "Package built at: #{built_path}"
113
+ end
114
+
115
+ # Loads the copied package directory for solidification, with the
116
+ # pack-time store/registry available for dependency resolution.
117
+ def load_packed_package(directory, options)
118
+ Package.new(directory, store: options[:store], registry: options[:registry])
119
+ end
120
+
121
+ # Encapsulated packing (`pack --bundle-deps`): resolves every
122
+ # declared dependency through the store -> registry chain and embeds
123
+ # the resolved .cap files under packages/ (Capsium::Package::Bundle),
124
+ # so the packed package activates with no store or registry. Only
125
+ # the declared dependencies are bundled — the one-level policy: the
126
+ # parent's metadata.dependencies must list the transitive closure.
127
+ # Typed dependency errors (DependencyNotFoundError,
128
+ # UnsatisfiableDependencyError) propagate for unresolvable
129
+ # dependencies.
130
+ def bundle_dependencies(directory, options)
131
+ metadata = Package::Metadata.new(File.join(directory, Package::METADATA_FILE))
132
+ declared = metadata.dependencies
133
+ return if declared.empty?
134
+
135
+ resolver = Package::DependencyResolver.new(
136
+ options[:store] || Package::Store.default, registry: options[:registry]
137
+ )
138
+ resolutions = declared.map do |guid, range|
139
+ [guid, resolver.resolve_path(guid, range, chain: [metadata.guid])]
140
+ end
141
+ Package::Bundle.write(directory, resolutions)
142
+ end
143
+
77
144
  # Resolves an entry name against the destination and returns the
78
145
  # absolute target path, raising UnsafeEntryError when the entry would
79
146
  # escape the destination (zip-slip).
@@ -94,5 +161,13 @@ module Capsium
94
161
  security = Package::Security.generate(package.path)
95
162
  security.save_to_file
96
163
  end
164
+
165
+ # security.json is regenerated on pack; signing artifacts are dropped
166
+ # because signing is a post-pack step (`capsium package sign`).
167
+ def strip_security_artifacts(directory)
168
+ FileUtils.rm_f(File.join(directory, Package::SECURITY_FILE))
169
+ FileUtils.rm_f(File.join(directory, Package::SIGNATURE_FILE))
170
+ FileUtils.rm_f(File.join(directory, Package::Signer::PUBLIC_KEY_FILE))
171
+ end
97
172
  end
98
173
  end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Capsium
6
+ class Reactor
7
+ # Request authentication and route authorization for the reactor
8
+ # (05x-authentication, ARCHITECTURE.md section 4b).
9
+ #
10
+ # When the package's authentication.json enables basicAuth, every
11
+ # route is challenged (401 + WWW-Authenticate) until valid htpasswd
12
+ # credentials arrive. When oauth2 is enabled, the login and callback
13
+ # endpoints run the authorization-code flow and establish a signed
14
+ # session cookie. Route-level accessControl is enforced after
15
+ # authentication: 401 when unauthenticated, 403 when the identity
16
+ # lacks a required role.
17
+ class Authenticator
18
+ LOGIN_PATH = "/auth/login"
19
+
20
+ attr_reader :authentication, :deploy
21
+
22
+ def initialize(authentication, deploy:, package_path:, base_url: nil,
23
+ state_file: nil)
24
+ @authentication = authentication
25
+ @deploy = deploy
26
+ @package_path = package_path
27
+ @base_url = base_url
28
+ @state_file = state_file
29
+ build_oauth2_flow if oauth2_enabled?
30
+ end
31
+
32
+ def enabled?
33
+ @authentication.enabled?
34
+ end
35
+
36
+ # The paths this authenticator answers itself (OAuth2 login and
37
+ # callback), mounted by the reactor.
38
+ def endpoints
39
+ oauth2_enabled? ? [LOGIN_PATH, @authentication.oauth2.redirect_path] : []
40
+ end
41
+
42
+ def endpoint?(path)
43
+ endpoints.include?(path)
44
+ end
45
+
46
+ def serve_endpoint(request, response)
47
+ if request.path == LOGIN_PATH
48
+ response.status = 302
49
+ response["Location"] = @oauth2_flow.authorization_url(request)
50
+ response.body = "Redirecting to the identity provider"
51
+ else
52
+ handle_callback(request, response)
53
+ end
54
+ end
55
+
56
+ # The identity for a request — from the session cookie or Basic
57
+ # credentials — or nil. An identity always carries "roles"
58
+ # (possibly empty).
59
+ def authenticate(request)
60
+ identity = @session&.identity_from(request)
61
+ identity || authenticate_basic(request)
62
+ end
63
+
64
+ # 401, with a Basic challenge when basicAuth is enabled.
65
+ def challenge(response)
66
+ response.status = 401
67
+ response["Content-Type"] = "text/plain"
68
+ response["WWW-Authenticate"] = %(Basic realm="#{realm}") if basic_enabled?
69
+ response.body = "Unauthorized"
70
+ end
71
+
72
+ # :ok, :unauthenticated or :forbidden for a route's accessControl
73
+ # ({"roles": [...], "authenticationRequired": bool}).
74
+ def authorize(identity, access_control)
75
+ return :ok unless access_control
76
+ return :unauthenticated if unauthenticated?(identity, access_control)
77
+ return :forbidden unless roles_allowed?(identity, access_control["roles"])
78
+
79
+ :ok
80
+ end
81
+
82
+ private
83
+
84
+ def unauthenticated?(identity, access_control)
85
+ access_control.fetch("authenticationRequired", true) && identity.nil?
86
+ end
87
+
88
+ def roles_allowed?(identity, roles)
89
+ return true if roles.nil? || roles.empty?
90
+ return false if identity.nil?
91
+
92
+ roles.intersect?(identity["roles"])
93
+ end
94
+
95
+ def basic_enabled? = !!@authentication.basic_auth&.enabled?
96
+
97
+ def oauth2_enabled? = !!@authentication.oauth2&.enabled?
98
+
99
+ def realm = @authentication.basic_auth&.realm || "capsium"
100
+
101
+ def authenticate_basic(request)
102
+ return unless basic_enabled?
103
+
104
+ credentials = basic_credentials(request["Authorization"])
105
+ return unless credentials
106
+
107
+ username, password = credentials
108
+ return unless htpasswd.verify?(username, password)
109
+
110
+ { "name" => username, "roles" => Array(@deploy.roles[username]) }
111
+ end
112
+
113
+ def basic_credentials(header)
114
+ return unless header&.start_with?("Basic ")
115
+
116
+ Base64.decode64(header.delete_prefix("Basic ")).split(":", 2)
117
+ rescue ArgumentError
118
+ nil
119
+ end
120
+
121
+ def htpasswd
122
+ @htpasswd ||= Htpasswd.new(htpasswd_path)
123
+ end
124
+
125
+ # deploy.json's basicAuth.passwdFile overrides the package's
126
+ # (reactor-side secret storage); the package's passwdFile is
127
+ # package-relative otherwise.
128
+ def htpasswd_path
129
+ @deploy.passwd_file ||
130
+ File.join(@package_path, @authentication.basic_auth.passwd_file.to_s)
131
+ end
132
+
133
+ def build_oauth2_flow
134
+ secret = @deploy.client_secret
135
+ if secret.nil? || secret.empty?
136
+ raise Error,
137
+ "oauth2 is enabled but no clientSecret is configured " \
138
+ "(deploy.json or CAPSIUM_DEPLOY; never in the package)"
139
+ end
140
+
141
+ @session = Session.new(secret: @deploy.session_secret,
142
+ state_file: @state_file)
143
+ @oauth2_flow = OAuth2.new(@authentication.oauth2, client_secret: secret,
144
+ session: @session,
145
+ base_url: @base_url)
146
+ end
147
+
148
+ def handle_callback(request, response)
149
+ query = request.query
150
+ return respond_invalid_state(response) unless @oauth2_flow.valid_state?(query["state"])
151
+
152
+ userinfo = @oauth2_flow.complete(query["code"], request)
153
+ response.status = 302
154
+ response["Location"] = "/"
155
+ response["Set-Cookie"] = @session.cookie_for(identity_from(userinfo))
156
+ response.body = "Authenticated"
157
+ rescue OAuth2::FlowError => e
158
+ response.status = 502
159
+ response["Content-Type"] = "text/plain"
160
+ response.body = "OAuth2 provider error: #{e.message}"
161
+ end
162
+
163
+ def respond_invalid_state(response)
164
+ response.status = 401
165
+ response["Content-Type"] = "text/plain"
166
+ response.body = "Invalid OAuth2 state"
167
+ end
168
+
169
+ def identity_from(userinfo)
170
+ name = userinfo["email"] || userinfo["sub"] || userinfo["name"]
171
+ {
172
+ "sub" => userinfo["sub"] || userinfo["id"],
173
+ "email" => userinfo["email"],
174
+ "name" => userinfo["name"],
175
+ "roles" => Array(userinfo["roles"]) | Array(@deploy.roles[name])
176
+ }.compact
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Capsium
6
+ class Reactor
7
+ # Content writes over the mount's overlay (ARCHITECTURE.md section
8
+ # 5a): PUT creates/overwrites a content file (creating its route on
9
+ # demand), DELETE records a tombstone so the path 404s even when a
10
+ # lower layer holds the file. The immutable package never changes
11
+ # on disk. Text bodies only for v1.
12
+ class ContentApi
13
+ include Responses
14
+
15
+ WRITE_METHODS = %w[PUT DELETE].freeze
16
+ CONTENT_PREFIX = "#{Package::CONTENT_DIR}/".freeze
17
+
18
+ def self.write_method?(method)
19
+ WRITE_METHODS.include?(method)
20
+ end
21
+
22
+ def initialize(mount)
23
+ @mount = mount
24
+ end
25
+
26
+ def handle(inner_path, request, response)
27
+ return read_only(response) unless @mount.writable?
28
+
29
+ route = @mount.routes.resolve(inner_path)
30
+ return guard_route(route, inner_path, response) if route && !writable_route?(route)
31
+
32
+ case request.request_method
33
+ when "PUT" then put_content(inner_path, route, request, response)
34
+ when "DELETE" then delete_content(inner_path, route, response)
35
+ else respond_method_not_allowed(response)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # A route is writable when it is a plain resource route (or no
42
+ # route at all): dataset paths are the DataApi's, handler routes
43
+ # are not writable, dependency content stays immutable.
44
+ def writable_route?(route)
45
+ route.kind == :resource && !route.dependency_reference?
46
+ end
47
+
48
+ def guard_route(route, inner_path, response)
49
+ return respond_method_not_allowed(response) if route.kind == :dataset
50
+
51
+ respond_error(response, 400, "cannot write #{route.kind} content: #{inner_path}")
52
+ end
53
+
54
+ def put_content(inner_path, route, request, response)
55
+ relative = content_relative(resource_path(inner_path, route))
56
+ return unsafe_path(inner_path, response) if relative.nil?
57
+
58
+ @mount.overlay.write_content(relative, request.body.to_s)
59
+ resource = "#{Package::CONTENT_DIR}/#{relative}"
60
+ @mount.package.routes.add_route(inner_path, resource) unless route
61
+ respond_json(response, 200, { path: inner_path, resource: resource })
62
+ rescue Overlay::Error => e
63
+ respond_error(response, 400, e.message)
64
+ end
65
+
66
+ # Tombstones the served resource: 404 when the path is already
67
+ # tombstoned or nothing (here or in a lower layer) serves it.
68
+ def delete_content(inner_path, route, response)
69
+ relative = content_relative(resource_path(inner_path, route))
70
+ return unsafe_path(inner_path, response) if relative.nil?
71
+ return respond_not_found(response) if @mount.overlay.tombstones.include?(relative)
72
+ unless @mount.merged_view.resolve("#{Package::CONTENT_DIR}/#{relative}")
73
+ return respond_not_found(response)
74
+ end
75
+
76
+ @mount.overlay.delete_content(relative)
77
+ response.status = 204
78
+ rescue Overlay::Error => e
79
+ respond_error(response, 400, e.message)
80
+ end
81
+
82
+ def unsafe_path(inner_path, response)
83
+ respond_error(response, 400, "unsafe content path: #{inner_path}")
84
+ end
85
+
86
+ def read_only(response)
87
+ respond_error(response, 403, "package #{@mount.package.name} is read-only")
88
+ end
89
+
90
+ # The package resource a write addresses: the existing route's
91
+ # resource (overwrite what GET serves), else a content file
92
+ # derived from the URL path (created on demand).
93
+ def resource_path(inner_path, route)
94
+ route ? route.resource : "#{Package::CONTENT_DIR}/#{inner_path.delete_prefix('/')}"
95
+ end
96
+
97
+ # The content/-relative path for a package resource, nil for
98
+ # unsafe paths (outside content/, dot segments).
99
+ def content_relative(resource_path)
100
+ return nil unless resource_path.start_with?(CONTENT_PREFIX)
101
+
102
+ relative = resource_path.delete_prefix(CONTENT_PREFIX)
103
+ segments = relative.split("/")
104
+ return nil if segments.empty? || segments.any? { |s| s.empty? || s == ".." }
105
+
106
+ relative
107
+ end
108
+ end
109
+ end
110
+ end