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