Skip to main content

script/dom/
cookiestore.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::borrow::Cow;
6use std::collections::VecDeque;
7use std::rc::Rc;
8
9use cookie::{Cookie, SameSite};
10use dom_struct::dom_struct;
11use hyper_serde::Serde;
12use itertools::Itertools;
13use js::context::JSContext;
14use js::jsval::NullValue;
15use net_traits::CookieSource::NonHTTP;
16use net_traits::{CookieAsyncResponse, CookieData, CoreResourceMsg};
17use script_bindings::cell::DomRefCell;
18use script_bindings::codegen::GenericBindings::CookieStoreBinding::CookieSameSite;
19use script_bindings::reflector::reflect_dom_object_with_cx;
20use servo_base::generic_channel::{GenericCallback, GenericSend, GenericSender};
21use servo_base::id::CookieStoreId;
22use servo_url::ServoUrl;
23use time::OffsetDateTime;
24
25use crate::dom::bindings::codegen::Bindings::CookieStoreBinding::{
26    CookieInit, CookieListItem, CookieStoreDeleteOptions, CookieStoreGetOptions, CookieStoreMethods,
27};
28use crate::dom::bindings::error::Error;
29use crate::dom::bindings::refcounted::Trusted;
30use crate::dom::bindings::reflector::DomGlobal;
31use crate::dom::bindings::root::DomRoot;
32use crate::dom::bindings::str::USVString;
33use crate::dom::document::get_registrable_domain_suffix_of_or_is_equal_to;
34use crate::dom::eventtarget::EventTarget;
35use crate::dom::globalscope::GlobalScope;
36use crate::dom::promise::Promise;
37use crate::dom::window::Window;
38use crate::task_source::SendableTaskSource;
39
40#[derive(JSTraceable, MallocSizeOf)]
41struct DroppableCookieStore {
42    // Store an id so that we can send it with requests and the resource thread knows who to respond to
43    #[no_trace]
44    store_id: CookieStoreId,
45    #[no_trace]
46    unregister_channel: GenericSender<CoreResourceMsg>,
47}
48
49impl Drop for DroppableCookieStore {
50    fn drop(&mut self) {
51        let res = self
52            .unregister_channel
53            .send(CoreResourceMsg::RemoveCookieListener(self.store_id));
54        if res.is_err() {
55            error!("Failed to send cookiestore message to resource threads");
56        }
57    }
58}
59
60/// <https://cookiestore.spec.whatwg.org/>
61/// CookieStore provides an async API for pages and service workers to access and modify cookies.
62/// This requires setting up communication with resource thread's cookie storage that allows for
63/// the page to have multiple cookie storage promises in flight at the same time.
64#[dom_struct]
65pub(crate) struct CookieStore {
66    eventtarget: EventTarget,
67    #[conditional_malloc_size_of]
68    in_flight: DomRefCell<VecDeque<Rc<Promise>>>,
69    droppable: DroppableCookieStore,
70}
71
72struct CookieListener {
73    // TODO:(whatwg/cookiestore#239) The spec is missing details for what task source to use
74    task_source: SendableTaskSource,
75    context: Trusted<CookieStore>,
76}
77
78impl CookieListener {
79    pub(crate) fn handle(&self, message: CookieAsyncResponse) {
80        let context = self.context.clone();
81        self.task_source.queue(task!(cookie_message: move |cx| {
82            let Some(promise) = context.root().in_flight.safe_borrow_mut(cx.no_gc()).pop_front() else {
83                warn!("No promise exists for cookie store response");
84                return;
85            };
86            match message.data {
87                CookieData::Get(cookie) => {
88                    // If list is failure, then reject p with a TypeError and abort these steps.
89                    // (There is currently no way for list to result in failure)
90                    if let Some(cookie) = cookie {
91                        // Otherwise, resolve p with the first item of list.
92                        promise.resolve_native(cx, &cookie_to_list_item(cookie.into_inner()));
93                    } else {
94                        // If list is empty, then resolve p with null.
95                        promise.resolve_native(cx, &NullValue());
96                    }
97                },
98                CookieData::GetAll(cookies) => {
99                    // If list is failure, then reject p with a TypeError and abort these steps.
100                    promise.resolve_native(cx,
101                        &cookies
102                        .into_iter()
103                        .map(|cookie| cookie_to_list_item(cookie.0))
104                        .collect_vec(),);
105                },
106                CookieData::Delete(_) | CookieData::Change(_) | CookieData::Set(_) => {
107                    promise.resolve_native(cx, &());
108                }
109            }
110        }));
111    }
112}
113
114impl CookieStore {
115    fn new_inherited(unregister_channel: GenericSender<CoreResourceMsg>) -> CookieStore {
116        CookieStore {
117            eventtarget: EventTarget::new_inherited(),
118            in_flight: Default::default(),
119            droppable: DroppableCookieStore {
120                store_id: CookieStoreId::new(),
121                unregister_channel,
122            },
123        }
124    }
125
126    pub(crate) fn new(cx: &mut JSContext, global: &GlobalScope) -> DomRoot<CookieStore> {
127        let store = reflect_dom_object_with_cx(
128            Box::new(CookieStore::new_inherited(
129                global.resource_threads().core_thread.clone(),
130            )),
131            global,
132            cx,
133        );
134        store.setup_route();
135        store
136    }
137
138    fn setup_route(&self) {
139        let context = Trusted::new(self);
140        let cs_listener = CookieListener {
141            task_source: self
142                .global()
143                .task_manager()
144                .dom_manipulation_task_source()
145                .to_sendable(),
146            context,
147        };
148
149        let callback = GenericCallback::new(move |message| match message {
150            Ok(msg) => cs_listener.handle(msg),
151            Err(err) => warn!("Error receiving a CookieStore message: {:?}", err),
152        })
153        .expect("Could not create cookie store callback");
154
155        let res = self
156            .global()
157            .resource_threads()
158            .send(CoreResourceMsg::NewCookieListener(
159                self.droppable.store_id,
160                callback,
161                self.global().creation_url(),
162            ));
163        if res.is_err() {
164            error!("Failed to send cookiestore message to resource threads");
165        }
166    }
167}
168
169/// <https://cookiestore.spec.whatwg.org/#create-a-cookielistitem>
170fn cookie_to_list_item(cookie: Cookie) -> CookieListItem {
171    // TODO: Investigate if we need to explicitly UTF-8 decode without BOM here or if thats
172    // already being done by cookie-rs or implicitly by using rust strings
173    CookieListItem {
174        // Let name be the result of running UTF-8 decode without BOM on cookie’s name.
175        name: Some(cookie.name().to_string().into()),
176
177        // Let value be the result of running UTF-8 decode without BOM on cookie’s value.
178        value: Some(cookie.value().to_string().into()),
179    }
180}
181
182impl CookieStoreMethods<crate::DomTypeHolder> for CookieStore {
183    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-get>
184    fn Get(&self, cx: &mut JSContext, name: USVString) -> Rc<Promise> {
185        // 1. Let settings be this’s relevant settings object.
186        let global = self.global();
187
188        // 2. Let origin be settings’s origin.
189        let origin = global.origin();
190
191        // 5. Let p be a new promise.
192        let p = Promise::new(cx, &global);
193
194        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
195        if !origin.is_tuple() {
196            p.reject_error(cx, Error::Security(None));
197            return p;
198        }
199
200        // 4. Let url be settings’s creation URL.
201        let creation_url = global.creation_url();
202
203        let name = CookieStore::normalize(&name);
204
205        // 6. Run the following steps in parallel:
206        let res = self
207            .global()
208            .resource_threads()
209            .send(CoreResourceMsg::GetCookieDataForUrlAsync(
210                self.droppable.store_id,
211                creation_url,
212                Some(name),
213            ));
214        if res.is_err() {
215            error!("Failed to send cookiestore message to resource threads");
216        } else {
217            self.in_flight
218                .safe_borrow_mut(cx.no_gc())
219                .push_back(p.clone());
220        }
221
222        // 7. Return p.
223        p
224    }
225
226    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-get-options>
227    fn Get_(&self, cx: &mut JSContext, options: &CookieStoreGetOptions) -> Rc<Promise> {
228        // 1. Let settings be this’s relevant settings object.
229        let global = self.global();
230
231        // 2. Let origin be settings’s origin.
232        let origin = global.origin();
233
234        // 7. Let p be a new promise.
235        let p = Promise::new(cx, &global);
236
237        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
238        if !origin.is_tuple() {
239            p.reject_error(cx, Error::Security(None));
240            return p;
241        }
242
243        // 4. Let url be settings’s creation URL.
244        let creation_url = global.creation_url();
245
246        // 5. If options is empty, then return a promise rejected with a TypeError.
247        // "is empty" is not strictly defined anywhere in the spec but the only value we require here is "url"
248        if options.url.is_none() && options.name.is_none() {
249            p.reject_error(cx, Error::Type(c"Options cannot be empty".to_owned()));
250            return p;
251        }
252
253        let mut final_url = creation_url.clone();
254
255        // 6. If options["url"] is present, then run these steps:
256        if let Some(get_url) = &options.url {
257            // 6.1. Let parsed be the result of parsing options["url"] with settings’s API base URL.
258            let parsed_url = ServoUrl::parse_with_base(Some(&global.api_base_url()), get_url);
259
260            // 6.2. If this’s relevant global object is a Window object and parsed does not equal url with exclude fragments set to true,
261            // then return a promise rejected with a TypeError.
262            if let Some(_window) = DomRoot::downcast::<Window>(self.global()) &&
263                parsed_url
264                    .as_ref()
265                    .is_ok_and(|parsed| !parsed.is_equal_excluding_fragments(&creation_url))
266            {
267                p.reject_error(cx, Error::Type(c"URL does not match context".to_owned()));
268                return p;
269            }
270
271            // 6.3. If parsed’s origin and url’s origin are not the same origin,
272            // then return a promise rejected with a TypeError.
273            if parsed_url
274                .as_ref()
275                .is_ok_and(|parsed| creation_url.origin() != parsed.origin())
276            {
277                p.reject_error(cx, Error::Type(c"Not same origin".to_owned()));
278                return p;
279            }
280
281            // 6.4. Set url to parsed.
282            if let Ok(url) = parsed_url {
283                final_url = url;
284            }
285        }
286
287        // 6. Run the following steps in parallel:
288        let res = self
289            .global()
290            .resource_threads()
291            .send(CoreResourceMsg::GetCookieDataForUrlAsync(
292                self.droppable.store_id,
293                final_url,
294                options.name.clone().map(|val| CookieStore::normalize(&val)),
295            ));
296        if res.is_err() {
297            error!("Failed to send cookiestore message to resource threads");
298        } else {
299            self.in_flight
300                .safe_borrow_mut(cx.no_gc())
301                .push_back(p.clone());
302        }
303
304        p
305    }
306
307    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-getall>
308    fn GetAll(&self, cx: &mut JSContext, name: USVString) -> Rc<Promise> {
309        // 1. Let settings be this’s relevant settings object.
310        let global = self.global();
311
312        // 2. Let origin be settings’s origin.
313        let origin = global.origin();
314
315        // 5. Let p be a new promise.
316        let p = Promise::new(cx, &global);
317
318        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
319        if !origin.is_tuple() {
320            p.reject_error(cx, Error::Security(None));
321            return p;
322        }
323        // 4. Let url be settings’s creation URL.
324        let creation_url = global.creation_url();
325
326        // Normalize name here rather than passing the un-nomarlized name around to the resource thread and back
327        let name = CookieStore::normalize(&name);
328
329        // 6. Run the following steps in parallel:
330        let res =
331            self.global()
332                .resource_threads()
333                .send(CoreResourceMsg::GetAllCookieDataForUrlAsync(
334                    self.droppable.store_id,
335                    creation_url,
336                    Some(name),
337                ));
338        if res.is_err() {
339            error!("Failed to send cookiestore message to resource threads");
340        } else {
341            self.in_flight
342                .safe_borrow_mut(cx.no_gc())
343                .push_back(p.clone());
344        }
345
346        // 7. Return p.
347        p
348    }
349
350    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-getall-options>
351    fn GetAll_(&self, cx: &mut JSContext, options: &CookieStoreGetOptions) -> Rc<Promise> {
352        // 1. Let settings be this’s relevant settings object.
353        let global = self.global();
354
355        // 2. Let origin be settings’s origin.
356        let origin = global.origin();
357
358        // 6. Let p be a new promise.
359        let p = Promise::new(cx, &global);
360
361        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
362        if !origin.is_tuple() {
363            p.reject_error(cx, Error::Security(None));
364            return p;
365        }
366
367        // 4. Let url be settings’s creation URL.
368        let creation_url = global.creation_url();
369
370        let mut final_url = creation_url.clone();
371
372        // 5. If options["url"] is present, then run these steps:
373        if let Some(get_url) = &options.url {
374            // 5.1. Let parsed be the result of parsing options["url"] with settings’s API base URL.
375            let parsed_url = ServoUrl::parse_with_base(Some(&global.api_base_url()), get_url);
376
377            // If this’s relevant global object is a Window object and parsed does not equal url with exclude fragments set to true,
378            // then return a promise rejected with a TypeError.
379            if let Some(_window) = DomRoot::downcast::<Window>(self.global()) &&
380                parsed_url
381                    .as_ref()
382                    .is_ok_and(|parsed| !parsed.is_equal_excluding_fragments(&creation_url))
383            {
384                p.reject_error(cx, Error::Type(c"URL does not match context".to_owned()));
385                return p;
386            }
387
388            // 5.3. If parsed’s origin and url’s origin are not the same origin,
389            // then return a promise rejected with a TypeError.
390            if parsed_url
391                .as_ref()
392                .is_ok_and(|parsed| creation_url.origin() != parsed.origin())
393            {
394                p.reject_error(cx, Error::Type(c"Not same origin".to_owned()));
395                return p;
396            }
397
398            // 5.4. Set url to parsed.
399            if let Ok(url) = parsed_url {
400                final_url = url;
401            }
402        }
403
404        // 7. Run the following steps in parallel:
405        let res =
406            self.global()
407                .resource_threads()
408                .send(CoreResourceMsg::GetAllCookieDataForUrlAsync(
409                    self.droppable.store_id,
410                    final_url,
411                    options.name.clone().map(|val| CookieStore::normalize(&val)),
412                ));
413        if res.is_err() {
414            error!("Failed to send cookiestore message to resource threads");
415        } else {
416            self.in_flight
417                .safe_borrow_mut(cx.no_gc())
418                .push_back(p.clone());
419        }
420
421        // 8. Return p
422        p
423    }
424
425    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-set>
426    fn Set(&self, cx: &mut JSContext, name: USVString, value: USVString) -> Rc<Promise> {
427        // 1. Let settings be this’s relevant settings object.
428        let global = self.global();
429
430        // 2. Let origin be settings’s origin.
431        let origin = global.origin();
432
433        // 9. Let p be a new promise.
434        let p = Promise::new(cx, &global);
435
436        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
437        if !origin.is_tuple() {
438            p.reject_error(cx, Error::Security(None));
439            return p;
440        }
441
442        // 6.1 Let r be the result of running set a cookie with url, name, value, null, null, "/", "strict", false, and null.
443        let properties = CookieInit {
444            name,
445            value,
446            expires: None,
447            domain: None,
448            path: USVString(String::from("/")),
449            sameSite: CookieSameSite::Strict,
450            partitioned: false,
451        };
452        let creation_url = global.creation_url();
453        let Some(cookie) = CookieStore::set_a_cookie(&creation_url, &properties) else {
454            // If r is failure, then reject p with a TypeError and abort these steps.
455            p.reject_error(cx, Error::Type(c"Invalid cookie".to_owned()));
456            return p;
457        };
458
459        // 6. Run the following steps in parallel:
460        let res = self
461            .global()
462            .resource_threads()
463            .send(CoreResourceMsg::SetCookieForUrlAsync(
464                self.droppable.store_id,
465                creation_url.clone(),
466                Serde(cookie.into_owned()),
467                NonHTTP,
468            ));
469        if res.is_err() {
470            error!("Failed to send cookiestore message to resource threads");
471        } else {
472            self.in_flight
473                .safe_borrow_mut(cx.no_gc())
474                .push_back(p.clone());
475        }
476
477        // 7. Return p.
478        p
479    }
480
481    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-set-options>
482    fn Set_(&self, cx: &mut JSContext, options: &CookieInit) -> Rc<Promise> {
483        // 1. Let settings be this’s relevant settings object.
484        let global = self.global();
485
486        // 2. Let origin be settings’s origin.
487        let origin = global.origin();
488
489        // 5. Let p be a new promise.
490        let p = Promise::new(cx, &global);
491
492        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
493        if !origin.is_tuple() {
494            p.reject_error(cx, Error::Security(None));
495            return p;
496        }
497
498        // 4. Let url be settings’s creation URL.
499        let creation_url = global.creation_url();
500
501        // 6.1. Let r be the result of running set a cookie with url, options["name"], options["value"],
502        // options["expires"], options["domain"], options["path"], options["sameSite"], and options["partitioned"].
503        let Some(cookie) = CookieStore::set_a_cookie(&creation_url, options) else {
504            p.reject_error(cx, Error::Type(c"Invalid cookie".to_owned()));
505            return p;
506        };
507
508        // 6. Run the following steps in parallel:
509        let res = self
510            .global()
511            .resource_threads()
512            .send(CoreResourceMsg::SetCookieForUrlAsync(
513                self.droppable.store_id,
514                creation_url.clone(),
515                Serde(cookie.into_owned()),
516                NonHTTP,
517            ));
518        if res.is_err() {
519            error!("Failed to send cookiestore message to resource threads");
520        } else {
521            self.in_flight
522                .safe_borrow_mut(cx.no_gc())
523                .push_back(p.clone());
524        }
525
526        // 7. Return p
527        p
528    }
529
530    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-delete>
531    fn Delete(&self, cx: &mut JSContext, name: USVString) -> Rc<Promise> {
532        // 1. Let settings be this’s relevant settings object.
533        let global = self.global();
534
535        // 2. Let origin be settings’s origin.
536        let origin = global.origin();
537
538        // 5. Let p be a new promise.
539        let p = Promise::new(cx, &global);
540
541        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
542        if !origin.is_tuple() {
543            p.reject_error(cx, Error::Security(None));
544            return p;
545        }
546
547        // 6. Run the following steps in parallel:
548        // TODO: the spec passes additional parameters to _delete a cookie_ that we don't handle yet
549        let res = global
550            .resource_threads()
551            .send(CoreResourceMsg::DeleteCookieAsync(
552                self.droppable.store_id,
553                global.creation_url(),
554                name.0,
555            ));
556        if res.is_err() {
557            error!("Failed to send cookiestore message to resource threads");
558        } else {
559            self.in_flight
560                .safe_borrow_mut(cx.no_gc())
561                .push_back(p.clone());
562        }
563
564        // 7. Return p.
565        p
566    }
567
568    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-delete-options>
569    fn Delete_(&self, cx: &mut JSContext, options: &CookieStoreDeleteOptions) -> Rc<Promise> {
570        // 1. Let settings be this’s relevant settings object.
571        let global = self.global();
572
573        // 2. Let origin be settings’s origin.
574        let origin = global.origin();
575
576        // 5. Let p be a new promise.
577        let p = Promise::new(cx, &global);
578
579        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
580        if !origin.is_tuple() {
581            p.reject_error(cx, Error::Security(None));
582            return p;
583        }
584
585        // 6. Run the following steps in parallel:
586        // TODO: the spec passes additional parameters to _delete a cookie_ that we don't handle yet
587        let res = global
588            .resource_threads()
589            .send(CoreResourceMsg::DeleteCookieAsync(
590                self.droppable.store_id,
591                global.creation_url(),
592                options.name.to_string(),
593            ));
594        if res.is_err() {
595            error!("Failed to send cookiestore message to resource threads");
596        } else {
597            self.in_flight
598                .safe_borrow_mut(cx.no_gc())
599                .push_back(p.clone());
600        }
601
602        // 7. Return p.
603        p
604    }
605}
606
607impl CookieStore {
608    /// <https://cookiestore.spec.whatwg.org/#normalize-a-cookie-name-or-value>
609    fn normalize(value: &USVString) -> String {
610        value.trim_matches([' ', '\t']).into()
611    }
612
613    /// <https://cookiestore.spec.whatwg.org/#set-cookie-algorithm>
614    fn set_a_cookie<'a>(url: &'a ServoUrl, properties: &'a CookieInit) -> Option<Cookie<'a>> {
615        // 1. Normalize name.
616        let name = CookieStore::normalize(&properties.name);
617        // 2. Normalize value.
618        let value = CookieStore::normalize(&properties.value);
619
620        // 3. If name or value contain U+003B (;), any C0 control character except U+0009 TAB, or U+007F DELETE, then return failure.
621        if CookieStore::contains_control_characters(&name) ||
622            CookieStore::contains_control_characters(&value)
623        {
624            return None;
625        }
626
627        // 4. If name contains U+003D (=), then return failure.
628        if name.contains('=') {
629            return None;
630        }
631
632        // 5. If name’s length is 0:
633        if name.is_empty() {
634            // 5.1 If value contains U+003D (=), then return failure.
635            // 5.2 If value’s length is 0, then return failure.
636            if value.contains('=') || value.is_empty() {
637                return None;
638            }
639            // 5.3 If value, byte-lowercased, starts with `__host-`, `__host-http-`, `__http-`, or `__secure-`, then return failure.
640            let lowercased_value = value.to_ascii_lowercase();
641            if ["__host-", "__host-http-", "__http-", "__secure-"]
642                .iter()
643                .any(|prefix| lowercased_value.starts_with(prefix))
644            {
645                return None;
646            }
647        }
648
649        // 6. If name, byte-lowercased, starts with `__host-http-` or `__http-`, then return failure.
650        let lowercased_name = name.to_ascii_lowercase();
651        if lowercased_name.starts_with("__host-http-") || lowercased_name.starts_with("__http-") {
652            return None;
653        }
654
655        // 9. If the byte sequence length of encodedName plus the byte sequence length of encodedValue is greater than the maximum name/value pair size, then return failure.
656        if name.len() + value.len() > 4096 {
657            return None;
658        }
659
660        let mut cookie = Cookie::build((Cow::Owned(name), Cow::Owned(value)))
661            // 21. Append ('Secure', '') to attributes
662            .secure(true)
663            // 23. If partitioned is true, Append (`Partitioned`, ``) to attributes.
664            .partitioned(properties.partitioned)
665            // 21. Switch on sameSite:
666            .same_site(match properties.sameSite {
667                CookieSameSite::Lax => SameSite::Lax,
668                CookieSameSite::Strict => SameSite::Strict,
669                CookieSameSite::None => SameSite::None,
670            });
671
672        // 12. If domain is non-null
673        if let Some(domain) = &properties.domain {
674            // 10. Let host be url's host.
675            let host = match url.host() {
676                Some(host) => host.to_owned(),
677                None => return None,
678            };
679            // 12.1 If domain starts with U+002E (.), then return failure
680            if domain.starts_with('.') {
681                return None;
682            }
683            // 12.2 If name, byte-lowercased, starts with `__host-`, then return failure.
684            if lowercased_name.starts_with("__host-") {
685                return None;
686            }
687
688            // 12.3 If domain is not a registrable domain suffix of and is not equal to host, then return failure.
689            // 12.4 Let parsedDomain be the result of host parsing domain.
690            // 12.5 Assert: parsedDomain is not failure.
691            // Note: this function parses the host and returns the parsed host on success
692            let domain = get_registrable_domain_suffix_of_or_is_equal_to(domain, host)?;
693
694            // 12.6 Let encodedDomain be the result of UTF-8 encoding parsedDomain.
695            let domain = domain.to_string();
696            // 12.7 If the byte sequence length of encodedDomain is greater than the maximum attribute value size, then return failure.
697            if domain.len() > 1024 {
698                return None;
699            }
700            // 12.8 Append (`Domain`, encodedDomain) to attributes.
701            cookie.inner_mut().set_domain(domain);
702        }
703
704        // 13. If expires is non-null:
705        if let Some(expiry) = properties.expires {
706            // TODO: update cookiestore to take new maxAge parameter
707            // 13.2 Append (`Expires`, expires (date serialized)) to attributes.
708            cookie.inner_mut().set_expires(
709                OffsetDateTime::from_unix_timestamp((*expiry / 1000.0) as i64)
710                    .expect("cookie expiry out of range"),
711            );
712        }
713
714        // 15. If path is the empty string, then set path to the serialized cookie default path of url.
715        let path = if properties.path.is_empty() {
716            // 15.1 Let cloneURL be a clone of url.
717            let mut cloned_url = url.clone();
718            {
719                // 15.2 Set cloneURL’s path to the cookie default path of cloneURL’s path.
720                // <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-layered-cookies#name-cookie-default-path>
721                let mut path_segments = cloned_url
722                    .as_mut_url()
723                    .path_segments_mut()
724                    .expect("document creation url cannot be a base");
725                // 2. If path's size is greater than 1, then remove path's last item
726                if url.path_segments().is_some_and(|ps| ps.count() > 1) {
727                    path_segments.pop();
728                } else {
729                    // 3. Otherwise, set path[0] to the empty string.
730                    path_segments.clear();
731                }
732            }
733            cloned_url.path().to_owned()
734        } else {
735            properties.path.to_string()
736        };
737        // 16. If path does not start with U+002F (/), then return failure.
738        if !path.starts_with('/') {
739            return None;
740        }
741        // 17. If path is not U+002F (/), and name, byte-lowercased, starts with `__host-`, then return failure.
742        if path != "/" && lowercased_name.starts_with("__host-") {
743            return None;
744        }
745        // 19. If the byte sequence length of encodedPath is greater than the maximum attribute value size, then return failure.
746        if path.len() > 1024 {
747            return None;
748        }
749        // 20. Append (`Path`, encodedPath) to attributes.
750        cookie.inner_mut().set_path(path);
751
752        Some(cookie.build())
753    }
754
755    /// <https://cookiestore.spec.whatwg.org/#set-cookie-algorithm>
756    fn contains_control_characters(val: &str) -> bool {
757        // If name or value contain U+003B (;), any C0 control character except U+0009 TAB, or U+007F DELETE, then return failure.
758        val.contains(
759            |v| matches!(v, '\u{0000}'..='\u{0008}' | '\u{000a}'..='\u{001f}' | '\u{007f}' | ';'),
760        )
761    }
762}