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.
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radioactive
4
+ module MonotonicClock
5
+ def self.now
6
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radioactive
4
+ Result = Data.define(:url, :final_url, :status, :headers, :body, :hops)
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Radioactive
4
+ VERSION = "0.1.1"
5
+ end
@@ -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
@@ -0,0 +1,5 @@
1
+ require "bundler/gem_helper"
2
+
3
+ namespace :gem do
4
+ Bundler::GemHelper.install_tasks
5
+ end
@@ -0,0 +1,11 @@
1
+ namespace :lint do
2
+ desc "Run all linter checks"
3
+ task all: %i[rubocop] do
4
+ puts "All lints completed successfully!"
5
+ end
6
+
7
+ namespace :all do
8
+ desc "Run all linter autocorrects"
9
+ task autocorrect: %i[rubocop:autocorrect]
10
+ end
11
+ end
@@ -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,11 @@
1
+ require "bundler/audit/task"
2
+
3
+ Bundler::Audit::Task.new
4
+
5
+ namespace :security do
6
+ desc "Update vulnerability database and run audit"
7
+ task :audit do
8
+ Rake::Task["bundle:audit:update"].invoke
9
+ Rake::Task["bundle:audit"].invoke
10
+ end
11
+ 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"]
@@ -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