basecamp-sdk 0.9.0 → 0.11.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +106 -10
  3. data/lib/basecamp/client.rb +13 -8
  4. data/lib/basecamp/config.rb +4 -3
  5. data/lib/basecamp/generated/metadata.json +280 -12
  6. data/lib/basecamp/generated/services/account_service.rb +1 -1
  7. data/lib/basecamp/generated/services/automation_service.rb +2 -2
  8. data/lib/basecamp/generated/services/boosts_service.rb +3 -3
  9. data/lib/basecamp/generated/services/campfires_service.rb +7 -7
  10. data/lib/basecamp/generated/services/card_columns_service.rb +1 -1
  11. data/lib/basecamp/generated/services/card_steps_service.rb +1 -1
  12. data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
  13. data/lib/basecamp/generated/services/cards_service.rb +4 -4
  14. data/lib/basecamp/generated/services/checkins_service.rb +8 -8
  15. data/lib/basecamp/generated/services/client_approvals_service.rb +2 -2
  16. data/lib/basecamp/generated/services/client_correspondences_service.rb +2 -2
  17. data/lib/basecamp/generated/services/client_replies_service.rb +2 -2
  18. data/lib/basecamp/generated/services/comments_service.rb +2 -2
  19. data/lib/basecamp/generated/services/documents_service.rb +2 -2
  20. data/lib/basecamp/generated/services/events_service.rb +1 -1
  21. data/lib/basecamp/generated/services/everything_service.rb +169 -0
  22. data/lib/basecamp/generated/services/forwards_service.rb +5 -5
  23. data/lib/basecamp/generated/services/gauges_service.rb +3 -3
  24. data/lib/basecamp/generated/services/hill_charts_service.rb +1 -1
  25. data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
  26. data/lib/basecamp/generated/services/message_types_service.rb +20 -15
  27. data/lib/basecamp/generated/services/messages_service.rb +2 -2
  28. data/lib/basecamp/generated/services/my_assignments_service.rb +5 -5
  29. data/lib/basecamp/generated/services/my_notifications_service.rb +16 -2
  30. data/lib/basecamp/generated/services/people_service.rb +9 -9
  31. data/lib/basecamp/generated/services/projects_service.rb +2 -2
  32. data/lib/basecamp/generated/services/recordings_service.rb +3 -3
  33. data/lib/basecamp/generated/services/reports_service.rb +5 -5
  34. data/lib/basecamp/generated/services/schedules_service.rb +13 -5
  35. data/lib/basecamp/generated/services/search_service.rb +2 -2
  36. data/lib/basecamp/generated/services/subscriptions_service.rb +1 -1
  37. data/lib/basecamp/generated/services/templates_service.rb +3 -3
  38. data/lib/basecamp/generated/services/timeline_service.rb +1 -1
  39. data/lib/basecamp/generated/services/timesheets_service.rb +5 -5
  40. data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
  41. data/lib/basecamp/generated/services/todolists_service.rb +2 -2
  42. data/lib/basecamp/generated/services/todos_service.rb +2 -2
  43. data/lib/basecamp/generated/services/todosets_service.rb +1 -1
  44. data/lib/basecamp/generated/services/tools_service.rb +4 -3
  45. data/lib/basecamp/generated/services/uploads_service.rb +3 -3
  46. data/lib/basecamp/generated/services/vaults_service.rb +2 -2
  47. data/lib/basecamp/generated/services/webhooks_service.rb +2 -2
  48. data/lib/basecamp/generated/types.rb +374 -18
  49. data/lib/basecamp/http.rb +47 -18
  50. data/lib/basecamp/oauth/device_authorization.rb +37 -0
  51. data/lib/basecamp/oauth/device_flow.rb +873 -0
  52. data/lib/basecamp/oauth/device_flow_error.rb +46 -0
  53. data/lib/basecamp/oauth/discovery.rb +13 -11
  54. data/lib/basecamp/oauth/exchange.rb +44 -5
  55. data/lib/basecamp/oauth/fetcher.rb +626 -56
  56. data/lib/basecamp/oauth/oauth_error.rb +3 -1
  57. data/lib/basecamp/oauth/refresh_request.rb +8 -2
  58. data/lib/basecamp/oauth/resource.rb +13 -10
  59. data/lib/basecamp/oauth/token.rb +10 -3
  60. data/lib/basecamp/oauth.rb +47 -2
  61. data/lib/basecamp/services/cards_extensions.rb +67 -0
  62. data/lib/basecamp/version.rb +2 -2
  63. data/lib/basecamp.rb +5 -0
  64. data/scripts/generate-services.rb +24 -6
  65. data/scripts/generate-types.rb +29 -2
  66. metadata +7 -2
@@ -2,6 +2,15 @@
2
2
 
3
3
  require "faraday"
4
4
  require "json"
5
+ # Full cgi, not cgi/escape: on Ruby 3.2-3.4 (the gem floor is 3.2) the
6
+ # escape-only require leaves CGI.unescapeURIComponent broken with an
7
+ # uninitialized @@accept_charset NameError.
8
+ require "cgi"
9
+ require "net/http"
10
+ require "openssl"
11
+ require "timeout"
12
+ require "uri"
13
+ require "zlib"
5
14
 
6
15
  module Basecamp
7
16
  module Oauth
@@ -41,23 +50,112 @@ module Basecamp
41
50
  #
42
51
  # @param timeout [Object] caller-supplied timeout
43
52
  # @return [Numeric] a finite, positive timeout in seconds
44
- def self.normalize_timeout(timeout)
45
- # +real?+ gates out Complex before +finite?+/+positive?+ (which Complex does
46
- # not define calling them would raise NoMethodError). Integer, Float, and
47
- # Rational are all real and answer both.
48
- return timeout if timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive?
53
+ # +default+ is operation-specific: discovery falls back to +DEFAULT_TIMEOUT+
54
+ # (10s), device flow passes its own 30s budget, so an invalid runtime value
55
+ # falls back to that operation's own timeout rather than a foreign one.
56
+ def self.normalize_timeout(timeout, default: DEFAULT_TIMEOUT)
57
+ return timeout if valid_timeout?(timeout)
58
+ # Validate the fallback too: a caller passing an invalid +default+ must not
59
+ # be able to disable both timeout bounds. Fall back to the finite constant.
60
+ return default if valid_timeout?(default)
49
61
 
50
62
  DEFAULT_TIMEOUT
51
63
  end
52
64
 
53
- # Raised internally to abort a streaming read once the cap is exceeded.
54
- # Never escapes this module it is mapped to an OauthError.
65
+ # Upper bound (seconds) for any single OAuth request timeout. A huge but
66
+ # FINITE caller value (1e100) would pass a bare finite/positive check and
67
+ # hold both Faraday's socket timeout and the monotonic deadline open
68
+ # effectively forever — the same cap discipline as Python
69
+ # (_MAX_DEVICE_REQUEST_TIMEOUT = 3600) and TS (resolveDeviceTimeoutMs
70
+ # rejects oversized values back to the default).
71
+ MAX_REQUEST_TIMEOUT = 3600
72
+
73
+ # +real?+ gates out Complex before +finite?+/+positive?+ (which Complex does
74
+ # not define — calling them would raise NoMethodError). Integer, Float, and
75
+ # Rational are all real and answer both.
76
+ # Monotonic clock read (seconds). A module seam — rather than inline
77
+ # Process.clock_gettime — so tests can stub Fetcher's own notion of
78
+ # "now" without touching the process-wide clock that Net::HTTP, the
79
+ # watchdog sleeps, and the test server all rely on.
80
+ # net-http >= 0.5 (Ruby 3.4+) added Net::HTTP.new's eighth p_use_ssl
81
+ # parameter; the gem floor is Ruby 3.2, whose bundled net-http lacks it.
82
+ def self.proxy_tls_capable?
83
+ Net::HTTP.method(:new).parameters.length >= 8
84
+ end
85
+
86
+ def self.monotonic_now
87
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
+ end
89
+
90
+ def self.valid_timeout?(value)
91
+ value.is_a?(Numeric) && value.real? && value.finite? && value.positive? \
92
+ && value <= MAX_REQUEST_TIMEOUT
93
+ end
94
+
95
+ # Coerce the public body cap to a non-negative Integer. A nil, non-Integer
96
+ # (+Float::INFINITY+ included), or negative value would disable the streaming
97
+ # memory bound (+total > cap+ never trips), defeating the bounded-read
98
+ # guarantee. This is the one shared policy for Discovery, Resource, and the
99
+ # device flow; the +default+ is validated too, so an invalid fallback cannot
100
+ # disable the bound either.
101
+ def self.normalize_body_cap(cap, default: DEFAULT_MAX_BODY_BYTES)
102
+ return cap if valid_body_cap?(cap)
103
+ return default if valid_body_cap?(default)
104
+
105
+ DEFAULT_MAX_BODY_BYTES
106
+ end
107
+
108
+ def self.valid_body_cap?(value)
109
+ value.is_a?(Integer) && value >= 0
110
+ end
111
+
112
+ # Classifies a wire fault from {stream_http}'s read into the Faraday error
113
+ # the caller rescues. Once the watchdog DEADLINE has fired, the forced
114
+ # close can surface in the blocked reader as IOError, a SystemCallError
115
+ # (ECONNRESET/EBADF and friends), or SocketError depending on platform
116
+ # and read phase — ALL of them are the timeout then, or the device poll
117
+ # would terminate as transport instead of applying its transient backoff.
118
+ # Before the deadline they are genuine wire faults: a peer closing
119
+ # mid-headers, a malformed status line/header (the Net parse errors are
120
+ # direct StandardError subclasses, not IOError, so they must be mapped
121
+ # explicitly or they leak raw), or malformed compressed bytes from
122
+ # Net::HTTP's automatic Content-Encoding decode (Zlib::Error).
123
+ def self.classify_stream_error(error, deadline_fired)
124
+ if deadline_fired
125
+ Faraday::TimeoutError.new("OAuth request exceeded the timeout deadline")
126
+ else
127
+ # Wrap the exception itself (the conventional Faraday pattern), not
128
+ # just its message: the original class and backtrace survive for
129
+ # debugging while callers' rescues see the same Faraday class.
130
+ Faraday::ConnectionFailed.new(error)
131
+ end
132
+ end
133
+
134
+ # Raised to abort a streaming read once the cap is exceeded. Crosses the
135
+ # module boundary by design: {stream_http} lets it propagate raw, and each
136
+ # caller (fetch_json here, the device flow's post paths) maps it to its
137
+ # own typed cap fault.
55
138
  class BodyTooLarge < StandardError; end
56
139
 
57
- # Raised internally when a streaming read exceeds its wall-clock deadline.
58
- # Never escapes this module it is mapped to a retryable +network+ OauthError.
140
+ # Raised when a streaming read exceeds its wall-clock deadline. Crosses
141
+ # the module boundary like {BodyTooLarge}: callers map it to a retryable
142
+ # +network+ fault (fetch_json) or a Faraday timeout (the device flow).
59
143
  class ReadDeadlineExceeded < StandardError; end
60
144
 
145
+ # Raised from +on_data+ to STOP reading a response whose body the caller does
146
+ # not use (a non-2xx device-auth, a 3xx token redirect). Draining such a slow
147
+ # body would otherwise time out and be misclassified as a transport failure.
148
+ # Carries the response status so the caller can classify by it; like the
149
+ # markers above it crosses the module boundary raw and is mapped there.
150
+ class SkipBody < StandardError
151
+ attr_reader :status
152
+
153
+ def initialize(status)
154
+ @status = status
155
+ super("device flow response body skipped for status #{status}")
156
+ end
157
+ end
158
+
61
159
  # Builds a +[chunks, on_data]+ pair for a genuine bounded/streaming read.
62
160
  # Assign +on_data+ to a request's +req.options.on_data+; after the request
63
161
  # returns, +chunks.join+ is the accumulated body. The proc raises
@@ -77,14 +175,30 @@ module Basecamp
77
175
  # @param max_body_bytes [Integer] bounded read cap in bytes
78
176
  # @param deadline [Float, nil] monotonic clock deadline (CLOCK_MONOTONIC seconds)
79
177
  # @return [Array(Array<String>, Proc)] the chunk buffer and the +on_data+ proc
80
- def self.bounded_reader(max_body_bytes, deadline: nil)
178
+ def self.bounded_reader(max_body_bytes, deadline: nil, skip_status: nil)
81
179
  chunks = []
82
180
  total = 0
83
- reader = proc do |chunk, _received|
84
- if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
181
+ reader = proc do |chunk, _received, env|
182
+ # Fast-path status-first skip: Faraday >= 2.5 passes +env+ (with the
183
+ # response status) to +on_data+ once headers are in, so a body the caller
184
+ # will not use (a non-2xx device-auth / a 3xx token redirect) is abandoned
185
+ # at the FIRST body chunk rather than drained. +env+ is nil on older
186
+ # Faraday (2.0–2.4) or a 2-arg call; every response that COMPLETES is
187
+ # still classified by status via the caller's post-request re-check (see
188
+ # +DeviceFlow#post_form+). +on_data+ is a body callback, not a headers
189
+ # callback, so the one unreachable case — headers arrive, then the body
190
+ # stalls past the read timeout — surfaces as a bounded transport timeout
191
+ # instead (never followed, never unbounded; see +post_form+).
192
+ # Deadline FIRST: a first chunk that becomes runnable past the total
193
+ # bound means the status was not known in time — the timeout wins,
194
+ # matching the default transport's header-time gate. (The resetting
195
+ # per-read timeout alone would admit it.)
196
+ if deadline && monotonic_now > deadline
85
197
  raise ReadDeadlineExceeded
86
198
  end
87
199
 
200
+ raise SkipBody.new(env.status) if skip_status && env && skip_status.call(env.status)
201
+
88
202
  total += chunk.bytesize
89
203
  raise BodyTooLarge if total > max_body_bytes
90
204
 
@@ -93,29 +207,16 @@ module Basecamp
93
207
  [ chunks, reader ]
94
208
  end
95
209
 
96
- # Builds the default SSRF-hardened Faraday connection. No redirect
97
- # middleware is registered, so redirects are not followed.
98
- #
99
- # @param timeout [Integer] request + connect timeout in seconds
100
- # @return [Faraday::Connection]
101
- def self.build_client(timeout)
102
- Faraday.new do |conn|
103
- conn.options.timeout = timeout
104
- conn.options.open_timeout = timeout
105
- conn.adapter Faraday.default_adapter
106
- end
107
- end
108
-
109
210
  # Rejects an INJECTED connection whose middleware stack we cannot verify to
110
211
  # be redirect-free. Redirect suppression is a load-bearing SSRF control (RFC
111
212
  # 9728 §7.7): a caller-supplied client that follows redirects would silently
112
213
  # chase an attacker-controlled +Location+. A class-NAME heuristic (matching
113
214
  # +/redirect/+) is bypassable by a follower whose class name does not contain
114
215
  # "redirect", so we enforce a POLICY instead of guessing by name: an injected
115
- # connection may carry ONLY adapter handlers. The default {build_client}
116
- # connection (adapter only) and a test's mock adapter qualify; ANY request/
117
- # response middleware — which could follow redirects under any name, or
118
- # otherwise rewrite the request — is refused rather than trusted.
216
+ # connection may carry ONLY adapter handlers (an adapter-only connection or
217
+ # a test's mock adapter qualifies); ANY request/response middleware — which
218
+ # could follow redirects under any name, or otherwise rewrite the request —
219
+ # is refused rather than trusted.
119
220
  #
120
221
  # @param client [Faraday::Connection]
121
222
  # @raise [OauthError] +validation+ when non-adapter middleware is present
@@ -144,45 +245,423 @@ module Basecamp
144
245
  )
145
246
  end
146
247
 
248
+ # Headers-first bounded HTTP over Net::HTTP — the default transport for
249
+ # every SDK-built OAuth fetch (both discovery hops and the device flow).
250
+ # Injected Faraday connections keep the Faraday path; this primitive exists
251
+ # because Faraday cannot provide these two guarantees:
252
+ #
253
+ # 1. **Status at header time.** Faraday's +on_data+ is a body callback, so a
254
+ # response whose body never arrives can only be classified after a
255
+ # timeout. Net::HTTP's block form yields the response once HEADERS are
256
+ # in: +skip_status+ classifies by status BEFORE any body read, and
257
+ # raising out of the block makes +Net::HTTP.start+'s ensure close the
258
+ # socket with the body undrained — exact status-first classification
259
+ # (SPEC.md §16) for every response shape, including a stalled body.
260
+ # 2. **A total wall-clock bound.** A per-read timeout resets on every byte,
261
+ # so a peer dripping header or body bytes defeats it. A WATCHDOG thread
262
+ # closes the connection at a monotonic deadline, which interrupts even a
263
+ # blocked or dripped HEADER read (closing the socket from another thread
264
+ # raises IOError in the blocked reader — verified on a live socket).
265
+ # +max_retries = 0+ is load-bearing: Net::HTTP's idempotent-retry would
266
+ # otherwise silently REOPEN the connection the watchdog just closed.
267
+ # Scope: the watchdog governs from session-start on. The CONNECTION
268
+ # phases (TCP connect, proxy CONNECT, TLS handshake) are not
269
+ # watchdog-interruptible — Net::HTTP marks the session started only
270
+ # after they complete — so connection setup runs under its own
271
+ # whole-phase Timeout.timeout bound (a proxy's CONNECT response is
272
+ # parsed under the per-read timeout, which a byte-dripping proxy
273
+ # resets forever), and a post-connect deadline re-check refuses the
274
+ # request when setup consumed the budget. Total wall clock is
275
+ # ~timeout, never unbounded.
276
+ #
277
+ # The body streams under the same cap + deadline as the Faraday path, and
278
+ # redirects are structurally never followed (+Net::HTTP#request+ has no
279
+ # follow logic). Transport failures surface as Faraday errors
280
+ # (+TimeoutError+ for timeouts, +ConnectionFailed+ for connection and
281
+ # protocol-parse failures) so both transport paths classify through the
282
+ # same caller rescues. Bounded-read violations keep raising the shared
283
+ # {BodyTooLarge} / {ReadDeadlineExceeded} markers — deliberately NOT
284
+ # Faraday errors, so each caller maps them to its own operation-specific
285
+ # error message, exactly as on the Faraday path.
286
+ #
287
+ # @param method [Symbol] +:get+ or +:post+
288
+ # @param url [String] fully-qualified URL (already origin-validated)
289
+ # @param headers [Hash] request headers
290
+ # @param form [Hash, nil] form params; www-form-encoded into the POST body
291
+ # @param timeout [Numeric] total request bound in seconds (already
292
+ # normalized by the caller). The deadline is anchored BEFORE connect and
293
+ # open_timeout carries the same value, so the total wall time is
294
+ # ~timeout regardless of which phase stalls (the watchdog closes the
295
+ # session the moment it exists if the deadline fired mid-connect)
296
+ # @param max_body_bytes [Integer] bounded read cap in bytes
297
+ # @param skip_status [Proc, nil] statuses whose body is never read
298
+ # @param on_headers [Proc, nil] called with the +Net::HTTPResponse+ once
299
+ # headers arrive, before any body read or skip decision — the device
300
+ # poll loop reads +Retry-After+ here without widening the return shape
301
+ # @return [Array(Integer, String)] status and (possibly empty) body
302
+ def self.stream_http(method, url, headers: {}, form: nil, timeout:, max_body_bytes: DEFAULT_MAX_BODY_BYTES, skip_status: nil, on_headers: nil)
303
+ # Response state first, before ANY call the def-level rescues could
304
+ # catch, so every rescue path sees it assigned (the completed_at guard
305
+ # means it is only READ once genuinely populated).
306
+ status = nil
307
+ chunks = []
308
+ total = 0
309
+ uri = begin
310
+ URI.parse(url)
311
+ rescue URI::InvalidURIError
312
+ nil
313
+ end
314
+ # Fail closed on an unparsable or hostless URL ("https:foo" parses with a
315
+ # nil hostname): require_https checks only the scheme, and a nil host
316
+ # would otherwise surface as a raw ArgumentError from inside Net::HTTP —
317
+ # outside the transport's Faraday-error contract.
318
+ if uri.nil? || uri.hostname.nil? || uri.hostname.empty?
319
+ raise OauthError.new("validation", "OAuth endpoint URL has no host: #{url.inspect}")
320
+ end
321
+ # Fail closed on a non-HTTP(S) scheme: callers TLS-guard upstream, but
322
+ # the primitive must not run an HTTP conversation against ftp:// etc.
323
+ unless %w[http https].include?(uri.scheme)
324
+ raise OauthError.new("validation", "OAuth endpoint URL must be http(s): #{url.inspect}")
325
+ end
326
+
327
+ # Fail closed on an un-normalized timeout (the operation entry points
328
+ # normalize; this guards direct callers): a non-finite, non-positive,
329
+ # or beyond-ceiling value would leave the socket timeouts and the
330
+ # watchdog's sleep unbounded, defeating the total-request bound this
331
+ # primitive exists to guarantee — mirroring the Python transport's
332
+ # fail-fast guard.
333
+ unless valid_timeout?(timeout)
334
+ raise OauthError.new("validation", \
335
+ "stream_http timeout must be a positive number of seconds no greater than #{MAX_REQUEST_TIMEOUT}")
336
+ end
337
+ # The body cap gets the same fail-closed discipline: nil, a Float
338
+ # (Infinity included), or a negative value would disable or crash the
339
+ # streaming bound. Same predicate as {normalize_body_cap} so the
340
+ # transport can never reject a cap the public entry points accept —
341
+ # zero is a legitimate strict cap (any non-empty body trips it).
342
+ unless valid_body_cap?(max_body_bytes)
343
+ raise OauthError.new("validation", "stream_http max_body_bytes must be a non-negative Integer")
344
+ end
345
+
346
+ # URI#hostname strips IPv6 brackets ("[::1]" -> "::1"), which is the form
347
+ # Net::HTTP.new expects.
348
+ #
349
+ # Net::HTTP's built-in :ENV proxy detection hardcodes an http:// URI
350
+ # before calling find_proxy, so an HTTPS_PROXY-only environment would
351
+ # silently bypass its proxy for HTTPS endpoints (and http_proxy would
352
+ # wrongly govern TLS requests). Resolve the proxy against the REAL
353
+ # scheme — matching faraday-net_http's per-scheme resolution — and
354
+ # pass it explicitly; a nil p_addr disables the broken built-in.
355
+ # The total deadline starts BEFORE proxy resolution: find_proxy
356
+ # resolves the target hostname (IPSocket.getaddress) to evaluate its
357
+ # loopback rule — a blocking DNS call that must sit inside the
358
+ # advertised bound like every other network step. Net::OpenTimeout
359
+ # maps through the Timeout::Error rescue to the transport timeout.
360
+ deadline = monotonic_now + timeout
361
+ deadline_fired = false
362
+ completed_at = nil
363
+ proxy_uri = Timeout.timeout(timeout, Net::OpenTimeout) { uri.find_proxy }
364
+ http = if proxy_uri
365
+ proxy_tls = proxy_uri.scheme == "https"
366
+ # An https:// proxy needs TLS on its own connection via the
367
+ # p_use_ssl argument, which exists only in net-http >= 0.5
368
+ # (Ruby 3.4+). On the older bundled net-http (Ruby 3.2/3.3)
369
+ # the 8-arg call would raise ArgumentError — refuse with an
370
+ # actionable error instead of plaintext-to-a-TLS-proxy.
371
+ if proxy_tls && !proxy_tls_capable?
372
+ raise OauthError.new(
373
+ "validation",
374
+ "https:// proxies need net-http >= 0.5 (Ruby 3.4+, or add the net-http gem)"
375
+ )
376
+ end
377
+
378
+ # Percent-decode the credentials: URI#user/#password return the
379
+ # encoded forms, and the explicit-proxy Net::HTTP.new does NOT
380
+ # unescape them the way its :ENV mode does — p%40ss would be
381
+ # sent verbatim in Proxy-Authorization and fail authentication.
382
+ # unescapeURIComponent, NOT form decoding: a literal + in a
383
+ # userinfo component is a plus sign, never a space.
384
+ # p_no_proxy nil (find_proxy already honored no_proxy).
385
+ args = [
386
+ uri.hostname, uri.port,
387
+ proxy_uri.hostname, proxy_uri.port,
388
+ proxy_uri.user && CGI.unescapeURIComponent(proxy_uri.user),
389
+ proxy_uri.password && CGI.unescapeURIComponent(proxy_uri.password)
390
+ ]
391
+ args += [ nil, true ] if proxy_tls
392
+ Net::HTTP.new(*args)
393
+ else
394
+ Net::HTTP.new(uri.hostname, uri.port, nil)
395
+ end
396
+ http.use_ssl = uri.scheme == "https"
397
+ http.open_timeout = timeout
398
+ http.read_timeout = timeout
399
+ http.write_timeout = timeout
400
+ http.max_retries = 0
401
+
402
+ # A form body is only meaningful on POST — a GET-with-body masks a
403
+ # call-site mistake and contradicts this method's own contract.
404
+ raise ArgumentError, "stream_http: form is only valid with :post" if form && method != :post
405
+
406
+ # Identity encoding IN THE INITHEADER: Net::HTTP inflates gzip/deflate
407
+ # BEFORE read_body yields, so the per-chunk cap would measure DECODED
408
+ # bytes — a small compressed body could balloon far past max_body_bytes
409
+ # in memory (compression bomb). A caller-supplied Accept-Encoding at
410
+ # construction time is the supported way to switch decode_content off
411
+ # (assigning the header later does not); a server compressing anyway
412
+ # hands us raw bytes bounded by the cap, which then fail
413
+ # classification upstream instead of exhausting memory.
414
+ identity = { "Accept-Encoding" => "identity" }
415
+ request =
416
+ case method
417
+ when :post then Net::HTTP::Post.new(uri, identity)
418
+ when :get then Net::HTTP::Get.new(uri, identity)
419
+ else raise ArgumentError, "stream_http supports :get and :post, got #{method.inspect}"
420
+ end
421
+ headers.each do |name, value|
422
+ # The identity Accept-Encoding above is load-bearing (compression
423
+ # bomb bound): a caller-supplied override would reintroduce
424
+ # transparent inflation, so it is dropped — matching the Python
425
+ # transport, which forcibly overwrites the header.
426
+ next if name.to_s.casecmp("accept-encoding").zero?
427
+
428
+ request[name] = value
429
+ end
430
+ if form
431
+ request.body = URI.encode_www_form(form)
432
+ # A form body implies the form content type; explicit headers win.
433
+ request["Content-Type"] ||= "application/x-www-form-urlencoded"
434
+ end
435
+
436
+ # Net::HTTP#request implicitly re-starts a finished session, and that
437
+ # restart runs OUTSIDE the connect Timeout.timeout below — an
438
+ # ENV-proxied CONNECT could drip unbounded there (started? stays
439
+ # false, so the watchdog cannot close it). The only path to a
440
+ # finished session is the watchdog, which fires only after the
441
+ # deadline: guarding EVERY start on deadline_fired closes every
442
+ # implicit-reconnect path deterministically.
443
+ http.define_singleton_method(:start) do |&blk|
444
+ raise Net::OpenTimeout, "total deadline exceeded before (re)connect" if deadline_fired
445
+
446
+ super(&blk)
447
+ end
448
+ watchdog = Thread.new do
449
+ remaining = deadline - monotonic_now
450
+ sleep(remaining) if remaining.positive?
451
+ deadline_fired = true
452
+ # Close the session and KEEP closing until the ensure below kills this
453
+ # thread. A one-shot close is wrong twice over: finish raises IOError
454
+ # while the session is still CONNECTING (a single failed attempt would
455
+ # leave the subsequent header read unbounded), and Net::HTTP#request
456
+ # implicitly RE-STARTS a finished session — a close that landed
457
+ # between the post-connect deadline re-check and the request write
458
+ # would hand the reopened connection a fresh, unwatched header wait.
459
+ # Looping bounds any such reopen to one tick. The ensure kills this
460
+ # thread the moment the request completes, so the loop cannot outlive
461
+ # the call.
462
+ loop do
463
+ # The ensure's kill must never land INSIDE finish: interrupted
464
+ # after do_finish clears started? but before the socket close,
465
+ # BOTH closers skip — the request-side ensure sees started? false
466
+ # and this thread is dead — leaking the socket until GC. Defer the
467
+ # kill across each close attempt; it lands at the sleep below.
468
+ Thread.handle_interrupt(Object => :never) do
469
+ http.finish
470
+ rescue IOError
471
+ # Not started: still connecting, or between implicit reopens.
472
+ end
473
+ sleep(0.05)
474
+ end
475
+ end
476
+
477
+ # The connection phases (TCP connect, proxy CONNECT, TLS handshake) run
478
+ # before Net::HTTP marks the session started, so the watchdog cannot
479
+ # interrupt them (finish raises IOError until then) — and only TCP
480
+ # connect and the TLS handshake carry native open_timeout bounds. A
481
+ # proxy's CONNECT response is parsed under the PER-READ timeout, which
482
+ # resets on every dripped byte: without a whole-phase bound, an
483
+ # ENV-proxied HTTPS request could sit in connection setup indefinitely.
484
+ # Timeout.timeout's async raise is safe here precisely because the
485
+ # guarded region is ONLY connection setup: no response state exists
486
+ # yet, and Net::HTTP's connect rescue closes both sockets on any raise.
487
+ connect_remaining = deadline - monotonic_now
488
+ raise ReadDeadlineExceeded unless connect_remaining.positive?
489
+
490
+ begin
491
+ # start INSIDE the cleanup region: Timeout.timeout's asynchronous
492
+ # Net::OpenTimeout can land after do_start marked the session
493
+ # started but before the next statement — outside the ensure, that
494
+ # window leaked a live connection until GC (the outer ensure can
495
+ # kill the watchdog before it ever closes).
496
+ Timeout.timeout(connect_remaining, Net::OpenTimeout) { http.start }
497
+ # Re-check the deadline the moment the session is up: if setup
498
+ # consumed the whole budget, fail now rather than granting the
499
+ # request a fresh header wait.
500
+ raise ReadDeadlineExceeded if monotonic_now > deadline
501
+
502
+ http.request(request) do |response|
503
+ status = response.code.to_i
504
+ # Headers are available before any body read; the device poll loop
505
+ # uses this to read Retry-After without widening the return shape.
506
+ on_headers&.call(response)
507
+ if skip_status&.call(status)
508
+ # Deadline first for a status NOT known in time: headers that
509
+ # become runnable past the monotonic deadline (but before the
510
+ # watchdog flips deadline_fired) are a timeout, not a
511
+ # classification — matching the Python transport. A status
512
+ # known IN time is still raised before any body read: the
513
+ # SkipBody unwinds to the ensure below, which closes the
514
+ # socket undrained, so a skipped status's body is NEVER
515
+ # drained and a discovery 500 or token redirect known before
516
+ # the deadline never softens into a retryable timeout.
517
+ raise ReadDeadlineExceeded if monotonic_now > deadline
518
+
519
+ raise SkipBody.new(status)
520
+ end
521
+
522
+ # Net::HTTP#request implicitly re-starts a finished session: if the
523
+ # watchdog's close landed between the deadline re-check above and
524
+ # the request write, this round trip rode a fresh post-deadline
525
+ # connection. The watchdog loop closes such a connection within a
526
+ # tick; this classifies a non-skipped round trip that beat the
527
+ # next tick, BEFORE its body is read.
528
+ raise ReadDeadlineExceeded if deadline_fired
529
+
530
+ response.read_body do |chunk|
531
+ raise ReadDeadlineExceeded if monotonic_now > deadline
532
+
533
+ total += chunk.bytesize
534
+ raise BodyTooLarge if total > max_body_bytes
535
+
536
+ chunks << chunk
537
+ end
538
+ # Stamp completion INSIDE the request block: Net::HTTP's own
539
+ # end_transport runs before http.request returns, and the
540
+ # watchdog racing that cleanup must not erase a completed body.
541
+ completed_at = monotonic_now
542
+ end
543
+ # Final monotonic re-check AFTER the response completes: a peer can
544
+ # deliver the last body chunk just before the deadline and the
545
+ # terminating EOF just after it — read_body runs no further per-chunk
546
+ # check, and the request thread can process that completion before
547
+ # the watchdog sets deadline_fired. A completed response is never
548
+ # accepted past the advertised total-request bound. (Skipped statuses
549
+ # keep status-first classification — SkipBody unwinds before this.)
550
+ raise ReadDeadlineExceeded if monotonic_now > deadline
551
+ ensure
552
+ # Block-form start would close the session itself; with the explicit
553
+ # start (needed so ONLY connection setup sits under Timeout.timeout)
554
+ # close it here. When the connect timeout fires between Net::HTTP's
555
+ # connect assigning a live @socket and do_start marking the session
556
+ # started, started? is still false and Net::HTTP's own connect
557
+ # rescue is out of scope — close the orphaned socket directly or it
558
+ # leaks until GC.
559
+ begin
560
+ if http.started?
561
+ http.finish
562
+ else
563
+ orphan = http.instance_variable_get(:@socket)
564
+ orphan&.close
565
+ end
566
+ rescue IOError
567
+ # Already closed by the watchdog.
568
+ end
569
+ end
570
+ [ status, chunks.join.force_encoding(Encoding::UTF_8) ]
571
+ rescue SkipBody => e
572
+ [ e.status, "" ]
573
+ rescue Timeout::Error, Errno::ETIMEDOUT => e
574
+ # Timeout::Error covers Net::OpenTimeout/ReadTimeout/WriteTimeout alike
575
+ # (a peer that accepts the connection but stops READING trips the write
576
+ # timeout). Errno::ETIMEDOUT is a SystemCallError, but it is a TIMEOUT:
577
+ # both must map with the timeouts — the exact pair faraday-net_http
578
+ # rescues — or the device poll would terminate instead of applying its
579
+ # transient backoff.
580
+ raise Faraday::TimeoutError, "OAuth request timed out: #{e.message}"
581
+ rescue IOError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError,
582
+ SystemCallError, SocketError, Zlib::Error => e
583
+ # The clock outranks the watchdog FLAG: a wire fault observed past the
584
+ # monotonic deadline is the timeout it raced, even when the watchdog
585
+ # thread has not yet been scheduled to flip deadline_fired — otherwise
586
+ # a post-deadline peer reset classifies as ConnectionFailed and the
587
+ # device poll terminates instead of applying its transient backoff.
588
+ raise classify_stream_error(e, deadline_fired || monotonic_now > deadline)
589
+ rescue NoMethodError => e
590
+ # The watchdog's cross-thread finish can nil @socket between the body
591
+ # completing and Net::HTTP's own end_transport, whose @socket.closed?
592
+ # then raises NoMethodError on the request thread. An in-deadline
593
+ # COMPLETED response dominates that cleanup race (mirroring the
594
+ # Python transport's outcome preservation); otherwise, past the
595
+ # deadline, it is the timeout the cleanup raced. A genuine
596
+ # NoMethodError (a bug) re-raises untouched.
597
+ if completed_at && completed_at <= deadline
598
+ return [ status, chunks.join.force_encoding(Encoding::UTF_8) ]
599
+ end
600
+ raise classify_stream_error(e, true) if deadline_fired || monotonic_now > deadline
601
+
602
+ raise
603
+ rescue OpenSSL::SSL::SSLError => e
604
+ # The watchdog's forced close surfaces mid-handshake/mid-read as an
605
+ # SSLError: past the deadline it is the timeout it raced, same clock
606
+ # rule as the wire-error branch above. A genuine pre-deadline TLS
607
+ # failure (an unverifiable peer certificate above all) still maps to
608
+ # Faraday::SSLError exactly as faraday-net_http maps it, so the
609
+ # default and injected paths classify certificate rejection alike.
610
+ raise Faraday::TimeoutError, "OAuth request timed out: TLS interrupted past the deadline" \
611
+ if deadline_fired || monotonic_now > deadline
612
+
613
+ raise Faraday::SSLError, e.message
614
+ ensure
615
+ watchdog&.kill
616
+ watchdog&.join
617
+ end
618
+
147
619
  # Fetches +url+ and returns the parsed JSON object (a Hash).
148
620
  #
149
- # The request timeout is applied per-request (not only on the connection)
150
- # so a bounded read is enforced even when the caller INJECTS its own
151
- # connection: an injected client's adapter default would otherwise leave the
152
- # requested +timeout+ unenforced. This mirrors the device flow's +post_form+.
621
+ # With a nil +http_client+ the fetch runs on the headers-first
622
+ # {stream_http} primitive (total wall-clock bound incl. the header phase).
623
+ # An INJECTED connection keeps the Faraday path: the request timeout is
624
+ # applied per-request (not only on the connection) so a bounded read is
625
+ # enforced even under the injected adapter's defaults, and the wall-clock
626
+ # deadline bounds the whole body read — but Faraday exposes no
627
+ # headers-time callback, so a body that stalls past the read timeout on
628
+ # the injected path surfaces as a bounded transport timeout.
153
629
  #
154
- # @param http_client [Faraday::Connection] the SSRF-hardened connection
630
+ # @param http_client [Faraday::Connection, nil] injected connection, or
631
+ # nil for the default headers-first transport
155
632
  # @param url [String] fully-qualified well-known URL to fetch
156
- # @param timeout [Integer] per-request timeout in seconds
633
+ # @param timeout [Numeric] per-request timeout in seconds (fractional accepted)
157
634
  # @param max_body_bytes [Integer] bounded read cap in bytes
158
635
  # @return [Hash] the parsed JSON document
159
636
  # @raise [OauthError] +api_error+ on non-2xx, oversized body, non-object
160
637
  # JSON, or parse failure; +network+ on transport failure
161
638
  def self.fetch_json(http_client, url, timeout:, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
162
- # Wall-clock deadline over the WHOLE read: req.options.timeout below bounds
163
- # only each socket read and resets on every chunk, so a slow-drip peer could
164
- # otherwise hang the fetch indefinitely while staying under max_body_bytes.
165
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
166
- chunks, on_data = bounded_reader(max_body_bytes, deadline: deadline)
167
-
168
- response = http_client.get(url) do |req|
169
- req.headers["Accept"] = "application/json"
170
- # Bounded streaming read: abort the moment the cap is exceeded so an
171
- # oversized body is never fully buffered.
172
- req.options.on_data = on_data
173
- # Apply the request timeout on every request — even an injected client —
174
- # so a stalled socket can't hang discovery under the adapter default.
175
- req.options.timeout = timeout
176
- req.options.open_timeout = timeout
177
- end
178
-
179
- body = chunks.join.force_encoding(Encoding::UTF_8)
639
+ status, body =
640
+ if http_client.nil?
641
+ stream_http(
642
+ :get, url,
643
+ headers: { "Accept" => "application/json" },
644
+ timeout: timeout, max_body_bytes: max_body_bytes,
645
+ # STATUS DOMINATES THE BODY (SPEC.md: non-2xx on either hop →
646
+ # api_error, never network): skip draining a non-2xx body so a
647
+ # stalled/dripped error body cannot convert the required api_error
648
+ # into a network timeout. The body text was only optional
649
+ # diagnostics — the other SDKs read it best-effort at most.
650
+ skip_status: ->(response_status) { !(200..299).cover?(response_status) }
651
+ )
652
+ else
653
+ faraday_fetch(http_client, url, timeout: timeout, max_body_bytes: max_body_bytes)
654
+ end
180
655
 
181
- unless (200..299).cover?(response.status)
656
+ unless (200..299).cover?(status)
657
+ # Status-only, on BOTH paths: the error category, retryability, and
658
+ # http_status are the observable contract; embedding response body
659
+ # text would put attacker-influenced content in exception messages
660
+ # and diverge from the (body-less) default transport and Python.
182
661
  raise OauthError.new(
183
662
  "api_error",
184
- "OAuth discovery failed with status #{response.status}: #{Basecamp::Security.truncate(body)}",
185
- http_status: response.status
663
+ "OAuth discovery failed with status #{status}",
664
+ http_status: status
186
665
  )
187
666
  end
188
667
 
@@ -199,6 +678,97 @@ module Basecamp
199
678
  rescue JSON::ParserError => e
200
679
  raise OauthError.new("api_error", "Failed to parse OAuth discovery response: #{e.message}")
201
680
  end
681
+
682
+ # The Faraday transport for an INJECTED connection. The request timeout is
683
+ # applied per-request (not only on the connection) so a bounded read is
684
+ # enforced even under the injected adapter's defaults, and the wall-clock
685
+ # deadline bounds the whole body read.
686
+ #
687
+ # Known residual (injected connections only): unlike the default
688
+ # {stream_http} path — which skips unusable bodies by status at header
689
+ # time — Faraday exposes no headers-time seam, so a non-2xx body is
690
+ # drained here under the same cap + deadline before the status error
691
+ # surfaces. Bounded, never followed; callers who inject a connection
692
+ # keep their transport's semantics by design.
693
+ #
694
+ # @return [Array(Integer, String)] status and body
695
+ def self.faraday_fetch(http_client, url, timeout:, max_body_bytes:)
696
+ # Wall-clock deadline over the WHOLE read: req.options.timeout below bounds
697
+ # only each socket read and resets on every chunk, so a slow-drip peer could
698
+ # otherwise hang the fetch indefinitely while staying under max_body_bytes.
699
+ deadline = monotonic_now + timeout
700
+ # Streaming adapters (Faraday >= 2.5 pass +env+ to on_data) classify a
701
+ # non-2xx at HEADER time like the default path — fetch_json discards
702
+ # non-2xx bodies, so draining one is pure waste. Buffered adapters
703
+ # remain the documented drain-then-classify residual.
704
+ chunks, on_data = bounded_reader(
705
+ max_body_bytes, deadline: deadline,
706
+ skip_status: ->(s) { !(200..299).cover?(s) }
707
+ )
708
+
709
+ # Timeout.timeout wraps the WHOLE call: the per-read timeout resets on
710
+ # every socket read, so a peer dripping HEADER bytes under it would
711
+ # otherwise hold the request open indefinitely — on_data (a body
712
+ # callback) never runs during the header phase, leaving nothing else
713
+ # to enforce the wall clock on an injected client.
714
+ # The window is the REMAINING budget, not a fresh +timeout+: time
715
+ # spent before dispatch (descheduling included) already counts
716
+ # against the deadline, so the request can never run past it.
717
+ remaining = deadline - monotonic_now
718
+ raise Faraday::TimeoutError, "request budget exhausted before dispatch" if remaining <= 0
719
+
720
+ response = Timeout.timeout(remaining, Faraday::TimeoutError) do
721
+ http_client.get(url) do |req|
722
+ req.headers["Accept"] = "application/json"
723
+ # Bounded streaming read: abort the moment the cap is exceeded so an
724
+ # oversized body is never fully buffered.
725
+ req.options.on_data = on_data
726
+ # Apply the request timeout on every request — even an injected client —
727
+ # so a stalled socket can't hang discovery under the adapter default.
728
+ req.options.timeout = timeout
729
+ req.options.open_timeout = timeout
730
+ end
731
+ end
732
+
733
+ # Timeout.timeout's interrupt can be delivered late: a 2xx whose block
734
+ # returns just after the deadline would otherwise be accepted past the
735
+ # wall clock. A completed non-2xx stays status-classified (status
736
+ # outranks the deadline race, matching the default path); only a late
737
+ # 2xx is refused as the same transport-shaped timeout.
738
+ raise Faraday::TimeoutError, "response completed after the deadline" \
739
+ if (200..299).cover?(response.status) && monotonic_now > deadline
740
+
741
+ body =
742
+ if chunks.empty?
743
+ if (200..299).cover?(response.status)
744
+ # A buffered adapter (Faraday's test adapter above all) never
745
+ # invokes on_data: the streamed chunks are empty while the body
746
+ # sits on the response. Fall back to it under the same cap so an
747
+ # injected buffered client gets the document instead of a bogus
748
+ # empty-body parse failure — mirroring post_form's fallback.
749
+ # dup ONLY a frozen body (test adapters return literals, which
750
+ # cannot take force_encoding below): copying every large
751
+ # buffered 2xx unconditionally would double peak memory.
752
+ raw = response.body.to_s
753
+ raw = raw.dup if raw.frozen?
754
+ raise BodyTooLarge if raw.bytesize > max_body_bytes
755
+
756
+ raw
757
+ else
758
+ # fetch_json discards non-2xx bodies (status dominates, SPEC.md):
759
+ # return status-only rather than dup-and-size-checking a body
760
+ # nobody reads, so an oversized buffered error body surfaces as
761
+ # the status fault — not a size-cap one — and is never copied.
762
+ # +"" — the frozen literal cannot take force_encoding below.
763
+ +""
764
+ end
765
+ else
766
+ chunks.join
767
+ end
768
+ [ response.status, body.force_encoding(Encoding::UTF_8) ]
769
+ rescue SkipBody => e
770
+ [ e.status, "" ]
771
+ end
202
772
  end
203
773
  end
204
774
  end