ace-lab 0.14.1 → 0.15.1
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/CHANGELOG.md +12 -0
- data/docs/usage.md +78 -16
- data/lib/ace/lab/atoms/install_schema.rb +71 -5
- data/lib/ace/lab/auth/credential_handoff.rb +34 -10
- data/lib/ace/lab/auth/error.rb +13 -0
- data/lib/ace/lab/auth/lease_store.rb +91 -29
- data/lib/ace/lab/auth/local_command.rb +62 -0
- data/lib/ace/lab/auth/plugins/forgejo_provider.rb +123 -55
- data/lib/ace/lab/auth/profile_schema.rb +6 -1
- data/lib/ace/lab/auth/providers.rb +8 -0
- data/lib/ace/lab/auth/service.rb +350 -112
- data/lib/ace/lab/auth/session_configuration.rb +44 -0
- data/lib/ace/lab/cli/commands/prepare.rb +2 -1
- data/lib/ace/lab/cli/commands/verify.rb +2 -1
- data/lib/ace/lab/molecules/extension_projection.rb +60 -1
- data/lib/ace/lab/molecules/extension_provisioner.rb +21 -4
- data/lib/ace/lab/molecules/gem_payload_permissions.rb +12 -5
- data/lib/ace/lab/molecules/gemset_provisioner.rb +6 -1
- data/lib/ace/lab/organisms/install.rb +1 -0
- data/lib/ace/lab/organisms/verify.rb +56 -13
- data/lib/ace/lab/version.rb +1 -1
- data/libexec/forgejo-askpass +10 -0
- metadata +12 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "error"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lab
|
|
8
|
+
module Auth
|
|
9
|
+
# One ordinary child process group. Credentials are environment values,
|
|
10
|
+
# never argv. Cleanup runs on normal exit, deadline and interruption.
|
|
11
|
+
# This does not contain a malicious process that leaves its group.
|
|
12
|
+
class LocalCommand
|
|
13
|
+
def call(argv, environment, deadline:)
|
|
14
|
+
remaining = deadline - Time.now
|
|
15
|
+
raise Refusal, "lease expired before execution" unless remaining.positive?
|
|
16
|
+
|
|
17
|
+
pid = Process.spawn(environment, [argv.first, argv.first], *argv.drop(1),
|
|
18
|
+
unsetenv_others: true, pgroup: true)
|
|
19
|
+
begin
|
|
20
|
+
previous = Signal.trap("TERM") { raise SignalException, "TERM" } if Thread.current == Thread.main
|
|
21
|
+
_, status = Timeout.timeout(remaining) { Process.wait2(pid) }
|
|
22
|
+
reaped = true
|
|
23
|
+
status.exitstatus || 128 + status.termsig.to_i
|
|
24
|
+
rescue Timeout::Error
|
|
25
|
+
124
|
|
26
|
+
ensure
|
|
27
|
+
begin
|
|
28
|
+
reaped = stop_group(pid, reaped: reaped)
|
|
29
|
+
Timeout.timeout(2) { Process.wait(pid) } unless reaped
|
|
30
|
+
rescue Errno::ECHILD
|
|
31
|
+
nil
|
|
32
|
+
rescue SystemCallError, Timeout::Error => e
|
|
33
|
+
raise ExecutionCleanupError, "command process group cleanup is unconfirmed (#{e.class}: #{e.message})"
|
|
34
|
+
ensure
|
|
35
|
+
Signal.trap("TERM", previous) if previous
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def stop_group(pid, reaped:)
|
|
43
|
+
Process.kill("TERM", -pid)
|
|
44
|
+
sleep 0.05
|
|
45
|
+
# Darwin reports EPERM when a group contains only our unreaped
|
|
46
|
+
# zombie. Reap it before checking for surviving descendants.
|
|
47
|
+
begin
|
|
48
|
+
reaped ||= !Process.waitpid(pid, Process::WNOHANG).nil?
|
|
49
|
+
rescue Errno::ECHILD
|
|
50
|
+
# wait2 may have reaped the leader before an interrupt updated
|
|
51
|
+
# our flag. Descendants still need the group KILL below.
|
|
52
|
+
reaped = true
|
|
53
|
+
end
|
|
54
|
+
Process.kill("KILL", -pid)
|
|
55
|
+
reaped
|
|
56
|
+
rescue Errno::ESRCH
|
|
57
|
+
reaped
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -1,48 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "base64"
|
|
4
5
|
require "openssl"
|
|
5
6
|
require "open3"
|
|
6
7
|
require "tempfile"
|
|
8
|
+
require "tmpdir"
|
|
9
|
+
require "fileutils"
|
|
10
|
+
require "uri"
|
|
11
|
+
require "rbconfig"
|
|
12
|
+
require "ipaddr"
|
|
13
|
+
require_relative "../credential_handoff"
|
|
7
14
|
|
|
8
15
|
module Ace
|
|
9
16
|
module Lab
|
|
10
17
|
module Auth
|
|
11
18
|
module Plugins
|
|
12
|
-
# Forgejo
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# Wire contract (PR #398 re-review R1): the issuer credential is
|
|
19
|
-
# an admin PAT, so both endpoints are the admin variants
|
|
20
|
-
# (`POST/DELETE /api/v1/admin/users/{account}/tokens`) — the
|
|
21
|
-
# user-scoped `/api/v1/users/.../tokens` requires BasicAuth or
|
|
22
|
-
# reverse-proxy auth and does not accept `Authorization: token`.
|
|
23
|
-
# Repository scoping uses the server's RepoTargetOption shape:
|
|
24
|
-
# objects with `owner` and `name`. A repo entry without an
|
|
25
|
-
# owner does not identify anything and is REFUSED before any
|
|
26
|
-
# request — never an all-repositories token. The creation
|
|
27
|
-
# response's granted scopes are validated against the request
|
|
28
|
-
# when the server reports them; a mismatch is a classified
|
|
29
|
-
# failure, never a trusted grant. Forgejo tokens carry no proven
|
|
30
|
-
# native TTL across versions, so the profile's expiry comes
|
|
31
|
-
# from the host's scheduled revoke (expiry_mode host-revoke).
|
|
32
|
-
#
|
|
33
|
-
# The token NAME is deterministic from the lease id
|
|
34
|
-
# (`ace-lab-<lease id>`): it is written to the lease record at
|
|
35
|
-
# CREATION, so an interrupted issue (lost response, dead
|
|
36
|
-
# process) can still be settled deterministically — the name is
|
|
37
|
-
# reconstructed and revoked; a 404 means it never existed
|
|
38
|
-
# remotely (:already_invalid, an idempotent result).
|
|
39
|
-
#
|
|
40
|
-
# Transport is an injectable seam (argv runner/capture shapes):
|
|
41
|
-
# tests pin the wire contract with fakes emulating the server;
|
|
42
|
-
# the default runs `curl` with the issuer token from the host's
|
|
43
|
-
# protected store. The issuer secret is read from its file
|
|
44
|
-
# reference and never appears in argv of the CHILD.
|
|
19
|
+
# Forgejo 15 requires Basic authentication with an administrator
|
|
20
|
+
# credential and Sudo header on the user-token endpoints. The
|
|
21
|
+
# credential stays in a private, temporary curl header file. A
|
|
22
|
+
# successful issue must prove the returned repository restrictions.
|
|
23
|
+
# Host-owned issuance and timed revocation are separate requirements.
|
|
45
24
|
class ForgejoProvider
|
|
25
|
+
ASKPASS = File.expand_path("../../../../../libexec/forgejo-askpass", __dir__).freeze
|
|
46
26
|
def initialize(runner: nil, capture: nil)
|
|
47
27
|
@runner = runner || method(:run_curl)
|
|
48
28
|
@capture = capture || method(:run_capture)
|
|
@@ -58,36 +38,75 @@ module Ace
|
|
|
58
38
|
"ace-lab-#{lease_id}"
|
|
59
39
|
end
|
|
60
40
|
|
|
41
|
+
# fj and ACE's Forgejo HTTP adapters read the same XDG login file.
|
|
42
|
+
# Give this child only its leased token, with no ambient fj aliases
|
|
43
|
+
# or logins, and remove the private directory when it exits.
|
|
44
|
+
def with_exec_environment(profile:, credential:)
|
|
45
|
+
unless linux_xdg?
|
|
46
|
+
raise Providers::RefusedError, "forgejo session projection currently requires Linux XDG data directories"
|
|
47
|
+
end
|
|
48
|
+
issuer = profile["issuer"]
|
|
49
|
+
url = issuer_origin!(issuer)
|
|
50
|
+
host = url.hostname
|
|
51
|
+
authority = host.include?(":") ? "[#{host}]" : host
|
|
52
|
+
authority += ":#{url.port}" if url.port != url.default_port
|
|
53
|
+
account = issuer["account"].to_s
|
|
54
|
+
raise Providers::RefusedError, "forgejo role account is invalid" unless account.match?(/\A[a-zA-Z0-9_.-]+\z/)
|
|
55
|
+
token = begin
|
|
56
|
+
File.read(credential).strip
|
|
57
|
+
rescue SystemCallError => e
|
|
58
|
+
raise Providers::RefusedError, "forgejo lease credential is unreadable (#{e.class.name})"
|
|
59
|
+
end
|
|
60
|
+
raise Providers::RefusedError, "forgejo lease credential is empty" if token.empty?
|
|
61
|
+
prefix = CredentialHandoff.projection_prefix(credential)
|
|
62
|
+
Dir.mktmpdir(prefix, File.dirname(credential)) do |dir|
|
|
63
|
+
login_dir = File.join(dir, "forgejo-cli")
|
|
64
|
+
Dir.mkdir(login_dir, 0o700)
|
|
65
|
+
File.open(File.join(login_dir, "keys.json"), File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
66
|
+
file.write(JSON.generate({"hosts" => {authority => {"type" => "Application", "token" => token}},
|
|
67
|
+
"aliases" => {}, "default_ssh" => []}))
|
|
68
|
+
end
|
|
69
|
+
yield({"XDG_DATA_HOME" => dir, "GIT_ASKPASS" => ASKPASS, "GIT_TERMINAL_PROMPT" => "0",
|
|
70
|
+
"GIT_CONFIG_COUNT" => "2", "GIT_CONFIG_KEY_0" => "credential.helper", "GIT_CONFIG_VALUE_0" => "",
|
|
71
|
+
"GIT_CONFIG_KEY_1" => "credential.useHttpPath", "GIT_CONFIG_VALUE_1" => "false", "LC_ALL" => "C.UTF-8",
|
|
72
|
+
"ACE_LAB_FORGEJO_ORIGIN" => "#{url.scheme}://#{authority}", "ACE_LAB_FORGEJO_SCHEME" => url.scheme,
|
|
73
|
+
"ACE_LAB_FORGEJO_AUTHORITY" => authority, "ACE_LAB_FORGEJO_ACCOUNT" => account})
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
61
77
|
def issue(profile:, lease:)
|
|
62
|
-
issuer = profile
|
|
63
|
-
url = issuer
|
|
78
|
+
issuer = profile["issuer"]
|
|
79
|
+
url = issuer_origin!(issuer).to_s.chomp("/")
|
|
64
80
|
account = issuer["account"] or raise Providers::RefusedError, "forgejo profile has no role account"
|
|
65
|
-
|
|
66
|
-
"forgejo profile has no issuer token_file reference"
|
|
67
|
-
raise Providers::RefusedError, "issuer token file '#{token_file}' is not readable" unless File.readable?(token_file)
|
|
81
|
+
header_value = issuer_header(issuer)
|
|
68
82
|
|
|
69
83
|
token_name = token_ref_for(lease_id: lease["id"])
|
|
70
84
|
scopes = profile.fetch("scopes")
|
|
71
|
-
|
|
85
|
+
requested_repos = lease.fetch("repos")
|
|
86
|
+
repositories = repo_targets(requested_repos)
|
|
72
87
|
|
|
73
88
|
payload = JSON.generate({
|
|
74
89
|
"name" => token_name,
|
|
75
90
|
"scopes" => scopes,
|
|
76
91
|
"repositories" => repositories
|
|
77
92
|
}.compact)
|
|
78
|
-
result = with_secret_header(
|
|
93
|
+
result = with_secret_header(header_value) do |header|
|
|
79
94
|
@runner.call(
|
|
80
|
-
["curl", "-sS", "-f", "-X", "POST",
|
|
81
|
-
"#{url}/api/v1/
|
|
95
|
+
["curl", "-q", "--connect-timeout", "5", "--max-time", "20", "-sS", "-f", "-X", "POST",
|
|
96
|
+
"#{url}/api/v1/users/#{account}/tokens",
|
|
82
97
|
"-H", "Content-Type: application/json",
|
|
83
98
|
*header,
|
|
84
99
|
"-d", payload]
|
|
85
100
|
)
|
|
86
101
|
end
|
|
87
102
|
body = JSON.parse(result)
|
|
88
|
-
|
|
89
|
-
|
|
103
|
+
raise Providers::TransientError, "forgejo returned an invalid token response" unless body.is_a?(Hash)
|
|
104
|
+
secret = body["sha1"]
|
|
105
|
+
unless secret.is_a?(String) && !secret.empty?
|
|
106
|
+
raise Providers::TransientError, "forgejo did not return the token secret (check server API version)"
|
|
107
|
+
end
|
|
90
108
|
validate_granted_scopes(body, scopes)
|
|
109
|
+
validate_granted_repositories(body, requested_repos)
|
|
91
110
|
|
|
92
111
|
{"token" => secret, "token_ref" => token_name,
|
|
93
112
|
"provider_expiry" => nil,
|
|
@@ -102,19 +121,19 @@ module Ace
|
|
|
102
121
|
# contract. 404 is the idempotent already-invalid result —
|
|
103
122
|
# deterministic recovery of an interrupted issue.
|
|
104
123
|
def revoke(profile:, token_ref:, token: nil)
|
|
105
|
-
issuer = profile
|
|
106
|
-
url = issuer
|
|
124
|
+
issuer = profile["issuer"]
|
|
125
|
+
url = issuer_origin!(issuer).to_s.chomp("/")
|
|
107
126
|
account = issuer["account"] or raise Providers::RefusedError, "forgejo profile has no role account"
|
|
108
|
-
|
|
109
|
-
_out, err, status = with_secret_header("Authorization: token #{File.read(token_file).strip}") do |header|
|
|
127
|
+
_out, err, status = with_secret_header(issuer_header(issuer)) do |header|
|
|
110
128
|
@capture.call(
|
|
111
|
-
["curl", "-sS", "-f", "-X", "DELETE",
|
|
112
|
-
"#{url}/api/v1/
|
|
129
|
+
["curl", "-q", "--connect-timeout", "5", "--max-time", "20", "-sS", "-f", "-X", "DELETE",
|
|
130
|
+
"#{url}/api/v1/users/#{account}/tokens/#{token_ref}",
|
|
113
131
|
*header]
|
|
114
132
|
)
|
|
115
133
|
end
|
|
116
134
|
return :revoked if status.success?
|
|
117
|
-
return :already_invalid if
|
|
135
|
+
return :already_invalid if status.exitstatus == 22 &&
|
|
136
|
+
err.to_s.match?(/(?:\A|\n)curl: \(22\) The requested URL returned error: 404\s*(?:\n|\z)/)
|
|
118
137
|
|
|
119
138
|
raise Providers::TransientError,
|
|
120
139
|
"forgejo revoke failed: #{err.to_s.lines.first&.strip}"
|
|
@@ -122,6 +141,54 @@ module Ace
|
|
|
122
141
|
|
|
123
142
|
private
|
|
124
143
|
|
|
144
|
+
def linux_xdg?
|
|
145
|
+
RbConfig::CONFIG.fetch("host_os").start_with?("linux")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def issuer_origin!(issuer)
|
|
149
|
+
raise Providers::RefusedError, "forgejo issuer is invalid" unless issuer.is_a?(Hash)
|
|
150
|
+
url = URI.parse(issuer.fetch("url"))
|
|
151
|
+
unless %w[http https].include?(url.scheme) && url.host && url.userinfo.nil? &&
|
|
152
|
+
["", "/"].include?(url.path.to_s) && url.query.nil? && url.fragment.nil?
|
|
153
|
+
raise Providers::RefusedError, "forgejo issuer url must be a bare HTTP(S) origin"
|
|
154
|
+
end
|
|
155
|
+
if url.scheme == "http" && !loopback_host?(url.hostname)
|
|
156
|
+
raise Providers::RefusedError, "forgejo issuer requires HTTPS except on loopback"
|
|
157
|
+
end
|
|
158
|
+
url
|
|
159
|
+
rescue URI::InvalidURIError, KeyError, TypeError
|
|
160
|
+
raise Providers::RefusedError, "forgejo issuer url is invalid"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def loopback_host?(host)
|
|
164
|
+
return true if host.casecmp?("localhost")
|
|
165
|
+
|
|
166
|
+
IPAddr.new(host).loopback?
|
|
167
|
+
rescue IPAddr::InvalidAddressError
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def issuer_header(issuer)
|
|
172
|
+
account = issuer["account"].to_s
|
|
173
|
+
username = issuer["admin_username"].to_s
|
|
174
|
+
path = issuer["password_file"].to_s
|
|
175
|
+
unless account.match?(/\A[a-zA-Z0-9_.-]+\z/) && username.match?(/\A[a-zA-Z0-9_.-]+\z/)
|
|
176
|
+
raise Providers::RefusedError, "forgejo issuer account and admin_username must be valid user names"
|
|
177
|
+
end
|
|
178
|
+
raise Providers::RefusedError, "forgejo issuer password file is not readable" unless File.file?(path) && File.readable?(path)
|
|
179
|
+
password = File.read(path).strip
|
|
180
|
+
raise Providers::RefusedError, "forgejo issuer password is empty" if password.empty?
|
|
181
|
+
"Authorization: Basic #{Base64.strict_encode64("#{username}:#{password}")}\nSudo: #{account}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_granted_repositories(body, requested)
|
|
185
|
+
repos = body["repositories"]
|
|
186
|
+
granted = repos.map { |repo| repo.is_a?(Hash) ? repo["full_name"].to_s.downcase : "" }.sort if repos.is_a?(Array)
|
|
187
|
+
return if granted && granted == requested.map { |repo| repo.to_s.downcase }.sort
|
|
188
|
+
|
|
189
|
+
raise Providers::TransientError, "forgejo did not confirm the requested repository restrictions"
|
|
190
|
+
end
|
|
191
|
+
|
|
125
192
|
# RepoTargetOption: {"owner" =>, "name" =>} objects parsed
|
|
126
193
|
# from `owner/name` entries. An entry without an owner does
|
|
127
194
|
# not identify a repository — refusing beats issuing an
|
|
@@ -145,12 +212,10 @@ module Ace
|
|
|
145
212
|
end
|
|
146
213
|
end
|
|
147
214
|
|
|
148
|
-
# The
|
|
149
|
-
# them) must cover the request — the enforced scope is the
|
|
150
|
-
# server's answer, never the request's claim.
|
|
215
|
+
# The server's answer, not the request, establishes the grant.
|
|
151
216
|
def validate_granted_scopes(body, requested)
|
|
152
217
|
granted = body["scopes"]
|
|
153
|
-
|
|
218
|
+
raise Providers::TransientError, "forgejo did not confirm granted scopes" unless granted.is_a?(Array)
|
|
154
219
|
|
|
155
220
|
missing = requested.map(&:to_s) - granted.map(&:to_s)
|
|
156
221
|
unless missing.empty?
|
|
@@ -158,6 +223,9 @@ module Ace
|
|
|
158
223
|
"forgejo granted scopes [#{granted.join(', ')}] do not cover the " \
|
|
159
224
|
"requested [#{missing.join(', ')}]; refusing the grant"
|
|
160
225
|
end
|
|
226
|
+
unless (granted.map(&:to_s) - requested.map(&:to_s)).empty?
|
|
227
|
+
raise Providers::TransientError, "forgejo granted unrequested scopes; refusing the grant"
|
|
228
|
+
end
|
|
161
229
|
end
|
|
162
230
|
|
|
163
231
|
# The Authorization secret NEVER appears in curl argv (visible
|
|
@@ -69,6 +69,11 @@ module Ace
|
|
|
69
69
|
roles = string_list(raw["roles"], name, "roles")
|
|
70
70
|
principals = string_list(raw["principals"], name, "principals")
|
|
71
71
|
repos = string_list(raw["repos"], name, "repos")
|
|
72
|
+
env_names = string_list(raw["env_names"], name, "env_names")
|
|
73
|
+
if env_names.any? { |value| value.include?("=") || value.include?("\0") }
|
|
74
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
75
|
+
"auth profile '#{name}' env_names cannot contain '=' or NUL"
|
|
76
|
+
end
|
|
72
77
|
ttl = positive_minutes(raw["ttl_minutes"], name, DEFAULT_TTL_MINUTES)
|
|
73
78
|
max_ttl = positive_minutes(raw["max_ttl_minutes"], name, DEFAULT_MAX_TTL_MINUTES)
|
|
74
79
|
raise_conflict(name, "ttl_minutes exceeds max_ttl_minutes") if ttl > max_ttl
|
|
@@ -92,7 +97,7 @@ module Ace
|
|
|
92
97
|
"ttl_minutes" => ttl, "max_ttl_minutes" => max_ttl,
|
|
93
98
|
"expiry_mode" => expiry_mode,
|
|
94
99
|
"issuer" => raw["issuer"].is_a?(Hash) ? raw["issuer"] : {},
|
|
95
|
-
"env_names" =>
|
|
100
|
+
"env_names" => env_names}
|
|
96
101
|
end
|
|
97
102
|
|
|
98
103
|
def default_expiry_mode(provider)
|
|
@@ -23,6 +23,9 @@ module Ace
|
|
|
23
23
|
# {"token" => secret (never serialized), "token_ref" => provider id,
|
|
24
24
|
# "provider_expiry" => time-ish | nil, "via" => handoff descriptor}
|
|
25
25
|
# or raises RefusedError / TransientError / OutcomeUnknownError
|
|
26
|
+
# RefusedError guarantees no credential was issued. If issuance
|
|
27
|
+
# may have happened, the provider must raise OutcomeUnknownError
|
|
28
|
+
# or TransientError so recovery never treats the outcome as closed.
|
|
26
29
|
# revoke(profile:, token_ref:, token:) -> :revoked | :already_invalid |
|
|
27
30
|
# raises TransientError / OutcomeUnknownError
|
|
28
31
|
# (`token` is the issued secret from the handoff — providers
|
|
@@ -31,6 +34,11 @@ module Ace
|
|
|
31
34
|
# id ignore it; a 404-shaped unknown id settles as
|
|
32
35
|
# :already_invalid so interrupted issues recover
|
|
33
36
|
# deterministically)
|
|
37
|
+
# with_exec_environment(profile:, credential:) { |env| ... }
|
|
38
|
+
# optional scoped child-environment projection. Raise RefusedError
|
|
39
|
+
# before dispatch on failure; clean up temporary secrets when the
|
|
40
|
+
# block exits. Crash-recoverable projections use the existing
|
|
41
|
+
# CredentialHandoff.projection_prefix lifecycle.
|
|
34
42
|
class Providers
|
|
35
43
|
class RefusedError < StandardError; end
|
|
36
44
|
class TransientError < StandardError; end
|