radioactive 0.1.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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +85 -0
- data/CLAUDE.md +52 -0
- data/README.md +530 -0
- data/Rakefile +11 -0
- data/Steepfile +24 -0
- data/lib/radioactive/address_check.rb +59 -0
- data/lib/radioactive/errors.rb +28 -0
- data/lib/radioactive/fetcher.rb +355 -0
- data/lib/radioactive/monotonic_clock.rb +9 -0
- data/lib/radioactive/result.rb +5 -0
- data/lib/radioactive/version.rb +5 -0
- data/lib/radioactive.rb +19 -0
- data/lib/tasks/gem.rake +5 -0
- data/lib/tasks/lint/all.rake +11 -0
- data/lib/tasks/lint/rubocop.rake +15 -0
- data/lib/tasks/security.rake +11 -0
- data/lib/tasks/types.rake +16 -0
- data/sig/radioactive.rbs +234 -0
- data/sig/zeitwerk.rbs +13 -0
- data.tar.gz.sig +0 -0
- metadata +112 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "resolv"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
require "uri"
|
|
9
|
+
require "zlib"
|
|
10
|
+
|
|
11
|
+
module Radioactive
|
|
12
|
+
class Fetcher
|
|
13
|
+
REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
|
|
14
|
+
RESERVED_HEADERS = %w[host user-agent accept-encoding].freeze
|
|
15
|
+
CHUNK_SIZE = 16 * 1024
|
|
16
|
+
DEFAULT_USER_AGENT = "Radioactive/#{Radioactive::VERSION}"
|
|
17
|
+
|
|
18
|
+
# Single-label hosts that are entirely digits or 0x-prefix hex are not
|
|
19
|
+
# valid RFC 1123 hostnames; they're SSRF-bypass attempts that some libc
|
|
20
|
+
# getaddrinfo implementations historically resolved as IPs.
|
|
21
|
+
NUMERIC_ONLY_HOST = /\A(\d+|0x[\da-f]+)\z/i
|
|
22
|
+
|
|
23
|
+
# CRLF and NUL are illegal in HTTP header names and values (RFC 9110);
|
|
24
|
+
# caller-supplied input containing these is a header-injection attempt.
|
|
25
|
+
HEADER_INVALID_CHAR = /[\r\n\0]/
|
|
26
|
+
|
|
27
|
+
DEFAULTS = {
|
|
28
|
+
schemes: %w[http https].freeze,
|
|
29
|
+
max_size: 2_097_152,
|
|
30
|
+
open_timeout: 5,
|
|
31
|
+
read_timeout: 10,
|
|
32
|
+
total_timeout: 30,
|
|
33
|
+
max_redirects: 3,
|
|
34
|
+
accept_encoding: "identity",
|
|
35
|
+
user_agent: DEFAULT_USER_AGENT,
|
|
36
|
+
private_ranges: AddressCheck::DEFAULT_PRIVATE_RANGES,
|
|
37
|
+
allow_private: false,
|
|
38
|
+
allow_credentials: false,
|
|
39
|
+
headers: {}.freeze
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
def initialize(**opts)
|
|
43
|
+
validate_opts!(opts)
|
|
44
|
+
@opts = DEFAULTS.merge(opts)
|
|
45
|
+
@resolver = opts[:resolver] || Resolv
|
|
46
|
+
@clock = opts[:clock] || MonotonicClock
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fetch(url, **call_opts)
|
|
50
|
+
body = String.new(capacity: CHUNK_SIZE)
|
|
51
|
+
meta = run_streaming(url, call_opts) { |chunk| body << chunk }
|
|
52
|
+
Result.new(
|
|
53
|
+
url: meta[:url],
|
|
54
|
+
final_url: meta[:final_url],
|
|
55
|
+
status: meta[:status],
|
|
56
|
+
headers: meta[:headers],
|
|
57
|
+
body: body,
|
|
58
|
+
hops: meta[:hops]
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# No-block form returns a StringIO of the fully-buffered body (size-capped at
|
|
63
|
+
# max_size; matches `URI.open` semantics). Block form streams chunks straight
|
|
64
|
+
# to a Tempfile and yields it rewound, so peak memory per fetch is ~CHUNK_SIZE
|
|
65
|
+
# rather than max_size — useful for high-concurrency or low-RAM callers.
|
|
66
|
+
def open(url, **call_opts)
|
|
67
|
+
return StringIO.new(fetch(url, **call_opts).body) unless block_given?
|
|
68
|
+
|
|
69
|
+
io = Tempfile.new("radioactive")
|
|
70
|
+
io.binmode
|
|
71
|
+
begin
|
|
72
|
+
run_streaming(url, call_opts) { |chunk| io.write(chunk) }
|
|
73
|
+
io.rewind
|
|
74
|
+
yield io
|
|
75
|
+
ensure
|
|
76
|
+
io.close
|
|
77
|
+
io.unlink
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def validate_opts!(opts)
|
|
84
|
+
if opts.key?(:max_redirects) && opts[:max_redirects].negative?
|
|
85
|
+
raise ArgumentError, "max_redirects must be >= 0"
|
|
86
|
+
end
|
|
87
|
+
if opts.key?(:max_size) && opts[:max_size] <= 0
|
|
88
|
+
raise ArgumentError, "max_size must be > 0"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Runs the full fetch pipeline (parse, DNS pin, request, redirect-with-revalidation).
|
|
93
|
+
# Yields each chunk of the *final* response body to the given block as it is read,
|
|
94
|
+
# without intermediate buffering. Returns metadata only (no body).
|
|
95
|
+
def run_streaming(url, call_opts, &chunk_block)
|
|
96
|
+
validate_opts!(call_opts)
|
|
97
|
+
opts = call_opts.empty? ? @opts : @opts.merge(call_opts)
|
|
98
|
+
resolver = call_opts[:resolver] || @resolver
|
|
99
|
+
clock = call_opts[:clock] || @clock
|
|
100
|
+
|
|
101
|
+
start_uri = parse_url(url, opts)
|
|
102
|
+
|
|
103
|
+
total_timeout = opts[:total_timeout]
|
|
104
|
+
deadline = total_timeout ? clock.now + total_timeout : nil
|
|
105
|
+
|
|
106
|
+
hops = []
|
|
107
|
+
current = start_uri
|
|
108
|
+
redirects_left = opts[:max_redirects]
|
|
109
|
+
|
|
110
|
+
loop do
|
|
111
|
+
check_deadline(deadline, clock)
|
|
112
|
+
|
|
113
|
+
ip = pin_address(current, resolver, opts)
|
|
114
|
+
kind, status, headers, body = perform_request(current, ip, opts, deadline, clock, &chunk_block)
|
|
115
|
+
|
|
116
|
+
case kind
|
|
117
|
+
when :redirect
|
|
118
|
+
raise RedirectError, "redirect budget exhausted" if redirects_left <= 0
|
|
119
|
+
|
|
120
|
+
redirects_left -= 1
|
|
121
|
+
hops << current
|
|
122
|
+
current = resolve_redirect(current, headers["location"], opts)
|
|
123
|
+
when :final
|
|
124
|
+
unless (200..299).cover?(status)
|
|
125
|
+
raise ResponseError.new(
|
|
126
|
+
"non-success status: #{status}",
|
|
127
|
+
status: status, headers: headers, body: body
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
url: start_uri,
|
|
133
|
+
final_url: current,
|
|
134
|
+
status: status,
|
|
135
|
+
headers: headers,
|
|
136
|
+
hops: hops.freeze
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def parse_url(url, opts)
|
|
143
|
+
uri = url.is_a?(URI) ? url.dup : URI.parse(url.to_s)
|
|
144
|
+
raise SchemeError, "URL has no host" if uri.host.nil? || uri.host.empty?
|
|
145
|
+
unless opts[:schemes].include?(uri.scheme)
|
|
146
|
+
raise SchemeError, "scheme not allowed: #{uri.scheme.inspect}"
|
|
147
|
+
end
|
|
148
|
+
if (uri.userinfo || uri.user) && !opts[:allow_credentials]
|
|
149
|
+
raise SchemeError, "embedded credentials not allowed"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
uri.host = canonicalize_host(uri.host)
|
|
153
|
+
uri.fragment = nil
|
|
154
|
+
uri
|
|
155
|
+
rescue URI::InvalidURIError => e
|
|
156
|
+
raise SchemeError, "invalid URL: #{e.message}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Defense-in-depth: canonicalize IP-literal hosts so the safety property
|
|
160
|
+
# (we connect to the resolver-returned IP, not the user's input string)
|
|
161
|
+
# doesn't depend on IPAddr.new being strict about leading zeros, octal,
|
|
162
|
+
# decimal, or hex forms. Single-label numeric/hex hosts cannot be valid
|
|
163
|
+
# hostnames and are rejected outright as ambiguous.
|
|
164
|
+
def canonicalize_host(host)
|
|
165
|
+
ip = IPAddr.new(host)
|
|
166
|
+
ip.to_s
|
|
167
|
+
rescue IPAddr::Error
|
|
168
|
+
raise SchemeError, "ambiguous numeric host: #{host.inspect}" if NUMERIC_ONLY_HOST.match?(host)
|
|
169
|
+
|
|
170
|
+
host
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def pin_address(uri, resolver, opts)
|
|
174
|
+
host = uri.host or raise AddressError, "URL has no host"
|
|
175
|
+
addresses = AddressCheck.resolve(host, resolver)
|
|
176
|
+
raise AddressError, "no addresses for #{host}" if addresses.empty?
|
|
177
|
+
|
|
178
|
+
unless opts[:allow_private]
|
|
179
|
+
addresses.each do |ip|
|
|
180
|
+
if AddressCheck.forbidden?(ip, opts[:private_ranges])
|
|
181
|
+
raise AddressError, "address in forbidden range: #{ip}"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
addresses.first
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def resolve_redirect(current, location, opts)
|
|
190
|
+
target = URI.join(current.to_s, location)
|
|
191
|
+
parse_url(target, opts)
|
|
192
|
+
rescue URI::InvalidURIError => e
|
|
193
|
+
raise SchemeError, "invalid redirect target: #{e.message}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def check_deadline(deadline, clock)
|
|
197
|
+
return unless deadline
|
|
198
|
+
|
|
199
|
+
remaining = deadline - clock.now
|
|
200
|
+
raise TimeoutError, "total_timeout exceeded" if remaining <= 0
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def clamp_timeout(value, deadline, clock)
|
|
204
|
+
return value unless deadline && value
|
|
205
|
+
|
|
206
|
+
remaining = deadline - clock.now
|
|
207
|
+
raise TimeoutError, "total_timeout exceeded" if remaining <= 0
|
|
208
|
+
|
|
209
|
+
[value, remaining].min
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def perform_request(uri, ip, opts, deadline, clock, &chunk_block)
|
|
213
|
+
http = build_http(uri, ip, opts, deadline, clock)
|
|
214
|
+
req = build_request(uri, opts)
|
|
215
|
+
|
|
216
|
+
result = nil
|
|
217
|
+
begin
|
|
218
|
+
http.start do |conn|
|
|
219
|
+
conn.request(req) do |res|
|
|
220
|
+
code = res.code.to_i
|
|
221
|
+
headers = headers_hash(res)
|
|
222
|
+
|
|
223
|
+
if REDIRECT_STATUSES.include?(code) && headers["location"]
|
|
224
|
+
result = [:redirect, code, headers, nil]
|
|
225
|
+
elsif (200..299).cover?(code)
|
|
226
|
+
# 2xx: stream chunks straight to caller; no buffering here.
|
|
227
|
+
read_body!(res, headers, opts, deadline, clock, &chunk_block)
|
|
228
|
+
result = [:final, code, headers, nil]
|
|
229
|
+
else
|
|
230
|
+
# Non-2xx: buffer body so ResponseError can carry partial data.
|
|
231
|
+
error_body = String.new(capacity: CHUNK_SIZE)
|
|
232
|
+
read_body!(res, headers, opts, deadline, clock) { |chunk| error_body << chunk }
|
|
233
|
+
result = [:final, code, headers, error_body]
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
238
|
+
raise TimeoutError, e.message
|
|
239
|
+
rescue OpenSSL::SSL::SSLError, SocketError, Errno::ECONNREFUSED,
|
|
240
|
+
Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ECONNRESET,
|
|
241
|
+
IOError => e
|
|
242
|
+
raise ResponseError, "transport error: #{e.class}: #{e.message}"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
result || raise(ResponseError, "request produced no response")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def build_http(uri, ip, opts, deadline, clock)
|
|
249
|
+
host = uri.host or raise SchemeError, "URL has no host"
|
|
250
|
+
port = uri.port || ((uri.scheme == "https") ? 443 : 80)
|
|
251
|
+
http = Net::HTTP.new(host, port)
|
|
252
|
+
http.ipaddr = ip.to_s
|
|
253
|
+
http.use_ssl = (uri.scheme == "https")
|
|
254
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
255
|
+
|
|
256
|
+
if (open_t = clamp_timeout(opts[:open_timeout], deadline, clock))
|
|
257
|
+
http.open_timeout = open_t
|
|
258
|
+
end
|
|
259
|
+
if (read_t = clamp_timeout(opts[:read_timeout], deadline, clock))
|
|
260
|
+
http.read_timeout = read_t
|
|
261
|
+
end
|
|
262
|
+
if http.respond_to?(:write_timeout=) && (write_t = clamp_timeout(opts[:open_timeout], deadline, clock))
|
|
263
|
+
http.write_timeout = write_t
|
|
264
|
+
end
|
|
265
|
+
http
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def build_request(uri, opts)
|
|
269
|
+
path = uri.path.to_s
|
|
270
|
+
path = "/" if path.empty?
|
|
271
|
+
path = "#{path}?#{uri.query}" if uri.query
|
|
272
|
+
req = Net::HTTP::Get.new(path)
|
|
273
|
+
req["User-Agent"] = opts[:user_agent]
|
|
274
|
+
req["Accept-Encoding"] = opts[:accept_encoding]
|
|
275
|
+
(opts[:headers] || {}).each do |k, v|
|
|
276
|
+
name = k.to_s
|
|
277
|
+
value = v.to_s
|
|
278
|
+
next if RESERVED_HEADERS.include?(name.downcase)
|
|
279
|
+
if HEADER_INVALID_CHAR.match?(name) || HEADER_INVALID_CHAR.match?(value)
|
|
280
|
+
raise SchemeError, "header contains CR/LF/NUL: #{name.inspect}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
req[name] = value
|
|
284
|
+
end
|
|
285
|
+
req
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def headers_hash(res)
|
|
289
|
+
res.each_header.to_h { |k, v| [k.downcase, v] }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Reads the response body, decoding if needed, yielding each chunk to the
|
|
293
|
+
# given block. Enforces max_size on the *post-decoding* size so opt-in gzip
|
|
294
|
+
# is bounded by decoded bytes.
|
|
295
|
+
def read_body!(res, headers, opts, deadline, clock, &chunk_block)
|
|
296
|
+
max = opts[:max_size]
|
|
297
|
+
cl = res.content_length
|
|
298
|
+
raise SizeError, "Content-Length #{cl} exceeds max_size #{max}" if cl && cl > max
|
|
299
|
+
|
|
300
|
+
ce = headers["content-encoding"].to_s.downcase
|
|
301
|
+
accept = opts[:accept_encoding].to_s.downcase
|
|
302
|
+
|
|
303
|
+
if ce.empty? || ce == "identity"
|
|
304
|
+
read_plain!(res, max, deadline, clock, &chunk_block)
|
|
305
|
+
elsif accept == "identity"
|
|
306
|
+
raise EncodingError, "unexpected Content-Encoding: #{ce} (accept_encoding=identity)"
|
|
307
|
+
elsif accept.include?("gzip") && (ce == "gzip" || ce == "x-gzip")
|
|
308
|
+
read_gzip!(res, max, deadline, clock, &chunk_block)
|
|
309
|
+
else
|
|
310
|
+
raise EncodingError, "unsupported Content-Encoding: #{ce}"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def read_plain!(res, max, deadline, clock)
|
|
315
|
+
total = 0
|
|
316
|
+
res.read_body do |chunk|
|
|
317
|
+
check_deadline(deadline, clock)
|
|
318
|
+
total += chunk.bytesize
|
|
319
|
+
raise SizeError, "body exceeded max_size #{max}" if total > max
|
|
320
|
+
|
|
321
|
+
yield chunk
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def read_gzip!(res, max, deadline, clock)
|
|
326
|
+
inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 16)
|
|
327
|
+
total = 0
|
|
328
|
+
begin
|
|
329
|
+
res.read_body do |chunk|
|
|
330
|
+
check_deadline(deadline, clock)
|
|
331
|
+
decoded = inflater.inflate(chunk)
|
|
332
|
+
total += decoded.bytesize
|
|
333
|
+
raise SizeError, "body exceeded max_size #{max}" if total > max
|
|
334
|
+
|
|
335
|
+
yield decoded unless decoded.empty?
|
|
336
|
+
end
|
|
337
|
+
tail = inflater.finish
|
|
338
|
+
unless tail.empty?
|
|
339
|
+
total += tail.bytesize
|
|
340
|
+
raise SizeError, "body exceeded max_size #{max}" if total > max
|
|
341
|
+
|
|
342
|
+
yield tail
|
|
343
|
+
end
|
|
344
|
+
rescue Zlib::Error => e
|
|
345
|
+
raise EncodingError, "gzip decode failed: #{e.message}"
|
|
346
|
+
ensure
|
|
347
|
+
begin
|
|
348
|
+
inflater.close
|
|
349
|
+
rescue
|
|
350
|
+
# already closed
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
data/lib/radioactive.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zeitwerk"
|
|
4
|
+
loader = Zeitwerk::Loader.for_gem
|
|
5
|
+
loader.ignore("#{__dir__}/radioactive/errors.rb")
|
|
6
|
+
loader.setup
|
|
7
|
+
|
|
8
|
+
require_relative "radioactive/errors"
|
|
9
|
+
|
|
10
|
+
module Radioactive
|
|
11
|
+
def self.fetch(url, **opts)
|
|
12
|
+
Fetcher.new(**opts).fetch(url)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.open(url, **opts, &block)
|
|
16
|
+
fetcher = Fetcher.new(**opts)
|
|
17
|
+
block ? fetcher.open(url, &block) : fetcher.open(url)
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/tasks/gem.rake
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require "rubocop/rake_task"
|
|
2
|
+
|
|
3
|
+
namespace :lint do
|
|
4
|
+
desc "Run rubocop linter check"
|
|
5
|
+
task :rubocop do
|
|
6
|
+
exec "bundle exec rubocop"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
namespace :rubocop do
|
|
10
|
+
desc "Run rubocop autocorrect"
|
|
11
|
+
task :autocorrect do
|
|
12
|
+
exec "bundle exec rubocop --autocorrect-all"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
namespace :types do
|
|
2
|
+
desc "Validate RBS syntax in sig/ (catches sigs that reference nonexistent types)"
|
|
3
|
+
task :validate do
|
|
4
|
+
sh "bundle exec rbs " \
|
|
5
|
+
"-r uri -r ipaddr -r stringio -r tempfile -r net-http -r openssl -r resolv -r zlib " \
|
|
6
|
+
"-I sig validate"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
desc "Type-check lib/ against sig/ with Steep (catches sig drift from code)"
|
|
10
|
+
task :check do
|
|
11
|
+
sh "bundle exec steep check"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
desc "Validate RBS sigs and type-check the implementation"
|
|
16
|
+
task types: ["types:validate", "types:check"]
|
data/sig/radioactive.rbs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
module Radioactive
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
type uri_like = String | URI::Generic
|
|
5
|
+
type header_hash = Hash[String, String]
|
|
6
|
+
|
|
7
|
+
# Common interface for what `Radioactive.open` yields/returns.
|
|
8
|
+
# StringIO (no-block) and Tempfile (block form) both satisfy this.
|
|
9
|
+
interface _ReadableIO
|
|
10
|
+
def read: () -> String
|
|
11
|
+
| (Integer?) -> String?
|
|
12
|
+
def rewind: () -> Integer
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
interface _Clock
|
|
16
|
+
def now: () -> Float
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module MonotonicClock
|
|
20
|
+
def self.now: () -> Float
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.fetch: (
|
|
24
|
+
uri_like url,
|
|
25
|
+
?schemes: Array[String],
|
|
26
|
+
?max_size: Integer,
|
|
27
|
+
?open_timeout: Float | Integer,
|
|
28
|
+
?read_timeout: Float | Integer,
|
|
29
|
+
?total_timeout: (Float | Integer)?,
|
|
30
|
+
?max_redirects: Integer,
|
|
31
|
+
?accept_encoding: String,
|
|
32
|
+
?user_agent: String,
|
|
33
|
+
?private_ranges: Array[IPAddr],
|
|
34
|
+
?allow_private: bool,
|
|
35
|
+
?allow_credentials: bool,
|
|
36
|
+
?headers: header_hash,
|
|
37
|
+
?resolver: AddressCheck::_Resolver,
|
|
38
|
+
?clock: _Clock
|
|
39
|
+
) -> Result
|
|
40
|
+
|
|
41
|
+
def self.open: (
|
|
42
|
+
uri_like url,
|
|
43
|
+
?schemes: Array[String],
|
|
44
|
+
?max_size: Integer,
|
|
45
|
+
?open_timeout: Float | Integer,
|
|
46
|
+
?read_timeout: Float | Integer,
|
|
47
|
+
?total_timeout: (Float | Integer)?,
|
|
48
|
+
?max_redirects: Integer,
|
|
49
|
+
?accept_encoding: String,
|
|
50
|
+
?user_agent: String,
|
|
51
|
+
?private_ranges: Array[IPAddr],
|
|
52
|
+
?allow_private: bool,
|
|
53
|
+
?allow_credentials: bool,
|
|
54
|
+
?headers: header_hash,
|
|
55
|
+
?resolver: AddressCheck::_Resolver,
|
|
56
|
+
?clock: _Clock
|
|
57
|
+
) -> StringIO
|
|
58
|
+
| (
|
|
59
|
+
uri_like url,
|
|
60
|
+
?schemes: Array[String],
|
|
61
|
+
?max_size: Integer,
|
|
62
|
+
?open_timeout: Float | Integer,
|
|
63
|
+
?read_timeout: Float | Integer,
|
|
64
|
+
?total_timeout: (Float | Integer)?,
|
|
65
|
+
?max_redirects: Integer,
|
|
66
|
+
?accept_encoding: String,
|
|
67
|
+
?user_agent: String,
|
|
68
|
+
?private_ranges: Array[IPAddr],
|
|
69
|
+
?allow_private: bool,
|
|
70
|
+
?allow_credentials: bool,
|
|
71
|
+
?headers: header_hash,
|
|
72
|
+
?resolver: AddressCheck::_Resolver,
|
|
73
|
+
?clock: _Clock
|
|
74
|
+
) { (_ReadableIO io) -> untyped } -> untyped
|
|
75
|
+
|
|
76
|
+
class Error < StandardError
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class SchemeError < Error
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class AddressError < Error
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class TimeoutError < Error
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class SizeError < Error
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class RedirectError < Error
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class EncodingError < Error
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class ResponseError < Error
|
|
98
|
+
attr_reader status: Integer?
|
|
99
|
+
attr_reader headers: header_hash?
|
|
100
|
+
attr_reader body: String?
|
|
101
|
+
|
|
102
|
+
def initialize: (?String? message, ?status: Integer?, ?headers: header_hash?, ?body: String?) -> void
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class Result
|
|
106
|
+
attr_reader url: URI::Generic
|
|
107
|
+
attr_reader final_url: URI::Generic
|
|
108
|
+
attr_reader status: Integer
|
|
109
|
+
attr_reader headers: header_hash
|
|
110
|
+
attr_reader body: String
|
|
111
|
+
attr_reader hops: Array[URI::Generic]
|
|
112
|
+
|
|
113
|
+
def self.new: (
|
|
114
|
+
url: URI::Generic,
|
|
115
|
+
final_url: URI::Generic,
|
|
116
|
+
status: Integer,
|
|
117
|
+
headers: header_hash,
|
|
118
|
+
body: String,
|
|
119
|
+
hops: Array[URI::Generic]
|
|
120
|
+
) -> Result
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
module AddressCheck
|
|
124
|
+
DEFAULT_PRIVATE_RANGES: Array[IPAddr]
|
|
125
|
+
|
|
126
|
+
interface _Resolver
|
|
127
|
+
def getaddresses: (String host) -> Array[String]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self?.forbidden?: (IPAddr ip, ?Array[IPAddr] ranges) -> bool
|
|
131
|
+
def self?.resolve: (String host, _Resolver resolver) -> Array[IPAddr]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class Fetcher
|
|
135
|
+
REDIRECT_STATUSES: Array[Integer]
|
|
136
|
+
RESERVED_HEADERS: Array[String]
|
|
137
|
+
CHUNK_SIZE: Integer
|
|
138
|
+
DEFAULT_USER_AGENT: String
|
|
139
|
+
NUMERIC_ONLY_HOST: Regexp
|
|
140
|
+
HEADER_INVALID_CHAR: Regexp
|
|
141
|
+
DEFAULTS: Hash[Symbol, untyped]
|
|
142
|
+
|
|
143
|
+
def initialize: (
|
|
144
|
+
?schemes: Array[String],
|
|
145
|
+
?max_size: Integer,
|
|
146
|
+
?open_timeout: Numeric,
|
|
147
|
+
?read_timeout: Numeric,
|
|
148
|
+
?total_timeout: Numeric?,
|
|
149
|
+
?max_redirects: Integer,
|
|
150
|
+
?accept_encoding: String,
|
|
151
|
+
?user_agent: String,
|
|
152
|
+
?private_ranges: Array[IPAddr],
|
|
153
|
+
?allow_private: bool,
|
|
154
|
+
?allow_credentials: bool,
|
|
155
|
+
?headers: header_hash,
|
|
156
|
+
?resolver: AddressCheck::_Resolver,
|
|
157
|
+
?clock: _Clock
|
|
158
|
+
) -> void
|
|
159
|
+
|
|
160
|
+
def fetch: (
|
|
161
|
+
uri_like url,
|
|
162
|
+
?schemes: Array[String],
|
|
163
|
+
?max_size: Integer,
|
|
164
|
+
?open_timeout: Numeric,
|
|
165
|
+
?read_timeout: Numeric,
|
|
166
|
+
?total_timeout: Numeric?,
|
|
167
|
+
?max_redirects: Integer,
|
|
168
|
+
?accept_encoding: String,
|
|
169
|
+
?user_agent: String,
|
|
170
|
+
?private_ranges: Array[IPAddr],
|
|
171
|
+
?allow_private: bool,
|
|
172
|
+
?allow_credentials: bool,
|
|
173
|
+
?headers: header_hash,
|
|
174
|
+
?resolver: AddressCheck::_Resolver,
|
|
175
|
+
?clock: _Clock
|
|
176
|
+
) -> Result
|
|
177
|
+
|
|
178
|
+
def open: (
|
|
179
|
+
uri_like url,
|
|
180
|
+
?schemes: Array[String],
|
|
181
|
+
?max_size: Integer,
|
|
182
|
+
?open_timeout: Numeric,
|
|
183
|
+
?read_timeout: Numeric,
|
|
184
|
+
?total_timeout: Numeric?,
|
|
185
|
+
?max_redirects: Integer,
|
|
186
|
+
?accept_encoding: String,
|
|
187
|
+
?user_agent: String,
|
|
188
|
+
?private_ranges: Array[IPAddr],
|
|
189
|
+
?allow_private: bool,
|
|
190
|
+
?allow_credentials: bool,
|
|
191
|
+
?headers: header_hash,
|
|
192
|
+
?resolver: AddressCheck::_Resolver,
|
|
193
|
+
?clock: _Clock
|
|
194
|
+
) -> StringIO
|
|
195
|
+
| (
|
|
196
|
+
uri_like url,
|
|
197
|
+
?schemes: Array[String],
|
|
198
|
+
?max_size: Integer,
|
|
199
|
+
?open_timeout: Numeric,
|
|
200
|
+
?read_timeout: Numeric,
|
|
201
|
+
?total_timeout: Numeric?,
|
|
202
|
+
?max_redirects: Integer,
|
|
203
|
+
?accept_encoding: String,
|
|
204
|
+
?user_agent: String,
|
|
205
|
+
?private_ranges: Array[IPAddr],
|
|
206
|
+
?allow_private: bool,
|
|
207
|
+
?allow_credentials: bool,
|
|
208
|
+
?headers: header_hash,
|
|
209
|
+
?resolver: AddressCheck::_Resolver,
|
|
210
|
+
?clock: _Clock
|
|
211
|
+
) { (_ReadableIO io) -> untyped } -> untyped
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
# Private internals are declared with mostly-untyped signatures so Steep
|
|
216
|
+
# knows they exist (no spurious "method not declared" warnings) without
|
|
217
|
+
# us having to maintain types for every internal helper.
|
|
218
|
+
def validate_opts!: (Hash[Symbol, untyped] opts) -> void
|
|
219
|
+
def run_streaming: (uri_like url, Hash[Symbol, untyped] call_opts) { (String) -> void } -> Hash[Symbol, untyped]
|
|
220
|
+
def parse_url: (uri_like url, Hash[Symbol, untyped] opts) -> URI::Generic
|
|
221
|
+
def canonicalize_host: (String host) -> String
|
|
222
|
+
def pin_address: (URI::Generic uri, AddressCheck::_Resolver resolver, Hash[Symbol, untyped] opts) -> IPAddr
|
|
223
|
+
def resolve_redirect: (URI::Generic current, String location, Hash[Symbol, untyped] opts) -> URI::Generic
|
|
224
|
+
def check_deadline: (Float? deadline, _Clock clock) -> void
|
|
225
|
+
def clamp_timeout: ((Float | Integer)? value, Float? deadline, _Clock clock) -> (Float | Integer)?
|
|
226
|
+
def perform_request: (URI::Generic uri, IPAddr ip, Hash[Symbol, untyped] opts, Float? deadline, _Clock clock) { (String) -> void } -> Array[untyped]
|
|
227
|
+
def build_http: (URI::Generic uri, IPAddr ip, Hash[Symbol, untyped] opts, Float? deadline, _Clock clock) -> Net::HTTP
|
|
228
|
+
def build_request: (URI::Generic uri, Hash[Symbol, untyped] opts) -> Net::HTTPRequest
|
|
229
|
+
def headers_hash: (Net::HTTPResponse res) -> header_hash
|
|
230
|
+
def read_body!: (Net::HTTPResponse res, header_hash headers, Hash[Symbol, untyped] opts, Float? deadline, _Clock clock) { (String) -> void } -> void
|
|
231
|
+
def read_plain!: (Net::HTTPResponse res, Integer max, Float? deadline, _Clock clock) { (String) -> void } -> void
|
|
232
|
+
def read_gzip!: (Net::HTTPResponse res, Integer max, Float? deadline, _Clock clock) { (String) -> void } -> void
|
|
233
|
+
end
|
|
234
|
+
end
|