dommy 0.14.0 → 0.16.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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -1
  3. data/lib/dommy/attr.rb +56 -32
  4. data/lib/dommy/backend.rb +251 -208
  5. data/lib/dommy/blob.rb +24 -0
  6. data/lib/dommy/bridge/methods.rb +12 -2
  7. data/lib/dommy/bridge/values.rb +29 -2
  8. data/lib/dommy/bridge/wire_tags.rb +8 -4
  9. data/lib/dommy/browser.rb +328 -53
  10. data/lib/dommy/callable_invoker.rb +27 -31
  11. data/lib/dommy/character_data.rb +21 -28
  12. data/lib/dommy/class_list.rb +55 -23
  13. data/lib/dommy/cookie_jar.rb +408 -0
  14. data/lib/dommy/cookie_store.rb +104 -49
  15. data/lib/dommy/css.rb +37 -8
  16. data/lib/dommy/custom_elements.rb +360 -116
  17. data/lib/dommy/data_transfer.rb +22 -2
  18. data/lib/dommy/document.rb +1258 -346
  19. data/lib/dommy/dom_exception.rb +6 -0
  20. data/lib/dommy/dom_parser.rb +46 -14
  21. data/lib/dommy/dom_string_list.rb +36 -0
  22. data/lib/dommy/element.rb +408 -569
  23. data/lib/dommy/element_internals.rb +215 -0
  24. data/lib/dommy/encodings.rb +10 -0
  25. data/lib/dommy/event.rb +382 -148
  26. data/lib/dommy/event_source.rb +8 -34
  27. data/lib/dommy/fetch.rb +8 -12
  28. data/lib/dommy/file_reader.rb +5 -29
  29. data/lib/dommy/form_data.rb +53 -16
  30. data/lib/dommy/form_entry_list.rb +107 -138
  31. data/lib/dommy/fragment.rb +14 -16
  32. data/lib/dommy/history.rb +247 -90
  33. data/lib/dommy/html_canvas_element.rb +15 -35
  34. data/lib/dommy/html_collection.rb +76 -50
  35. data/lib/dommy/html_elements/base.rb +377 -71
  36. data/lib/dommy/html_elements/embedded.rb +350 -106
  37. data/lib/dommy/html_elements/forms/form.rb +424 -383
  38. data/lib/dommy/html_elements/forms/input.rb +351 -189
  39. data/lib/dommy/html_elements/forms/select.rb +97 -81
  40. data/lib/dommy/html_elements/interactive.rb +285 -72
  41. data/lib/dommy/html_elements/media.rb +73 -32
  42. data/lib/dommy/html_elements/metadata.rb +226 -78
  43. data/lib/dommy/html_elements/miscellaneous.rb +58 -14
  44. data/lib/dommy/html_elements/tables.rb +20 -42
  45. data/lib/dommy/html_elements.rb +28 -3
  46. data/lib/dommy/hyperlink_utils.rb +19 -8
  47. data/lib/dommy/interaction/drag_and_drop.rb +187 -0
  48. data/lib/dommy/interaction/event_synthesis.rb +182 -24
  49. data/lib/dommy/interaction/field_interactor.rb +18 -6
  50. data/lib/dommy/interaction/form_submission.rb +12 -6
  51. data/lib/dommy/interaction/key_sender.rb +102 -17
  52. data/lib/dommy/interaction.rb +1 -0
  53. data/lib/dommy/internal/accessibility_tree.rb +19 -7
  54. data/lib/dommy/internal/accessibility_visibility.rb +2 -2
  55. data/lib/dommy/internal/accessible_description.rb +3 -3
  56. data/lib/dommy/internal/accessible_name.rb +33 -45
  57. data/lib/dommy/internal/an_plus_b.rb +125 -0
  58. data/lib/dommy/internal/aria_role.rb +27 -18
  59. data/lib/dommy/internal/aria_state.rb +7 -7
  60. data/lib/dommy/internal/autofill.rb +2 -2
  61. data/lib/dommy/internal/backend_prefilter.rb +21 -18
  62. data/lib/dommy/internal/ce_reactions.rb +285 -0
  63. data/lib/dommy/internal/ce_reactions_table.rb +296 -0
  64. data/lib/dommy/internal/child_list.rb +39 -0
  65. data/lib/dommy/internal/child_navigable.rb +155 -0
  66. data/lib/dommy/internal/child_node.rb +44 -41
  67. data/lib/dommy/internal/close_watcher_manager.rb +238 -0
  68. data/lib/dommy/internal/complex_match.rb +129 -0
  69. data/lib/dommy/internal/constraint_validation.rb +130 -0
  70. data/lib/dommy/internal/css/ancestor_filter.rb +107 -0
  71. data/lib/dommy/internal/css/cascade.rb +7 -7
  72. data/lib/dommy/internal/css/cascaded_declarations.rb +14 -20
  73. data/lib/dommy/internal/css/computed_style_builder.rb +30 -3
  74. data/lib/dommy/internal/css/custom_properties.rb +63 -80
  75. data/lib/dommy/internal/css/parser.rb +53 -63
  76. data/lib/dommy/internal/css/renderability.rb +6 -0
  77. data/lib/dommy/internal/css/rule_index.rb +170 -108
  78. data/lib/dommy/internal/css/rule_index_inputs.rb +135 -0
  79. data/lib/dommy/internal/css/selector_dependencies.rb +8 -2
  80. data/lib/dommy/internal/css/style_cache.rb +9 -6
  81. data/lib/dommy/internal/css/ua_stylesheet.rb +37 -2
  82. data/lib/dommy/internal/css_source.rb +223 -0
  83. data/lib/dommy/internal/css_syntax.rb +153 -0
  84. data/lib/dommy/internal/declarative_shadow_roots.rb +182 -0
  85. data/lib/dommy/internal/directionality.rb +2 -2
  86. data/lib/dommy/internal/document_generations.rb +91 -18
  87. data/lib/dommy/internal/document_interaction_state.rb +293 -9
  88. data/lib/dommy/internal/dom_matching.rb +7 -7
  89. data/lib/dommy/internal/element_aria.rb +65 -23
  90. data/lib/dommy/internal/element_css_inline_style.rb +26 -0
  91. data/lib/dommy/internal/element_editing.rb +41 -0
  92. data/lib/dommy/internal/element_nonce.rb +28 -0
  93. data/lib/dommy/internal/element_popover.rb +310 -0
  94. data/lib/dommy/internal/element_shadow.rb +107 -33
  95. data/lib/dommy/internal/element_state.rb +131 -26
  96. data/lib/dommy/internal/element_top_layer.rb +21 -51
  97. data/lib/dommy/internal/enumerated_keyword_sets.rb +40 -0
  98. data/lib/dommy/internal/event_handler_tables.rb +241 -0
  99. data/lib/dommy/internal/event_handlers.rb +149 -0
  100. data/lib/dommy/internal/exception_report.rb +1 -1
  101. data/lib/dommy/internal/focusability.rb +267 -0
  102. data/lib/dommy/internal/form_associated_custom_elements.rb +127 -0
  103. data/lib/dommy/internal/html_or_svg_or_mathml_element.rb +86 -0
  104. data/lib/dommy/internal/html_serialization.rb +292 -0
  105. data/lib/dommy/internal/idl_reflection.rb +202 -0
  106. data/lib/dommy/internal/idl_reflection_table.rb +589 -0
  107. data/lib/dommy/internal/infra.rb +36 -0
  108. data/lib/dommy/internal/input_type.rb +90 -20
  109. data/lib/dommy/internal/insertion_point.rb +27 -3
  110. data/lib/dommy/internal/js_number.rb +60 -0
  111. data/lib/dommy/internal/light_dismiss.rb +178 -0
  112. data/lib/dommy/internal/literal_lookup.rb +103 -0
  113. data/lib/dommy/internal/mime_type.rb +8 -0
  114. data/lib/dommy/internal/mutation_coordinator.rb +94 -60
  115. data/lib/dommy/internal/node_adopter.rb +165 -97
  116. data/lib/dommy/internal/node_factory.rb +111 -49
  117. data/lib/dommy/internal/node_identity.rb +1 -1
  118. data/lib/dommy/internal/node_traversal.rb +30 -13
  119. data/lib/dommy/internal/node_wrapper_cache.rb +34 -148
  120. data/lib/dommy/internal/observable_callback.rb +2 -5
  121. data/lib/dommy/internal/observer_options.rb +24 -14
  122. data/lib/dommy/internal/origin.rb +86 -0
  123. data/lib/dommy/internal/parent_node.rb +12 -10
  124. data/lib/dommy/internal/popover_invoker_element.rb +67 -0
  125. data/lib/dommy/internal/popover_stack.rb +120 -0
  126. data/lib/dommy/internal/post_insertion_steps.rb +146 -77
  127. data/lib/dommy/internal/reflected_attributes.rb +173 -43
  128. data/lib/dommy/internal/rendered_text/collector.rb +12 -9
  129. data/lib/dommy/internal/rendered_text/fragment.rb +6 -2
  130. data/lib/dommy/internal/selector_arguments.rb +2 -16
  131. data/lib/dommy/internal/selector_index.rb +19 -10
  132. data/lib/dommy/internal/selector_match.rb +190 -0
  133. data/lib/dommy/internal/selector_matcher.rb +91 -256
  134. data/lib/dommy/internal/selector_parser.rb +176 -100
  135. data/lib/dommy/internal/sequential_focus_navigation.rb +210 -0
  136. data/lib/dommy/internal/shadow_root_registry.rb +14 -6
  137. data/lib/dommy/internal/sibling_positions.rb +59 -0
  138. data/lib/dommy/internal/svg_uri_reference.rb +33 -0
  139. data/lib/dommy/internal/template_content_registry.rb +11 -5
  140. data/lib/dommy/internal/text_selection.rb +185 -32
  141. data/lib/dommy/internal/toggle_events.rb +6 -4
  142. data/lib/dommy/internal/unsafe_html.rb +107 -0
  143. data/lib/dommy/internal/url_parser.rb +84 -8
  144. data/lib/dommy/internal/user_activation.rb +192 -0
  145. data/lib/dommy/internal/web_idl.rb +160 -16
  146. data/lib/dommy/internal/window_constructors.rb +34 -14
  147. data/lib/dommy/internal/xml_serialization.rb +9 -45
  148. data/lib/dommy/js/constructor_resolver.rb +18 -0
  149. data/lib/dommy/js/custom_element_bridge.rb +57 -82
  150. data/lib/dommy/js/dom_interfaces.rb +51 -15
  151. data/lib/dommy/js/host_bridge.rb +189 -40
  152. data/lib/dommy/js/host_callbacks.rb +10 -0
  153. data/lib/dommy/js/host_runtime.js +1921 -416
  154. data/lib/dommy/js/marshaller.rb +13 -4
  155. data/lib/dommy/js/module_loader.rb +46 -5
  156. data/lib/dommy/js/module_preload.rb +194 -0
  157. data/lib/dommy/js/runtime.rb +9 -0
  158. data/lib/dommy/js/script_boot.rb +200 -114
  159. data/lib/dommy/js/serialized_record.rb +32 -0
  160. data/lib/dommy/js/webidl_event_handlers.js +45 -0
  161. data/lib/dommy/js/webidl_members.js +196 -0
  162. data/lib/dommy/js/webidl_signatures.js +2055 -0
  163. data/lib/dommy/js/webidl_tables.js +72 -101
  164. data/lib/dommy/js/window_wiring.rb +9 -5
  165. data/lib/dommy/location.rb +129 -76
  166. data/lib/dommy/mathml_elements.rb +13 -0
  167. data/lib/dommy/media_query_list.rb +4 -13
  168. data/lib/dommy/message_channel.rb +137 -64
  169. data/lib/dommy/mutation_observer.rb +3 -7
  170. data/lib/dommy/navigation.rb +61 -11
  171. data/lib/dommy/navigator.rb +132 -10
  172. data/lib/dommy/node.rb +81 -44
  173. data/lib/dommy/notification.rb +10 -6
  174. data/lib/dommy/parser.rb +5 -16
  175. data/lib/dommy/promise.rb +20 -26
  176. data/lib/dommy/range.rb +20 -47
  177. data/lib/dommy/resources.rb +38 -3
  178. data/lib/dommy/scheduler.rb +176 -48
  179. data/lib/dommy/shadow_root.rb +125 -25
  180. data/lib/dommy/storage.rb +135 -25
  181. data/lib/dommy/streams.rb +2 -6
  182. data/lib/dommy/structured_data.rb +45 -0
  183. data/lib/dommy/style_declaration.rb +38 -6
  184. data/lib/dommy/svg_elements.rb +25 -11
  185. data/lib/dommy/text_codec.rb +2 -14
  186. data/lib/dommy/tree_walker.rb +12 -26
  187. data/lib/dommy/url.rb +12 -9
  188. data/lib/dommy/url_pattern.rb +1 -9
  189. data/lib/dommy/validity_state.rb +109 -107
  190. data/lib/dommy/version.rb +1 -1
  191. data/lib/dommy/web_socket.rb +10 -33
  192. data/lib/dommy/window.rb +817 -105
  193. data/lib/dommy/worker.rb +4 -28
  194. data/lib/dommy/xml_http_request.rb +32 -45
  195. data/lib/dommy.rb +43 -10
  196. metadata +52 -5
  197. data/lib/dommy/backend/makiri_adapter.rb +0 -408
  198. data/lib/dommy/internal/cookie_jar.rb +0 -29
@@ -0,0 +1,408 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Dommy
6
+ # One browsing session's cookie store (RFC 6265bis, "Cookies: HTTP State
7
+ # Management Mechanism"): what `document.cookie` and `cookieStore` read and
8
+ # write, and — through an embedder that sends requests — what a request's
9
+ # `Cookie` header carries and a response's `Set-Cookie` stores.
10
+ #
11
+ # A cookie is keyed by name, domain (host-only or not) and path, and stored
12
+ # with the RFC's attribute processing: Expires / Max-Age (capped at 400
13
+ # days; a past one deletes), Domain (which must domain-match the request
14
+ # host), Path (default-path from the URL), Secure (only from a secure URL:
15
+ # https, wss, or a loopback host, as browsers treat http://localhost),
16
+ # HttpOnly (refused from, and hidden from, a "non-HTTP" API such as
17
+ # `document.cookie`), SameSite, the `__Secure-` / `__Host-` prefixes, and the
18
+ # limits (name + value at most 4096 octets, an attribute value at most 1024).
19
+ # A cookie string with a control character is ignored whole.
20
+ #
21
+ # `http: false` marks the "non-HTTP" APIs. Thread-safe: an embedder's network
22
+ # workers may store and read while the page thread does.
23
+ #
24
+ # Not modelled: a public suffix list (a single-label Domain is refused unless
25
+ # it is the host itself), SameSite enforcement on retrieval (every request
26
+ # counts as same-site), and per-domain cookie count limits.
27
+ class CookieJar
28
+ Cookie = Struct.new(
29
+ :name, :value, :domain, :path, :expires, :secure, :http_only, :host_only,
30
+ :same_site, :creation, keyword_init: true
31
+ ) do
32
+ # `expires` is a Time, or nil for a session cookie.
33
+ def persistent? = !expires.nil?
34
+ end
35
+
36
+ MAX_NAME_VALUE_OCTETS = 4096
37
+ MAX_ATTRIBUTE_VALUE_OCTETS = 1024
38
+ MAX_AGE_CAP_SECONDS = 400 * 24 * 60 * 60
39
+ # %x00-08 / %x0A-1F / %x7F: every control character but HTAB.
40
+ CONTROL_CHARACTERS = /[\x00-\x08\x0A-\x1F\x7F]/
41
+ EARLIEST = Time.at(0).utc
42
+
43
+ def initialize(clock: nil)
44
+ @cookies = []
45
+ @clock = clock || -> { Time.now }
46
+ @sequence = 0
47
+ @mutex = Mutex.new
48
+ end
49
+
50
+ # "Receive a set-cookie-string" from `url`: parse it and store the cookie
51
+ # it describes (a past expiry deletes its match). `http: false` for a
52
+ # non-HTTP API. Returns the stored Cookie, or nil when it was ignored.
53
+ def store(set_cookie_string, url, http: true)
54
+ parsed = parse(set_cookie_string.to_s)
55
+ return nil unless parsed
56
+
57
+ record = request_record(url)
58
+ return nil unless record
59
+
60
+ @mutex.synchronize { store_parsed(parsed, record, http) }
61
+ end
62
+
63
+ # Store every `Set-Cookie` of a response's headers (a Hash whose value is
64
+ # a String — several cookies separated by newlines, as Rack joins them —
65
+ # or an Array; or an Array of [name, value] pairs) received from `url`.
66
+ def store_response_headers(headers, url)
67
+ set_cookie_values(headers).each { |value| store(value, url, http: true) }
68
+ nil
69
+ end
70
+
71
+ # The cookie-string for a request to `url` (`http: false` leaves out
72
+ # HttpOnly cookies): matching cookies, longer paths first, then oldest.
73
+ def cookie_string(url, http: true)
74
+ matching(url, http: http).map { |c| c.name.empty? ? c.value : "#{c.name}=#{c.value}" }.join("; ")
75
+ end
76
+
77
+ # The cookies a request to `url` would carry, in cookie-string order.
78
+ def matching(url, http: true)
79
+ record = request_record(url)
80
+ return [] unless record
81
+
82
+ host = record[:host]
83
+ path = record[:path]
84
+ secure = record[:secure]
85
+ found = @mutex.synchronize do
86
+ evict_expired
87
+ @cookies.select do |c|
88
+ domain_matches_cookie?(c, host) && path_match?(c.path, path) &&
89
+ (!c.secure || secure) && (http || !c.http_only)
90
+ end
91
+ end
92
+ found.sort_by { |c| [-c.path.length, c.creation] }
93
+ end
94
+
95
+ # Every unexpired cookie.
96
+ def all
97
+ @mutex.synchronize do
98
+ evict_expired
99
+ @cookies.dup
100
+ end
101
+ end
102
+
103
+ # The value of the first unexpired cookie named `name`, or nil.
104
+ def get(name)
105
+ all.find { |c| c.name == name.to_s }&.value
106
+ end
107
+
108
+ # Store a cookie as given, bypassing the request checks (an embedder or a
109
+ # test setting up state). A nil `domain` makes it host-only for `host`.
110
+ def set!(name, value, domain: nil, host: nil, path: "/", expires: nil, secure: false, http_only: false,
111
+ same_site: nil, host_only: nil)
112
+ dom = (domain || host || "").to_s.sub(/\A\./, "").downcase
113
+ cookie = Cookie.new(
114
+ name: name.to_s, value: value.to_s, domain: dom, path: path || "/", expires: expires,
115
+ secure: secure ? true : false, http_only: http_only ? true : false,
116
+ host_only: host_only.nil? ? domain.nil? : host_only, same_site: same_site
117
+ )
118
+ @mutex.synchronize { insert(cookie) }
119
+ cookie
120
+ end
121
+
122
+ # Remove the cookie named `name` with this domain and path (any, when
123
+ # nil).
124
+ def delete(name, domain: nil, path: nil)
125
+ @mutex.synchronize do
126
+ @cookies.reject! do |c|
127
+ c.name == name.to_s && (domain.nil? || c.domain == domain.to_s.sub(/\A\./, "").downcase) &&
128
+ (path.nil? || c.path == path)
129
+ end
130
+ end
131
+ nil
132
+ end
133
+
134
+ def clear
135
+ @mutex.synchronize { @cookies = [] }
136
+ nil
137
+ end
138
+
139
+ # Every unexpired cookie as a plain Hash, for #import!.
140
+ def export
141
+ all.map { |c| c.to_h.except(:creation) }
142
+ end
143
+
144
+ # Restore a cookie from an #export Hash (symbol or string keys), keeping
145
+ # its host-only flag; an expired one is skipped.
146
+ def import!(attrs)
147
+ h = attrs.to_h.transform_keys(&:to_sym)
148
+ expires = h[:expires]
149
+ expires = Time.parse(expires) if expires.is_a?(String)
150
+ cookie = Cookie.new(
151
+ name: h[:name].to_s, value: h[:value].to_s, domain: h[:domain].to_s.sub(/\A\./, "").downcase,
152
+ path: h[:path] || "/", expires: expires, secure: h[:secure] ? true : false,
153
+ http_only: h[:http_only] ? true : false, host_only: h[:host_only] ? true : false,
154
+ same_site: h[:same_site]
155
+ )
156
+ @mutex.synchronize { insert(cookie) unless expired?(cookie) }
157
+ nil
158
+ end
159
+
160
+ # The jar's current time (a Time), which its expiries are measured
161
+ # against.
162
+ def now = @clock.call
163
+
164
+ private
165
+
166
+ def set_cookie_values(headers)
167
+ pairs = headers.respond_to?(:each_pair) ? headers.each_pair.to_a : Array(headers)
168
+ pairs.flat_map do |name, value|
169
+ next [] unless name.to_s.casecmp?("set-cookie")
170
+
171
+ Array(value).flat_map { |v| v.to_s.split("\n") }
172
+ end
173
+ end
174
+
175
+ # RFC 6265bis §5.6 "The Set-Cookie Header Field": the name, value and
176
+ # recognized attributes, or nil for a string to ignore.
177
+ def parse(string)
178
+ return nil if string.match?(CONTROL_CHARACTERS)
179
+
180
+ name_value, _, unparsed = string.partition(";")
181
+ if name_value.include?("=")
182
+ name, _, value = name_value.partition("=")
183
+ else
184
+ name = ""
185
+ value = name_value
186
+ end
187
+ name = trim(name)
188
+ value = trim(value)
189
+ return nil if name.bytesize + value.bytesize > MAX_NAME_VALUE_OCTETS
190
+
191
+ attrs = {}
192
+ unparsed.split(";").each do |av|
193
+ key, _, val = av.partition("=")
194
+ key = trim(key).downcase
195
+ val = trim(val)
196
+ next if val.bytesize > MAX_ATTRIBUTE_VALUE_OCTETS
197
+
198
+ process_attribute(attrs, key, val)
199
+ end
200
+ {name: name, value: value, attrs: attrs}
201
+ end
202
+
203
+ def trim(string) = string.to_s.gsub(/\A[ \t]+|[ \t]+\z/, "")
204
+
205
+ def process_attribute(attrs, key, val)
206
+ case key
207
+ when "expires"
208
+ time = parse_cookie_date(val)
209
+ attrs[:expires] = time if time
210
+ when "max-age"
211
+ return unless val.match?(/\A-?\d+\z/)
212
+
213
+ attrs[:max_age] = val.to_i
214
+ when "domain"
215
+ attrs[:domain] = val.sub(/\A\./, "").downcase unless val.empty?
216
+ when "path"
217
+ attrs[:path] = val.start_with?("/") ? val : nil
218
+ attrs[:path_given] = true
219
+ when "secure"
220
+ attrs[:secure] = true
221
+ when "httponly"
222
+ attrs[:http_only] = true
223
+ when "samesite"
224
+ attrs[:same_site] = {"strict" => "Strict", "lax" => "Lax", "none" => "None"}.fetch(val.downcase, "Default")
225
+ end
226
+ end
227
+
228
+ # RFC 6265bis §5.7 "Storage Model", given the parsed string.
229
+ def store_parsed(parsed, record, http)
230
+ name = parsed[:name]
231
+ value = parsed[:value]
232
+ attrs = parsed[:attrs]
233
+ return nil if name.empty? && value.empty?
234
+ # A cookie with no name must not look like a prefixed one.
235
+ return nil if name.empty? && value.match?(/\A__(secure|host)-/i)
236
+
237
+ expires = expiry_of(attrs)
238
+ host = record[:host]
239
+ domain_attr = attrs[:domain].to_s
240
+ if !domain_attr.empty? && public_suffix?(domain_attr)
241
+ return nil unless domain_attr == host
242
+
243
+ domain_attr = ""
244
+ end
245
+ if domain_attr.empty?
246
+ host_only = true
247
+ domain = host
248
+ else
249
+ return nil unless domain_match?(host, domain_attr)
250
+
251
+ host_only = false
252
+ domain = domain_attr
253
+ end
254
+ path = attrs[:path] || default_path(record[:path])
255
+ secure = attrs[:secure] == true
256
+ return nil if secure && !record[:secure]
257
+
258
+ http_only = attrs[:http_only] == true
259
+ return nil if http_only && !http
260
+ # Leave secure cookies alone: an insecure request cannot shadow one.
261
+ return nil if !secure && !record[:secure] && shadows_secure_cookie?(name, domain, path)
262
+
263
+ same_site = attrs[:same_site] || "Default"
264
+ return nil if same_site == "None" && !secure
265
+ return nil if name.match?(/\A__secure-/i) && !secure
266
+ return nil if name.match?(/\A__host-/i) && !(secure && host_only && path == "/")
267
+
268
+ cookie = Cookie.new(name: name, value: value, domain: domain, path: path, expires: expires,
269
+ secure: secure, http_only: http_only, host_only: host_only, same_site: same_site)
270
+ old = @cookies.find { |c| same_key?(c, cookie) }
271
+ return nil if old&.http_only && !http
272
+
273
+ if expired?(cookie)
274
+ @cookies.delete(old) if old
275
+ return nil
276
+ end
277
+ insert(cookie, creation: old&.creation)
278
+ cookie
279
+ end
280
+
281
+ def same_key?(a, b)
282
+ a.name == b.name && a.domain == b.domain && a.host_only == b.host_only && a.path == b.path
283
+ end
284
+
285
+ def insert(cookie, creation: nil)
286
+ @cookies.reject! { |c| same_key?(c, cookie) }
287
+ cookie.creation = creation || (@sequence += 1)
288
+ @cookies << cookie
289
+ cookie
290
+ end
291
+
292
+ # RFC 6265bis "Storage Model" step 16: a secure cookie of the name whose
293
+ # domain matches either way, and under whose path the new cookie's path
294
+ # falls (the new path path-matches the stored one).
295
+ def shadows_secure_cookie?(name, domain, path)
296
+ @cookies.any? do |c|
297
+ c.secure && c.name == name &&
298
+ (domain_match?(domain, c.domain) || domain_match?(c.domain, domain)) && path_match?(c.path, path)
299
+ end
300
+ end
301
+
302
+ # Max-Age wins over Expires; both are capped at 400 days, and a Max-Age of
303
+ # zero or less is the earliest representable time (an immediate delete).
304
+ def expiry_of(attrs)
305
+ cap = now + MAX_AGE_CAP_SECONDS
306
+ if attrs.key?(:max_age)
307
+ seconds = attrs[:max_age]
308
+ return EARLIEST if seconds <= 0
309
+
310
+ return [now + seconds, cap].min
311
+ end
312
+ return nil unless attrs[:expires]
313
+
314
+ [attrs[:expires], cap].min
315
+ end
316
+
317
+ def expired?(cookie)
318
+ cookie.expires && cookie.expires <= now
319
+ end
320
+
321
+ def evict_expired
322
+ @cookies.reject! { |c| expired?(c) }
323
+ end
324
+
325
+ # A single-label domain stands in for a public suffix (no list here).
326
+ def public_suffix?(domain)
327
+ !domain.include?(".") && !ip_address?(domain)
328
+ end
329
+
330
+ def ip_address?(host)
331
+ host.match?(/\A\d+\.\d+\.\d+\.\d+\z/) || host.start_with?("[")
332
+ end
333
+
334
+ # RFC 6265bis "domain-match".
335
+ def domain_match?(string, domain)
336
+ return true if string == domain
337
+ return false if ip_address?(string)
338
+
339
+ string.end_with?(".#{domain}")
340
+ end
341
+
342
+ def domain_matches_cookie?(cookie, host)
343
+ cookie.host_only ? cookie.domain == host : domain_match?(host, cookie.domain)
344
+ end
345
+
346
+ # RFC 6265bis "path-match".
347
+ def path_match?(cookie_path, request_path)
348
+ return true if cookie_path == request_path
349
+ return false unless request_path.start_with?(cookie_path)
350
+
351
+ cookie_path.end_with?("/") || request_path[cookie_path.length] == "/"
352
+ end
353
+
354
+ # RFC 6265bis "default-path" of a request path.
355
+ def default_path(request_path)
356
+ return "/" if request_path.empty? || !request_path.start_with?("/")
357
+
358
+ idx = request_path.rindex("/")
359
+ idx.zero? ? "/" : request_path[0...idx]
360
+ end
361
+
362
+ # The host, path and whether the URL is secure (a "secure protocol" is the
363
+ # user agent's call: https, wss, and — as Chrome and Firefox do — a
364
+ # loopback host), for an http(s)/ws(s) URL; nil for any other.
365
+ def request_record(url)
366
+ record = Internal::UrlParser.parse(url.to_s)
367
+ return nil unless %w[http https ws wss].include?(record.scheme)
368
+
369
+ host = record.host.to_s.downcase
370
+ path = Internal::UrlParser.serialize_path(record)
371
+ secure = %w[https wss].include?(record.scheme) || Internal::Origin.trustworthy_record?(record)
372
+ {host: host, path: path.empty? ? "/" : path, secure: secure}
373
+ rescue Internal::UrlParser::Failure
374
+ nil
375
+ end
376
+
377
+ MONTHS = %w[jan feb mar apr may jun jul aug sep oct nov dec].freeze
378
+ DATE_DELIMITER = /[\x09\x20-\x2F\x3B-\x40\x5B-\x60\x7B-\x7E]+/
379
+
380
+ # RFC 6265bis §5.1.1 "Dates": the cookie-date parsing algorithm. nil when
381
+ # the string is not a date.
382
+ def parse_cookie_date(string)
383
+ time = day = month = year = nil
384
+ string.split(DATE_DELIMITER).each do |token|
385
+ next if token.empty?
386
+
387
+ if time.nil? && (m = token.match(/\A(\d{1,2}):(\d{1,2}):(\d{1,2})(?:\D.*)?\z/))
388
+ time = m.captures.map(&:to_i)
389
+ elsif day.nil? && (m = token.match(/\A(\d{1,2})(?:\D.*)?\z/))
390
+ day = m[1].to_i
391
+ elsif month.nil? && (idx = MONTHS.index(token[0, 3].downcase))
392
+ month = idx + 1
393
+ elsif year.nil? && (m = token.match(/\A(\d{2,4})(?:\D.*)?\z/))
394
+ year = m[1].to_i
395
+ end
396
+ end
397
+ return nil unless time && day && month && year
398
+
399
+ year += 1900 if year.between?(70, 99)
400
+ year += 2000 if year.between?(0, 69)
401
+ return nil if day < 1 || day > 31 || year < 1601 || time[0] > 23 || time[1] > 59 || time[2] > 59
402
+
403
+ Time.utc(year, month, day, *time)
404
+ rescue ArgumentError
405
+ nil
406
+ end
407
+ end
408
+ end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dommy
4
- # `cookieStore` — the asynchronous Cookie Store API. Reads and
5
- # writes go through the existing `Document#cookie_jar`, so values
6
- # set via `document.cookie = ...` round-trip via `cookieStore.get`.
4
+ # `cookieStore` — the asynchronous Cookie Store API, over the same cookie
5
+ # jar as `document.cookie` (the window's Dommy::CookieJar), for the
6
+ # document's URL and as a "non-HTTP" API: an HttpOnly cookie is neither
7
+ # read nor overwritten. A cookie it sets is Secure, Path=/ and
8
+ # SameSite=Strict unless told otherwise.
7
9
  #
8
- # Spec: https://wicg.github.io/cookie-store/
10
+ # Spec: https://cookiestore.spec.whatwg.org/
9
11
  class CookieStore
10
12
  include EventTarget
11
13
 
@@ -13,51 +15,46 @@ module Dommy
13
15
  @window = window
14
16
  end
15
17
 
16
- def get(name_or_options)
17
- name = name_or_options.is_a?(Hash) ? (name_or_options["name"] || name_or_options[:name]) : name_or_options
18
- raw = cookie_jar.cookies[name.to_s]
19
- raw ? PromiseValue.resolve(@window, build_record(name.to_s, raw)) : PromiseValue.resolve(@window, nil)
18
+ def get(name_or_options = nil)
19
+ name, url = query_of(name_or_options)
20
+ found = cookies_for(url).find { |c| name.nil? || c.name == name }
21
+ PromiseValue.resolve(@window, found && build_record(found))
22
+ rescue Bridge::TypeError => e
23
+ PromiseValue.reject(@window, e)
20
24
  end
21
25
 
22
- def get_all(name = nil)
23
- records = cookie_jar.cookies.map { |k, v| build_record(k, v) }
24
- records = records.select { |r| r["name"] == name.to_s } if name
26
+ def get_all(name_or_options = nil)
27
+ name, url = query_of(name_or_options)
28
+ records = cookies_for(url).select { |c| name.nil? || c.name == name }.map { |c| build_record(c) }
25
29
  PromiseValue.resolve(@window, records)
30
+ rescue Bridge::TypeError => e
31
+ PromiseValue.reject(@window, e)
26
32
  end
27
33
 
28
34
  alias getAll get_all
29
35
 
30
36
  def set(name_or_options, value = nil)
31
- if name_or_options.is_a?(Hash)
32
- opts = name_or_options.transform_keys(&:to_s)
33
- name = opts["name"]
34
- value = opts["value"]
35
- else
36
- name = name_or_options
37
- end
38
-
39
- cookie_jar.set_cookie("#{name}=#{value}")
40
- dispatch_event(
41
- CookieChangeEvent.new(
42
- "change",
43
- "changed" => [build_record(name.to_s, value.to_s)],
44
- "deleted" => []
45
- )
46
- )
37
+ opts = name_or_options.is_a?(Hash) ? name_or_options.transform_keys(&:to_s) : {"name" => name_or_options, "value" => value}
38
+ cookie = write(opts)
39
+ event =
40
+ if cookie
41
+ {"changed" => [build_record(cookie)], "deleted" => []}
42
+ else
43
+ {"changed" => [], "deleted" => [{"name" => opts["name"].to_s, "value" => nil}]}
44
+ end
45
+ dispatch_event(CookieChangeEvent.new("change", event))
47
46
  PromiseValue.resolve(@window, nil)
47
+ rescue Bridge::TypeError => e
48
+ PromiseValue.reject(@window, e)
48
49
  end
49
50
 
50
51
  def delete(name_or_options)
51
- name = name_or_options.is_a?(Hash) ? (name_or_options["name"] || name_or_options[:name]) : name_or_options
52
- cookie_jar.cookies.delete(name.to_s)
53
- dispatch_event(
54
- CookieChangeEvent.new(
55
- "change",
56
- "changed" => [],
57
- "deleted" => [build_record(name.to_s, "")]
58
- )
59
- )
52
+ opts = name_or_options.is_a?(Hash) ? name_or_options.transform_keys(&:to_s) : {"name" => name_or_options}
53
+ write(opts.merge("value" => "", "expires" => 0))
54
+ dispatch_event(CookieChangeEvent.new("change", "changed" => [], "deleted" => [{"name" => opts["name"].to_s, "value" => nil}]))
60
55
  PromiseValue.resolve(@window, nil)
56
+ rescue Bridge::TypeError => e
57
+ PromiseValue.reject(@window, e)
61
58
  end
62
59
 
63
60
  include Bridge::Methods
@@ -87,22 +84,80 @@ module Dommy
87
84
 
88
85
  private
89
86
 
90
- def cookie_jar
91
- # `document.cookie_jar` is private; we reach the same backing
92
- # store via the public `document.cookie` round-trip. Easier: use
93
- # instance_variable_get on the document.
94
- @window.document.instance_variable_get(:@cookie_jar)
87
+ def document_url = @window.document.url
88
+
89
+ # The name a query asks for (nil: any) and the URL it is for — the
90
+ # document's, or one with the same origin.
91
+ def query_of(arg)
92
+ return [nil, document_url] if arg.nil?
93
+ return [arg.to_s, document_url] unless arg.is_a?(Hash)
94
+
95
+ opts = arg.transform_keys(&:to_s)
96
+ url = document_url
97
+ if opts["url"]
98
+ parsed = @window.__internal_parse_url__(opts["url"].to_s)
99
+ raise Bridge::TypeError, "invalid url" unless parsed && Internal::Origin.of_url(parsed) == @window.origin
100
+
101
+ url = parsed
102
+ end
103
+ [opts["name"]&.to_s, url]
104
+ end
105
+
106
+ def cookies_for(url)
107
+ return [] if @window.document.__internal_cookie_averse__?
108
+
109
+ @window.cookie_jar.matching(url, http: false)
110
+ end
111
+
112
+ # "Set a cookie" with the store's defaults, through the jar's storage
113
+ # model: the cookie stored, or nil for one that expires at once, which
114
+ # deletes its match. A cookie the jar refuses is a TypeError.
115
+ def write(opts)
116
+ name = opts["name"].to_s
117
+ value = opts["value"].to_s
118
+ if (name + value).match?(Dommy::CookieJar::CONTROL_CHARACTERS) || name.include?("=") || name.include?(";") || value.include?(";")
119
+ raise Bridge::TypeError, "invalid cookie name or value"
120
+ end
121
+ raise Bridge::TypeError, "a nameless cookie's value cannot contain '='" if name.empty? && value.include?("=")
122
+
123
+ parts = ["#{name}=#{value}"]
124
+ domain = opts["domain"]
125
+ if domain
126
+ raise Bridge::TypeError, "domain must not start with '.'" if domain.to_s.start_with?(".")
127
+
128
+ parts << "Domain=#{domain}"
129
+ end
130
+ path = (opts["path"] || "/").to_s
131
+ raise Bridge::TypeError, "path must start with '/'" unless path.start_with?("/")
132
+
133
+ parts << "Path=#{path}"
134
+ if opts.key?("expires") && !opts["expires"].nil?
135
+ parts << "Expires=#{Time.at(opts["expires"].to_f / 1000).utc.httpdate}"
136
+ end
137
+ parts << "SameSite=#{(opts["sameSite"] || "strict").to_s.capitalize}"
138
+ parts << "Secure"
139
+ cookie = @window.document.__internal_cookie_averse__? ? nil : @window.cookie_jar.store(parts.join("; "), document_url, http: false)
140
+ return cookie if cookie
141
+ return nil if expired_at_once?(opts)
142
+
143
+ raise Bridge::TypeError, "the cookie was refused"
144
+ end
145
+
146
+ def expired_at_once?(opts)
147
+ expires = opts["expires"]
148
+ !expires.nil? && expires.to_f / 1000 <= @window.cookie_jar.now.to_f
95
149
  end
96
150
 
97
- def build_record(name, value)
151
+ def build_record(cookie)
98
152
  {
99
- "name" => name.to_s,
100
- "value" => value.to_s,
101
- "domain" => nil,
102
- "path" => "/",
103
- "expires" => nil,
104
- "secure" => false,
105
- "sameSite" => "strict"
153
+ "name" => cookie.name.to_s,
154
+ "value" => cookie.value.to_s,
155
+ "domain" => cookie.host_only ? nil : cookie.domain,
156
+ "path" => cookie.path || "/",
157
+ "expires" => cookie.expires ? (cookie.expires.to_f * 1000).round : nil,
158
+ "secure" => cookie.secure ? true : false,
159
+ "sameSite" => (cookie.same_site || "Default").to_s.downcase.then { |v| v == "default" ? "lax" : v },
160
+ "partitioned" => false
106
161
  }
107
162
  end
108
163
  end