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.
- checksums.yaml +4 -4
- data/.rubocop.yml +17 -1
- data/CHANGELOG.md +322 -0
- data/README.adoc +643 -7
- data/capsium.gemspec +3 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +106 -7
- data/lib/capsium/cli/reactor.rb +120 -6
- data/lib/capsium/cli.rb +30 -0
- data/lib/capsium/log_buffer.rb +47 -0
- data/lib/capsium/package/authentication.rb +38 -0
- data/lib/capsium/package/authentication_config.rb +78 -0
- data/lib/capsium/package/bundle.rb +136 -0
- data/lib/capsium/package/cipher.rb +253 -0
- data/lib/capsium/package/composition.rb +93 -0
- data/lib/capsium/package/dataset.rb +20 -0
- data/lib/capsium/package/dependency_resolver.rb +91 -0
- data/lib/capsium/package/encryption_config.rb +42 -0
- data/lib/capsium/package/manifest.rb +1 -1
- data/lib/capsium/package/merged_view.rb +170 -0
- data/lib/capsium/package/metadata.rb +1 -1
- data/lib/capsium/package/metadata_config.rb +9 -1
- data/lib/capsium/package/open_pgp.rb +65 -0
- data/lib/capsium/package/open_pgp_cipher.rb +66 -0
- data/lib/capsium/package/open_pgp_signer.rb +176 -0
- data/lib/capsium/package/preparation.rb +81 -0
- data/lib/capsium/package/routes_config.rb +60 -6
- data/lib/capsium/package/security.rb +21 -4
- data/lib/capsium/package/security_config.rb +6 -2
- data/lib/capsium/package/signer.rb +216 -0
- data/lib/capsium/package/storage_config.rb +29 -1
- data/lib/capsium/package/store.rb +136 -0
- data/lib/capsium/package/testing/config_test.rb +70 -0
- data/lib/capsium/package/testing/context.rb +11 -0
- data/lib/capsium/package/testing/data_validation_test.rb +63 -0
- data/lib/capsium/package/testing/file_test.rb +45 -0
- data/lib/capsium/package/testing/report.rb +33 -0
- data/lib/capsium/package/testing/route_test.rb +64 -0
- data/lib/capsium/package/testing/test_case.rb +60 -0
- data/lib/capsium/package/testing/test_suite.rb +86 -0
- data/lib/capsium/package/testing.rb +20 -0
- data/lib/capsium/package/validator.rb +39 -3
- data/lib/capsium/package/verification.rb +34 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +78 -75
- data/lib/capsium/packager.rb +97 -22
- data/lib/capsium/reactor/authenticator.rb +180 -0
- data/lib/capsium/reactor/content_api.rb +110 -0
- data/lib/capsium/reactor/data_api.rb +154 -0
- data/lib/capsium/reactor/deploy.rb +71 -0
- data/lib/capsium/reactor/endpoints.rb +37 -0
- data/lib/capsium/reactor/graphql_api.rb +123 -0
- data/lib/capsium/reactor/graphql_schema.rb +138 -0
- data/lib/capsium/reactor/htpasswd.rb +154 -0
- data/lib/capsium/reactor/introspection.rb +151 -31
- data/lib/capsium/reactor/metrics.rb +30 -0
- data/lib/capsium/reactor/mount.rb +206 -0
- data/lib/capsium/reactor/oauth2.rb +106 -0
- data/lib/capsium/reactor/overlay.rb +248 -0
- data/lib/capsium/reactor/package_saver.rb +99 -0
- data/lib/capsium/reactor/responses.rb +41 -0
- data/lib/capsium/reactor/serving.rb +154 -0
- data/lib/capsium/reactor/session.rb +82 -0
- data/lib/capsium/reactor.rb +116 -67
- data/lib/capsium/registry/local.rb +120 -0
- data/lib/capsium/registry/remote.rb +105 -0
- data/lib/capsium/registry.rb +190 -0
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +2 -0
- data/sig/capsium/log_buffer.rbs +30 -0
- data/sig/capsium/package/authentication.rbs +23 -0
- data/sig/capsium/package/authentication_config.rbs +71 -0
- data/sig/capsium/package/bundle.rbs +43 -0
- data/sig/capsium/package/cipher.rbs +62 -0
- data/sig/capsium/package/composition.rbs +21 -0
- data/sig/capsium/package/dataset.rbs +8 -0
- data/sig/capsium/package/dependency_resolver.rbs +53 -0
- data/sig/capsium/package/encryption_config.rbs +39 -0
- data/sig/capsium/package/merged_view.rbs +47 -0
- data/sig/capsium/package/metadata.rbs +1 -0
- data/sig/capsium/package/open_pgp.rbs +38 -0
- data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
- data/sig/capsium/package/open_pgp_signer.rbs +46 -0
- data/sig/capsium/package/preparation.rbs +23 -0
- data/sig/capsium/package/routes_config.rbs +36 -3
- data/sig/capsium/package/security.rbs +9 -2
- data/sig/capsium/package/security_config.rbs +6 -3
- data/sig/capsium/package/signer.rbs +66 -0
- data/sig/capsium/package/storage_config.rbs +26 -2
- data/sig/capsium/package/store.rbs +43 -0
- data/sig/capsium/package/testing/config_test.rbs +21 -0
- data/sig/capsium/package/testing/context.rbs +14 -0
- data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
- data/sig/capsium/package/testing/file_test.rbs +17 -0
- data/sig/capsium/package/testing/report.rbs +20 -0
- data/sig/capsium/package/testing/route_test.rbs +22 -0
- data/sig/capsium/package/testing/test_case.rbs +41 -0
- data/sig/capsium/package/testing/test_suite.rbs +20 -0
- data/sig/capsium/package/testing.rbs +7 -0
- data/sig/capsium/package/verification.rbs +26 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +29 -16
- data/sig/capsium/packager.rbs +9 -0
- data/sig/capsium/reactor/authenticator.rbs +40 -0
- data/sig/capsium/reactor/content_api.rbs +19 -0
- data/sig/capsium/reactor/data_api.rbs +23 -0
- data/sig/capsium/reactor/deploy.rbs +34 -0
- data/sig/capsium/reactor/endpoints.rbs +16 -0
- data/sig/capsium/reactor/graphql_api.rbs +27 -0
- data/sig/capsium/reactor/graphql_schema.rbs +20 -0
- data/sig/capsium/reactor/htpasswd.rbs +36 -0
- data/sig/capsium/reactor/introspection.rbs +27 -7
- data/sig/capsium/reactor/metrics.rbs +13 -0
- data/sig/capsium/reactor/mount.rbs +83 -0
- data/sig/capsium/reactor/oauth2.rbs +31 -0
- data/sig/capsium/reactor/overlay.rbs +72 -0
- data/sig/capsium/reactor/package_saver.rbs +12 -0
- data/sig/capsium/reactor/responses.rbs +23 -0
- data/sig/capsium/reactor/serving.rbs +46 -0
- data/sig/capsium/reactor/session.rbs +36 -0
- data/sig/capsium/reactor.rbs +22 -2
- data/sig/capsium/registry/local.rbs +16 -0
- data/sig/capsium/registry/remote.rbs +18 -0
- data/sig/capsium/registry.rbs +83 -0
- data/sig/graphql.rbs +12 -0
- metadata +135 -1
data/lib/capsium/package.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/capsium/packager.rb
CHANGED
|
@@ -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
|
-
|
|
24
|
-
cap_file_path
|
|
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
|
-
|
|
36
|
-
FileUtils.
|
|
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
|
|
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
|