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
@@ -1,28 +1,86 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "json"
4
5
  require "listen"
6
+ require "tmpdir"
5
7
  require "webrick"
6
8
 
9
+ # WEBrick's ProcHandler only dispatches do_GET/do_POST/do_PUT; the
10
+ # writable-package API (REST CRUD, content writes) also needs DELETE
11
+ # and PATCH. AbstractServlet#service looks the do_<METHOD> up per
12
+ # request, so aliasing the missing verbs routes them to the same proc.
13
+ # rubocop:disable Style/OneClassPerFile
14
+ module WEBrick
15
+ module HTTPServlet
16
+ class ProcHandler < AbstractServlet
17
+ alias do_DELETE do_GET # rubocop:disable Naming/MethodName
18
+ alias do_PATCH do_GET # rubocop:disable Naming/MethodName
19
+ end
20
+ end
21
+ end
22
+
7
23
  module Capsium
8
24
  class Reactor
25
+ autoload :Authenticator, "capsium/reactor/authenticator"
26
+ autoload :ContentApi, "capsium/reactor/content_api"
27
+ autoload :DataApi, "capsium/reactor/data_api"
28
+ autoload :Deploy, "capsium/reactor/deploy"
29
+ autoload :Endpoints, "capsium/reactor/endpoints"
30
+ autoload :GraphqlApi, "capsium/reactor/graphql_api"
31
+ autoload :GraphqlSchema, "capsium/reactor/graphql_schema"
32
+ autoload :Htpasswd, "capsium/reactor/htpasswd"
9
33
  autoload :Introspection, "capsium/reactor/introspection"
34
+ autoload :Metrics, "capsium/reactor/metrics"
35
+ autoload :Mount, "capsium/reactor/mount"
36
+ autoload :MountConflictError, "capsium/reactor/mount"
37
+ autoload :OAuth2, "capsium/reactor/oauth2"
38
+ autoload :Overlay, "capsium/reactor/overlay"
39
+ autoload :PackageSaver, "capsium/reactor/package_saver"
40
+ autoload :Responses, "capsium/reactor/responses"
41
+ autoload :Serving, "capsium/reactor/serving"
42
+ autoload :Session, "capsium/reactor/session"
43
+
44
+ include Responses
45
+ include Endpoints
46
+ include Serving
10
47
 
11
48
  DEFAULT_PORT = 8864
12
49
  DEFAULT_CACHE_CONTROL = "public, max-age=31536000"
50
+ PACKAGE_SAVE_PATTERN = %r{\A/package/(?<name>[^/]+)/save\z}
13
51
 
14
52
  attr_reader :package, :package_path, :routes, :port, :cache_control,
15
- :server, :server_thread, :introspection
16
-
17
- def initialize(package:, port: DEFAULT_PORT,
18
- cache_control: DEFAULT_CACHE_CONTROL, do_not_listen: false)
19
- @package = package.is_a?(String) ? Package.new(package) : package
20
- @package_path = @package.path
53
+ :server, :server_thread, :introspection, :authenticator,
54
+ :store, :registry, :started_at, :metrics, :log_buffer,
55
+ :mounts, :workdir
56
+
57
+ # Serves one or more packages: either a single `package` (directory,
58
+ # .cap file or Package, mounted at "/") or a list of `mounts`
59
+ # (Reactor::Mount) resolved by longest-prefix matching. `workdir`
60
+ # holds the writable overlays (ARCHITECTURE.md section 5a) and
61
+ # saved packages; it defaults to a temporary directory the reactor
62
+ # removes on cleanup.
63
+ def initialize(package: nil, mounts: nil, port: DEFAULT_PORT,
64
+ cache_control: DEFAULT_CACHE_CONTROL, do_not_listen: false,
65
+ store: nil, deploy: nil, registry: nil, workdir: nil)
66
+ @store = store
67
+ @registry = registry
68
+ @workdir = workdir || Dir.mktmpdir("capsium-reactor-")
69
+ @own_workdir = workdir.nil?
70
+ @mounts = mounts || [Mount.new(path: Mount::ROOT_PATH, package: package,
71
+ store: store, registry: registry)]
72
+ @mounts.each { |mount| mount.attach_workdir(@workdir) }
21
73
  @port = port
22
74
  @cache_control = cache_control
75
+ @started_at = Time.now
76
+ @metrics = Metrics.new
77
+ @log_buffer = Capsium::LogBuffer.new
78
+ @deploy_config = Deploy.load(deploy)
23
79
  setup_server(do_not_listen)
24
80
  load_state
25
81
  mount_routes
82
+ @log_buffer.add("reactor started: " \
83
+ "#{@mounts.map(&:summary).join(', ')} on port #{@port}")
26
84
  end
27
85
 
28
86
  def serve
@@ -31,29 +89,43 @@ module Capsium
31
89
  start_listener
32
90
  end
33
91
 
92
+ # Entry point for every mounted path: dispatches the request, then
93
+ # records it in the request metrics and the log buffer.
34
94
  def handle_request(request, response)
35
- return serve_introspection(request, response) if @introspection.endpoint?(request.path)
36
-
37
- route = @routes.resolve(request.path)
38
- route ? serve_route(route, response) : respond_not_found(response)
95
+ dispatch_request(request, response)
96
+ ensure
97
+ record_request(request, response)
39
98
  end
40
99
 
41
100
  def mount_routes
42
- paths = @routes.config.routes.map(&:path) + Introspection::PATHS
101
+ paths = Introspection::PATHS + Introspection::REACTOR_PATHS +
102
+ @authenticator.endpoints
103
+ paths.concat(@mounts.flat_map(&:server_paths))
43
104
  paths.each do |path|
44
105
  @server.mount_proc(path.to_s) { |req, res| handle_request(req, res) }
45
106
  end
107
+ # WEBrick longest-prefix matching: catches every "/package/...".
108
+ @server.mount_proc(Introspection::PACKAGE_MOUNT) do |req, res|
109
+ handle_request(req, res)
110
+ end
46
111
  end
47
112
 
48
113
  def restart_server
49
114
  @server.shutdown
50
115
  @server_thread&.join
51
- load_package
116
+ load_packages
52
117
  setup_server(false)
53
118
  mount_routes
54
119
  @server_thread = start_server
55
120
  end
56
121
 
122
+ # Cleans up every mounted package (Package#cleanup for all) and the
123
+ # workdir when the reactor created it.
124
+ def cleanup
125
+ @mounts.each { |mount| mount.package.cleanup }
126
+ FileUtils.remove_entry(@workdir) if @own_workdir && File.directory?(@workdir)
127
+ end
128
+
57
129
  private
58
130
 
59
131
  def setup_server(do_not_listen)
@@ -86,69 +158,46 @@ module Capsium
86
158
  @server_thread.join
87
159
  end
88
160
 
89
- def load_package
90
- @package = Package.new(@package_path)
161
+ def load_packages
162
+ @mounts.each do |mount|
163
+ @log_buffer.add("package reloaded: #{mount.reload.name}")
164
+ end
91
165
  load_state
92
166
  end
93
167
 
94
168
  def load_state
95
- @routes = @package.routes
96
- @introspection = Introspection.new(@package)
97
- end
98
-
99
- def serve_introspection(request, response)
100
- return respond_method_not_allowed(response) unless request.request_method == "GET"
101
-
102
- response.status = 200
103
- response["Content-Type"] = "application/json"
104
- response.body = JSON.generate(@introspection.report_for(request.path))
105
- end
106
-
107
- def serve_route(route, response)
108
- case route.kind
109
- when :dataset then serve_dataset(route.dataset, response)
110
- when :resource then serve_file(route, response)
111
- else respond_not_implemented(response)
112
- end
113
- end
114
-
115
- def serve_file(route, response)
116
- content_path = route.fs_path(@package.path)
117
- if content_path && File.exist?(content_path)
118
- response.status = 200
119
- response["Content-Type"] = route.mime(@package.manifest) || "application/octet-stream"
120
- headers_for(route).each { |name, value| response[name] = value }
121
- response.body = File.read(content_path)
122
- else
123
- respond_not_found(response)
124
- end
125
- end
126
-
127
- def headers_for(route)
128
- route.headers || (@cache_control ? { "Cache-Control" => @cache_control } : {})
129
- end
130
-
131
- def serve_dataset(dataset_name, response)
132
- dataset = @package.storage.dataset(dataset_name)
133
- if dataset
134
- response.status = 200
135
- response["Content-Type"] = "application/json"
136
- response.body = JSON.generate(dataset.data)
137
- else
138
- respond_not_found(response)
169
+ root = root_mount
170
+ @package = root.package
171
+ @package_path = @package.path
172
+ @routes = root.routes
173
+ @merged_view = root.merged_view
174
+ @introspection = Introspection.new(@mounts.map(&:package), reactor: self)
175
+ @authenticator = Authenticator.new(
176
+ @package.authentication,
177
+ deploy: @deploy_config,
178
+ package_path: @package.path,
179
+ base_url: @deploy_config.base_url,
180
+ state_file: File.join(Dir.tmpdir, "capsium-#{@package.name}-session-secret")
181
+ )
182
+ end
183
+
184
+ def dispatch_request(request, response)
185
+ if @authenticator.endpoint?(request.path)
186
+ @authenticator.serve_endpoint(request, response)
187
+ return
139
188
  end
140
- end
141
189
 
142
- def respond_not_found(response) = respond_text(response, 404, "Not Found")
143
-
144
- def respond_not_implemented(response) = respond_text(response, 501, "Not Implemented")
190
+ identity = @authenticator.authenticate(request)
191
+ return @authenticator.challenge(response) if @authenticator.enabled? && identity.nil?
192
+ return serve_introspection(request, response) if @introspection.endpoint?(request.path)
193
+ return serve_package_save(request, response) if PACKAGE_SAVE_PATTERN.match?(request.path)
145
194
 
146
- def respond_method_not_allowed(response) = respond_text(response, 405, "Method Not Allowed")
195
+ mount = resolve_mount(request.path)
196
+ return respond_not_found(response) unless mount
147
197
 
148
- def respond_text(response, status, body)
149
- response.status = status
150
- response["Content-Type"] = "text/plain"
151
- response.body = body
198
+ serve_mounted_request(mount, identity, request, response)
152
199
  end
153
200
  end
154
201
  end
202
+
203
+ # rubocop:enable Style/OneClassPerFile
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Capsium
8
+ class Registry
9
+ # A static registry in a local directory (read-write). Push
10
+ # validates the .cap (Capsium::Package::Validator), copies it into
11
+ # the registry directory and atomically rewrites index.json
12
+ # (tmp + rename) with recomputed sha256 and size.
13
+ class Local < Registry
14
+ attr_reader :dir
15
+
16
+ def initialize(dir)
17
+ super()
18
+ if File.exist?(dir) && !File.directory?(dir)
19
+ raise InvalidRegistryError, "registry path is not a directory: #{dir}"
20
+ end
21
+
22
+ @dir = dir
23
+ end
24
+
25
+ def location = dir
26
+
27
+ # Validates cap_path, copies it into the registry as
28
+ # "<name>-<version>.cap" and records it in index.json. Returns the
29
+ # recorded Entry. Raises InvalidPackageError when the package
30
+ # fails validation.
31
+ def push(cap_path)
32
+ validate_package!(cap_path)
33
+ entry = build_entry(read_metadata(cap_path), cap_path)
34
+ FileUtils.mkdir_p(dir)
35
+ atomic_copy(cap_path, File.join(dir, entry.file))
36
+ record(entry)
37
+ entry
38
+ end
39
+
40
+ private
41
+
42
+ def index
43
+ @index ||= begin
44
+ path = File.join(dir, INDEX_FILE)
45
+ File.file?(path) ? parse_index(File.read(path)) : { "packages" => {} }
46
+ end
47
+ end
48
+
49
+ def with_entry_file(entry)
50
+ path = File.join(dir, entry.file)
51
+ unless File.file?(path)
52
+ raise InvalidRegistryError, "#{location}: indexed file missing: #{entry.file}"
53
+ end
54
+
55
+ yield path
56
+ end
57
+
58
+ def validate_package!(cap_path)
59
+ raise InvalidPackageError, "not a file: #{cap_path}" unless File.file?(cap_path)
60
+
61
+ failures = Package::Validator.new(cap_path).run.reject(&:ok?)
62
+ return if failures.empty?
63
+
64
+ details = failures.flat_map do |failure|
65
+ failure.messages.map { |message| "#{failure.name}: #{message}" }
66
+ end
67
+ raise InvalidPackageError,
68
+ "package validation failed for #{cap_path} — #{details.join('; ')}"
69
+ end
70
+
71
+ def read_metadata(cap_path)
72
+ Packager.new.with_unpacked_cap(cap_path) do |unpacked|
73
+ JSON.parse(File.read(File.join(unpacked, Package::METADATA_FILE)))
74
+ end
75
+ end
76
+
77
+ def build_entry(metadata, cap_path)
78
+ guid = metadata["guid"]
79
+ name = metadata["name"]
80
+ version = metadata["version"]
81
+ unless guid.is_a?(String) && name.is_a?(String) && version.is_a?(String)
82
+ raise InvalidPackageError, "metadata.json must declare guid, name and version"
83
+ end
84
+
85
+ Entry.new(guid: guid, name: name, version: parse_package_version(version),
86
+ file: "#{name}-#{version}.cap",
87
+ sha256: Digest::SHA256.file(cap_path).hexdigest,
88
+ size: File.size(cap_path))
89
+ end
90
+
91
+ def parse_package_version(string)
92
+ Package::Version.parse(string)
93
+ rescue Capsium::Error => e
94
+ raise InvalidPackageError, "metadata.json version is invalid: #{e.message}"
95
+ end
96
+
97
+ def record(entry)
98
+ listing = index["packages"][entry.guid] ||
99
+ { "name" => entry.name, "versions" => {} }
100
+ listing["versions"][entry.version.to_s] = {
101
+ "file" => entry.file, "sha256" => entry.sha256, "size" => entry.size
102
+ }
103
+ index["packages"][entry.guid] = listing
104
+ atomic_write(File.join(dir, INDEX_FILE), JSON.pretty_generate(index))
105
+ end
106
+
107
+ def atomic_copy(source, destination)
108
+ tmp = "#{destination}.tmp-#{Process.pid}"
109
+ FileUtils.cp(source, tmp)
110
+ File.rename(tmp, destination)
111
+ end
112
+
113
+ def atomic_write(path, content)
114
+ tmp = "#{path}.tmp-#{Process.pid}"
115
+ File.write(tmp, content)
116
+ File.rename(tmp, path)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "json"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "tempfile"
8
+ require "timeout"
9
+ require "uri"
10
+
11
+ module Capsium
12
+ class Registry
13
+ # A read-only static registry behind an https base URL (any static
14
+ # host: GitHub Pages, S3, nginx). index.json and .cap files are
15
+ # fetched over net/http with redirect following and timeouts; plain
16
+ # http is accepted for loopback hosts only (local development).
17
+ class Remote < Registry
18
+ OPEN_TIMEOUT = 5
19
+ READ_TIMEOUT = 15
20
+ MAX_REDIRECTS = 5
21
+ LOOPBACK_NETWORKS = [IPAddr.new("127.0.0.0/8"), IPAddr.new("::1/128")].freeze
22
+
23
+ attr_reader :base_url
24
+
25
+ def initialize(base_url)
26
+ super()
27
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
28
+ validate_scheme!(request_uri(INDEX_FILE))
29
+ end
30
+
31
+ def location = base_url
32
+
33
+ private
34
+
35
+ def index
36
+ @index ||= parse_index(fetch_bytes(INDEX_FILE))
37
+ rescue FetchError => e
38
+ raise InvalidRegistryError, "no readable #{INDEX_FILE} at #{base_url}: #{e.message}"
39
+ end
40
+
41
+ def with_entry_file(entry)
42
+ Tempfile.create(["capsium-registry", ".cap"]) do |tmp|
43
+ tmp.binmode
44
+ tmp.write(fetch_bytes(entry.file))
45
+ tmp.flush
46
+ yield tmp.path
47
+ end
48
+ end
49
+
50
+ def fetch_bytes(relative_path)
51
+ uri = request_uri(relative_path)
52
+ MAX_REDIRECTS.times do
53
+ response = http_get(uri)
54
+ return response.body if response.is_a?(Net::HTTPSuccess)
55
+
56
+ uri = follow_redirect(uri, response)
57
+ end
58
+ raise FetchError, "GET #{uri}: too many redirects (limit #{MAX_REDIRECTS})"
59
+ end
60
+
61
+ def follow_redirect(uri, response)
62
+ location = response["location"] if response.is_a?(Net::HTTPRedirection)
63
+ raise FetchError, "GET #{uri} failed: HTTP #{response.code}" if location.nil?
64
+
65
+ target = URI.join(uri.to_s, location)
66
+ validate_scheme!(target)
67
+ target
68
+ end
69
+
70
+ def http_get(uri)
71
+ http = Net::HTTP.new(uri.host, uri.port)
72
+ http.use_ssl = uri.is_a?(URI::HTTPS)
73
+ http.open_timeout = OPEN_TIMEOUT
74
+ http.read_timeout = READ_TIMEOUT
75
+ http.request(Net::HTTP::Get.new(uri.request_uri))
76
+ rescue Timeout::Error, SocketError, SystemCallError,
77
+ OpenSSL::SSL::SSLError => e
78
+ raise FetchError, "GET #{uri} failed: #{e.message}"
79
+ end
80
+
81
+ def request_uri(relative_path)
82
+ URI.parse("#{base_url}/#{relative_path}")
83
+ rescue URI::InvalidURIError => e
84
+ raise InvalidRegistryError, "invalid registry URL #{base_url.inspect}: #{e.message}"
85
+ end
86
+
87
+ def validate_scheme!(uri)
88
+ return if uri.is_a?(URI::HTTPS)
89
+ return if uri.is_a?(URI::HTTP) && loopback_host?(uri.host)
90
+
91
+ raise InvalidRegistryError,
92
+ "registry URL must use https (plain http for loopback only): #{uri}"
93
+ end
94
+
95
+ def loopback_host?(host)
96
+ return true if host == "localhost" || host.to_s.end_with?(".localhost")
97
+
98
+ address = IPAddr.new(host)
99
+ LOOPBACK_NETWORKS.any? { |network| network.include?(address) }
100
+ rescue IPAddr::InvalidAddressError
101
+ false
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Capsium
8
+ # A static package registry: a directory or a static https base URL
9
+ # holding an index.json plus .cap files stored relative to the
10
+ # registry root, so any static host (GitHub Pages, S3, nginx) can
11
+ # serve it. Index shape:
12
+ #
13
+ # { "packages": { "<guid>": { "name": "story-of-claire",
14
+ # "versions": { "1.0.0": { "file": "story-of-claire-1.0.0.cap",
15
+ # "sha256": "<hex>", "size": 642047 } } } } }
16
+ #
17
+ # Registry.fetch(ref) returns the implementation for a reference: a
18
+ # Local directory (read-write) or a Remote http(s) base URL
19
+ # (read-only). Both resolve and install; only Local pushes.
20
+ class Registry
21
+ autoload :Local, "capsium/registry/local"
22
+ autoload :Remote, "capsium/registry/remote"
23
+
24
+ INDEX_FILE = "index.json"
25
+ ENV_VAR = "CAPSIUM_REGISTRY"
26
+
27
+ # Base error for every registry failure.
28
+ class RegistryError < Capsium::Error; end
29
+
30
+ # No registry reference was given (neither flag nor CAPSIUM_REGISTRY).
31
+ class RegistryNotConfiguredError < RegistryError; end
32
+
33
+ # The registry itself is unusable (bad path or URL, unreadable index).
34
+ class InvalidRegistryError < RegistryError; end
35
+
36
+ # A .cap offered to push failed package validation.
37
+ class InvalidPackageError < RegistryError; end
38
+
39
+ # The index has no entry for the requested package GUID.
40
+ class PackageNotFoundError < RegistryError; end
41
+
42
+ # The registry has the GUID but no version satisfies the constraint.
43
+ class UnsatisfiableConstraintError < RegistryError; end
44
+
45
+ # A fetched .cap does not match the sha256 declared in the index.
46
+ class ChecksumMismatchError < RegistryError; end
47
+
48
+ # A registry file could not be retrieved (network or HTTP failure).
49
+ class FetchError < RegistryError; end
50
+
51
+ # One indexed version of a registry package.
52
+ Entry = Data.define(:guid, :name, :version, :file, :sha256, :size) do
53
+ # The canonical store file name for this entry's .cap.
54
+ def cap_file_name = "#{name}-#{version}.cap"
55
+ end
56
+
57
+ class << self
58
+ # The registry at the given reference: a Local directory or a
59
+ # Remote http(s) base URL. Raises RegistryNotConfiguredError when
60
+ # no reference is given.
61
+ def fetch(ref)
62
+ if ref.nil? || ref.to_s.empty?
63
+ raise RegistryNotConfiguredError,
64
+ "no registry configured (pass --registry or set #{ENV_VAR})"
65
+ end
66
+
67
+ ref.to_s.match?(%r{\Ahttps?://}) ? Remote.new(ref.to_s) : Local.new(ref.to_s)
68
+ end
69
+
70
+ # The registry named by the CAPSIUM_REGISTRY environment variable,
71
+ # or nil when unset.
72
+ def default
73
+ ref = ENV.fetch(ENV_VAR, nil)
74
+ ref.nil? || ref.empty? ? nil : fetch(ref)
75
+ end
76
+ end
77
+
78
+ def initialize
79
+ @index = nil
80
+ end
81
+
82
+ # Where this registry lives (directory path or base URL), for
83
+ # messages and reports.
84
+ def location = raise(NotImplementedError)
85
+
86
+ # The newest indexed version of the package GUID satisfying the
87
+ # semver constraint (default "*"). Raises PackageNotFoundError or
88
+ # UnsatisfiableConstraintError.
89
+ def resolve(guid, constraint = "*")
90
+ listing = packages[guid]
91
+ raise PackageNotFoundError, "no package #{guid} in registry #{location}" if listing.nil?
92
+
93
+ versions = listing_versions(guid, listing)
94
+ range = Package::VersionRange.parse(constraint)
95
+ satisfying = versions.keys.select { |version| range.satisfied_by?(version) }
96
+ raise_unsatisfiable(guid, constraint, versions) if satisfying.empty?
97
+
98
+ entry_for(guid, listing, satisfying.max)
99
+ end
100
+
101
+ # Resolves the newest satisfying version, fetches its .cap (verifying
102
+ # the sha256 declared in the index) and installs it into the package
103
+ # store as "<name>-<version>.cap". Returns the installed store path.
104
+ def install(guid, constraint = "*", store:)
105
+ entry = resolve(guid, constraint)
106
+ with_entry_file(entry) do |path|
107
+ verify_checksum!(entry, path)
108
+ store_for(store).install(path, guid: entry.guid, file_name: entry.cap_file_name)
109
+ end
110
+ end
111
+
112
+ # Only Local registries are writable; Remote overrides nothing.
113
+ def push(_cap_path)
114
+ raise RegistryError, "registry is read-only: #{location}"
115
+ end
116
+
117
+ private
118
+
119
+ # Subclass hooks: the parsed index.json hash, and a yield of a local
120
+ # filesystem path holding the entry's .cap bytes.
121
+ def index = raise(NotImplementedError)
122
+
123
+ def with_entry_file(entry) = raise(NotImplementedError)
124
+
125
+ def packages
126
+ all = index["packages"]
127
+ unless all.is_a?(Hash)
128
+ raise InvalidRegistryError, "#{location}: #{INDEX_FILE} has no \"packages\" object"
129
+ end
130
+
131
+ all
132
+ end
133
+
134
+ def parse_index(json_text)
135
+ JSON.parse(json_text)
136
+ rescue JSON::ParserError => e
137
+ raise InvalidRegistryError, "#{location}: #{INDEX_FILE} is not valid JSON: #{e.message}"
138
+ end
139
+
140
+ def listing_versions(guid, listing)
141
+ versions = listing["versions"]
142
+ unless versions.is_a?(Hash) && !versions.empty?
143
+ raise InvalidRegistryError, "#{location}: no versions indexed for #{guid}"
144
+ end
145
+
146
+ versions.to_h { |string, data| [parse_index_version(guid, string), data] }
147
+ end
148
+
149
+ def parse_index_version(guid, string)
150
+ Package::Version.parse(string)
151
+ rescue Capsium::Error
152
+ raise InvalidRegistryError, "#{location}: invalid version #{string.inspect} for #{guid}"
153
+ end
154
+
155
+ def raise_unsatisfiable(guid, constraint, versions)
156
+ available = versions.keys.map(&:to_s).sort.join(", ")
157
+ raise UnsatisfiableConstraintError,
158
+ "no version of #{guid} satisfies '#{constraint}' (registry has: #{available})"
159
+ end
160
+
161
+ def entry_for(guid, listing, version)
162
+ data = listing.fetch("versions").fetch(version.to_s)
163
+ unless data["file"].is_a?(String) && data["sha256"].is_a?(String)
164
+ raise InvalidRegistryError,
165
+ "#{location}: incomplete index entry for #{guid} #{version} (file/sha256)"
166
+ end
167
+
168
+ Entry.new(guid: guid, name: listing["name"].to_s, version: version,
169
+ file: data["file"], sha256: data["sha256"], size: data["size"].to_i)
170
+ end
171
+
172
+ def verify_checksum!(entry, path)
173
+ actual = Digest::SHA256.file(path).hexdigest
174
+ return if actual == entry.sha256.downcase
175
+
176
+ raise ChecksumMismatchError,
177
+ "sha256 mismatch for #{entry.cap_file_name} from #{location}: " \
178
+ "index declares #{entry.sha256}, file hashes to #{actual}"
179
+ end
180
+
181
+ # A Store for the given Store or directory path (created when
182
+ # missing, so installing into a fresh store directory works).
183
+ def store_for(store)
184
+ return store if store.is_a?(Package::Store)
185
+
186
+ FileUtils.mkdir_p(store.to_s)
187
+ Package::Store.new(store.to_s)
188
+ end
189
+ end
190
+ end
@@ -60,7 +60,7 @@ module Capsium
60
60
  return if error.is_a?(Errno::EPIPE)
61
61
  raise if ENV["VERBOSE"] || !config.fetch(:exit_on_failure, true)
62
62
 
63
- message = error.message.to_s
63
+ message = +error.message.to_s
64
64
  message.prepend("[#{error.class}] ") if message.empty? || !error.is_a?(Thor::Error)
65
65
 
66
66
  config[:shell]&.say_error(message, :red)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Capsium
4
- VERSION = "0.2.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/capsium.rb CHANGED
@@ -6,8 +6,10 @@ module Capsium
6
6
  autoload :VERSION, "capsium/version"
7
7
  autoload :Cli, "capsium/cli"
8
8
  autoload :Converters, "capsium/converters"
9
+ autoload :LogBuffer, "capsium/log_buffer"
9
10
  autoload :Package, "capsium/package"
10
11
  autoload :Packager, "capsium/packager"
11
12
  autoload :Reactor, "capsium/reactor"
13
+ autoload :Registry, "capsium/registry"
12
14
  autoload :ThorExt, "capsium/thor_ext"
13
15
  end