code-ruby 3.1.2 → 4.0.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/VERSION +1 -1
  3. data/bin/code +97 -20
  4. data/lib/code/concerns/shared.rb +331 -15
  5. data/lib/code/format.rb +15 -1
  6. data/lib/code/network.rb +87 -0
  7. data/lib/code/node/call.rb +79 -2
  8. data/lib/code/node/call_argument.rb +14 -0
  9. data/lib/code/node/code.rb +5 -4
  10. data/lib/code/node/function_parameter.rb +7 -4
  11. data/lib/code/node/list.rb +29 -1
  12. data/lib/code/object/base_64.rb +132 -6
  13. data/lib/code/object/boolean.rb +60 -0
  14. data/lib/code/object/class.rb +138 -2
  15. data/lib/code/object/code.rb +111 -3
  16. data/lib/code/object/context.rb +57 -1
  17. data/lib/code/object/cryptography.rb +63 -0
  18. data/lib/code/object/date.rb +13339 -462
  19. data/lib/code/object/decimal.rb +1725 -0
  20. data/lib/code/object/dictionary.rb +1790 -11
  21. data/lib/code/object/duration.rb +28 -0
  22. data/lib/code/object/function.rb +261 -23
  23. data/lib/code/object/global.rb +534 -1
  24. data/lib/code/object/html.rb +179 -7
  25. data/lib/code/object/http.rb +244 -14
  26. data/lib/code/object/ics.rb +75 -13
  27. data/lib/code/object/identifier_list.rb +17 -2
  28. data/lib/code/object/integer.rb +1937 -2
  29. data/lib/code/object/json.rb +75 -1
  30. data/lib/code/object/list.rb +3383 -10
  31. data/lib/code/object/nothing.rb +53 -0
  32. data/lib/code/object/number.rb +110 -0
  33. data/lib/code/object/parameter.rb +140 -0
  34. data/lib/code/object/range.rb +576 -14
  35. data/lib/code/object/smtp.rb +95 -12
  36. data/lib/code/object/string.rb +944 -3
  37. data/lib/code/object/super.rb +10 -1
  38. data/lib/code/object/time.rb +13358 -498
  39. data/lib/code/object/url.rb +65 -0
  40. data/lib/code/object.rb +543 -0
  41. data/lib/code/parser.rb +161 -24
  42. data/lib/code-ruby.rb +3 -0
  43. data/lib/code.rb +30 -3
  44. metadata +135 -84
  45. data/.github/dependabot.yml +0 -15
  46. data/.github/workflows/ci.yml +0 -38
  47. data/.gitignore +0 -30
  48. data/.node-version +0 -1
  49. data/.npm-version +0 -1
  50. data/.prettierignore +0 -2
  51. data/.rspec +0 -1
  52. data/.rubocop.yml +0 -140
  53. data/.ruby-version +0 -1
  54. data/.tool-versions +0 -3
  55. data/AGENTS.md +0 -43
  56. data/Gemfile +0 -22
  57. data/Gemfile.lock +0 -292
  58. data/Rakefile +0 -5
  59. data/bin/bundle +0 -123
  60. data/bin/bundle-audit +0 -31
  61. data/bin/bundler-audit +0 -31
  62. data/bin/dorian +0 -31
  63. data/bin/rspec +0 -31
  64. data/bin/rubocop +0 -31
  65. data/bin/test +0 -5
  66. data/code-ruby.gemspec +0 -34
  67. data/docs/precedence.txt +0 -36
  68. data/package-lock.json +0 -14
  69. data/package.json +0 -7
  70. data/spec/bin/code_spec.rb +0 -48
  71. data/spec/code/format_spec.rb +0 -153
  72. data/spec/code/node/call_spec.rb +0 -11
  73. data/spec/code/object/boolean_spec.rb +0 -18
  74. data/spec/code/object/cryptography_spec.rb +0 -25
  75. data/spec/code/object/decimal_spec.rb +0 -50
  76. data/spec/code/object/dictionary_spec.rb +0 -98
  77. data/spec/code/object/function_spec.rb +0 -268
  78. data/spec/code/object/http_spec.rb +0 -33
  79. data/spec/code/object/ics_spec.rb +0 -50
  80. data/spec/code/object/integer_spec.rb +0 -42
  81. data/spec/code/object/list_spec.rb +0 -22
  82. data/spec/code/object/nothing_spec.rb +0 -14
  83. data/spec/code/object/range_spec.rb +0 -23
  84. data/spec/code/object/string_spec.rb +0 -26
  85. data/spec/code/parser/boolean_spec.rb +0 -11
  86. data/spec/code/parser/chained_call_spec.rb +0 -16
  87. data/spec/code/parser/dictionary_spec.rb +0 -18
  88. data/spec/code/parser/function_spec.rb +0 -16
  89. data/spec/code/parser/group_spec.rb +0 -11
  90. data/spec/code/parser/if_modifier_spec.rb +0 -18
  91. data/spec/code/parser/list_spec.rb +0 -17
  92. data/spec/code/parser/number_spec.rb +0 -11
  93. data/spec/code/parser/string_spec.rb +0 -20
  94. data/spec/code/parser_spec.rb +0 -52
  95. data/spec/code/type_spec.rb +0 -21
  96. data/spec/code_spec.rb +0 -717
  97. data/spec/spec_helper.rb +0 -21
  98. data/spec/zeitwerk/loader_spec.rb +0 -7
@@ -3,6 +3,144 @@
3
3
  class Code
4
4
  class Object
5
5
  class Html < Object
6
+ CLASS_DOCUMENTATION = {
7
+ name: "Html",
8
+ description: "builds, escapes, parses, and queries html fragments.",
9
+ examples: [
10
+ "Html.p { :hello }",
11
+ "Html.escape(\"<p>a</p>\")",
12
+ "Html.raw(\"<p>a</p>\").css(\"p\")"
13
+ ]
14
+ }.freeze
15
+ CLASS_FUNCTIONS = {
16
+ "escape" => {
17
+ name: "escape",
18
+ description: "escapes a value for html text.",
19
+ examples: [
20
+ "Html.escape(\"<p>\")",
21
+ "Html.escape(:hello)",
22
+ "Html.escape(() => { \"<strong>hello</strong>\" })"
23
+ ]
24
+ },
25
+ "unescape" => {
26
+ name: "unescape",
27
+ description: "converts html entities and tags to text.",
28
+ examples: [
29
+ "Html.unescape(\"&lt;p&gt;\")",
30
+ "Html.unescape(\"<p>hello</p>\")",
31
+ "Html.unescape(() => { \"&amp;\" })"
32
+ ]
33
+ },
34
+ "join" => {
35
+ name: "join",
36
+ description: "joins html or text values with an optional separator.",
37
+ examples: [
38
+ "Html.join([:a, :b], \", \")",
39
+ "Html.join([Html.text(\"a\"), Html.text(\"b\")])",
40
+ "Html.join(\"<br>\", () => { [\"a\", \"b\"] })"
41
+ ]
42
+ },
43
+ "text" => {
44
+ name: "text",
45
+ description: "builds an escaped html text fragment.",
46
+ examples: [
47
+ "Html.text(:hello)",
48
+ "Html.text(\"<strong>hello</strong>\")",
49
+ "Html.text(() => { :hello })"
50
+ ]
51
+ },
52
+ "raw" => {
53
+ name: "raw",
54
+ description: "parses markup into an html fragment without escaping text.",
55
+ examples: [
56
+ "Html.raw(\"<strong>hello</strong>\")",
57
+ "Html.raw(Html.text(:hello))",
58
+ "Html.raw(() => { \"<em>hello</em>\" })"
59
+ ]
60
+ }
61
+ }.freeze
62
+ INSTANCE_FUNCTIONS = {
63
+ "css" => {
64
+ name: "css",
65
+ description: "returns all html nodes matching a css selector.",
66
+ examples: [
67
+ "Html.raw(\"<p>a</p><p>b</p>\").css(\"p\")",
68
+ "Html.raw(\"<main><p>a</p></main>\").css(\"main p\")",
69
+ "Html.raw(\"<p class='x'>a</p>\").css(\".x\")"
70
+ ]
71
+ },
72
+ "at_css" => {
73
+ name: "at_css",
74
+ description: "returns the first html node matching a css selector.",
75
+ examples: [
76
+ "Html.raw(\"<p>a</p><p>b</p>\").at_css(\"p\")",
77
+ "Html.raw(\"<main><p>a</p></main>\").at_css(\"main p\")",
78
+ "Html.raw(\"<p class='x'>a</p>\").at_css(\".x\")"
79
+ ]
80
+ },
81
+ "map" => {
82
+ name: "map",
83
+ description: "returns a list by calling a function for each html node.",
84
+ examples: [
85
+ "Html.raw(\"<p>a</p><p>b</p>\").css(\"p\").map((node) => { node.to_string })",
86
+ "Html.raw(\"<p>a</p>\").css(:p).map((node, index) => { index })",
87
+ "Html.raw(\"<p>a</p>\").css(:p).map((node, index, nodes) => { nodes.to_string })"
88
+ ]
89
+ },
90
+ "to_string" => {
91
+ name: "to_string",
92
+ description: "returns the html fragment's text content.",
93
+ examples: [
94
+ "Html.raw(\"<p>hello</p>\").to_string",
95
+ "Html.text(:hello).to_string",
96
+ "Html.raw(\"<p><strong>hello</strong></p>\").to_string"
97
+ ]
98
+ },
99
+ "to_html" => {
100
+ name: "to_html",
101
+ description: "returns the html fragment as html markup.",
102
+ examples: [
103
+ "Html.raw(\"<p>hello</p>\").to_html",
104
+ "Html.text(:p).to_html",
105
+ "Html.raw(\"<strong>hello</strong>\").to_html"
106
+ ]
107
+ },
108
+ "inner_text" => {
109
+ name: "inner_text",
110
+ description: "returns the html fragment's text content.",
111
+ examples: [
112
+ "Html.raw(\"<p>hello</p>\").inner_text",
113
+ "Html.text(:hello).inner_text",
114
+ "Html.raw(\"<p><strong>hello</strong></p>\").inner_text"
115
+ ]
116
+ },
117
+ "attribute" => {
118
+ name: "attribute",
119
+ description: "returns an html node attribute by name.",
120
+ examples: [
121
+ "Html.raw(\"<a href='/'>home</a>\").at_css(:a).attribute(:href)",
122
+ "Html.raw(\"<p class='x'>a</p>\").at_css(:p).attribute(:class)",
123
+ "Html.raw(\"<img alt='logo'>\").at_css(:img).attribute(:alt)"
124
+ ]
125
+ },
126
+ "attributes" => {
127
+ name: "attributes",
128
+ description: "returns an html node's attributes as a dictionary.",
129
+ examples: [
130
+ "Html.raw(\"<a href='/'>home</a>\").at_css(:a).attributes",
131
+ "Html.raw(\"<p class='x'>a</p>\").at_css(:p).attributes",
132
+ "Html.raw(\"<img alt='logo'>\").at_css(:img).attributes"
133
+ ]
134
+ }
135
+ }.freeze
136
+
137
+ def self.function_documentation(scope)
138
+ return INSTANCE_FUNCTIONS if scope == :instance
139
+ return CLASS_FUNCTIONS if scope == :class
140
+
141
+ {}
142
+ end
143
+
6
144
  TAGS = %w[
7
145
  a
8
146
  abbr
@@ -124,7 +262,9 @@ class Code
124
262
  args.first.is_a?(Nokogiri::XML::Node)
125
263
  args.first
126
264
  else
127
- Nokogiri.HTML(args.first.to_s)
265
+ source = args.first.to_s
266
+ ::Code.ensure_input_size!(source, label: "html")
267
+ Nokogiri.HTML(source)
128
268
  end
129
269
  end
130
270
 
@@ -199,7 +339,7 @@ class Code
199
339
 
200
340
  content =
201
341
  if code_content.is_an?(Html)
202
- Nokogiri::HTML::DocumentFragment.parse(code_content.to_html)
342
+ fragment_from_html(code_content)
203
343
  else
204
344
  Nokogiri::XML::Text.new(code_content.to_s, fragment.document)
205
345
  end
@@ -243,7 +383,10 @@ class Code
243
383
  value_or_function.to_code
244
384
  end
245
385
 
246
- String.new(Nokogiri::HTML.fragment(code_value.to_s).text)
386
+ source = code_value.to_s
387
+ ::Code.ensure_input_size!(source, label: "html")
388
+
389
+ String.new(Nokogiri::HTML.fragment(source).text)
247
390
  rescue Error::Break => e
248
391
  e.code_value
249
392
  end
@@ -270,14 +413,14 @@ class Code
270
413
  code_contents.raw.each.with_index do |code_content, index|
271
414
  content =
272
415
  if code_content.is_an?(Html)
273
- Nokogiri::HTML::DocumentFragment.parse(code_content.to_html)
416
+ fragment_from_html(code_content)
274
417
  else
275
418
  Nokogiri::XML::Text.new(code_content.to_s, fragment.document)
276
419
  end
277
420
 
278
421
  separator =
279
422
  if code_separator.is_an?(Html)
280
- Nokogiri::HTML::DocumentFragment.parse(code_separator.to_html)
423
+ fragment_from_html(code_separator)
281
424
  else
282
425
  Nokogiri::XML::Text.new(code_separator.to_s, fragment.document)
283
426
  end
@@ -326,14 +469,22 @@ class Code
326
469
  end
327
470
 
328
471
  if code_value.is_an?(Html)
329
- Html.new(Nokogiri::HTML::DocumentFragment.parse(code_value.to_html))
472
+ Html.new(fragment_from_html(code_value))
330
473
  else
331
- Html.new(Nokogiri::HTML::DocumentFragment.parse(code_value.to_s))
474
+ source = code_value.to_s
475
+ ::Code.ensure_input_size!(source, label: "html")
476
+ Html.new(Nokogiri::HTML::DocumentFragment.parse(source))
332
477
  end
333
478
  rescue Error::Break => e
334
479
  e.code_value
335
480
  end
336
481
 
482
+ def self.fragment_from_html(html)
483
+ source = html.to_html
484
+ ::Code.ensure_input_size!(source, label: "html")
485
+ Nokogiri::HTML::DocumentFragment.parse(source)
486
+ end
487
+
337
488
  def call(**args)
338
489
  code_operator = args.fetch(:operator, nil).to_code
339
490
  code_arguments = args.fetch(:arguments, []).to_code
@@ -356,9 +507,15 @@ class Code
356
507
  when "to_html"
357
508
  sig(args)
358
509
  code_to_html
510
+ when "inner_text"
511
+ sig(args)
512
+ code_inner_text
359
513
  when "attribute"
360
514
  sig(args) { String }
361
515
  code_attribute(code_value)
516
+ when "attributes"
517
+ sig(args)
518
+ code_attributes
362
519
  else
363
520
  super
364
521
  end
@@ -409,10 +566,25 @@ class Code
409
566
  String.new(raw.text)
410
567
  end
411
568
 
569
+ def code_inner_text
570
+ code_to_string
571
+ end
572
+
412
573
  def code_attribute(value = nil)
413
574
  code_value = value.to_code
414
575
  String.new(raw.attr(code_value.to_s))
415
576
  end
577
+
578
+ def code_attributes
579
+ node = raw.is_a?(::Nokogiri::XML::NodeSet) ? raw.first : raw
580
+ return Dictionary.new if node.blank?
581
+
582
+ Dictionary.new(
583
+ node.attribute_nodes.to_h do |attribute|
584
+ [attribute.name.to_code, attribute.value.to_code]
585
+ end
586
+ )
587
+ end
416
588
  end
417
589
  end
418
590
  end
@@ -3,6 +3,105 @@
3
3
  class Code
4
4
  class Object
5
5
  class Http < Object
6
+ CLASS_DOCUMENTATION = {
7
+ name: "Http",
8
+ description: "sends http requests and returns dictionaries with request and response details.",
9
+ examples: [
10
+ "Http.get(\"http://httpbin.org/status/200\").success?",
11
+ "Http.post(\"http://httpbin.org/status/200\", body: :hello).request.body",
12
+ "Http.fetch(:get, \"http://httpbin.org/status/204\").code"
13
+ ]
14
+ }.freeze
15
+ CLASS_FUNCTIONS = {
16
+ "get" => {
17
+ name: "get",
18
+ description: "sends a get request and returns the response dictionary.",
19
+ examples: [
20
+ "Http.get(\"http://httpbin.org/status/200\").code",
21
+ "Http.get(\"http://httpbin.org/status/200\", query: { q: :ruby }).url",
22
+ "Http.get(\"http://httpbin.org/status/200\", headers: { accept: \"application/json\" }).request.headers"
23
+ ]
24
+ },
25
+ "head" => {
26
+ name: "head",
27
+ description: "sends a head request and returns the response dictionary.",
28
+ examples: [
29
+ "Http.head(\"http://httpbin.org/status/200\").code",
30
+ "Http.head(\"http://httpbin.org/status/204\").status",
31
+ "Http.head(\"http://httpbin.org/status/200\", headers: { accept: \"text/html\" }).request.headers"
32
+ ]
33
+ },
34
+ "post" => {
35
+ name: "post",
36
+ description: "sends a post request with optional body, form data, and headers.",
37
+ examples: [
38
+ "Http.post(\"http://httpbin.org/status/200\", body: :hello).request.body",
39
+ "Http.post(\"http://httpbin.org/status/200\", data: { a: 1 }).success?",
40
+ "Http.post(\"http://httpbin.org/status/200\", headers: { content_type: \"text/plain\" }).request.headers"
41
+ ]
42
+ },
43
+ "put" => {
44
+ name: "put",
45
+ description: "sends a put request with optional body or form data.",
46
+ examples: [
47
+ "Http.put(\"http://httpbin.org/status/200\", body: :hello).request.body",
48
+ "Http.put(\"http://httpbin.org/status/200\", data: { a: 1 }).method",
49
+ "Http.put(\"http://httpbin.org/status/204\").code"
50
+ ]
51
+ },
52
+ "delete" => {
53
+ name: "delete",
54
+ description: "sends a delete request and returns the response dictionary.",
55
+ examples: [
56
+ "Http.delete(\"http://httpbin.org/status/200\").success?",
57
+ "Http.delete(\"http://httpbin.org/status/200\", body: :hello).request.body",
58
+ "Http.delete(\"http://httpbin.org/status/204\").status"
59
+ ]
60
+ },
61
+ "options" => {
62
+ name: "options",
63
+ description: "sends an options request and returns the response dictionary.",
64
+ examples: [
65
+ "Http.options(\"http://httpbin.org/status/200\").method",
66
+ "Http.options(\"http://httpbin.org/status/204\").code",
67
+ "Http.options(\"http://httpbin.org/status/200\", headers: { accept: \"*/*\" }).request.headers"
68
+ ]
69
+ },
70
+ "trace" => {
71
+ name: "trace",
72
+ description: "sends a trace request and returns the response dictionary.",
73
+ examples: [
74
+ "Http.trace(\"http://httpbin.org/status/200\").method",
75
+ "Http.trace(\"http://httpbin.org/status/204\").code",
76
+ "Http.trace(\"http://httpbin.org/status/200\", headers: { accept: \"message/http\" }).request.headers"
77
+ ]
78
+ },
79
+ "patch" => {
80
+ name: "patch",
81
+ description: "sends a patch request with optional body or form data.",
82
+ examples: [
83
+ "Http.patch(\"http://httpbin.org/status/200\", body: :hello).request.body",
84
+ "Http.patch(\"http://httpbin.org/status/200\", data: { a: 1 }).method",
85
+ "Http.patch(\"http://httpbin.org/status/204\").code"
86
+ ]
87
+ },
88
+ "fetch" => {
89
+ name: "fetch",
90
+ description: "sends an http request using the given method name.",
91
+ examples: [
92
+ "Http.fetch(:get, \"http://httpbin.org/status/200\").success?",
93
+ "Http.fetch(:post, \"http://httpbin.org/status/200\", body: :hello).request.body",
94
+ "Http.fetch(:patch, \"http://httpbin.org/status/200\", data: { a: 1 }).method"
95
+ ]
96
+ }
97
+ }.freeze
98
+
99
+ def self.function_documentation(scope)
100
+ return CLASS_FUNCTIONS if scope == :class
101
+
102
+ {}
103
+ end
104
+
6
105
  SIG = [
7
106
  String,
8
107
  {
@@ -85,6 +184,20 @@ class Code
85
184
  network_authentication_required: 511
86
185
  }.freeze
87
186
  DEFAULT_TIMEOUT = 1.hour.to_f
187
+ MAX_REQUEST_BYTES = ::Code::MAX_INPUT_BYTES
188
+ MAX_RESPONSE_BYTES = ::Code::MAX_INPUT_BYTES
189
+ MAX_HEADER_BYTES = 32.kilobytes
190
+ HEADER_NAME = /\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/
191
+ RESTRICTED_HEADERS = %w[
192
+ connection
193
+ content-length
194
+ host
195
+ proxy-authorization
196
+ te
197
+ trailer
198
+ transfer-encoding
199
+ upgrade
200
+ ].freeze
88
201
 
89
202
  def self.call(**args)
90
203
  code_operator = args.fetch(:operator, nil).to_code
@@ -163,7 +276,7 @@ class Code
163
276
  username = options.code_get("username").to_s
164
277
  password = options.code_get("password").to_s
165
278
  body = options.code_get("body").to_s
166
- headers = options.code_get("headers").raw || {}
279
+ headers = sanitized_headers(options.code_get("headers").raw || {})
167
280
  data = options.code_get("data").raw || {}
168
281
  timeout = options.code_get("timeout")
169
282
  open_timeout = options.code_get("open_timeout")
@@ -171,31 +284,39 @@ class Code
171
284
  write_timeout = options.code_get("write_timeout")
172
285
  query = options.code_get("query").raw || {}
173
286
  query = query.to_a.flatten.map(&:to_s).each_slice(2).to_h.to_query
287
+ validate_payload_size!(username, label: "http username")
288
+ validate_payload_size!(password, label: "http password")
289
+ validate_payload_size!(query, label: "http query")
290
+ validate_payload_size!(body, label: "http request body")
291
+ validate_payload_size!(data.as_json.to_query, label: "http form data") if data.present?
174
292
 
175
293
  url = original_url
176
294
  url = "#{url}?#{query}" if query.present?
295
+ validate_payload_size!(url, label: "http url")
177
296
 
178
297
  if username.present? || password.present?
179
298
  authorization = ::Base64.strict_encode64("#{username}:#{password}")
180
299
  headers["Authorization"] = "Basic #{authorization}"
300
+ validate_header_size!("Authorization", headers["Authorization"])
181
301
  end
182
302
 
183
- uri = ::URI.parse(url)
184
- http = ::Net::HTTP.new(uri.host, uri.port)
303
+ uri = parse_uri(url)
304
+ resolved_ip = ::Code::Network.validate_public_uri!(uri, service: "http")
305
+
306
+ http = ::Net::HTTP.new(uri.hostname, uri.port, nil)
307
+ http.ipaddr = resolved_ip
185
308
  http.use_ssl = true if uri.scheme == "https"
186
- default_timeout = timeout.nothing? ? DEFAULT_TIMEOUT : timeout.to_f
309
+ default_timeout = http_timeout(timeout, DEFAULT_TIMEOUT)
187
310
  open_timeout_value =
188
- open_timeout.nothing? ? default_timeout : open_timeout.to_f
311
+ http_timeout(open_timeout, default_timeout)
189
312
  read_timeout_value =
190
- read_timeout.nothing? ? default_timeout : read_timeout.to_f
313
+ http_timeout(read_timeout, default_timeout)
191
314
  write_timeout_value =
192
- write_timeout.nothing? ? default_timeout : write_timeout.to_f
315
+ http_timeout(write_timeout, default_timeout)
193
316
 
194
317
  http.open_timeout = open_timeout_value if open_timeout_value
195
318
  http.read_timeout = read_timeout_value if read_timeout_value
196
- if http.respond_to?(:write_timeout=) && write_timeout_value
197
- http.write_timeout = write_timeout_value
198
- end
319
+ http.write_timeout = write_timeout_value if write_timeout_value
199
320
 
200
321
  request_class =
201
322
  case verb
@@ -223,7 +344,19 @@ class Code
223
344
  request.set_form_data(**data.as_json) if data.present?
224
345
 
225
346
  begin
226
- response = http.request(request)
347
+ response_body = +""
348
+ response =
349
+ http.request(request) do |http_response|
350
+ validate_response_headers!(http_response)
351
+
352
+ http_response.read_body do |chunk|
353
+ if response_body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
354
+ raise ::Code::Error, "http response is too large"
355
+ end
356
+
357
+ response_body << chunk
358
+ end
359
+ end
227
360
  rescue ::Timeout::Error, ::Errno::ETIMEDOUT
228
361
  raise ::Code::Error, "http timeout"
229
362
  rescue OpenSSL::SSL::SSLError, IOError, SystemCallError, SocketError
@@ -234,9 +367,10 @@ class Code
234
367
  location = response["location"].to_s
235
368
 
236
369
  if (300..399).cover?(code) && location.present? && redirects.positive?
237
- new_uri = ::URI.join(uri, location)
370
+ new_uri = parse_uri(::URI.join(uri, location).to_s)
371
+ ::Code::Network.validate_public_uri!(new_uri, service: "http")
238
372
 
239
- if new_uri.host == uri.host
373
+ if same_origin?(new_uri, uri)
240
374
  code_fetch(
241
375
  "get",
242
376
  new_uri.to_s,
@@ -248,8 +382,104 @@ class Code
248
382
  end
249
383
  else
250
384
  status = STATUS_CODES.key(code) || :ok
251
- Dictionary.new(code: code, status: status, body: response.body.to_s)
385
+ response_headers = response.each_header.to_h
386
+ request_headers = request.to_hash.transform_values do |values|
387
+ List.new(values)
388
+ end
389
+ body = response_body.to_s
390
+
391
+ Dictionary.new(
392
+ code: code,
393
+ status: status,
394
+ body: body,
395
+ headers: response_headers,
396
+ method: verb,
397
+ url: url,
398
+ "success?" => code.between?(200, 299),
399
+ "redirect?" => code.between?(300, 399),
400
+ request: {
401
+ method: verb,
402
+ url: url,
403
+ headers: request_headers,
404
+ body: request.body.to_s
405
+ },
406
+ response: {
407
+ code: code,
408
+ status: status,
409
+ headers: response_headers,
410
+ body: body
411
+ }
412
+ )
413
+ end
414
+ end
415
+
416
+ def self.parse_uri(url)
417
+ ::URI.parse(url)
418
+ rescue ::URI::InvalidURIError
419
+ raise ::Code::Error, "http: invalid url"
420
+ end
421
+
422
+ def self.same_origin?(new_uri, original_uri)
423
+ new_uri.scheme == original_uri.scheme &&
424
+ new_uri.hostname.to_s.downcase == original_uri.hostname.to_s.downcase &&
425
+ new_uri.port == original_uri.port
426
+ end
427
+
428
+ def self.sanitized_headers(headers)
429
+ headers.to_h.to_h do |key, value|
430
+ name = key.to_s
431
+ header_value = value.to_s
432
+
433
+ validate_header_name!(name)
434
+ validate_header_value!(header_value)
435
+ validate_header_size!(name, header_value)
436
+
437
+ [name, header_value]
438
+ end
439
+ end
440
+
441
+ def self.validate_header_name!(name)
442
+ normalized_name = name.downcase
443
+
444
+ unless HEADER_NAME.match?(name)
445
+ raise ::Code::Error, "http: invalid header name"
252
446
  end
447
+
448
+ return unless RESTRICTED_HEADERS.include?(normalized_name)
449
+
450
+ raise ::Code::Error, "http: restricted header #{name.inspect}"
451
+ end
452
+
453
+ def self.validate_header_value!(value)
454
+ return unless /[\r\n\0]/.match?(value)
455
+
456
+ raise ::Code::Error, "http: invalid header value"
457
+ end
458
+
459
+ def self.validate_header_size!(name, value)
460
+ return if name.bytesize + value.bytesize <= MAX_HEADER_BYTES
461
+
462
+ raise ::Code::Error, "http: request headers are too large"
463
+ end
464
+
465
+ def self.validate_response_headers!(response)
466
+ size =
467
+ response.each_header.sum do |name, value|
468
+ name.to_s.bytesize + value.to_s.bytesize
469
+ end
470
+ return if size <= MAX_HEADER_BYTES
471
+
472
+ raise ::Code::Error, "http response headers are too large"
473
+ end
474
+
475
+ def self.validate_payload_size!(value, label:)
476
+ return if value.to_s.bytesize <= MAX_REQUEST_BYTES
477
+
478
+ raise ::Code::Error, "#{label} is too large"
479
+ end
480
+
481
+ def self.http_timeout(value, default)
482
+ ::Code.normalize_timeout!(value.nothing? ? default : value)
253
483
  end
254
484
  end
255
485
  end