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.
- checksums.yaml +4 -4
- data/README.md +15 -1
- data/lib/dommy/attr.rb +56 -32
- data/lib/dommy/backend.rb +251 -208
- data/lib/dommy/blob.rb +24 -0
- data/lib/dommy/bridge/methods.rb +12 -2
- data/lib/dommy/bridge/values.rb +29 -2
- data/lib/dommy/bridge/wire_tags.rb +8 -4
- data/lib/dommy/browser.rb +328 -53
- data/lib/dommy/callable_invoker.rb +27 -31
- data/lib/dommy/character_data.rb +21 -28
- data/lib/dommy/class_list.rb +55 -23
- data/lib/dommy/cookie_jar.rb +408 -0
- data/lib/dommy/cookie_store.rb +104 -49
- data/lib/dommy/css.rb +37 -8
- data/lib/dommy/custom_elements.rb +360 -116
- data/lib/dommy/data_transfer.rb +22 -2
- data/lib/dommy/document.rb +1258 -346
- data/lib/dommy/dom_exception.rb +6 -0
- data/lib/dommy/dom_parser.rb +46 -14
- data/lib/dommy/dom_string_list.rb +36 -0
- data/lib/dommy/element.rb +408 -569
- data/lib/dommy/element_internals.rb +215 -0
- data/lib/dommy/encodings.rb +10 -0
- data/lib/dommy/event.rb +382 -148
- data/lib/dommy/event_source.rb +8 -34
- data/lib/dommy/fetch.rb +8 -12
- data/lib/dommy/file_reader.rb +5 -29
- data/lib/dommy/form_data.rb +53 -16
- data/lib/dommy/form_entry_list.rb +107 -138
- data/lib/dommy/fragment.rb +14 -16
- data/lib/dommy/history.rb +247 -90
- data/lib/dommy/html_canvas_element.rb +15 -35
- data/lib/dommy/html_collection.rb +76 -50
- data/lib/dommy/html_elements/base.rb +377 -71
- data/lib/dommy/html_elements/embedded.rb +350 -106
- data/lib/dommy/html_elements/forms/form.rb +424 -383
- data/lib/dommy/html_elements/forms/input.rb +351 -189
- data/lib/dommy/html_elements/forms/select.rb +97 -81
- data/lib/dommy/html_elements/interactive.rb +285 -72
- data/lib/dommy/html_elements/media.rb +73 -32
- data/lib/dommy/html_elements/metadata.rb +226 -78
- data/lib/dommy/html_elements/miscellaneous.rb +58 -14
- data/lib/dommy/html_elements/tables.rb +20 -42
- data/lib/dommy/html_elements.rb +28 -3
- data/lib/dommy/hyperlink_utils.rb +19 -8
- data/lib/dommy/interaction/drag_and_drop.rb +187 -0
- data/lib/dommy/interaction/event_synthesis.rb +182 -24
- data/lib/dommy/interaction/field_interactor.rb +18 -6
- data/lib/dommy/interaction/form_submission.rb +12 -6
- data/lib/dommy/interaction/key_sender.rb +102 -17
- data/lib/dommy/interaction.rb +1 -0
- data/lib/dommy/internal/accessibility_tree.rb +19 -7
- data/lib/dommy/internal/accessibility_visibility.rb +2 -2
- data/lib/dommy/internal/accessible_description.rb +3 -3
- data/lib/dommy/internal/accessible_name.rb +33 -45
- data/lib/dommy/internal/an_plus_b.rb +125 -0
- data/lib/dommy/internal/aria_role.rb +27 -18
- data/lib/dommy/internal/aria_state.rb +7 -7
- data/lib/dommy/internal/autofill.rb +2 -2
- data/lib/dommy/internal/backend_prefilter.rb +21 -18
- data/lib/dommy/internal/ce_reactions.rb +285 -0
- data/lib/dommy/internal/ce_reactions_table.rb +296 -0
- data/lib/dommy/internal/child_list.rb +39 -0
- data/lib/dommy/internal/child_navigable.rb +155 -0
- data/lib/dommy/internal/child_node.rb +44 -41
- data/lib/dommy/internal/close_watcher_manager.rb +238 -0
- data/lib/dommy/internal/complex_match.rb +129 -0
- data/lib/dommy/internal/constraint_validation.rb +130 -0
- data/lib/dommy/internal/css/ancestor_filter.rb +107 -0
- data/lib/dommy/internal/css/cascade.rb +7 -7
- data/lib/dommy/internal/css/cascaded_declarations.rb +14 -20
- data/lib/dommy/internal/css/computed_style_builder.rb +30 -3
- data/lib/dommy/internal/css/custom_properties.rb +63 -80
- data/lib/dommy/internal/css/parser.rb +53 -63
- data/lib/dommy/internal/css/renderability.rb +6 -0
- data/lib/dommy/internal/css/rule_index.rb +170 -108
- data/lib/dommy/internal/css/rule_index_inputs.rb +135 -0
- data/lib/dommy/internal/css/selector_dependencies.rb +8 -2
- data/lib/dommy/internal/css/style_cache.rb +9 -6
- data/lib/dommy/internal/css/ua_stylesheet.rb +37 -2
- data/lib/dommy/internal/css_source.rb +223 -0
- data/lib/dommy/internal/css_syntax.rb +153 -0
- data/lib/dommy/internal/declarative_shadow_roots.rb +182 -0
- data/lib/dommy/internal/directionality.rb +2 -2
- data/lib/dommy/internal/document_generations.rb +91 -18
- data/lib/dommy/internal/document_interaction_state.rb +293 -9
- data/lib/dommy/internal/dom_matching.rb +7 -7
- data/lib/dommy/internal/element_aria.rb +65 -23
- data/lib/dommy/internal/element_css_inline_style.rb +26 -0
- data/lib/dommy/internal/element_editing.rb +41 -0
- data/lib/dommy/internal/element_nonce.rb +28 -0
- data/lib/dommy/internal/element_popover.rb +310 -0
- data/lib/dommy/internal/element_shadow.rb +107 -33
- data/lib/dommy/internal/element_state.rb +131 -26
- data/lib/dommy/internal/element_top_layer.rb +21 -51
- data/lib/dommy/internal/enumerated_keyword_sets.rb +40 -0
- data/lib/dommy/internal/event_handler_tables.rb +241 -0
- data/lib/dommy/internal/event_handlers.rb +149 -0
- data/lib/dommy/internal/exception_report.rb +1 -1
- data/lib/dommy/internal/focusability.rb +267 -0
- data/lib/dommy/internal/form_associated_custom_elements.rb +127 -0
- data/lib/dommy/internal/html_or_svg_or_mathml_element.rb +86 -0
- data/lib/dommy/internal/html_serialization.rb +292 -0
- data/lib/dommy/internal/idl_reflection.rb +202 -0
- data/lib/dommy/internal/idl_reflection_table.rb +589 -0
- data/lib/dommy/internal/infra.rb +36 -0
- data/lib/dommy/internal/input_type.rb +90 -20
- data/lib/dommy/internal/insertion_point.rb +27 -3
- data/lib/dommy/internal/js_number.rb +60 -0
- data/lib/dommy/internal/light_dismiss.rb +178 -0
- data/lib/dommy/internal/literal_lookup.rb +103 -0
- data/lib/dommy/internal/mime_type.rb +8 -0
- data/lib/dommy/internal/mutation_coordinator.rb +94 -60
- data/lib/dommy/internal/node_adopter.rb +165 -97
- data/lib/dommy/internal/node_factory.rb +111 -49
- data/lib/dommy/internal/node_identity.rb +1 -1
- data/lib/dommy/internal/node_traversal.rb +30 -13
- data/lib/dommy/internal/node_wrapper_cache.rb +34 -148
- data/lib/dommy/internal/observable_callback.rb +2 -5
- data/lib/dommy/internal/observer_options.rb +24 -14
- data/lib/dommy/internal/origin.rb +86 -0
- data/lib/dommy/internal/parent_node.rb +12 -10
- data/lib/dommy/internal/popover_invoker_element.rb +67 -0
- data/lib/dommy/internal/popover_stack.rb +120 -0
- data/lib/dommy/internal/post_insertion_steps.rb +146 -77
- data/lib/dommy/internal/reflected_attributes.rb +173 -43
- data/lib/dommy/internal/rendered_text/collector.rb +12 -9
- data/lib/dommy/internal/rendered_text/fragment.rb +6 -2
- data/lib/dommy/internal/selector_arguments.rb +2 -16
- data/lib/dommy/internal/selector_index.rb +19 -10
- data/lib/dommy/internal/selector_match.rb +190 -0
- data/lib/dommy/internal/selector_matcher.rb +91 -256
- data/lib/dommy/internal/selector_parser.rb +176 -100
- data/lib/dommy/internal/sequential_focus_navigation.rb +210 -0
- data/lib/dommy/internal/shadow_root_registry.rb +14 -6
- data/lib/dommy/internal/sibling_positions.rb +59 -0
- data/lib/dommy/internal/svg_uri_reference.rb +33 -0
- data/lib/dommy/internal/template_content_registry.rb +11 -5
- data/lib/dommy/internal/text_selection.rb +185 -32
- data/lib/dommy/internal/toggle_events.rb +6 -4
- data/lib/dommy/internal/unsafe_html.rb +107 -0
- data/lib/dommy/internal/url_parser.rb +84 -8
- data/lib/dommy/internal/user_activation.rb +192 -0
- data/lib/dommy/internal/web_idl.rb +160 -16
- data/lib/dommy/internal/window_constructors.rb +34 -14
- data/lib/dommy/internal/xml_serialization.rb +9 -45
- data/lib/dommy/js/constructor_resolver.rb +18 -0
- data/lib/dommy/js/custom_element_bridge.rb +57 -82
- data/lib/dommy/js/dom_interfaces.rb +51 -15
- data/lib/dommy/js/host_bridge.rb +189 -40
- data/lib/dommy/js/host_callbacks.rb +10 -0
- data/lib/dommy/js/host_runtime.js +1921 -416
- data/lib/dommy/js/marshaller.rb +13 -4
- data/lib/dommy/js/module_loader.rb +46 -5
- data/lib/dommy/js/module_preload.rb +194 -0
- data/lib/dommy/js/runtime.rb +9 -0
- data/lib/dommy/js/script_boot.rb +200 -114
- data/lib/dommy/js/serialized_record.rb +32 -0
- data/lib/dommy/js/webidl_event_handlers.js +45 -0
- data/lib/dommy/js/webidl_members.js +196 -0
- data/lib/dommy/js/webidl_signatures.js +2055 -0
- data/lib/dommy/js/webidl_tables.js +72 -101
- data/lib/dommy/js/window_wiring.rb +9 -5
- data/lib/dommy/location.rb +129 -76
- data/lib/dommy/mathml_elements.rb +13 -0
- data/lib/dommy/media_query_list.rb +4 -13
- data/lib/dommy/message_channel.rb +137 -64
- data/lib/dommy/mutation_observer.rb +3 -7
- data/lib/dommy/navigation.rb +61 -11
- data/lib/dommy/navigator.rb +132 -10
- data/lib/dommy/node.rb +81 -44
- data/lib/dommy/notification.rb +10 -6
- data/lib/dommy/parser.rb +5 -16
- data/lib/dommy/promise.rb +20 -26
- data/lib/dommy/range.rb +20 -47
- data/lib/dommy/resources.rb +38 -3
- data/lib/dommy/scheduler.rb +176 -48
- data/lib/dommy/shadow_root.rb +125 -25
- data/lib/dommy/storage.rb +135 -25
- data/lib/dommy/streams.rb +2 -6
- data/lib/dommy/structured_data.rb +45 -0
- data/lib/dommy/style_declaration.rb +38 -6
- data/lib/dommy/svg_elements.rb +25 -11
- data/lib/dommy/text_codec.rb +2 -14
- data/lib/dommy/tree_walker.rb +12 -26
- data/lib/dommy/url.rb +12 -9
- data/lib/dommy/url_pattern.rb +1 -9
- data/lib/dommy/validity_state.rb +109 -107
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +10 -33
- data/lib/dommy/window.rb +817 -105
- data/lib/dommy/worker.rb +4 -28
- data/lib/dommy/xml_http_request.rb +32 -45
- data/lib/dommy.rb +43 -10
- metadata +52 -5
- data/lib/dommy/backend/makiri_adapter.rb +0 -408
- 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
|
data/lib/dommy/cookie_store.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Dommy
|
|
4
|
-
# `cookieStore` — the asynchronous Cookie Store API
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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://
|
|
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 =
|
|
18
|
-
|
|
19
|
-
|
|
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(
|
|
23
|
-
|
|
24
|
-
records =
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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(
|
|
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" => "
|
|
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
|