capsium 0.3.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -3
  3. data/CHANGELOG.md +197 -0
  4. data/README.adoc +323 -14
  5. data/capsium.gemspec +2 -0
  6. data/lib/capsium/cli/package.rb +62 -25
  7. data/lib/capsium/cli/reactor.rb +112 -6
  8. data/lib/capsium/cli.rb +29 -0
  9. data/lib/capsium/log_buffer.rb +47 -0
  10. data/lib/capsium/package/bundle.rb +136 -0
  11. data/lib/capsium/package/cipher.rb +73 -17
  12. data/lib/capsium/package/composition.rb +28 -12
  13. data/lib/capsium/package/dataset.rb +20 -0
  14. data/lib/capsium/package/dependency_resolver.rb +39 -9
  15. data/lib/capsium/package/encryption_config.rb +7 -3
  16. data/lib/capsium/package/manifest.rb +1 -1
  17. data/lib/capsium/package/merged_view.rb +4 -2
  18. data/lib/capsium/package/metadata.rb +1 -1
  19. data/lib/capsium/package/metadata_config.rb +9 -1
  20. data/lib/capsium/package/open_pgp.rb +65 -0
  21. data/lib/capsium/package/open_pgp_cipher.rb +66 -0
  22. data/lib/capsium/package/open_pgp_signer.rb +176 -0
  23. data/lib/capsium/package/security_config.rb +6 -2
  24. data/lib/capsium/package/signer.rb +18 -3
  25. data/lib/capsium/package/store.rb +27 -0
  26. data/lib/capsium/package/validator.rb +27 -2
  27. data/lib/capsium/package/verification.rb +7 -5
  28. data/lib/capsium/package.rb +24 -5
  29. data/lib/capsium/packager.rb +64 -22
  30. data/lib/capsium/reactor/content_api.rb +110 -0
  31. data/lib/capsium/reactor/data_api.rb +154 -0
  32. data/lib/capsium/reactor/endpoints.rb +37 -0
  33. data/lib/capsium/reactor/graphql_api.rb +123 -0
  34. data/lib/capsium/reactor/graphql_schema.rb +138 -0
  35. data/lib/capsium/reactor/introspection.rb +151 -34
  36. data/lib/capsium/reactor/metrics.rb +30 -0
  37. data/lib/capsium/reactor/mount.rb +206 -0
  38. data/lib/capsium/reactor/overlay.rb +248 -0
  39. data/lib/capsium/reactor/package_saver.rb +99 -0
  40. data/lib/capsium/reactor/responses.rb +41 -0
  41. data/lib/capsium/reactor/serving.rb +90 -11
  42. data/lib/capsium/reactor.rb +96 -49
  43. data/lib/capsium/registry/local.rb +120 -0
  44. data/lib/capsium/registry/remote.rb +105 -0
  45. data/lib/capsium/registry.rb +190 -0
  46. data/lib/capsium/version.rb +1 -1
  47. data/lib/capsium.rb +2 -0
  48. data/sig/capsium/log_buffer.rbs +30 -0
  49. data/sig/capsium/package/bundle.rbs +43 -0
  50. data/sig/capsium/package/cipher.rbs +12 -1
  51. data/sig/capsium/package/composition.rbs +4 -3
  52. data/sig/capsium/package/dataset.rbs +8 -0
  53. data/sig/capsium/package/dependency_resolver.rbs +8 -4
  54. data/sig/capsium/package/encryption_config.rbs +6 -2
  55. data/sig/capsium/package/merged_view.rbs +2 -1
  56. data/sig/capsium/package/metadata.rbs +1 -0
  57. data/sig/capsium/package/open_pgp.rbs +38 -0
  58. data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
  59. data/sig/capsium/package/open_pgp_signer.rbs +46 -0
  60. data/sig/capsium/package/security_config.rbs +6 -3
  61. data/sig/capsium/package/signer.rbs +8 -1
  62. data/sig/capsium/package/store.rbs +6 -0
  63. data/sig/capsium/package/verification.rbs +5 -3
  64. data/sig/capsium/package.rbs +8 -3
  65. data/sig/capsium/reactor/content_api.rbs +19 -0
  66. data/sig/capsium/reactor/data_api.rbs +23 -0
  67. data/sig/capsium/reactor/endpoints.rbs +16 -0
  68. data/sig/capsium/reactor/graphql_api.rbs +27 -0
  69. data/sig/capsium/reactor/graphql_schema.rbs +20 -0
  70. data/sig/capsium/reactor/introspection.rbs +27 -7
  71. data/sig/capsium/reactor/metrics.rbs +13 -0
  72. data/sig/capsium/reactor/mount.rbs +83 -0
  73. data/sig/capsium/reactor/overlay.rbs +72 -0
  74. data/sig/capsium/reactor/package_saver.rbs +12 -0
  75. data/sig/capsium/reactor/responses.rbs +23 -0
  76. data/sig/capsium/reactor/serving.rbs +28 -4
  77. data/sig/capsium/reactor.rbs +17 -2
  78. data/sig/capsium/registry/local.rbs +16 -0
  79. data/sig/capsium/registry/remote.rbs +18 -0
  80. data/sig/capsium/registry.rbs +83 -0
  81. data/sig/graphql.rbs +12 -0
  82. metadata +66 -1
@@ -1,40 +1,86 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "json"
4
5
  require "listen"
5
6
  require "tmpdir"
6
7
  require "webrick"
7
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
+
8
23
  module Capsium
9
24
  class Reactor
10
25
  autoload :Authenticator, "capsium/reactor/authenticator"
26
+ autoload :ContentApi, "capsium/reactor/content_api"
27
+ autoload :DataApi, "capsium/reactor/data_api"
11
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"
12
32
  autoload :Htpasswd, "capsium/reactor/htpasswd"
13
33
  autoload :Introspection, "capsium/reactor/introspection"
34
+ autoload :Metrics, "capsium/reactor/metrics"
35
+ autoload :Mount, "capsium/reactor/mount"
36
+ autoload :MountConflictError, "capsium/reactor/mount"
14
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"
15
41
  autoload :Serving, "capsium/reactor/serving"
16
42
  autoload :Session, "capsium/reactor/session"
17
43
 
44
+ include Responses
45
+ include Endpoints
18
46
  include Serving
19
47
 
20
48
  DEFAULT_PORT = 8864
21
49
  DEFAULT_CACHE_CONTROL = "public, max-age=31536000"
50
+ PACKAGE_SAVE_PATTERN = %r{\A/package/(?<name>[^/]+)/save\z}
22
51
 
23
52
  attr_reader :package, :package_path, :routes, :port, :cache_control,
24
- :server, :server_thread, :introspection, :authenticator
25
-
26
- def initialize(package:, port: DEFAULT_PORT,
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,
27
64
  cache_control: DEFAULT_CACHE_CONTROL, do_not_listen: false,
28
- store: nil, deploy: nil)
29
- @package = package.is_a?(String) ? Package.new(package, store: store) : package
30
- @package_path = @package.path
65
+ store: nil, deploy: nil, registry: nil, workdir: nil)
31
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) }
32
73
  @port = port
33
74
  @cache_control = cache_control
75
+ @started_at = Time.now
76
+ @metrics = Metrics.new
77
+ @log_buffer = Capsium::LogBuffer.new
34
78
  @deploy_config = Deploy.load(deploy)
35
79
  setup_server(do_not_listen)
36
80
  load_state
37
81
  mount_routes
82
+ @log_buffer.add("reactor started: " \
83
+ "#{@mounts.map(&:summary).join(', ')} on port #{@port}")
38
84
  end
39
85
 
40
86
  def serve
@@ -43,44 +89,43 @@ module Capsium
43
89
  start_listener
44
90
  end
45
91
 
92
+ # Entry point for every mounted path: dispatches the request, then
93
+ # records it in the request metrics and the log buffer.
46
94
  def handle_request(request, response)
47
- if @authenticator.endpoint?(request.path)
48
- @authenticator.serve_endpoint(request, response)
49
- return
50
- end
51
-
52
- identity = @authenticator.authenticate(request)
53
- return @authenticator.challenge(response) if @authenticator.enabled? && identity.nil?
54
- return serve_introspection(request, response) if @introspection.endpoint?(request.path)
55
-
56
- route = @routes.resolve(request.path)
57
- return respond_not_found(response) unless route
58
-
59
- case @authenticator.authorize(identity, route.access_control)
60
- when :unauthenticated then return @authenticator.challenge(response)
61
- when :forbidden then return respond_forbidden(response)
62
- end
63
-
64
- serve_route(route, response)
95
+ dispatch_request(request, response)
96
+ ensure
97
+ record_request(request, response)
65
98
  end
66
99
 
67
100
  def mount_routes
68
- paths = @routes.config.routes.map(&:serving_path) +
69
- Introspection::PATHS + @authenticator.endpoints
101
+ paths = Introspection::PATHS + Introspection::REACTOR_PATHS +
102
+ @authenticator.endpoints
103
+ paths.concat(@mounts.flat_map(&:server_paths))
70
104
  paths.each do |path|
71
105
  @server.mount_proc(path.to_s) { |req, res| handle_request(req, res) }
72
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
73
111
  end
74
112
 
75
113
  def restart_server
76
114
  @server.shutdown
77
115
  @server_thread&.join
78
- load_package
116
+ load_packages
79
117
  setup_server(false)
80
118
  mount_routes
81
119
  @server_thread = start_server
82
120
  end
83
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
+
84
129
  private
85
130
 
86
131
  def setup_server(do_not_listen)
@@ -113,15 +158,20 @@ module Capsium
113
158
  @server_thread.join
114
159
  end
115
160
 
116
- def load_package
117
- @package = Package.new(@package_path, store: @store)
161
+ def load_packages
162
+ @mounts.each do |mount|
163
+ @log_buffer.add("package reloaded: #{mount.reload.name}")
164
+ end
118
165
  load_state
119
166
  end
120
167
 
121
168
  def load_state
122
- @routes = @package.routes
123
- @merged_view = @package.merged_view
124
- @introspection = Introspection.new(@package)
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)
125
175
  @authenticator = Authenticator.new(
126
176
  @package.authentication,
127
177
  deploy: @deploy_config,
@@ -131,26 +181,23 @@ module Capsium
131
181
  )
132
182
  end
133
183
 
134
- def serve_introspection(request, response)
135
- return respond_method_not_allowed(response) unless request.request_method == "GET"
136
-
137
- response.status = 200
138
- response["Content-Type"] = "application/json"
139
- response.body = JSON.generate(@introspection.report_for(request.path))
140
- end
141
-
142
- def respond_not_found(response) = respond_text(response, 404, "Not Found")
143
-
144
- def respond_forbidden(response) = respond_text(response, 403, "Forbidden")
184
+ def dispatch_request(request, response)
185
+ if @authenticator.endpoint?(request.path)
186
+ @authenticator.serve_endpoint(request, response)
187
+ return
188
+ end
145
189
 
146
- 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)
147
194
 
148
- 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
149
197
 
150
- def respond_text(response, status, body)
151
- response.status = status
152
- response["Content-Type"] = "text/plain"
153
- response.body = body
198
+ serve_mounted_request(mount, identity, request, response)
154
199
  end
155
200
  end
156
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Capsium
4
- VERSION = "0.3.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
@@ -0,0 +1,30 @@
1
+ module Capsium
2
+ # A small thread-safe ring buffer of timestamped log entries with a
3
+ # fixed capacity: when full, the oldest entry is dropped.
4
+ class LogBuffer
5
+ DEFAULT_CAPACITY: Integer
6
+
7
+ # One buffer entry: a UTC timestamp and a message.
8
+ class Entry
9
+ def self.new: (timestamp: Time, message: String) -> void
10
+
11
+ def timestamp: () -> Time
12
+ def message: () -> String
13
+
14
+ # "2026-07-19T15:00:00Z message"
15
+ def line: () -> String
16
+ end
17
+
18
+ attr_reader capacity: Integer
19
+
20
+ def initialize: (?capacity: Integer capacity) -> void
21
+
22
+ def add: (String message, ?timestamp: Time timestamp) -> LogBuffer
23
+
24
+ # The last n entries, oldest first.
25
+ def last: (Integer count) -> Array[Entry]
26
+
27
+ # The last n entries as formatted lines, oldest first.
28
+ def lines: (Integer count) -> Array[String]
29
+ end
30
+ end